Invarix.Gate.AspNetCore 1.0.0-rc.1

This is a prerelease version of Invarix.Gate.AspNetCore.
dotnet add package Invarix.Gate.AspNetCore --version 1.0.0-rc.1
                    
NuGet\Install-Package Invarix.Gate.AspNetCore -Version 1.0.0-rc.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Invarix.Gate.AspNetCore" Version="1.0.0-rc.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Invarix.Gate.AspNetCore" Version="1.0.0-rc.1" />
                    
Directory.Packages.props
<PackageReference Include="Invarix.Gate.AspNetCore" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Invarix.Gate.AspNetCore --version 1.0.0-rc.1
                    
#r "nuget: Invarix.Gate.AspNetCore, 1.0.0-rc.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Invarix.Gate.AspNetCore@1.0.0-rc.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Invarix.Gate.AspNetCore&version=1.0.0-rc.1&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Invarix.Gate.AspNetCore&version=1.0.0-rc.1&prerelease
                    
Install as a Cake Tool

Invarix.Gate.AspNetCore

Human approval over HTTP for Invarix.Gate.

When a Gate policy escalates a tool call, this package posts a redacted approval request to a webhook and parks the run. An operator opens a link, reads what the agent is trying to do, and approves it, denies it, or stops the run. The answer comes back to the waiting agent and the call proceeds or does not.

Requires a commercial Gate license. Approvals are a Professional feature.


The flow, end to end

  1. A rule returns escalate. The engine builds an ApprovalTicket: the tool, the agent and run ids, the findings, and an argument preview with secrets already masked.
  2. WebhookApprovalBroker issues a single-use token, registers a pending entry in ApprovalRendezvous, and POSTs a JSON payload to your WebhookUri.
  3. The payload carries one link: a review URL. Your relay puts it in an email, a chat message, a ticket, wherever your operators are.
  4. The operator opens the link. GET /invarix-gate/approvals/{id}?t={token} renders a self-contained HTML page showing the redacted details and three buttons. It changes nothing.
  5. The operator clicks one. The browser POSTs to the same path with the token and the chosen outcome in the body. The token is consumed atomically, the rendezvous is completed, and a short result page is rendered.
  6. The broker's await returns. The engine turns the outcome into allow, deny, or terminate, and records the operator identity your authentication reported.

Meanwhile the engine holds its own clock. GateLimits.ApprovalTimeout (ten minutes by default) bounds how long the run waits, and on_approval_timeout decides the call if nobody answers. This package does not own that policy; it owns not wasting it.

Wiring

services.AddInvarixGateApprovals(approvals =>
{
    approvals.WebhookUri = new Uri("https://chat-relay.example.internal/gate");
    approvals.PublicBaseAddress = new Uri("https://agents.example.internal");
});

services.AddInvarixGate(gate => gate
    .Mode(GateMode.Enforce)
    .UseDefaultPack());

var app = builder.Build();

app.MapInvarixGateApprovals()
   .RequireAuthorization("AgentOversight");

AddInvarixGateApprovals registers the token store, the rendezvous, a typed HttpClient, and the broker as the container's IApprovalBroker. It refuses to run if another broker is already registered, because the engine uses exactly one and a second registration replaces rather than supplements it.

MapInvarixGateApprovals maps two routes, a GET and a POST, and returns a builder covering both. Anything you chain onto it, RequireAuthorization included, applies to both.

The pattern you pass must match GateApprovalOptions.EndpointPath; the broker builds operator links from that path and cannot read your route table. A mismatch throws at startup rather than producing links that 404 the first time someone needs to approve something.

Payload

{
  "approvalId": "5b2f…",
  "toolName": "run_sql",
  "agentId": "support-agent",
  "runId": "run-8812",
  "expiresAt": "2026-08-01T14:22:31.0000000+00:00",
  "findings": [
    { "ruleId": "destructive-sql", "action": "Escalate", "detail": "DELETE without WHERE in statement 2" }
  ],
  "redactedPreview": "DELETE FROM invoices",
  "reviewUrl": "https://agents.example.internal/invarix-gate/approvals/5b2f…?t=…",
  "decisionUrl": "https://agents.example.internal/invarix-gate/approvals/5b2f…",
  "decisionMethod": "POST",
  "token": "…",
  "outcomes": ["approve", "deny", "stop_run"]
}

No raw tool arguments, ever. A credential-egress escalation is a question about whether to send a secret somewhere; a payload quoting the argument would send that secret to the webhook destination in the act of asking. Everything argument-derived is masked and sanitised on the way out, a second time, even though the ticket already promises redaction.

An automated approver can skip the page: POST to decisionUrl with token=…&outcome=approve|deny|stop_run as application/x-www-form-urlencoded.

A GET never decides anything

This is the shape of the whole design, so it is worth saying plainly.

Approval links travel through email and chat. Corporate mail scanners, Outlook SafeLinks, and Slack and Teams link previewers fetch every URL they see, unprompted, with no human involved. An approve-by-GET design therefore approves every escalation that transits those systems, silently, before the operator has read the message, and those are exactly the channels an approval webhook feeds.

So:

  • GET {pattern}/{id} renders and mutates nothing. It does not consume the token, complete the rendezvous, or move any counter. A prefetcher can hit it a hundred times and change nothing.
  • The decision is a POST from the form on that page, with the token in the body.
  • The payload contains no "approve" URL. Not one that works by GET, and not one named approveUrl that a chat client would render as a clickable link. There is a review URL and a POST target, and that is all.

The token is what makes the POST safe from cross-site forgery: another origin can post to the endpoint, but it cannot read the token out of the review page to include.

Tokens

256 bits of CSPRNG entropy, stored server-side only as a SHA-256 hash, consumed atomically so a replay loses the race, compared in constant time. Unknown, expired, already-consumed, and malformed ids all produce byte-for-byte the same 404 page, and the token is hashed and compared even on a lookup miss so the timing does not tell them apart either. Distinguishing them would hand an unauthenticated caller an oracle for which approval ids exist.

The review URL carries the token in the query string, because a link-based flow has nowhere else to put it and a page with no script cannot read a URL fragment. Treat the review URL as a capability, the same way you treat a password-reset link. It is single use, it expires (TokenLifetime, 30 minutes by default), the page is served no-store and no-referrer, and the POST that actually decides reads the token from the body only.

The page

Self-contained: no external CSS, no script, no images, no remote references of any kind, served under default-src 'none'. Every dynamic value is masked for credentials, stripped of ANSI escapes, control characters, bidirectional overrides and zero-width characters, flattened to a single capped line, and then HTML-escaped.

That order is deliberate. A human reads this page and then clicks approve, which makes it a persuasion surface, and tool arguments are model-authored and therefore attacker-influenceable. An argument containing </div><h1>SAFE: routine cleanup</h1> renders as those exact characters. Entity escaping alone would not be enough: it would leave a right-to-left override free to render a tool name backwards on a page somebody is about to approve.

SafeText in Invarix.Gate ships console and markdown encoders but no HTML one, and adding one there would put a web concern in the package that deliberately has no web dependency. The escaping lives in GateApprovalEndpoint instead, layered on top of SafeText.Sanitize.

A broken webhook does not eat the approval timeout

Gate allows ten minutes for a human. That budget belongs to the human, not to a destination that is not there.

Delivery is capped at DeliveryBudget (5 seconds) across at most MaxDeliveryAttempts (3) attempts with jittered backoff of roughly 250 ms then 750 ms. Five seconds is under one percent of the ten minutes. Only transient responses are retried: 408, 429, and the 5xx family. Every other 4xx is a settled answer, usually a wrong URL or missing authentication, and retrying it produces load without ever fixing anything.

When delivery fails, the broker raises ApprovalDeliveryException. The engine catches it, logs the broker failure, and applies your on_approval_timeout policy, which denies by default. The run is not broken and the call is still decided.

It is a throw rather than a returned decision on purpose. ApprovalDecision has no outcome meaning "nobody answered", so a broker that returned Deny on an unreachable webhook would be recorded by the engine as an operator decision, and your verdict log and evidence record would state that a person denied a call no person ever saw. For a control whose entire value is that a human was in the loop, that is the one failure that cannot be allowed.

Per-process only

ApprovalRendezvous is a dictionary in memory in one process. Two instances behind a load balancer will not find each other's pending approvals.

A decision that lands on the instance which did not raise the escalation finds nothing and is answered with the same "not available" page as an expired link. The operator sees a dead link and the run times out.

If you run more than one instance, do one of these:

  • Route the approval path back to the instance holding the run, by sticky session on the approval id.
  • Give each instance its own PublicBaseAddress, so the link points at the instance that issued it.
  • Supply your own IApprovalBroker over a shared store.

A distributed cache is not used here on purpose. It would put a human oversight decision behind a network dependency that can fail, and a cache that loses an entry fails worse than a link that only works on one machine.

The pending cap

MaxPendingApprovals defaults to 256. Past it, registration is refused, the broker signals a delivery failure, and the engine applies its timeout policy. It does not queue.

The cap is about human capacity, not memory. A pending entry is a few hundred bytes, but it is also one agent run parked mid-task and one question a person has to read. Past a couple of hundred outstanding questions there is no oversight happening, only a queue, and the honest response is to stop pretending otherwise. Queuing would make the 257th escalation wait behind an unrelated human, spend its own approval timeout on a question nobody has been asked yet, and then time out anyway with no record of why.

What the host still owns

Authorisation. Gate authenticates nobody. Following the same rule as MapEvidenceExport, this endpoint applies no policy of its own: chain .RequireAuthorization(...) onto the returned builder, or put it behind mTLS or a gateway. The operator identity recorded with a decision is whatever your authentication put on HttpContext.User, and it is null when you authenticated no one. A valid token proves only that its holder received the approval request.

TLS. PublicBaseAddress accepts http so a development loop on localhost works. In production it means the token crosses the network in the clear. Terminate TLS.

Who can reach the endpoint. The review page discloses the tool, the agent, the run, the findings, and the redacted argument preview. That is a summary of what your agents are doing. Put the endpoint on a network your operators can reach and nobody else can.

The webhook destination. Whatever receives the payload gets the review link, and therefore the ability to approve. It is part of the oversight boundary. SharedSecret lets the receiver verify the request really came from Gate.

RequireInboundSecret additionally demands that secret on the decision POST, for hosts with no authentication middleware at all. Understand what it does before switching it on: it turns off the browser flow. An HTML form cannot set a request header, and putting the secret in a hidden field would hand it to everyone who can open a review link. It is for approvals answered by an automated relay, not by a person clicking a button.

Options

Option Default What it is
WebhookUri required Where the approval request is posted.
PublicBaseAddress required Origin the operator's review link is built from.
EndpointPath /invarix-gate/approvals Must match the pattern passed to MapInvarixGateApprovals.
TokenLifetime 30 minutes How long a link is worth anything.
MaxDeliveryAttempts 3 Total POST attempts, first included.
DeliveryRetryDelay 250 ms First backoff, tripled and jittered thereafter.
DeliveryBudget 5 seconds Hard ceiling on all attempts and backoffs together.
MaxPendingApprovals 256 Concurrent approvals awaiting a human.
SharedSecret none Sent outbound so the receiver can verify Gate sent it.
SharedSecretHeaderName X-Invarix-Gate-Secret Header the secret travels in.
RequireInboundSecret false Demand it on the decision POST. Disables the browser flow.
LicenseToken INVARIX_GATE_LICENSE Professional license token.

All of it is validated at construction. A misconfigured approval channel is invisible until the first escalation, and that is the worst moment to find out.


Commercial licensing: sales@invarix.dk · invarix.dk

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0-rc.1 63 8/6/2026