Parent directory

pipeline.ts

5037 bytes
  1import type {
  2  AuditEntry,
  3  Decision,
  4  DecisionAudit,
  5  DecisionCache,
  6  PolicyContext,
  7  PolicyEvaluation,
  8  PolicyPrecheck,
  9  PolicyRequest,
 10  PolicyReviewer,
 11} from "./types";
 12
 13export type PolicyPipelineOptions = {
 14  prechecks?: PolicyPrecheck[];
 15  deterministic(request: PolicyRequest, context: PolicyContext): Decision | undefined | Promise<Decision | undefined>;
 16  cache: DecisionCache;
 17  audit: DecisionAudit;
 18  reviewer: PolicyReviewer;
 19  normalize(request: PolicyRequest, context: PolicyContext): string;
 20  cacheKey(normalized: string): string;
 21  inputSummary(request: PolicyRequest): string;
 22};
 23
 24type EvaluationOptions = {
 25  skipPrechecks?: boolean;
 26  skipDeterministic?: boolean;
 27  skipLLMReview?: boolean;
 28};
 29
 30const EMPTY_DECISION: Decision = {
 31  decision: "allow",
 32  reason: "No operations to evaluate",
 33  category: "empty",
 34};
 35
 36export function createPolicyPipeline(options: PolicyPipelineOptions) {
 37  async function record(
 38    request: PolicyRequest,
 39    context: PolicyContext,
 40    result: PolicyEvaluation,
 41    startedAt: number,
 42  ): Promise<void> {
 43    const entry: AuditEntry = {
 44      toolName: request.toolName,
 45      inputSummary: options.inputSummary(request),
 46      decision: result.decision,
 47      source: result.source,
 48      timingMs: performance.now() - startedAt,
 49      sessionId: context.sessionId,
 50      rawResponse: result.error ? result.rawResponse : undefined,
 51    };
 52    try {
 53      await options.audit.record(entry);
 54    } catch {}
 55  }
 56
 57  async function complete(
 58    request: PolicyRequest,
 59    context: PolicyContext,
 60    result: PolicyEvaluation,
 61    startedAt: number,
 62  ): Promise<PolicyEvaluation> {
 63    await record(request, context, result, startedAt);
 64    return result;
 65  }
 66
 67  async function precheck(request: PolicyRequest, context: PolicyContext): Promise<PolicyEvaluation | undefined> {
 68    const startedAt = performance.now();
 69    for (const check of options.prechecks ?? []) {
 70      const decision = await check.decide(request, context);
 71      if (decision) return complete(request, context, { decision, source: check.source }, startedAt);
 72    }
 73    return undefined;
 74  }
 75
 76  async function evaluate(
 77    request: PolicyRequest,
 78    context: PolicyContext,
 79    evaluationOptions: EvaluationOptions = {},
 80  ): Promise<PolicyEvaluation> {
 81    if (!evaluationOptions.skipPrechecks) {
 82      const checked = await precheck(request, context);
 83      if (checked) return checked;
 84    }
 85
 86    const startedAt = performance.now();
 87    if (!evaluationOptions.skipDeterministic) {
 88      const deterministic = await options.deterministic(request, context);
 89      if (deterministic) {
 90        return complete(request, context, { decision: deterministic, source: "deterministic" }, startedAt);
 91      }
 92    }
 93
 94    const cacheAllows = request.toolName !== "bash";
 95    const normalized = options.normalize(request, context);
 96    const key = options.cacheKey(normalized);
 97    try {
 98      const cached = await options.cache.lookup(key);
 99      if (cached && (cached.decision !== "allow" || cacheAllows))
100        return complete(request, context, { decision: cached, source: "cache" }, startedAt);
101    } catch {}
102
103    if (evaluationOptions.skipLLMReview) {
104      return complete(
105        request,
106        context,
107        {
108          decision: {
109            decision: "allow",
110            reason: "LLM permission checks are disabled for this session",
111            category: "session_allow",
112          },
113          source: "session",
114        },
115        startedAt,
116      );
117    }
118
119    const reviewed = await options.reviewer.evaluate(request, context);
120    const source = reviewed.source ?? "llm";
121    const result: PolicyEvaluation = {
122      decision: reviewed.decision,
123      source,
124      rawResponse: reviewed.rawResponse,
125      error: reviewed.error,
126    };
127    if (!reviewed.error && source === "llm" && (reviewed.decision.decision !== "allow" || cacheAllows)) {
128      try {
129        await options.cache.write({
130          key,
131          toolName: request.toolName,
132          decision: reviewed.decision,
133          source: "llm",
134          createdAt: new Date().toISOString(),
135        });
136      } catch {}
137    }
138    return complete(request, context, result, startedAt);
139  }
140
141  async function evaluateMany(
142    requests: PolicyRequest[],
143    context: PolicyContext,
144    evaluationOptions: EvaluationOptions = {},
145  ): Promise<PolicyEvaluation> {
146    if (requests.length === 0) return { decision: EMPTY_DECISION, source: "deterministic" };
147
148    let worst: PolicyEvaluation | undefined;
149    const approvalRequests: PolicyRequest[] = [];
150    for (const request of requests) {
151      const result = await evaluate(request, context, evaluationOptions);
152      if (result.decision.decision === "deny") return result;
153      if (result.decision.decision === "ask") {
154        worst = result;
155        approvalRequests.push(request);
156      } else if (!worst) worst = result;
157    }
158    return approvalRequests.length > 0 ? { ...worst!, approvalRequests } : worst!;
159  }
160
161  return { precheck, evaluate, evaluateMany };
162}