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}