Skip to content
Open
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
42 changes: 42 additions & 0 deletions .changeset/execution-validators.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
"@voltagent/core": minor
"@voltagent/server-core": patch
---

feat(core): add pre-execution validators for tools and workflows

Agents and workflows can now define `executionValidators` that run before tool execution or
workflow step execution. Validators can return `false` or `{ pass: false }` to block the run with
an `ExecutionValidationError`, including a custom message, code, HTTP status, and metadata. They
can run synchronously or asynchronously.

```ts
const agent = new Agent({
name: "Policy Controlled Assistant",
instructions: "You enforce tenant policy.",
model: "openai/gpt-4o",
tools: [updateRecordTool],
executionValidators: {
Copy link
Copy Markdown
Contributor

@zrosenbauer zrosenbauer May 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make more sense to merge this into the GuardRails primitive? They seem like a very similar in concept?

We could add an execution guardrail for tools and steps.

Something like:

import { createToolGuardrail } from "@voltagent/core";

const blockDestructiveCommands = createToolGuardrail({
  id: "block-destructive-commands",
  name: "Block Destructive Shell Commands",
  description: "Prevents the agent from running rm -rf and similar destructive shell commands.",

  handler: async ({ tool, args }) => {
    if (tool.name !== "execute_command") {
      return { pass: true };
    }

    const command = args?.command ?? "";
    const dangerous = /\brm\s+-rf\s+\//i;

    if (!dangerous.test(command)) {
      return { pass: true };
    }

    return {
      pass: false,
      action: "block",
      message: "Destructive shell commands are not allowed",
      code: "DANGEROUS_COMMAND",
      httpStatus: 403,
      metadata: {
        tool: tool.name,
        command,
      },
    };
  },
});

This could then be used to implement things like PreToolUse hooks like in claude etc. aka permissions.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@omeraplak i might take a stab at integrating into Guardrails this week vs this implemenation, just ping me on discord to chat or we can discuss here direct..

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep honestly I think your approach makes a lot of sense. Also pretty sure we can merge your PR 😄

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, I'll take a stab here this weekend or next week!

tools: [
async ({ toolName, operationContext }) => {
const tenant = operationContext?.requestHeaders?.["x-tenant-id"];
const allowed = await checkTenantToolAccess({ tenant, toolName });

if (toolName === "update_record" && !allowed) {
return {
pass: false,
message: "This tenant cannot update records.",
code: "TOOL_TENANT_DENIED",
httpStatus: 403,
};
}
},
],
},
});
```

Server-core direct tool and workflow handlers now preserve `ClientHTTPError` details so blocked
executions can return the validator's status and code instead of a generic 500.

Fixes #1213
61 changes: 61 additions & 0 deletions packages/core/src/agent/agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1714,6 +1714,67 @@ Use pandas and summarize findings.`.split("\n"),

operationContext.traceContext.end("completed");
});

it("runs execution validators before tool start hooks and execution", async () => {
const validator = vi.fn(() => ({
pass: false as const,
message: "Destination is outside the allowed tenant",
code: "TENANT_POLICY_BLOCKED",
httpStatus: 412 as const,
}));
const onToolStart = vi.fn();
const executeTool = vi.fn().mockResolvedValue("should-not-run");
const agent = new Agent({
name: "TestAgent",
instructions: "Test",
model: mockModel as any,
hooks: createHooks({ onToolStart }),
executionValidators: {
tools: [validator],
},
});

const tool = new Tool({
name: "send-message",
description: "Send a message",
parameters: z.object({ destination: z.string() }),
execute: executeTool,
});

const operationContext = (agent as any).createOperationContext("input");
const executeFactory = (agent as any).createToolExecutionFactory(
operationContext,
agent.hooks,
);

const execute = executeFactory(tool);
const result = await execute({ destination: "external" });

expect(validator).toHaveBeenCalledWith(
expect.objectContaining({
type: "tool",
agent,
tool,
toolName: "send-message",
args: { destination: "external" },
operationContext,
timestamp: expect.any(Date),
}),
);
expect(onToolStart).not.toHaveBeenCalled();
expect(executeTool).not.toHaveBeenCalled();
expect(operationContext.abortController.signal.aborted).toBe(true);
expect(result).toMatchObject({
error: true,
name: "ExecutionValidationError",
message: "Destination is outside the allowed tenant",
code: "TENANT_POLICY_BLOCKED",
httpStatus: 412,
toolName: "send-message",
});

operationContext.traceContext.end("completed");
});
});

describe("Agent as Tool (toTool)", () => {
Expand Down
104 changes: 103 additions & 1 deletion packages/core/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ import {
validateUIMessages,
} from "ai";
import { z } from "zod";
import {
type AgentExecutionValidators,
type ToolExecutionValidationContext,
runExecutionValidators,
} from "../execution-validation";
import { LogEvents, LoggerProxy } from "../logger";
import { ActionType, buildAgentLogMessage } from "../logger/message-builder";
import { Memory } from "../memory";
Expand Down Expand Up @@ -97,6 +102,7 @@ import {
createVoltAgentError,
isBailError,
isClientHTTPError,
isExecutionValidationError,
isMiddlewareAbortError,
isToolDeniedError,
isVoltAgentError,
Expand Down Expand Up @@ -279,6 +285,20 @@ const firstDefined = <T>(...values: Array<T | null | undefined>): T | undefined
return undefined;
};

const normalizeAgentExecutionValidators = (
validators?: AgentExecutionValidators,
): AgentExecutionValidators | undefined => {
const tools = validators?.tools?.filter((validator) => typeof validator === "function") ?? [];
return tools.length > 0 ? { tools: [...tools] } : undefined;
};

const mergeAgentExecutionValidators = (
...configs: Array<AgentExecutionValidators | undefined>
): AgentExecutionValidators | undefined => {
const tools = configs.flatMap((config) => config?.tools ?? []);
return tools.length > 0 ? { tools } : undefined;
};

type OpenRouterUsageCost = {
cost?: number;
isByok?: boolean;
Expand Down Expand Up @@ -902,6 +922,9 @@ export interface BaseGenerationOptions<TProviderOptions extends ProviderOptions
// Hooks (can override agent hooks)
hooks?: AgentHooks;

// Execution validators (can add per-call validators)
executionValidators?: AgentExecutionValidators;

Comment on lines +925 to +927
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Strip executionValidators before forwarding options to the AI SDK.

executionValidators is a VoltAgent-only option, but it remains in aiSDKOptions and is forwarded into generateText, streamText, generateObject, and streamObject. That can leak validator functions into provider/AI SDK call options and create hard-to-debug behavior.

Proposed fix
               parentOperationContext,
               hooks,
+              executionValidators: _executionValidators,
               feedback: _feedback,
               maxSteps: userMaxSteps,

Apply the same destructuring exclusion in the streamText, generateObject, and streamObject option destructures.

Also applies to: 1337-1356, 1956-1976, 2860-2879, 3234-3254

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/agent/agent.ts` around lines 925 - 927, The aiSDKOptions
object being passed into the provider calls mistakenly includes the
VoltAgent-specific executionValidators field, so update the option destructuring
in the places that call generateText, streamText, generateObject, and
streamObject (including the aiSDKOptions construction around functions/methods
where aiSDKOptions is built) to explicitly exclude executionValidators (e.g.,
const { executionValidators, ...aiSDKOptions } = options) before forwarding;
apply this same destructuring pattern at the other listed sites (around the
calls referenced by symbols generateText, streamText, generateObject,
streamObject) so validator functions are stripped out of the options passed to
the AI SDK.

// Guardrails (can override agent-level guardrails)
inputGuardrails?: InputGuardrail[];
outputGuardrails?: OutputGuardrail<any>[];
Expand Down Expand Up @@ -1004,6 +1027,7 @@ export class Agent {
private readonly prompts?: PromptHelper;
private readonly evalConfig?: AgentEvalConfig;
private readonly feedbackOptions?: AgentFeedbackOptions | boolean;
private readonly executionValidators?: AgentExecutionValidators;
private readonly inputGuardrails: NormalizedInputGuardrail[];
private readonly outputGuardrails: NormalizedOutputGuardrail[];
private readonly inputMiddlewares: NormalizedInputMiddleware[];
Expand Down Expand Up @@ -1048,6 +1072,7 @@ export class Agent {
this.voltOpsClient = options.voltOpsClient;
this.evalConfig = options.eval;
this.feedbackOptions = options.feedback;
this.executionValidators = normalizeAgentExecutionValidators(options.executionValidators);
this.inputGuardrails = normalizeInputGuardrailList(options.inputGuardrails || []);
this.outputGuardrails = normalizeOutputGuardrailList(options.outputGuardrails || []);
this.inputMiddlewares = normalizeInputMiddlewareList(options.inputMiddlewares || []);
Expand Down Expand Up @@ -1140,6 +1165,53 @@ export class Agent {
// Public API Methods
// ============================================================================

async validateToolExecution({
tool,
args,
options,
toolCallId,
messages,
operationContext,
}: {
tool: Tool<any, any> | ProviderTool;
args: unknown;
options?: ToolExecuteOptions;
toolCallId?: string;
messages?: unknown[];
operationContext?: OperationContext;
}): Promise<void> {
const validators = options?.executionValidators?.tools ?? this.executionValidators?.tools;
if (!validators || validators.length === 0) {
return;
}
Comment on lines +1183 to +1186
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Resolve validators from the operation context first.

createOperationContext already merges parent, agent, and per-call validators into OperationContext.executionValidators, but this method ignores operationContext?.executionValidators. Direct calls that pass an operation context can silently skip operation-scoped validators; direct calls with options.executionValidators also skip agent-level validators.

Proposed fix
-    const validators = options?.executionValidators?.tools ?? this.executionValidators?.tools;
+    const validators =
+      operationContext?.executionValidators?.tools ??
+      mergeAgentExecutionValidators(this.executionValidators, options?.executionValidators)?.tools;
     if (!validators || validators.length === 0) {
       return;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/agent/agent.ts` around lines 1183 - 1186, The method
currently resolves validators from options or this.executionValidators but
ignores operationContext.executionValidators; update the resolver to prefer
operationContext?.executionValidators first, then options?.executionValidators,
then this.executionValidators so validators merged by createOperationContext are
honored; locate the code around the validators assignment (the const validators
= ... line) and change the lookup order to check
operationContext.executionValidators before falling back to options and
agent-level executionValidators, and keep the existing empty-array/length check
behavior.


const resolvedToolCallId = toolCallId ?? options?.toolContext?.callId ?? randomUUID();
const resolvedOperationContext =
operationContext ??
(options?.operationId && options.context && options.systemContext
? (options as OperationContext)
: undefined);
const context: ToolExecutionValidationContext = {
type: "tool",
agent: this,
tool,
toolName: tool.name,
args,
options,
operationContext: resolvedOperationContext,
toolCallId: resolvedToolCallId,
messages: messages ?? options?.toolContext?.messages ?? [],
timestamp: new Date(),
};

await runExecutionValidators(
validators,
context,
`Tool ${tool.name} execution blocked by validation.`,
"TOOL_VALIDATION_FAILED",
);
}

/**
* Generate text response
*/
Expand Down Expand Up @@ -4071,6 +4143,11 @@ export class Agent {
operationId,
context,
requestHeaders: options?.requestHeaders ?? options?.parentOperationContext?.requestHeaders,
executionValidators: mergeAgentExecutionValidators(
options?.parentOperationContext?.executionValidators,
this.executionValidators,
options?.executionValidators,
),
systemContext,
isActive: true,
logger,
Expand Down Expand Up @@ -6351,7 +6428,7 @@ export class Agent {
options: executionOptions,
});

if (isToolDeniedError(errorValue)) {
if (isToolDeniedError(errorValue) || isExecutionValidationError(errorValue)) {
oc.abortController.abort(errorValue);
}

Expand All @@ -6367,6 +6444,14 @@ export class Agent {
return async function* (this: Agent): AsyncGenerator<any, void, void> {
try {
await oc.traceContext.withSpan(toolSpan, async () => {
await this.validateToolExecution({
tool: tool as Tool<any, any>,
args,
options: executionOptions,
toolCallId,
messages,
operationContext: oc,
});
await runToolStartHooks();
});

Expand Down Expand Up @@ -6426,6 +6511,14 @@ export class Agent {

return oc.traceContext.withSpan(toolSpan, async () => {
try {
await this.validateToolExecution({
tool: tool as Tool<any, any>,
args,
options: executionOptions,
toolCallId,
messages,
operationContext: oc,
});
// Call tool start hook - can throw ToolDeniedError
await runToolStartHooks();

Expand Down Expand Up @@ -6946,6 +7039,15 @@ export class Agent {
executionOptions.toolContext?.callId ?? randomUUID(),
);

await this.validateToolExecution({
tool,
args,
options: executionOptions,
toolCallId: executionOptions.toolContext?.callId,
messages: executionOptions.toolContext?.messages ?? [],
operationContext: oc,
});

const tools: Record<string, any> = {
[tool.name]: tool,
};
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/agent/errors/client-http-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ export type ToolDeniedErrorCode =
| "TOOL_PLAN_REQUIRED"
| "TOOL_QUOTA_EXCEEDED";

export type ExecutionValidationErrorCode =
| "EXECUTION_VALIDATION_FAILED"
| "TOOL_VALIDATION_FAILED"
| "WORKFLOW_VALIDATION_FAILED"
| string;

/**
* Error thrown when a tool execution is denied by a controller or policy layer
*/
Expand All @@ -72,3 +78,31 @@ export function isClientHTTPError(error: unknown): error is ClientHTTPError {
export function isToolDeniedError(error: unknown): error is ToolDeniedError {
return error instanceof ToolDeniedError;
}

/**
* Error thrown when an execution validator denies a tool or workflow execution.
*/
export class ExecutionValidationError extends ClientHTTPError {
readonly metadata?: Record<string, unknown>;

constructor({
targetName = "ExecutionValidationError",
message,
code = "EXECUTION_VALIDATION_FAILED",
httpStatus = 403,
metadata,
}: {
targetName?: string;
message: string;
code?: ExecutionValidationErrorCode;
httpStatus?: ClientHttpErrorCode;
metadata?: Record<string, unknown>;
}) {
super(targetName, httpStatus, code, message);
this.metadata = metadata;
}
}

export function isExecutionValidationError(error: unknown): error is ExecutionValidationError {
return error instanceof ExecutionValidationError;
}
7 changes: 7 additions & 0 deletions packages/core/src/agent/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,17 @@ export type { VoltAgentError } from "./voltagent-error";
export type { AbortError } from "./abort-error";
export type { BailError } from "./bail-error";
export type { MiddlewareAbortError, MiddlewareAbortOptions } from "./middleware-abort-error";
export type {
ClientHttpErrorCode,
ExecutionValidationErrorCode,
ToolDeniedErrorCode,
} from "./client-http-errors";
export {
ToolDeniedError,
ClientHTTPError,
ExecutionValidationError,
isClientHTTPError,
isExecutionValidationError,
isToolDeniedError,
} from "./client-http-errors";
export { createAbortError, isAbortError } from "./abort-error";
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/agent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type { VoltAgentTextStreamPart } from "./subagent/types";

import type { Logger } from "@voltagent/internal";
import type { LocalScorerDefinition, SamplingPolicy } from "../eval/runtime";
import type { AgentExecutionValidators } from "../execution-validation";
import type { MemoryOptions, MemoryStorageMetadata, WorkingMemorySummary } from "../memory/types";
import type { VoltAgentObservability } from "../observability";
import type { ModelRouterModelId } from "../registries/model-provider-types.generated";
Expand Down Expand Up @@ -695,6 +696,9 @@ export type AgentOptions = {
// Hooks
hooks?: AgentHooks;

// Execution validators
executionValidators?: AgentExecutionValidators;

// Guardrails
inputGuardrails?: InputGuardrail[];
outputGuardrails?: OutputGuardrail<any>[];
Expand Down Expand Up @@ -1083,6 +1087,9 @@ export interface CommonGenerateOptions {

// Optional hooks to be included only during the operation call and not persisted in the agent
hooks?: AgentHooks;

// Optional execution validators to include only during this operation
executionValidators?: AgentExecutionValidators;
}

/**
Expand Down Expand Up @@ -1312,6 +1319,9 @@ export type OperationContext = {
/** HTTP request headers associated with this operation, when available */
readonly requestHeaders?: Record<string, string>;

/** Execution validators active for this operation */
readonly executionValidators?: AgentExecutionValidators;

/** System-managed context map for internal operation tracking */
readonly systemContext: Map<string | symbol, unknown>;

Expand Down
Loading
Loading