Invarix.Gate.AspNetCore
1.0.0-rc.1
dotnet add package Invarix.Gate.AspNetCore --version 1.0.0-rc.1
NuGet\Install-Package Invarix.Gate.AspNetCore -Version 1.0.0-rc.1
<PackageReference Include="Invarix.Gate.AspNetCore" Version="1.0.0-rc.1" />
<PackageVersion Include="Invarix.Gate.AspNetCore" Version="1.0.0-rc.1" />
<PackageReference Include="Invarix.Gate.AspNetCore" />
paket add Invarix.Gate.AspNetCore --version 1.0.0-rc.1
#r "nuget: Invarix.Gate.AspNetCore, 1.0.0-rc.1"
#:package Invarix.Gate.AspNetCore@1.0.0-rc.1
#addin nuget:?package=Invarix.Gate.AspNetCore&version=1.0.0-rc.1&prerelease
#tool nuget:?package=Invarix.Gate.AspNetCore&version=1.0.0-rc.1&prerelease
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
- A rule returns
escalate. The engine builds anApprovalTicket: the tool, the agent and run ids, the findings, and an argument preview with secrets already masked. WebhookApprovalBrokerissues a single-use token, registers a pending entry inApprovalRendezvous, and POSTs a JSON payload to yourWebhookUri.- The payload carries one link: a review URL. Your relay puts it in an email, a chat message, a ticket, wherever your operators are.
- 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. - 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.
- The broker's
awaitreturns. The engine turns the outcome intoallow,deny, orterminate, 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
POSTfrom 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
approveUrlthat 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
IApprovalBrokerover 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 | Versions 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. |
-
net10.0
- Invarix.Gate (>= 1.0.0-rc.1)
-
net8.0
- Invarix.Gate (>= 1.0.0-rc.1)
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 |