Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/core/execution/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ export const formatPausedExecution = (
interaction: {
kind: req._tag === "UrlElicitation" ? "url" : "form",
message: req.message,
...(paused.elicitationContext.approval
? {
approval: paused.elicitationContext.approval,
}
: {}),
...(req._tag === "UrlElicitation" ? { url: req.url } : {}),
...(req._tag === "FormElicitation" ? { requestedSchema: req.requestedSchema } : {}),
},
Expand Down
6 changes: 5 additions & 1 deletion packages/core/sdk/src/elicitation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Effect, Schema } from "effect";

import { ToolId } from "./ids";
import { PolicyId, ToolId } from "./ids";

// ---------------------------------------------------------------------------
// Elicitation request — what a tool sends when it needs user input
Expand Down Expand Up @@ -44,6 +44,10 @@ export interface ElicitationContext {
readonly toolId: ToolId;
readonly args: unknown;
readonly request: ElicitationRequest;
readonly approval?: {
readonly source: "policy" | "annotation";
readonly matchedPolicyId?: PolicyId;
};
}

/**
Expand Down
7 changes: 7 additions & 0 deletions packages/core/sdk/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,10 @@ export class PolicyDeniedError extends Schema.TaggedError<PolicyDeniedError>()(
reason: Schema.String,
},
) {}

export class PolicyNotFoundError extends Schema.TaggedError<PolicyNotFoundError>()(
"PolicyNotFoundError",
{
policyId: PolicyId,
},
) {}
97 changes: 84 additions & 13 deletions packages/core/sdk/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,22 @@ import type {
InvokeOptions,
} from "./tools";
import type { Source, SourceDetectionResult, SourceRegistry } from "./sources";
import type { Policy, PolicyEngine } from "./policies";
import type {
Policy,
PolicyEngine,
PolicyDecision,
CreatePolicyPayload,
UpdatePolicyPayload,
} from "./policies";
import type { Scope } from "./scope";
import type { ExecutorPlugin, PluginExtensions, PluginHandle } from "./plugin";
import { PolicyDeniedError } from "./errors";
import type {
ToolNotFoundError,
ToolInvocationError,
SecretNotFoundError,
SecretResolutionError,
PolicyDeniedError,
PolicyNotFoundError,
} from "./errors";
import {
FormElicitation,
Expand Down Expand Up @@ -64,7 +71,12 @@ export type Executor<TPlugins extends readonly ExecutorPlugin<string, object>[]

readonly policies: {
readonly list: () => Effect.Effect<readonly Policy[]>;
readonly add: (policy: Omit<Policy, "id" | "createdAt">) => Effect.Effect<Policy>;
readonly get: (policyId: string) => Effect.Effect<Policy, PolicyNotFoundError>;
readonly add: (policy: CreatePolicyPayload) => Effect.Effect<Policy>;
readonly update: (
policyId: string,
patch: UpdatePolicyPayload,
) => Effect.Effect<Policy, PolicyNotFoundError>;
readonly remove: (policyId: string) => Effect.Effect<boolean>;
};

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

const runApproval = (
decision: PolicyDecision,
toolId: ToolId,
args: unknown,
options: InvokeOptions,
message: string,
source: "policy" | "annotation",
) => {
const handler = resolveElicitationHandler(options);
return handler({
toolId,
args,
request: new FormElicitation({
message,
requestedSchema: {},
}),
approval: {
source,
...(decision.matchedPolicyId ? { matchedPolicyId: decision.matchedPolicyId } : {}),
},
});
};

// Initialize all plugins
const handles = new Map<string, PluginHandle<object>>();
const extensions: Record<string, object> = {};
Expand All @@ -146,20 +181,53 @@ export const createExecutor = <
invoke: (toolId: string, args: unknown, options: InvokeOptions) => {
const tid = toolId as ToolId;
return Effect.gen(function* () {
yield* policies.check({ scopeId: scope.id, toolId: tid });
const decision = yield* policies.check({ scopeId: scope.id, toolId: tid });

if (decision.kind === "deny") {
return yield* new PolicyDeniedError({
policyId: decision.matchedPolicyId as PolicyId,
toolId: tid,
reason: decision.reason,
});
}

if (decision.kind === "require_interaction") {
const response = yield* runApproval(
decision,
tid,
args,
options,
decision.reason,
"policy",
);
if (response.action !== "accept") {
return yield* new ElicitationDeclinedError({
toolId: tid,
action: response.action,
});
}
return yield* tools.invoke(tid, args, options);
}

if (decision.kind === "allow") {
return yield* tools.invoke(tid, args, options);
}

// Dynamically resolve annotations from the plugin
const annotations = yield* tools.resolveAnnotations(tid);
if (annotations?.requiresApproval) {
const handler = resolveElicitationHandler(options);
const response = yield* handler({
toolId: tid,
const response = yield* runApproval(
{
kind: "fallback",
matchedPolicyId: null,
reason: annotations.approvalDescription ?? `Approve ${toolId}?`,
} as PolicyDecision,
tid,
args,
request: new FormElicitation({
message: annotations.approvalDescription ?? `Approve ${toolId}?`,
requestedSchema: {},
}),
});
options,
annotations.approvalDescription ?? `Approve ${toolId}?`,
"annotation",
);
if (response.action !== "accept") {
return yield* new ElicitationDeclinedError({
toolId: tid,
Expand All @@ -182,8 +250,11 @@ export const createExecutor = <

policies: {
list: () => policies.list(scope.id),
add: (policy: Omit<Policy, "id" | "createdAt">) =>
get: (policyId: string) => policies.get(policyId as PolicyId),
add: (policy: CreatePolicyPayload) =>
policies.add({ ...policy, scopeId: scope.id }),
update: (policyId: string, patch: UpdatePolicyPayload) =>
policies.update(policyId as PolicyId, patch),
remove: (policyId: string) => policies.remove(policyId as PolicyId),
},

Expand Down
36 changes: 30 additions & 6 deletions packages/core/sdk/src/in-memory/policy-engine.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,47 @@
import { Effect } from "effect";

import { ScopeId, PolicyId } from "../ids";
import type { Policy, PolicyCheckInput } from "../policies";
import { PolicyNotFoundError } from "../errors";
import { evaluatePolicyDecision, sortPoliciesByPrecedence } from "../policy-eval";
import { Policy } from "../policies";
import type { CreatePolicyInput, PolicyCheckInput, UpdatePolicyPayload } from "../policies";

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

return {
list: (scopeId: ScopeId) =>
Effect.succeed([...policies.values()].filter((p) => p.scopeId === scopeId)),
check: (_input: PolicyCheckInput) => Effect.void,
add: (policy: Omit<Policy, "id" | "createdAt">) =>
Effect.succeed(sortPoliciesByPrecedence([...policies.values()].filter((p) => p.scopeId === scopeId))),
get: (policyId: PolicyId) =>
Effect.fromNullable(policies.get(policyId)).pipe(
Effect.mapError(() => new PolicyNotFoundError({ policyId })),
),
check: (input: PolicyCheckInput) =>
Effect.sync(() => evaluatePolicyDecision([...policies.values()], input)),
add: (policy: CreatePolicyInput) =>
Effect.sync(() => {
const id = PolicyId.make(`policy-${++counter}`);
const full: Policy = { ...policy, id, createdAt: new Date() };
const now = new Date();
const id = PolicyId.make(`policy-${Date.now()}-${++counter}`);
const full = new Policy({ ...policy, id, createdAt: now, updatedAt: now });
policies.set(id, full);
return full;
}),
update: (policyId: PolicyId, patch: UpdatePolicyPayload) =>
Effect.gen(function* () {
const existing = yield* Effect.fromNullable(policies.get(policyId)).pipe(
Effect.mapError(() => new PolicyNotFoundError({ policyId })),
);
const next = new Policy({
...existing,
...Object.fromEntries(
Object.entries(patch).filter(([, value]) => value !== undefined),
),
updatedAt: new Date(),
});
policies.set(policyId, next);
return next;
}),
remove: (policyId: PolicyId) => Effect.succeed(policies.delete(policyId)),
};
};
21 changes: 20 additions & 1 deletion packages/core/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export {
SecretNotFoundError,
SecretResolutionError,
PolicyDeniedError,
PolicyNotFoundError,
} from "./errors";

// Tools
Expand Down Expand Up @@ -49,7 +50,25 @@ export {
export { SecretRef, SetSecretInput, SecretStore, type SecretProvider } from "./secrets";

// Policies
export { Policy, PolicyAction, PolicyCheckInput, PolicyEngine } from "./policies";
export {
Policy,
PolicyEffect,
PolicyApprovalMode,
PolicyCheckInput,
CreatePolicyPayload,
UpdatePolicyPayload,
PolicyDecision,
PolicyEngine,
type CreatePolicyInput,
} from "./policies";
export {
matchesPolicyPattern,
policyLiteralCharCount,
policySpecificity,
comparePoliciesByPrecedence,
sortPoliciesByPrecedence,
evaluatePolicyDecision,
} from "./policy-eval";

// Scope
export { Scope } from "./scope";
Expand Down
57 changes: 46 additions & 11 deletions packages/core/sdk/src/policies.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,70 @@
import { Context, Effect, Schema } from "effect";

import { PolicyNotFoundError } from "./errors";
import { PolicyId, ScopeId, ToolId } from "./ids";
import { PolicyDeniedError } from "./errors";

export const PolicyAction = Schema.Literal("allow", "deny", "require_approval");
export type PolicyAction = typeof PolicyAction.Type;
export const PolicyEffect = Schema.Literal("allow", "deny");
export type PolicyEffect = typeof PolicyEffect.Type;

export const PolicyApprovalMode = Schema.Literal("auto", "required");
export type PolicyApprovalMode = typeof PolicyApprovalMode.Type;

export class Policy extends Schema.Class<Policy>("Policy")({
id: PolicyId,
scopeId: ScopeId,
name: Schema.String,
action: PolicyAction,
match: Schema.Struct({
toolPattern: Schema.optional(Schema.String),
sourceId: Schema.optional(Schema.String),
}),
toolPattern: Schema.String,
effect: PolicyEffect,
approvalMode: PolicyApprovalMode,
priority: Schema.Number,
enabled: Schema.Boolean,
createdAt: Schema.DateFromNumber,
updatedAt: Schema.DateFromNumber,
}) {}

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

export const CreatePolicyPayload = Schema.Struct({
toolPattern: Schema.String,
effect: PolicyEffect,
approvalMode: PolicyApprovalMode,
priority: Schema.Number,
enabled: Schema.Boolean,
});
export type CreatePolicyPayload = typeof CreatePolicyPayload.Type;

export type CreatePolicyInput = CreatePolicyPayload & {
readonly scopeId: ScopeId;
};

export const UpdatePolicyPayload = Schema.Struct({
toolPattern: Schema.optional(Schema.String),
effect: Schema.optional(PolicyEffect),
approvalMode: Schema.optional(PolicyApprovalMode),
priority: Schema.optional(Schema.Number),
enabled: Schema.optional(Schema.Boolean),
});
export type UpdatePolicyPayload = typeof UpdatePolicyPayload.Type;

export class PolicyDecision extends Schema.Class<PolicyDecision>("PolicyDecision")({
kind: Schema.Literal("allow", "deny", "require_interaction", "fallback"),
matchedPolicyId: Schema.NullOr(PolicyId),
reason: Schema.String,
}) {}

export class PolicyEngine extends Context.Tag("@executor/sdk/PolicyEngine")<
PolicyEngine,
{
readonly list: (scopeId: ScopeId) => Effect.Effect<readonly Policy[]>;
readonly check: (input: PolicyCheckInput) => Effect.Effect<void, PolicyDeniedError>;
readonly add: (policy: Omit<Policy, "id" | "createdAt">) => Effect.Effect<Policy>;
readonly get: (policyId: PolicyId) => Effect.Effect<Policy, PolicyNotFoundError>;
readonly check: (input: PolicyCheckInput) => Effect.Effect<PolicyDecision>;
readonly add: (policy: CreatePolicyInput) => Effect.Effect<Policy>;
readonly update: (
policyId: PolicyId,
patch: UpdatePolicyPayload,
) => Effect.Effect<Policy, PolicyNotFoundError>;
readonly remove: (policyId: PolicyId) => Effect.Effect<boolean>;
}
>() {}
Loading
Loading