Approval workflows overview
Some tool calls are too consequential to gate behind allow/deny alone — delete_database, send_payment, rotate_secrets. AI.Sentinel's approval workflow lets you require a human approval out-of-band for matched tool calls, then resume the conversation when the approval lands.
When to use this vs. plain authorization
| You want... | Use |
|---|---|
| Allow/deny based on caller identity / role | IAuthorizationPolicy |
| Allow/deny based on caller identity, AND a human approves first | This (RequireApproval) |
RequireApproval is additive — your existing RequireToolPolicy(...) bindings are unchanged. The same tool can have both: e.g. authorize "is this caller in the DBA group?" and then require a separate approver to sign off.
The lifecycle
- Tool call hits the guard — middleware (
AuthorizationChatClient) or CLI hook routes throughIToolCallGuard. - Guard returns
RequireApprovalDecision— carries aRequestId,ApprovalUrl, andWaitTimeout. - Approval pends — the configured
IApprovalStore(InMemory / SQLite / Entra PIM) holds the request until an approver settles it. - Caller observes the outcome:
- In-process middleware: blocks via
WaitForDecisionAsync, then re-evaluates the guard. - Hook CLIs (
sentinel-hook,sentinel-copilot-hook): emits a deny-with-receipt, the user approves out-of-band and retries the prompt. - MCP proxy: wait-and-block when
SENTINEL_MCP_APPROVAL_WAIT_SECis set; fail-fast otherwise.
- In-process middleware: blocks via
Audit trail
When an IAuditStore is registered, every approval decision is written to the hash-chained audit log as an informational entry with policy code APPROVED, DENIED, EXPIRED or TIMED_OUT. The entry names the policy, the tool and the request id, and never the arguments, the approver's note or any caller or approver id. The IApprovalStore and IApprovalAdmin that DI hands out for the built-in backends, and for a store registered with AddSentinelApprovalStore, are an auditing wrapper over the store, so a decision is recorded wherever it is taken: in the tool-call guard, UseToolCallAuthorization, the dashboard panel, the MCP proxy, or your own approval UI resolving either interface from DI. This holds in any registration order, and the wrapper writes to whichever IAuditStore is registered last. A backend still goes before an AddAISentinel that has RequireApproval bindings, as the SQLite page explains. All of these share one wrapper, so a decision is written once.
- The concrete store is not audited. Resolving
SqliteApprovalStoreby its own type returns the store itself, and a decision taken through it is not recorded. ResolveIApprovalStoreorIApprovalAdmininstead, and don't cast either to a concrete store type: the cast fails while an audit store is registered. - A custom store is audited the same way when you register it with
AddSentinelApprovalStore, as Writing your own store shows. One registered straight asIApprovalStorewithAddSingletonis resolved from DI unwrapped: AI.Sentinel's own surfaces still record the decisions they take through it, but a decision your code takes through theIApprovalStoreit resolves is not recorded. When such a store replaces the built-in in-memory store, theIApprovalAdminyou resolve is still an audited wrapper over it, so decisions taken through that interface are recorded. - Keyed DI services. The store itself is registered as a keyed singleton under a private key, and
IApprovalStoreandIApprovalAdminare factories over it.RemoveAll<IApprovalStore>()removes the factory, and registering another backend afterwards replaces the rest. A third-party container must support keyed services, as Microsoft.Extensions.DependencyInjection 8 and later does. - Without an
IAuditStoreDI hands out the store unwrapped. The unnamedAddAISentinelalways registers an in-memory audit store, so this happens only when you register named pipelines alone or the backend withoutAddAISentinel.
With Entra PIM the decision is made in PIM, so AI.Sentinel records only what it observes:
- Denials: a PIM denial is recorded only by a process that waits for the decision, such as the in-process chat client or the MCP proxy with
SENTINEL_MCP_APPROVAL_WAIT_SECset. The hook CLIs and the fail-fast MCP proxy never wait, so they never observe a denial: the caller's next attempt simply opens a new request. A Graph error while waiting is not a decision and is not recorded. - Approvals in the hook CLIs: each hook invocation is a new process that remembers nothing. The first time a process sees a PIM grant, it looks in the audit store for an
APPROVEDentry of that request, written since the grant started, and records the approval only when there is none. A grant is therefore normally recorded once across processes that share the audit store; two hook processes that see the grant at the same moment can both record it. If that lookup fails, the approval is recorded anyway, since a duplicate entry is better than a missing decision; the compliance report counts each request once per outcome either way.
The three backends
| Backend | Persistence | Approver experience | Best for |
|---|---|---|---|
| In-memory | Process lifetime | Dashboard | Single-process apps; dev/demo |
| SQLite | File on disk | Dashboard | CLI deployments; multi-process on one host |
| Entra PIM | Azure AD | Native PIM portal | Enterprise tenants with PIM already in place |
Backends are exclusive — one IApprovalStore per process. Switching is a config change; no code change.
Wiring in code
Add a RequireApproval binding alongside your existing severity/policy config:
services.AddAISentinel(opts =>
{
opts.OnHigh = SentinelAction.Quarantine;
opts.RequireApproval("delete_database", spec =>
{
spec.GrantDuration = TimeSpan.FromMinutes(15);
spec.RequireJustification = true;
spec.BackendBinding = "DBA"; // role name passed to the backend
});
});
Rules that match tool arguments
WhenArgument(path, regex) limits a rule to calls whose arguments match. It is chainable, repeatable, and all conditions must hold. GrantScope = ApprovalGrantScope.Call ties the grant to the exact approved arguments instead of the whole rule:
opts.RequireApproval("mcp__api__http_request", spec =>
{
spec.BackendBinding = "Operator";
spec.GrantScope = ApprovalGrantScope.Call;
spec.WhenArgument("$.method", "^(POST|PUT|PATCH|DELETE)$");
});
// Two rules for one pattern. The first rule whose conditions hold applies.
opts.RequireApproval("Bash", spec =>
{
spec.BackendBinding = "ProdOps";
spec.WhenArgument("$.command", @"\bkubectl\b[\s\S]*\bdelete\b[\s\S]*\b(prod|production)\b");
});
// A second call for the same pattern adds the next rule.
opts.RequireApproval("Bash", spec =>
{
spec.BackendBinding = "Operator";
spec.WhenArgument("$.command", @"\bkubectl\s+delete\b");
});
A bad path or regex throws from WhenArgument. The second rule is named approval:Bash[1] unless you set PolicyName; a duplicate policy name throws at registration. GrantScope.Call with the Entra PIM store throws when the tool-call guard is first resolved. That check sees the store through AI.Sentinel's own auditing wrapper only: a decorator of your own around EntraPimApprovalStore hides it. A call-scoped grant covers the exact tool and every argument field, including fields such as Bash's description that a model rewords on retry, so prefer it for tools whose arguments repeat exactly; see Grant scope.
Path syntax, the value table, fail-closed behaviour and the limits of regexes on shell commands are the same as in the config file.
Semantic approvals
RequireSemanticApproval asks for approval when the text of a tool call is close in meaning to an example of a destructive call, even when no WhenArgument rule matched. It can only add approvals. It uses the pipeline's embedding generator: SentinelOptions.EmbeddingGenerator, or an IEmbeddingGenerator<string, Embedding<float>> registered in the container while UseEmbeddingGeneratorFromServices is on. Without one the layer is inert and the guard logs a warning.
opts.RequireSemanticApproval("Bash", spec =>
{
spec.BackendBinding = "Operator"; // required
spec.GrantDuration = TimeSpan.FromMinutes(15);
spec.Paths("$.command"); // optional; see below
spec.Threshold = 0.80f; // optional, per tool
});
opts.ConfigureSemanticApprovals(s =>
{
s.Threshold = 0.80f; // default for every binding
s.AddExamples("kubectl delete namespace", "decommission a tenant");
s.ReplaceExamples = false; // true: only the added examples
});
RequireSemanticApprovaltakes an exact tool name or a pattern ending in*, and each pattern can be registered once. A missingBackendBindingor a threshold outside (0, 1] throwsInvalidOperationException.Pathslimits the scoring to the text at those paths, each scored separately. Without itBash,bashandpowershellscore$.command, and any other tool scores the whole arguments JSON. Tool names match ordinally. Thepowershelldefault assumes Copilot's tool schema, see Which text is scored.AddExamplesadds to the built-in examples and throws on a blank one.ReplaceExamples = truewithout anyAddExamplesthrowsInvalidOperationExceptionwhen the guard is first resolved.ConfigureSemanticApprovalschecks the threshold and keeps the old settings when it throws.- A grant is always tied to the approved call, under the policy name
semantic:<pattern>#<fingerprint>. With the Entra PIM store the guard throws when first resolved, as forGrantScope.Call. - A call that cannot be scored asks for approval with the reason
Semantic check failed: .... There is no fail-open switch. - The request carries a
Reason, shown on the dashboard, in the receipt and in the webhook.
The built-in examples catch close variants only: on the held-out set the default flags 2 of 35 paraphrased destructive calls. Add your own examples and tune the threshold; see How well it works and the worked example. It is not a sandbox, for the reasons given under Semantic approvals are not a sandbox.
Behaviour change.
- Two
RequireApprovalcalls on the same pattern, both withoutWhenArgument, used to demand both approvals. Now only the first applies, because the second is the next rule of the same pattern. - A duplicate approval policy name now throws at registration. Before, two
RequireApprovalcalls with the same explicitPolicyNameboth registered, and one approval covered both.
For CLIs (where source-edits aren't an option), use a config file instead.
Policies and approval requests see the real argument values, not redaction placeholders, so an approver can judge the actual call.
Writing your own store
A custom backend implements IApprovalStore, and IApprovalAdmin as well when approvers decide in the dashboard panel. Register it with AddSentinelApprovalStore, which the built-in backends use too, so the decisions taken through it are audited wherever the store is resolved:
services.AddSentinelApprovalStore(sp => new MyApprovalStore(sp.GetRequiredService<MyApprovalOptions>()));
It registers IApprovalStore, and IApprovalAdmin when your type implements it, and throws if an IApprovalStore is already registered. Register it before an AddAISentinel that has RequireApproval bindings, or that call registers the in-memory store first.
Set RequestId on every ApprovalState.Active and ApprovalState.Denied you return whenever the store knows the request, as the built-in stores do. Approval decisions in the audit log are recorded under that id.
For a store that implements IApprovalAdmin, AI.Sentinel records ApproveAsync and DenyAsync itself, from the request id they are called with. For a store without it, whose decisions are made elsewhere as with Entra PIM, AI.Sentinel records what the store returns:
- A
Deniedwithout aRequestIdis not recorded as a decision. AI.Sentinel treats it as a denial that belongs to no request, such as a backend error while polling, so a real denial returned without an id is missing from the audit log. - An
Activewithout aRequestIdis recorded under the id this process waited on or saw pending. A process that saw neither, such as a hook invocation after an earlier one opened the request, records noAPPROVEDentry.
For either kind of store, an expired grant is returned as a Denied with the reason "expired" and recorded as EXPIRED. Without a RequestId that entry names the request this process remembers for the caller and policy, or unknown.
Next steps
- Pick a backend: in-memory, SQLite, or Entra PIM.
- For CLI hosts: config file reference.
- Approver UX: pending-approvals dashboard panel.