Skip to content

Commit d0bfff8

Browse files
committed
refactor(sdk): upgrade PolicyEngine with evaluation logic and full CRUD
Reworks the PolicyEngine from a stub to a fully functional service: - New Policy schema: toolPattern, effect (allow/deny), approvalMode (auto/required), enabled, priority, updatedAt - PolicyDecision type with kinds: allow, deny, require_interaction, fallback - policy-eval.ts: pattern matching, precedence sorting, decision evaluation - Executor enforcement handles all decision kinds with elicitation support - Full CRUD on PolicyEngine (list/get/add/update/remove) and Executor.policies - In-memory, KV, and Postgres implementations updated - Postgres migration for new schema shape - ElicitationContext gains approval field for policy/annotation tracking
1 parent 857a854 commit d0bfff8

18 files changed

Lines changed: 568 additions & 91 deletions

File tree

packages/core/execution/src/engine.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,11 @@ export const formatPausedExecution = (
126126
interaction: {
127127
kind: req._tag === "UrlElicitation" ? "url" : "form",
128128
message: req.message,
129+
...(paused.elicitationContext.approval
130+
? {
131+
approval: paused.elicitationContext.approval,
132+
}
133+
: {}),
129134
...(req._tag === "UrlElicitation" ? { url: req.url } : {}),
130135
...(req._tag === "FormElicitation" ? { requestedSchema: req.requestedSchema } : {}),
131136
},

packages/core/sdk/src/elicitation.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Effect, Schema } from "effect";
22

3-
import { ToolId } from "./ids";
3+
import { PolicyId, ToolId } from "./ids";
44

55
// ---------------------------------------------------------------------------
66
// Elicitation request — what a tool sends when it needs user input
@@ -44,6 +44,10 @@ export interface ElicitationContext {
4444
readonly toolId: ToolId;
4545
readonly args: unknown;
4646
readonly request: ElicitationRequest;
47+
readonly approval?: {
48+
readonly source: "policy" | "annotation";
49+
readonly matchedPolicyId?: PolicyId;
50+
};
4751
}
4852

4953
/**

packages/core/sdk/src/errors.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,10 @@ export class PolicyDeniedError extends Schema.TaggedError<PolicyDeniedError>()(
3434
reason: Schema.String,
3535
},
3636
) {}
37+
38+
export class PolicyNotFoundError extends Schema.TaggedError<PolicyNotFoundError>()(
39+
"PolicyNotFoundError",
40+
{
41+
policyId: PolicyId,
42+
},
43+
) {}

packages/core/sdk/src/executor.ts

Lines changed: 84 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,22 @@ import type {
1111
InvokeOptions,
1212
} from "./tools";
1313
import type { Source, SourceDetectionResult, SourceRegistry } from "./sources";
14-
import type { Policy, PolicyEngine } from "./policies";
14+
import type {
15+
Policy,
16+
PolicyEngine,
17+
PolicyDecision,
18+
CreatePolicyPayload,
19+
UpdatePolicyPayload,
20+
} from "./policies";
1521
import type { Scope } from "./scope";
1622
import type { ExecutorPlugin, PluginExtensions, PluginHandle } from "./plugin";
23+
import { PolicyDeniedError } from "./errors";
1724
import type {
1825
ToolNotFoundError,
1926
ToolInvocationError,
2027
SecretNotFoundError,
2128
SecretResolutionError,
22-
PolicyDeniedError,
29+
PolicyNotFoundError,
2330
} from "./errors";
2431
import {
2532
FormElicitation,
@@ -64,7 +71,12 @@ export type Executor<TPlugins extends readonly ExecutorPlugin<string, object>[]
6471

6572
readonly policies: {
6673
readonly list: () => Effect.Effect<readonly Policy[]>;
67-
readonly add: (policy: Omit<Policy, "id" | "createdAt">) => Effect.Effect<Policy>;
74+
readonly get: (policyId: string) => Effect.Effect<Policy, PolicyNotFoundError>;
75+
readonly add: (policy: CreatePolicyPayload) => Effect.Effect<Policy>;
76+
readonly update: (
77+
policyId: string,
78+
patch: UpdatePolicyPayload,
79+
) => Effect.Effect<Policy, PolicyNotFoundError>;
6880
readonly remove: (policyId: string) => Effect.Effect<boolean>;
6981
};
7082

@@ -120,6 +132,29 @@ export const createExecutor = <
120132
Effect.gen(function* () {
121133
const { scope, tools, sources, secrets, policies, plugins = [] } = config;
122134

135+
const runApproval = (
136+
decision: PolicyDecision,
137+
toolId: ToolId,
138+
args: unknown,
139+
options: InvokeOptions,
140+
message: string,
141+
source: "policy" | "annotation",
142+
) => {
143+
const handler = resolveElicitationHandler(options);
144+
return handler({
145+
toolId,
146+
args,
147+
request: new FormElicitation({
148+
message,
149+
requestedSchema: {},
150+
}),
151+
approval: {
152+
source,
153+
...(decision.matchedPolicyId ? { matchedPolicyId: decision.matchedPolicyId } : {}),
154+
},
155+
});
156+
};
157+
123158
// Initialize all plugins
124159
const handles = new Map<string, PluginHandle<object>>();
125160
const extensions: Record<string, object> = {};
@@ -146,20 +181,53 @@ export const createExecutor = <
146181
invoke: (toolId: string, args: unknown, options: InvokeOptions) => {
147182
const tid = toolId as ToolId;
148183
return Effect.gen(function* () {
149-
yield* policies.check({ scopeId: scope.id, toolId: tid });
184+
const decision = yield* policies.check({ scopeId: scope.id, toolId: tid });
185+
186+
if (decision.kind === "deny") {
187+
return yield* new PolicyDeniedError({
188+
policyId: decision.matchedPolicyId as PolicyId,
189+
toolId: tid,
190+
reason: decision.reason,
191+
});
192+
}
193+
194+
if (decision.kind === "require_interaction") {
195+
const response = yield* runApproval(
196+
decision,
197+
tid,
198+
args,
199+
options,
200+
decision.reason,
201+
"policy",
202+
);
203+
if (response.action !== "accept") {
204+
return yield* new ElicitationDeclinedError({
205+
toolId: tid,
206+
action: response.action,
207+
});
208+
}
209+
return yield* tools.invoke(tid, args, options);
210+
}
211+
212+
if (decision.kind === "allow") {
213+
return yield* tools.invoke(tid, args, options);
214+
}
150215

151216
// Dynamically resolve annotations from the plugin
152217
const annotations = yield* tools.resolveAnnotations(tid);
153218
if (annotations?.requiresApproval) {
154-
const handler = resolveElicitationHandler(options);
155-
const response = yield* handler({
156-
toolId: tid,
219+
const response = yield* runApproval(
220+
{
221+
kind: "fallback",
222+
matchedPolicyId: null,
223+
reason: annotations.approvalDescription ?? `Approve ${toolId}?`,
224+
} as PolicyDecision,
225+
tid,
157226
args,
158-
request: new FormElicitation({
159-
message: annotations.approvalDescription ?? `Approve ${toolId}?`,
160-
requestedSchema: {},
161-
}),
162-
});
227+
options,
228+
annotations.approvalDescription ?? `Approve ${toolId}?`,
229+
"annotation",
230+
);
163231
if (response.action !== "accept") {
164232
return yield* new ElicitationDeclinedError({
165233
toolId: tid,
@@ -182,8 +250,11 @@ export const createExecutor = <
182250

183251
policies: {
184252
list: () => policies.list(scope.id),
185-
add: (policy: Omit<Policy, "id" | "createdAt">) =>
253+
get: (policyId: string) => policies.get(policyId as PolicyId),
254+
add: (policy: CreatePolicyPayload) =>
186255
policies.add({ ...policy, scopeId: scope.id }),
256+
update: (policyId: string, patch: UpdatePolicyPayload) =>
257+
policies.update(policyId as PolicyId, patch),
187258
remove: (policyId: string) => policies.remove(policyId as PolicyId),
188259
},
189260

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,47 @@
11
import { Effect } from "effect";
22

33
import { ScopeId, PolicyId } from "../ids";
4-
import type { Policy, PolicyCheckInput } from "../policies";
4+
import { PolicyNotFoundError } from "../errors";
5+
import { evaluatePolicyDecision, sortPoliciesByPrecedence } from "../policy-eval";
6+
import { Policy } from "../policies";
7+
import type { CreatePolicyInput, PolicyCheckInput, UpdatePolicyPayload } from "../policies";
58

69
export const makeInMemoryPolicyEngine = () => {
710
const policies = new Map<string, Policy>();
811
let counter = 0;
912

1013
return {
1114
list: (scopeId: ScopeId) =>
12-
Effect.succeed([...policies.values()].filter((p) => p.scopeId === scopeId)),
13-
check: (_input: PolicyCheckInput) => Effect.void,
14-
add: (policy: Omit<Policy, "id" | "createdAt">) =>
15+
Effect.succeed(sortPoliciesByPrecedence([...policies.values()].filter((p) => p.scopeId === scopeId))),
16+
get: (policyId: PolicyId) =>
17+
Effect.fromNullable(policies.get(policyId)).pipe(
18+
Effect.mapError(() => new PolicyNotFoundError({ policyId })),
19+
),
20+
check: (input: PolicyCheckInput) =>
21+
Effect.sync(() => evaluatePolicyDecision([...policies.values()], input)),
22+
add: (policy: CreatePolicyInput) =>
1523
Effect.sync(() => {
16-
const id = PolicyId.make(`policy-${++counter}`);
17-
const full: Policy = { ...policy, id, createdAt: new Date() };
24+
const now = new Date();
25+
const id = PolicyId.make(`policy-${Date.now()}-${++counter}`);
26+
const full = new Policy({ ...policy, id, createdAt: now, updatedAt: now });
1827
policies.set(id, full);
1928
return full;
2029
}),
30+
update: (policyId: PolicyId, patch: UpdatePolicyPayload) =>
31+
Effect.gen(function* () {
32+
const existing = yield* Effect.fromNullable(policies.get(policyId)).pipe(
33+
Effect.mapError(() => new PolicyNotFoundError({ policyId })),
34+
);
35+
const next = new Policy({
36+
...existing,
37+
...Object.fromEntries(
38+
Object.entries(patch).filter(([, value]) => value !== undefined),
39+
),
40+
updatedAt: new Date(),
41+
});
42+
policies.set(policyId, next);
43+
return next;
44+
}),
2145
remove: (policyId: PolicyId) => Effect.succeed(policies.delete(policyId)),
2246
};
2347
};

packages/core/sdk/src/index.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export {
88
SecretNotFoundError,
99
SecretResolutionError,
1010
PolicyDeniedError,
11+
PolicyNotFoundError,
1112
} from "./errors";
1213

1314
// Tools
@@ -49,7 +50,25 @@ export {
4950
export { SecretRef, SetSecretInput, SecretStore, type SecretProvider } from "./secrets";
5051

5152
// Policies
52-
export { Policy, PolicyAction, PolicyCheckInput, PolicyEngine } from "./policies";
53+
export {
54+
Policy,
55+
PolicyEffect,
56+
PolicyApprovalMode,
57+
PolicyCheckInput,
58+
CreatePolicyPayload,
59+
UpdatePolicyPayload,
60+
PolicyDecision,
61+
PolicyEngine,
62+
type CreatePolicyInput,
63+
} from "./policies";
64+
export {
65+
matchesPolicyPattern,
66+
policyLiteralCharCount,
67+
policySpecificity,
68+
comparePoliciesByPrecedence,
69+
sortPoliciesByPrecedence,
70+
evaluatePolicyDecision,
71+
} from "./policy-eval";
5372

5473
// Scope
5574
export { Scope } from "./scope";

packages/core/sdk/src/policies.ts

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,70 @@
11
import { Context, Effect, Schema } from "effect";
22

3+
import { PolicyNotFoundError } from "./errors";
34
import { PolicyId, ScopeId, ToolId } from "./ids";
4-
import { PolicyDeniedError } from "./errors";
55

6-
export const PolicyAction = Schema.Literal("allow", "deny", "require_approval");
7-
export type PolicyAction = typeof PolicyAction.Type;
6+
export const PolicyEffect = Schema.Literal("allow", "deny");
7+
export type PolicyEffect = typeof PolicyEffect.Type;
8+
9+
export const PolicyApprovalMode = Schema.Literal("auto", "required");
10+
export type PolicyApprovalMode = typeof PolicyApprovalMode.Type;
811

912
export class Policy extends Schema.Class<Policy>("Policy")({
1013
id: PolicyId,
1114
scopeId: ScopeId,
12-
name: Schema.String,
13-
action: PolicyAction,
14-
match: Schema.Struct({
15-
toolPattern: Schema.optional(Schema.String),
16-
sourceId: Schema.optional(Schema.String),
17-
}),
15+
toolPattern: Schema.String,
16+
effect: PolicyEffect,
17+
approvalMode: PolicyApprovalMode,
1818
priority: Schema.Number,
19+
enabled: Schema.Boolean,
1920
createdAt: Schema.DateFromNumber,
21+
updatedAt: Schema.DateFromNumber,
2022
}) {}
2123

2224
export class PolicyCheckInput extends Schema.Class<PolicyCheckInput>("PolicyCheckInput")({
2325
scopeId: ScopeId,
2426
toolId: ToolId,
2527
}) {}
2628

29+
export const CreatePolicyPayload = Schema.Struct({
30+
toolPattern: Schema.String,
31+
effect: PolicyEffect,
32+
approvalMode: PolicyApprovalMode,
33+
priority: Schema.Number,
34+
enabled: Schema.Boolean,
35+
});
36+
export type CreatePolicyPayload = typeof CreatePolicyPayload.Type;
37+
38+
export type CreatePolicyInput = CreatePolicyPayload & {
39+
readonly scopeId: ScopeId;
40+
};
41+
42+
export const UpdatePolicyPayload = Schema.Struct({
43+
toolPattern: Schema.optional(Schema.String),
44+
effect: Schema.optional(PolicyEffect),
45+
approvalMode: Schema.optional(PolicyApprovalMode),
46+
priority: Schema.optional(Schema.Number),
47+
enabled: Schema.optional(Schema.Boolean),
48+
});
49+
export type UpdatePolicyPayload = typeof UpdatePolicyPayload.Type;
50+
51+
export class PolicyDecision extends Schema.Class<PolicyDecision>("PolicyDecision")({
52+
kind: Schema.Literal("allow", "deny", "require_interaction", "fallback"),
53+
matchedPolicyId: Schema.NullOr(PolicyId),
54+
reason: Schema.String,
55+
}) {}
56+
2757
export class PolicyEngine extends Context.Tag("@executor/sdk/PolicyEngine")<
2858
PolicyEngine,
2959
{
3060
readonly list: (scopeId: ScopeId) => Effect.Effect<readonly Policy[]>;
31-
readonly check: (input: PolicyCheckInput) => Effect.Effect<void, PolicyDeniedError>;
32-
readonly add: (policy: Omit<Policy, "id" | "createdAt">) => Effect.Effect<Policy>;
61+
readonly get: (policyId: PolicyId) => Effect.Effect<Policy, PolicyNotFoundError>;
62+
readonly check: (input: PolicyCheckInput) => Effect.Effect<PolicyDecision>;
63+
readonly add: (policy: CreatePolicyInput) => Effect.Effect<Policy>;
64+
readonly update: (
65+
policyId: PolicyId,
66+
patch: UpdatePolicyPayload,
67+
) => Effect.Effect<Policy, PolicyNotFoundError>;
3368
readonly remove: (policyId: PolicyId) => Effect.Effect<boolean>;
3469
}
3570
>() {}

0 commit comments

Comments
 (0)