Policy and approvals middleware for AI tool calls in .NET applications.
ToolGate evaluates every LLM tool call against configurable policies — Allow, Deny, or RequireApproval — before the tool executes. It integrates with Microsoft.Extensions.AI as a DelegatingChatClient decorator.
- Policy Pipeline — Sequential evaluator with first-deny-wins aggregation
- Built-in Policies — Denylist, allowlist, and high-risk approval policies
- Approval Workflow — Human-in-the-loop approval with state machine (Pending → Approved/Denied/Expired)
- Custom Policies — Implement
IToolPolicyto add your own evaluation logic - Argument Redaction — Sensitive fields (passwords, keys, tokens) are automatically redacted in logs and audit
- Observability — OpenTelemetry-compatible tracing (
ActivitySource) and metrics (Meter) - Fail-Closed by Default — Policy errors block tool execution (configurable)
| Package | Purpose |
|---|---|
ToolGate.Core |
Pipeline, models, abstractions, diagnostics |
ToolGate.Policies |
Built-in denylist, allowlist, and high-risk policies |
ToolGate.Approvals |
In-memory approval store and state machine |
ToolGate.Adapters.ExtensionsAI |
MEAI DelegatingChatClient adapter + convenience registration |
dotnet add package ToolGate.Adapters.ExtensionsAIThe adapter package transitively includes Core, Policies, and Approvals.
using ToolGate.Adapters.ExtensionsAI.DependencyInjection;
services.AddToolGate(
configureDenylist: deny =>
{
deny.DeniedTools.Add("delete_database");
deny.DeniedPatterns.Add("rm_*");
},
configureAllowlist: allow =>
{
allow.AllowedTools.Add("get_weather");
allow.AllowedTools.Add("get_time");
});
// Wrap your chat client with ToolGate
services.AddChatClient(innerClient)
.UseToolGate();When the LLM returns a tool call, ToolGate intercepts it:
- Allowed tools pass through unchanged
- Denied tools are replaced with a
FunctionResultContentcontaining the denial reason - RequireApproval tools are held until a human approves or denies via
IApprovalProvider
services.AddToolGateCore(options =>
{
options.FailMode = FailMode.FailClosed; // Default: errors block execution
options.DefaultDecision = Decision.Deny; // Default: unknown tools are denied
options.DefaultApprovalTtl = TimeSpan.FromMinutes(30);
options.Redaction.SensitiveKeys.Add("my_secret_field");
});Blocks tools by exact name or glob pattern. Evaluated first (Order: -1000).
services.AddToolGatePolicies(configureDenylist: deny =>
{
deny.DeniedTools.Add("delete_database");
deny.DeniedTools.Add("drop_table");
deny.DeniedPatterns.Add("rm_*"); // Glob pattern
deny.DeniedPatterns.Add("*.destructive");
});Explicitly allows tools. Optional DenyUnlisted mode blocks everything not in the list. Evaluated second (Order: -500).
services.AddToolGatePolicies(configureAllowlist: allow =>
{
allow.AllowedTools.Add("get_weather");
allow.AllowedTools.Add("search_docs");
allow.DenyUnlisted = true; // Deny everything not explicitly allowed
});Requires human approval for tools with high risk metadata. Evaluated late (Order: 500).
services.AddToolGatePolicies(configureHighRisk: risk =>
{
risk.MinimumRiskLevel = RiskLevel.High; // Default
});Triggered when ToolCallContext.Metadata.RiskLevel >= MinimumRiskLevel or when metadata contains the "high-risk" tag.
Implement IToolPolicy and register it in DI:
public class RateLimitPolicy : IToolPolicy
{
public string Name => "RateLimit";
public int Order => 100; // After allowlist, before high-risk
public Task<PolicyOutcome> EvaluateAsync(
ToolCallContext context,
CancellationToken cancellationToken = default)
{
if (IsOverLimit(context.ToolName))
return Task.FromResult(PolicyOutcome.DenyResult(
"RateLimited", "Too many calls to this tool"));
return Task.FromResult(PolicyOutcome.AbstainResult());
}
}
// Register:
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IToolPolicy, RateLimitPolicy>());Policy outcomes: AllowResult(), DenyResult(), RequireApprovalResult(), AbstainResult()
Policy ordering: Lower Order values evaluate first. Built-in: Denylist (-1000), Allowlist (-500), HighRisk (500). Custom policies default to 0.
When a policy returns RequireApproval, the pipeline creates an approval request:
var approvalProvider = sp.GetRequiredService<IApprovalProvider>();
// Approve programmatically (e.g., from an admin UI or webhook)
var result = await approvalProvider.ApproveAsync(approvalId, actor: "admin@example.com");
// On next evaluation of the same invocation, ToolGate recognizes the approval
// and allows the tool call to proceed (resume-after-approval)The in-memory approval store uses first-writer-wins concurrency and lazy TTL expiration.
ToolGate emits OpenTelemetry-compatible diagnostics:
- Tracing:
ActivitySourcenamed"ToolGate"with evaluation spans - Metrics:
Meternamed"ToolGate"with:toolgate.evaluations.count— Counter by decisiontoolgate.evaluations.duration— Histogram in mstoolgate.errors.count— Counter by error typetoolgate.approvals.count— Counter by state transition
- Logging: Structured log events with stable IDs (see
ToolGateEventIds)
| Sample | Description |
|---|---|
| ConsoleChat | Minimal console app demonstrating all decision types (Allow, Deny, RequireApproval) |
| AzureChat | Azure OpenAI integration with tool policies |
| ExpenseAgent | Expense report agent with custom policies (amount thresholds, read-only enforcement) |
dotnet run --project samples/ToolGate.Samples.ConsoleChatSee CONTRIBUTING.md for guidelines on building, testing, and submitting pull requests.