Skip to content

feat(core): add agent tool authorization guard - #1390

Open
zcxGGmu wants to merge 2 commits into
VoltAgent:mainfrom
zcxGGmu:feat/issue-1177-tool-authorization-middleware
Open

feat(core): add agent tool authorization guard#1390
zcxGGmu wants to merge 2 commits into
VoltAgent:mainfrom
zcxGGmu:feat/issue-1177-tool-authorization-middleware

Conversation

@zcxGGmu

@zcxGGmu zcxGGmu commented Aug 1, 2026

Copy link
Copy Markdown

Summary

  • Adds an Agent-level toolGuard hook for per-tool authorization before local tool execution.
  • Supports deny responses via false, { allowed: false }, or { denied: true, reason }.
  • Reuses the existing ToolDeniedError path so denied tool calls still reach onToolError / onToolEnd hooks for audit logging.
  • Adds behavior and type coverage for the public API.

Test Plan

  • vitest run packages/core/src/agent/agent.spec.ts packages/core/src/agent/hooks/index.spec.ts --config vitest.config.mts -t "Tool Execution|toolGuard|Hook Type Tests"
  • pnpm --filter @voltagent/core typecheck
  • pnpm --filter @voltagent/core build
  • biome check packages/core/src/agent/agent.ts packages/core/src/agent/types.ts packages/core/src/agent/hooks/index.ts packages/core/src/agent/agent.spec.ts packages/core/src/agent/agent.spec-d.ts

Related to #1177


Summary by cubic

Adds an agent-level toolGuard to authorize or deny tool calls before execution, with denials routed through ToolDeniedError so onToolError/onToolEnd include audit context. Addresses #1177.

  • New Features

    • AgentOptions.toolGuard: sync/async guard returning boolean or { allowed?: boolean; denied?: boolean; reason?: string }.
    • Guard runs before onToolStart for local and provider tools; denied calls block with TOOL_FORBIDDEN (403).
  • Bug Fixes

    • Enforces guard before routed provider tool calls, blocking provider invocation and preserving error/end hooks.
    • Expands hook types to accept provider tools; adds tests for provider guard denial and object result exclusivity.

Written for commit bb52a3f. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added optional tool authorization controls that can allow or deny tool execution.
    • Supports synchronous and asynchronous authorization decisions with customizable denial reasons.
    • Authorization applies consistently across regular, streaming, and provider tools.
  • Bug Fixes

    • Prevented unauthorized tools from executing.
    • Denied tools return a TOOL_FORBIDDEN error with relevant context while preserving lifecycle and error callbacks.

Add an Agent-level toolGuard hook that can deny local tool execution before the tool runs. Denied calls reuse the existing ToolDeniedError path so tool error/end hooks still receive audit context.\n\nAdds behavior and type coverage for the new guard API.\n\nRelated to VoltAgent#1177.
@changeset-bot

changeset-bot Bot commented Aug 1, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: bb52a3f

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The agent now supports an optional typed toolGuard callback. The callback can allow or deny regular, async-generator, and provider tools. Denials produce ToolDeniedError with TOOL_FORBIDDEN and HTTP 403 metadata.

Changes

Tool guard authorization

Layer / File(s) Summary
Tool guard contract
packages/core/src/agent/hooks/index.ts, packages/core/src/agent/types.ts
Adds typed guard results, provider-tool hook payloads, and the optional AgentOptions.toolGuard callback.
Guard evaluation and tool routing
packages/core/src/agent/agent.ts
Stores and evaluates the guard before tool start hooks for regular, async-generator, and provider-tool execution. Denials use ToolDeniedError and existing tool hook flows.
Guard typing and denial tests
packages/core/src/agent/agent.spec-d.ts, packages/core/src/agent/hooks/index.spec.ts, packages/core/src/agent/agent.spec.ts
Validates guard typing, mutually exclusive result shapes, blocked execution, denial output, and hook payloads.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ToolCaller
  participant Agent
  participant toolGuard
  participant ToolHooks
  participant Tool
  ToolCaller->>Agent: Invoke tool
  Agent->>toolGuard: Check tool arguments and context
  toolGuard-->>Agent: Return allow or denial
  alt Allowed
    Agent->>ToolHooks: Run tool start hooks
    Agent->>Tool: Execute tool
  else Denied
    Agent->>ToolHooks: Report ToolDeniedError
  end
Loading

Possibly related issues

Possibly related PRs

Suggested reviewers: omeraplak

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main change: adding an agent tool authorization guard.
Description check ✅ Passed The description explains the change, behavior, tests, and related issue, but omits several template checklist items and explicit behavior sections.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/core/src/agent/agent.ts (1)

7294-7301: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Authorize provider tools before provider execution.

runInternalGenerateText completes before Line 7294 evaluates toolGuard. The provider tool call and result are already available at this point. A denied guard result cannot prevent provider-tool execution.

This throw also bypasses onToolError and onToolEnd for the target provider tool. The outer wrapper reports hooks for callTool, not for tool.

Authorize before dispatching the provider request. Route a target-provider denial through its error and end hooks. Add a test that confirms a denied provider tool does not execute.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/agent/agent.ts` around lines 7294 - 7301, Move the tool
guard authorization in runInternalGenerateText to before the provider request is
dispatched, so denied provider tools never execute. For a denied target provider
tool, invoke that tool’s onToolError and onToolEnd hooks before propagating the
denial, while preserving normal onToolStart and execution behavior for allowed
tools. Add a test verifying the denied provider tool is not executed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/agent/agent.ts`:
- Around line 6673-6674: Remove the duplicate tool.hooks.onEnd invocation in
handleToolError for denied local calls, including the corresponding path near
the alternate call site, so each denied call triggers the end hook exactly once.
Add or update a test covering a denied local tool call and assert
tool.hooks.onEnd is invoked once.

In `@packages/core/src/agent/hooks/index.ts`:
- Around line 62-73: Update ToolGuardArgs, OnToolStartHookArgs, and
OnToolEndHookArgs so their tool property accepts the union of BaseTool and
ProviderTool, reflecting provider-defined tools at runtime. Update
AgentToolGuard and related hook usage to consume this type directly, then remove
all tool as any casts while preserving existing behavior.

---

Outside diff comments:
In `@packages/core/src/agent/agent.ts`:
- Around line 7294-7301: Move the tool guard authorization in
runInternalGenerateText to before the provider request is dispatched, so denied
provider tools never execute. For a denied target provider tool, invoke that
tool’s onToolError and onToolEnd hooks before propagating the denial, while
preserving normal onToolStart and execution behavior for allowed tools. Add a
test verifying the denied provider tool is not executed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 42826508-cdd6-4977-83db-8a094742deb6

📥 Commits

Reviewing files that changed from the base of the PR and between 3377f6d and e0cb2c4.

📒 Files selected for processing (5)
  • packages/core/src/agent/agent.spec-d.ts
  • packages/core/src/agent/agent.spec.ts
  • packages/core/src/agent/agent.ts
  • packages/core/src/agent/hooks/index.ts
  • packages/core/src/agent/types.ts

Comment on lines +6673 to 6674
await this.assertToolGuardAllows(tool, args, oc, executionOptions);
await runToolStartHooks();

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Call the tool end hook once for denied local calls.

When toolGuard denies a local tool, execution enters handleToolError. That handler invokes tool.hooks.onEnd twice at Lines 6617-6631. A denied call can therefore create duplicate tool-level audit records or duplicate cleanup side effects.

Remove the duplicate invocation. Add a test that asserts tool.hooks.onEnd runs exactly once for a denied call.

Also applies to: 6734-6736

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/agent/agent.ts` around lines 6673 - 6674, Remove the
duplicate tool.hooks.onEnd invocation in handleToolError for denied local calls,
including the corresponding path near the alternate call site, so each denied
call triggers the end hook exactly once. Add or update a test covering a denied
local tool call and assert tool.hooks.onEnd is invoked once.

Comment thread packages/core/src/agent/hooks/index.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

4 issues found across 5 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/core/src/agent/hooks/index.ts">

<violation number="1" location="packages/core/src/agent/hooks/index.ts:67">
P3: A type-valid guard result can state both authorization outcomes, but `assertToolGuardAllows` resolves that conflict as denial. Model object results as mutually exclusive variants so guard implementations cannot accidentally publish contradictory authorization decisions.</violation>
</file>

<file name="packages/core/src/agent/agent.ts">

<violation number="1" location="packages/core/src/agent/agent.ts:6673">
P2: Denying a tool call via toolGuard routes execution through handleToolError, which appears to invoke tool.hooks.onEnd twice. This can produce duplicate tool-level audit records or duplicate cleanup side effects for every denied call. Consider deduplicating the onEnd invocation in handleToolError and adding a test asserting onEnd fires exactly once for a denied call.</violation>

<violation number="2" location="packages/core/src/agent/agent.ts:7294">
P1: For provider tools this guard runs after the tool has already been executed, so it does not actually authorize the call.

In `executeProviderToolViaCallTool`, `runInternalGenerateText` (~line 7284) is invoked before this guard. It calls `generateText({ tools: { [tool.name]: tool }, toolChoice: { type: "tool", toolName: tool.name } })`, which makes the AI SDK invoke the provider tool's own `execute` and record its result. By the time `assertToolGuardAllows` is reached here, the provider tool has already run with all its side effects. A denial only prevents the result from being surfaced to the caller — it cannot stop the tool from executing. This is inconsistent with the local-tool paths where the guard runs before `tool.execute`, and it gives a false sense of authorization for provider tools routed via `callTool`.

Additionally, provider tools exposed directly to the model are passed through untouched in `ToolManager.prepareToolsForExecution` (`tools[tool.name] = tool;`), so they never go through `createToolExecutionFactory` and never hit `assertToolGuardAllows` at all on the direct-execution path. Providers routed only through `callTool` are the sole case that touches this guard, and that happens after execution. Consider moving the guard to before the provider tool's `callTool` execution begins (e.g., before `runInternalGenerateText`) and documenting/covering the direct pass-through case.</violation>

<violation number="3" location="packages/core/src/agent/agent.ts:7294">
P1: Denied routed provider calls skip that provider's `onToolError` and `onToolEnd` hooks, so audit logging records the enclosing `callTool` failure rather than the denied provider tool. Handle guard denials in this method with the target tool's error/end lifecycle before propagating or returning the denial.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/core/src/agent/agent.ts Outdated
Comment thread packages/core/src/agent/agent.ts Outdated
`Provider tool "${tool.name}" received arguments that do not match callTool input.`,
);
}
await this.assertToolGuardAllows(tool, callInput, oc, executionOptions);

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.

P1: For provider tools this guard runs after the tool has already been executed, so it does not actually authorize the call.

In executeProviderToolViaCallTool, runInternalGenerateText (~line 7284) is invoked before this guard. It calls generateText({ tools: { [tool.name]: tool }, toolChoice: { type: "tool", toolName: tool.name } }), which makes the AI SDK invoke the provider tool's own execute and record its result. By the time assertToolGuardAllows is reached here, the provider tool has already run with all its side effects. A denial only prevents the result from being surfaced to the caller — it cannot stop the tool from executing. This is inconsistent with the local-tool paths where the guard runs before tool.execute, and it gives a false sense of authorization for provider tools routed via callTool.

Additionally, provider tools exposed directly to the model are passed through untouched in ToolManager.prepareToolsForExecution (tools[tool.name] = tool;), so they never go through createToolExecutionFactory and never hit assertToolGuardAllows at all on the direct-execution path. Providers routed only through callTool are the sole case that touches this guard, and that happens after execution. Consider moving the guard to before the provider tool's callTool execution begins (e.g., before runInternalGenerateText) and documenting/covering the direct pass-through case.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/agent/agent.ts, line 7294:

<comment>For provider tools this guard runs after the tool has already been executed, so it does not actually authorize the call.

In `executeProviderToolViaCallTool`, `runInternalGenerateText` (~line 7284) is invoked before this guard. It calls `generateText({ tools: { [tool.name]: tool }, toolChoice: { type: "tool", toolName: tool.name } })`, which makes the AI SDK invoke the provider tool's own `execute` and record its result. By the time `assertToolGuardAllows` is reached here, the provider tool has already run with all its side effects. A denial only prevents the result from being surfaced to the caller — it cannot stop the tool from executing. This is inconsistent with the local-tool paths where the guard runs before `tool.execute`, and it gives a false sense of authorization for provider tools routed via `callTool`.

Additionally, provider tools exposed directly to the model are passed through untouched in `ToolManager.prepareToolsForExecution` (`tools[tool.name] = tool;`), so they never go through `createToolExecutionFactory` and never hit `assertToolGuardAllows` at all on the direct-execution path. Providers routed only through `callTool` are the sole case that touches this guard, and that happens after execution. Consider moving the guard to before the provider tool's `callTool` execution begins (e.g., before `runInternalGenerateText`) and documenting/covering the direct pass-through case.</comment>

<file context>
@@ -7242,6 +7291,7 @@ export class Agent {
           `Provider tool "${tool.name}" received arguments that do not match callTool input.`,
         );
       }
+      await this.assertToolGuardAllows(tool, callInput, oc, executionOptions);
       await hooks.onToolStart?.({
         agent: this,
</file context>

Comment thread packages/core/src/agent/agent.ts
Comment thread packages/core/src/agent/hooks/index.ts Outdated
@zcxGGmu

zcxGGmu commented Aug 2, 2026

Copy link
Copy Markdown
Author

Thanks for the review — I pushed bb52a3fd6 addressing the toolGuard lifecycle feedback.

Changes made:

  • Move the provider-tool guard check before provider execution in the routed callTool path.
  • Route denied provider calls through the target tool's onToolError / onToolEnd lifecycle before returning the denial payload.
  • Remove the duplicate tool-level onEnd call on denied local tool execution.
  • Broaden hook/guard tool types to include provider tools and make object guard results mutually exclusive.
  • Add regression coverage for denied provider calls and single onEnd invocation on denied local calls.

Validation:

  • pnpm vitest run src/agent/agent.spec.ts src/agent/hooks/index.spec.ts -t "blocks routed provider tool execution before invoking the provider|blocks tool execution when toolGuard denies the tool|models object toolGuard results as mutually exclusive outcomes" --typecheck — passed, 3 tests passed, no type errors.
  • pnpm run typecheck in packages/core — passed.
  • pnpm exec biome check packages/core/src/agent/agent.ts packages/core/src/agent/agent.spec.ts packages/core/src/agent/hooks/index.ts packages/core/src/agent/hooks/index.spec.ts — passed with existing complexity warnings only.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants