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%
12.8 KB · 340 lines typescript
Raw Blame History
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