/** * KHAELOR * File: src/permissions/service.ts * Description: PermissionService — combined evaluation per tool call, injected asker, persisted grants, durable permission events (PERMISSION_MODEL.md §4.4–§6). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { KhaelorError, ulid } from "../shared/index.js"; import type { Result } from "../shared/index.js"; import type { CapabilityRequest } from "./capabilities.js"; import { DEFAULT_RULES, HARDLINE_RULE, evaluate } from "./rules.js"; import type { Decision, PermissionRule } from "./rules.js"; import { collapseWhitespace } from "./paths.js"; // ─────────────────────── durable event inputs (§5.1, §8) ─────────────────────── // Structurally identical to the session catalog's DurableEventInput members // (src/session/events.ts) — defined locally because permissions is Layer 2 and // imports only shared + config (ARCHITECTURE.md §2.1). The composition root // forwards these to `EventBus.publishDurable` unchanged. export interface PermissionRequestedInput { type: "permission.requested"; payload: { permissionRequestId: string; toolUseId: string; capability: string; descriptor: string; suggestion?: { capability: string; pattern: string }; }; } export interface PermissionGrantedInput { type: "permission.granted"; payload: { permissionRequestId: string; scope: "once" | "always-project"; }; } export interface PermissionDeniedInput { type: "permission.denied"; payload: { permissionRequestId: string; source: "user" | "policy" | "hardline" | "timeout"; feedback: string; }; } export type PermissionEventInput = | PermissionRequestedInput | PermissionGrantedInput | PermissionDeniedInput; // ─────────────────────────── asker + persister seams ─────────────────────────── /** What the TUI panel receives — the asking requests of ONE tool call (one panel per call, §4.4). */ export interface PermissionPrompt { toolUseId: string; toolName: string; /** The requests that evaluated to `ask`, with their decisions (provenance for [Tab] details). */ requests: CapabilityRequest[]; decisions: Decision[]; } export type PermissionAnswer = | { kind: "allow-once" } /** `pattern` must be one of the offered `alwaysPatterns` (§3.3 refusal rule). */ | { kind: "allow-always"; pattern: string } | { kind: "deny"; feedback?: string }; /** Injected panel callback. Absent asker = non-interactive: every ask resolves deny (§5.2). */ export interface PermissionAsker { ask(prompt: PermissionPrompt): Promise; } /** * Injected config-writer: persisting "always allow in this project" is the * config module's job (ARCHITECTURE.md `persistPermissionGrant`); the service * never writes files itself. A failed persist is reported, the grant still * applies in-memory for the session (§6.2). */ export interface GrantPersister { persist(rule: PermissionRule): Promise>; } // ─────────────────────────────── outcomes ─────────────────────────────── export interface RequestDecision { request: CapabilityRequest; decision: Decision; } export type PermissionOutcome = | { kind: "allow"; via: "policy-allow" | "user-once" | "user-always"; decisions: RequestDecision[]; /** The rule persisted by an "always" grant (also active in-memory). */ persistedRule?: PermissionRule; /** Set when the project config could not be updated (§6.2 — fail loudly). */ persistWarning?: string; } | { kind: "deny"; source: "user" | "policy" | "hardline"; /** Model-facing ToolFailed content (§5.4 — denial is steering, not a dead end). */ feedback: string; decisions: RequestDecision[]; }; /** One tool call's worth of capability requests. */ export interface ToolPermissionCheck { toolUseId: string; toolName: string; requests: CapabilityRequest[]; } // ─────────────────────────── feedback templates (§5.4) ─────────────────────────── export const NON_INTERACTIVE_FEEDBACK = "KHAELOR is running non-interactively; interactive approval is unavailable."; function policyDenyFeedback(request: CapabilityRequest, rule: PermissionRule): string { const source = rule.source ?? "default"; return ( `Permission denied by policy: ${request.capability} for "${request.subject}" is denied ` + `(rule: ${rule.capability} / "${rule.pattern ?? "*"}", source: ${source}). ` + `Do not retry this command or attempt an equivalent workaround. ` + `Choose a different approach, or ask the user to adjust permissions.` ); } function hardlineDenyFeedback(request: CapabilityRequest): string { return ( `Permission denied: "${request.subject}" is blocked by KHAELOR's built-in safety floor — ` + `this cannot be allowed by configuration. Do not retry this command or attempt an equivalent workaround.` ); } function userDenyFeedback(subjects: string[], feedback?: string): string { const what = subjects.join(", "); if (feedback !== undefined && feedback.trim().length > 0) { return `The user declined to allow: ${what} — reason: "${feedback.trim()}". Adapt your approach accordingly.`; } return `The user declined to allow: ${what}. Continue without it, or propose an alternative.`; } // ─────────────────────────────── the service ─────────────────────────────── export interface PermissionServiceOptions { /** Layered rules: defaults ++ user ++ project ++ session grants (§4.2). */ rules?: { defaults?: readonly PermissionRule[]; user?: readonly PermissionRule[]; project?: readonly PermissionRule[]; }; asker?: PermissionAsker; persister?: GrantPersister; /** Durable event sink — the composition root forwards to the session bus. */ publish: (event: PermissionEventInput) => void; /** Id factory (tests); defaults to ULID. */ newId?: () => string; } export class PermissionService { private readonly defaults: readonly PermissionRule[]; private readonly userRules: readonly PermissionRule[]; private readonly projectRules: readonly PermissionRule[]; /** In-memory session grants — "always" grants land here AND in project config (§4.2, §6.2). */ private readonly sessionRules: PermissionRule[] = []; private readonly asker: PermissionAsker | undefined; private readonly persister: GrantPersister | undefined; private readonly publish: (event: PermissionEventInput) => void; private readonly newId: () => string; constructor(options: PermissionServiceOptions) { this.defaults = options.rules?.defaults ?? DEFAULT_RULES; this.userRules = options.rules?.user ?? []; this.projectRules = options.rules?.project ?? []; this.asker = options.asker; this.persister = options.persister; this.publish = options.publish; this.newId = options.newId ?? ulid; } /** The effective ruleset — plain concatenation, later layers win by position (§4.2). */ effectiveRules(): PermissionRule[] { return [...this.defaults, ...this.userRules, ...this.projectRules, ...this.sessionRules]; } /** * Decide one tool call: evaluate every capability request, combine with * deny > ask > allow (§4.4), surface at most ONE panel via the injected * asker, and emit the durable permission events (§5.1). */ async check(call: ToolPermissionCheck): Promise { const rules = this.effectiveRules(); const decisions: RequestDecision[] = call.requests.map((request) => ({ request, decision: evaluate(rules, request), })); // any deny → deny, the denied request named in the feedback (§4.4); no panel (§5.1). const denied = decisions.find((d) => d.decision.action === "deny"); if (denied !== undefined) { const rule = denied.decision.rule ?? HARDLINE_RULE; const isHardline = rule.source === "hardline"; const feedback = isHardline ? hardlineDenyFeedback(denied.request) : policyDenyFeedback(denied.request, rule); this.publish({ type: "permission.denied", payload: { permissionRequestId: this.newId(), source: isHardline ? "hardline" : "policy", feedback, }, }); return { kind: "deny", source: isHardline ? "hardline" : "policy", feedback, decisions }; } const asking = decisions.filter((d) => d.decision.action === "ask"); if (asking.length === 0) { return { kind: "allow", via: "policy-allow", decisions }; } // ask → durable PermissionRequested per asking request, then ONE panel (§4.4, §5.1, §8). const entries = asking.map((d) => { const permissionRequestId = this.newId(); const suggestion = d.request.alwaysPatterns[0]; this.publish({ type: "permission.requested", payload: { permissionRequestId, toolUseId: call.toolUseId, capability: d.request.capability, descriptor: d.request.display, ...(suggestion !== undefined ? { suggestion: { capability: d.request.capability, pattern: suggestion } } : {}), }, }); return { permissionRequestId, ...d }; }); if (this.asker === undefined) { // Non-interactive: every ask resolves deny (§5.2). Silence is not consent. for (const entry of entries) { this.publish({ type: "permission.denied", payload: { permissionRequestId: entry.permissionRequestId, source: "policy", feedback: NON_INTERACTIVE_FEEDBACK, }, }); } return { kind: "deny", source: "policy", feedback: NON_INTERACTIVE_FEEDBACK, decisions }; } const answer = await this.asker.ask({ toolUseId: call.toolUseId, toolName: call.toolName, requests: entries.map((e) => e.request), decisions: entries.map((e) => e.decision), }); switch (answer.kind) { case "allow-once": { // Covers exactly this callId — nothing is widened (§7 invariant 7). for (const entry of entries) { this.publish({ type: "permission.granted", payload: { permissionRequestId: entry.permissionRequestId, scope: "once" }, }); } return { kind: "allow", via: "user-once", decisions }; } case "allow-always": { const pattern = collapseWhitespace(answer.pattern); const owner = entries.find((e) => e.request.alwaysPatterns.some((p) => collapseWhitespace(p) === pattern), ); if (owner === undefined) { // Fail closed: a pattern the analyzer never offered must not become a standing grant (§3.3). throw new KhaelorError( "internal", `"always allow" pattern "${answer.pattern}" was not offered for this request — refusing to persist it.`, { toolUseId: call.toolUseId }, ); } const rule: PermissionRule = { capability: owner.request.capability, pattern, action: "allow", source: "session", }; this.sessionRules.push(rule); let persistWarning: string | undefined; if (this.persister !== undefined) { const persisted = await this.persister.persist({ ...rule, source: "project" }); if (!persisted.ok) { persistWarning = `The grant applies for this session, but the project config could not be updated: ` + persisted.error.message; } } for (const entry of entries) { this.publish({ type: "permission.granted", payload: { permissionRequestId: entry.permissionRequestId, scope: "always-project" }, }); } return { kind: "allow", via: "user-always", decisions, persistedRule: rule, ...(persistWarning !== undefined ? { persistWarning } : {}), }; } case "deny": { const feedback = userDenyFeedback( entries.map((e) => e.request.subject), answer.feedback, ); for (const entry of entries) { this.publish({ type: "permission.denied", payload: { permissionRequestId: entry.permissionRequestId, source: "user", feedback }, }); } return { kind: "deny", source: "user", feedback, decisions }; } } } }