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/service.ts4 * Description: PermissionService — combined evaluation per tool call, injected asker, persisted grants, durable permission events (PERMISSION_MODEL.md §4.4–§6).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { KhaelorError, ulid } from "../shared/index.js";11import type { Result } from "../shared/index.js";12import type { CapabilityRequest } from "./capabilities.js";13import { DEFAULT_RULES, HARDLINE_RULE, evaluate } from "./rules.js";14import type { Decision, PermissionRule } from "./rules.js";15import { collapseWhitespace } from "./paths.js";1617// ─────────────────────── durable event inputs (§5.1, §8) ───────────────────────18// Structurally identical to the session catalog's DurableEventInput members19// (src/session/events.ts) — defined locally because permissions is Layer 2 and20// imports only shared + config (ARCHITECTURE.md §2.1). The composition root21// forwards these to `EventBus.publishDurable` unchanged.2223export interface PermissionRequestedInput {24 type: "permission.requested";25 payload: {26 permissionRequestId: string;27 toolUseId: string;28 capability: string;29 descriptor: string;30 suggestion?: { capability: string; pattern: string };31 };32}3334export interface PermissionGrantedInput {35 type: "permission.granted";36 payload: {37 permissionRequestId: string;38 scope: "once" | "always-project";39 };40}4142export interface PermissionDeniedInput {43 type: "permission.denied";44 payload: {45 permissionRequestId: string;46 source: "user" | "policy" | "hardline" | "timeout";47 feedback: string;48 };49}5051export type PermissionEventInput =52 | PermissionRequestedInput53 | PermissionGrantedInput54 | PermissionDeniedInput;5556// ─────────────────────────── asker + persister seams ───────────────────────────5758/** What the TUI panel receives — the asking requests of ONE tool call (one panel per call, §4.4). */59export interface PermissionPrompt {60 toolUseId: string;61 toolName: string;62 /** The requests that evaluated to `ask`, with their decisions (provenance for [Tab] details). */63 requests: CapabilityRequest[];64 decisions: Decision[];65}6667export type PermissionAnswer =68 | { kind: "allow-once" }69 /** `pattern` must be one of the offered `alwaysPatterns` (§3.3 refusal rule). */70 | { kind: "allow-always"; pattern: string }71 | { kind: "deny"; feedback?: string };7273/** Injected panel callback. Absent asker = non-interactive: every ask resolves deny (§5.2). */74export interface PermissionAsker {75 ask(prompt: PermissionPrompt): Promise<PermissionAnswer>;76}7778/**79 * Injected config-writer: persisting "always allow in this project" is the80 * config module's job (ARCHITECTURE.md `persistPermissionGrant`); the service81 * never writes files itself. A failed persist is reported, the grant still82 * applies in-memory for the session (§6.2).83 */84export interface GrantPersister {85 persist(rule: PermissionRule): Promise<Result<void, KhaelorError>>;86}8788// ─────────────────────────────── outcomes ───────────────────────────────8990export interface RequestDecision {91 request: CapabilityRequest;92 decision: Decision;93}9495export type PermissionOutcome =96 | {97 kind: "allow";98 via: "policy-allow" | "user-once" | "user-always";99 decisions: RequestDecision[];100 /** The rule persisted by an "always" grant (also active in-memory). */101 persistedRule?: PermissionRule;102 /** Set when the project config could not be updated (§6.2 — fail loudly). */103 persistWarning?: string;104 }105 | {106 kind: "deny";107 source: "user" | "policy" | "hardline";108 /** Model-facing ToolFailed content (§5.4 — denial is steering, not a dead end). */109 feedback: string;110 decisions: RequestDecision[];111 };112113/** One tool call's worth of capability requests. */114export interface ToolPermissionCheck {115 toolUseId: string;116 toolName: string;117 requests: CapabilityRequest[];118}119120// ─────────────────────────── feedback templates (§5.4) ───────────────────────────121122export const NON_INTERACTIVE_FEEDBACK =123 "KHAELOR is running non-interactively; interactive approval is unavailable.";124125function policyDenyFeedback(request: CapabilityRequest, rule: PermissionRule): string {126 const source = rule.source ?? "default";127 return (128 `Permission denied by policy: ${request.capability} for "${request.subject}" is denied ` +129 `(rule: ${rule.capability} / "${rule.pattern ?? "*"}", source: ${source}). ` +130 `Do not retry this command or attempt an equivalent workaround. ` +131 `Choose a different approach, or ask the user to adjust permissions.`132 );133}134135function hardlineDenyFeedback(request: CapabilityRequest): string {136 return (137 `Permission denied: "${request.subject}" is blocked by KHAELOR's built-in safety floor — ` +138 `this cannot be allowed by configuration. Do not retry this command or attempt an equivalent workaround.`139 );140}141142function userDenyFeedback(subjects: string[], feedback?: string): string {143 const what = subjects.join(", ");144 if (feedback !== undefined && feedback.trim().length > 0) {145 return `The user declined to allow: ${what} — reason: "${feedback.trim()}". Adapt your approach accordingly.`;146 }147 return `The user declined to allow: ${what}. Continue without it, or propose an alternative.`;148}149150// ─────────────────────────────── the service ───────────────────────────────151152export interface PermissionServiceOptions {153 /** Layered rules: defaults ++ user ++ project ++ session grants (§4.2). */154 rules?: {155 defaults?: readonly PermissionRule[];156 user?: readonly PermissionRule[];157 project?: readonly PermissionRule[];158 };159 asker?: PermissionAsker;160 persister?: GrantPersister;161 /** Durable event sink — the composition root forwards to the session bus. */162 publish: (event: PermissionEventInput) => void;163 /** Id factory (tests); defaults to ULID. */164 newId?: () => string;165}166167export class PermissionService {168 private readonly defaults: readonly PermissionRule[];169 private readonly userRules: readonly PermissionRule[];170 private readonly projectRules: readonly PermissionRule[];171 /** In-memory session grants — "always" grants land here AND in project config (§4.2, §6.2). */172 private readonly sessionRules: PermissionRule[] = [];173 private readonly asker: PermissionAsker | undefined;174 private readonly persister: GrantPersister | undefined;175 private readonly publish: (event: PermissionEventInput) => void;176 private readonly newId: () => string;177178 constructor(options: PermissionServiceOptions) {179 this.defaults = options.rules?.defaults ?? DEFAULT_RULES;180 this.userRules = options.rules?.user ?? [];181 this.projectRules = options.rules?.project ?? [];182 this.asker = options.asker;183 this.persister = options.persister;184 this.publish = options.publish;185 this.newId = options.newId ?? ulid;186 }187188 /** The effective ruleset — plain concatenation, later layers win by position (§4.2). */189 effectiveRules(): PermissionRule[] {190 return [...this.defaults, ...this.userRules, ...this.projectRules, ...this.sessionRules];191 }192193 /**194 * Decide one tool call: evaluate every capability request, combine with195 * deny > ask > allow (§4.4), surface at most ONE panel via the injected196 * asker, and emit the durable permission events (§5.1).197 */198 async check(call: ToolPermissionCheck): Promise<PermissionOutcome> {199 const rules = this.effectiveRules();200 const decisions: RequestDecision[] = call.requests.map((request) => ({201 request,202 decision: evaluate(rules, request),203 }));204205 // any deny → deny, the denied request named in the feedback (§4.4); no panel (§5.1).206 const denied = decisions.find((d) => d.decision.action === "deny");207 if (denied !== undefined) {208 const rule = denied.decision.rule ?? HARDLINE_RULE;209 const isHardline = rule.source === "hardline";210 const feedback = isHardline211 ? hardlineDenyFeedback(denied.request)212 : policyDenyFeedback(denied.request, rule);213 this.publish({214 type: "permission.denied",215 payload: {216 permissionRequestId: this.newId(),217 source: isHardline ? "hardline" : "policy",218 feedback,219 },220 });221 return { kind: "deny", source: isHardline ? "hardline" : "policy", feedback, decisions };222 }223224 const asking = decisions.filter((d) => d.decision.action === "ask");225 if (asking.length === 0) {226 return { kind: "allow", via: "policy-allow", decisions };227 }228229 // ask → durable PermissionRequested per asking request, then ONE panel (§4.4, §5.1, §8).230 const entries = asking.map((d) => {231 const permissionRequestId = this.newId();232 const suggestion = d.request.alwaysPatterns[0];233 this.publish({234 type: "permission.requested",235 payload: {236 permissionRequestId,237 toolUseId: call.toolUseId,238 capability: d.request.capability,239 descriptor: d.request.display,240 ...(suggestion !== undefined241 ? { suggestion: { capability: d.request.capability, pattern: suggestion } }242 : {}),243 },244 });245 return { permissionRequestId, ...d };246 });247248 if (this.asker === undefined) {249 // Non-interactive: every ask resolves deny (§5.2). Silence is not consent.250 for (const entry of entries) {251 this.publish({252 type: "permission.denied",253 payload: {254 permissionRequestId: entry.permissionRequestId,255 source: "policy",256 feedback: NON_INTERACTIVE_FEEDBACK,257 },258 });259 }260 return { kind: "deny", source: "policy", feedback: NON_INTERACTIVE_FEEDBACK, decisions };261 }262263 const answer = await this.asker.ask({264 toolUseId: call.toolUseId,265 toolName: call.toolName,266 requests: entries.map((e) => e.request),267 decisions: entries.map((e) => e.decision),268 });269270 switch (answer.kind) {271 case "allow-once": {272 // Covers exactly this callId — nothing is widened (§7 invariant 7).273 for (const entry of entries) {274 this.publish({275 type: "permission.granted",276 payload: { permissionRequestId: entry.permissionRequestId, scope: "once" },277 });278 }279 return { kind: "allow", via: "user-once", decisions };280 }281 case "allow-always": {282 const pattern = collapseWhitespace(answer.pattern);283 const owner = entries.find((e) =>284 e.request.alwaysPatterns.some((p) => collapseWhitespace(p) === pattern),285 );286 if (owner === undefined) {287 // Fail closed: a pattern the analyzer never offered must not become a standing grant (§3.3).288 throw new KhaelorError(289 "internal",290 `"always allow" pattern "${answer.pattern}" was not offered for this request — refusing to persist it.`,291 { toolUseId: call.toolUseId },292 );293 }294 const rule: PermissionRule = {295 capability: owner.request.capability,296 pattern,297 action: "allow",298 source: "session",299 };300 this.sessionRules.push(rule);301 let persistWarning: string | undefined;302 if (this.persister !== undefined) {303 const persisted = await this.persister.persist({ ...rule, source: "project" });304 if (!persisted.ok) {305 persistWarning =306 `The grant applies for this session, but the project config could not be updated: ` +307 persisted.error.message;308 }309 }310 for (const entry of entries) {311 this.publish({312 type: "permission.granted",313 payload: { permissionRequestId: entry.permissionRequestId, scope: "always-project" },314 });315 }316 return {317 kind: "allow",318 via: "user-always",319 decisions,320 persistedRule: rule,321 ...(persistWarning !== undefined ? { persistWarning } : {}),322 };323 }324 case "deny": {325 const feedback = userDenyFeedback(326 entries.map((e) => e.request.subject),327 answer.feedback,328 );329 for (const entry of entries) {330 this.publish({331 type: "permission.denied",332 payload: { permissionRequestId: entry.permissionRequestId, source: "user", feedback },333 });334 }335 return { kind: "deny", source: "user", feedback, decisions };336 }337 }338 }339}340