An operator-run OpenShell supervisor middleware service that evaluates sandbox HTTP egress against Agent Threat Rules (ATR, MIT) and returns an allow/deny decision with audit-safe findings.
It implements openshell.middleware.v1.SupervisorMiddleware — Describe,
ValidateConfig, EvaluateHttpRequest — and binds to the
HTTP_REQUEST / PRE_CREDENTIALS stage.
Supervisor middleware is a research preview upstream and its contracts may change without compatibility guarantees. This service tracks that contract and carries the same caveat.
Written by the ATR maintainer. It is a working reference implementation of the middleware contract, not a production security control, and it has not been run against a live OpenShell gateway by anyone but its author. Everything claimed below is reproducible with the commands given.
npm install
npm run verify # build, 50 unit tests, then the end-to-end gRPC smoke
npm start -- --bind 0.0.0.0:50051
The smoke run drives the service over gRPC twice: once unauthenticated, and once over TLS with EdDSA tokens minted against a throwaway key, checking that a valid credential is accepted and that six kinds of bad one are not.
Register it with a gateway:
[[openshell.supervisor.middleware]]
name = "atr-guard"
grpc_endpoint = "https://atr-guard.internal:50051"
tls_ca_cert_path = "/etc/openshell/atr-guard-ca.pem"
audience = "urn:openshell:extension:middleware:atr-guard"
max_payload_bytes = 262144
timeout = "500ms"Since OpenShell #2638 the gateway attaches a short-lived EdDSA bearer token to every remote middleware call, and the service is expected to verify it. This one does, and it fails closed: with neither authentication configured nor explicitly disabled, it does not start.
| Variable | Meaning |
|---|---|
ATR_GUARD_AUDIENCE |
Exact audience to accept. Must equal the registration's audience, which defaults to urn:openshell:extension:middleware:<registration-name>. |
ATR_GUARD_GATEWAY_ID |
Gateway id. The expected issuer is openshell-gateway:<id>. |
ATR_GUARD_GATEWAY_URL |
Trusted gateway base URL, https:// only. Used for steady-state JWKS refresh. |
ATR_GUARD_GATEWAY_JWKS |
Inline JWKS, for provisioning key material directly. |
ATR_GUARD_TLS_CERT_PATH / ATR_GUARD_TLS_KEY_PATH |
Server TLS material. Required when authentication is on, because an authenticated registration uses an https:// endpoint. |
ATR_GUARD_CLOCK_SKEW_SECONDS |
Expiry tolerance, default 60. OpenShell publishes no skew guidance, so this number is ours. |
ATR_GUARD_ALLOW_INSECURE_TRANSPORT |
Run with no credential check at all. Mirrors the registration flag of the same name. Development and trusted networks only. |
Everything is read from the environment; nothing is accepted on argv.
What gets verified, per the upstream checklist: typ is exactly
openshell-ext+jwt, alg is pinned to EdDSA rather than read from the token,
then signature, exact issuer, exact audience and positive expiry, then
caller_kind cross-checked against sub and sandbox_id.
Two of those deserve saying out loud. Extension tokens and sandbox-to-gateway
bootstrap tokens are signed by the same key under the same kid and differ
only in typ and audience, so a verifier that checks signature and issuer
alone will accept a sandbox's own admission credential — there is a test for
exactly that. And aud is a scalar string here, so the usual includes() over
a string | string[] audience would widen the check into a hole.
A repeated jti is deliberately not rejected: OpenShell reuses one token
across calls until rotation, so treating repetition as replay would reject
nearly all legitimate traffic.
Set ATR_GUARD_GATEWAY_URL. With only an inline JWKS the service has no way to
learn that a key was retired, so a compromised one would keep verifying for as
long as the process runs.
With the URL set, a cached key is re-checked after five minutes rather than
only when an unknown kid happens to arrive. If the gateway is unreachable the
cached key keeps working — a brief outage should not take the guard down — but
only up to one hour, the contract's maximum token lifetime. Past that, every
token that could have been minted against it has expired anyway, so continuing
to trust it buys nothing and only widens the window on a compromised key.
Passed per binding as a google.protobuf.Struct. Unknown keys are rejected at
ValidateConfig time rather than ignored.
| Key | Default | Meaning |
|---|---|---|
deny_at_severity |
high |
Lowest rule severity that produces a DENY. Lower-severity matches are still reported as findings. |
maturity |
stable |
Only evaluate rules at this maturity. stable is ATR's enforce lane. |
report_not_evaluated |
true |
Emit an explicit finding counting rules this event shape cannot reach. |
The defaults deny only on stable rules at high or above. ATR's published
lane split puts roughly 9% false positives on the hunt lane against its 65K
benign gate; denying inline on that would break workloads.
The middleware contract describes a sandbox HTTP egress. ATR rules are written against agent-semantic events. The vocabularies do not fully line up, and the service reports the gap rather than hiding it.
Against ATR 3.5.11, on startup:
768 rules loaded, 408 not reachable on this event shape
ATREngine skips a rule whose agent_source.type does not match the event
type, so 53% of the corpus is never evaluated here. Those rules are reported
as an atr_not_evaluated finding with that count — never as a clean result,
because reporting "we could not look" as "we looked and found nothing" makes
every coverage number computed downstream wrong in the flattering direction.
The three specific mismatches, also encoded in MAPPING_LIMITS in
src/mapping.ts so this file cannot drift from the code:
| Concept | Contract carries | Consequence |
|---|---|---|
| Event type | nothing agent-semantic | ATR has no network event type; egress is mapped to tool_call, which is lossy — rules written for tool calls now also see raw network egress |
| Session identity | sandbox_id |
A sandbox is not a session. Several agent sessions can share one sandbox and a session can outlive one, so session-scoped correlation is not available |
| Agent identity | originating_process.binary |
That is an interpreter path such as /usr/bin/python3, not an agent. agentId is left unset rather than filled with something that would read as an agent identity in an audit trail |
A consequence worth stating plainly: any ATR rule that needs to join observations across a session or an agent cannot run here at all.
One more thing observed while running it: on a jailbreak payload the top
finding came back as ATR-2026-00120 "SKILL.md Prompt Injection". The
detection is correct — the payload does contain an instruction override — but
the rule was authored for skill files, so its label no longer describes the
situation it fired in. Rule labels are surface-specific in a way that a
cross-surface integration makes visible.
The contract requires findings to be audit-safe: a finding label "does not
contain request content". Enforced in one place (src/findings.ts) and tested
by putting a canary string into the body, path, query and a header, then
asserting it does not appear anywhere in the response:
✔ never puts request content in a finding
Credential headers (authorization, cookie, x-api-key, …) are replaced
with [redacted] before the content ever reaches the engine, so a rule cannot
match on a token and a match range cannot contain one.
Findings carry the ATR rule id as the stable type, the rule's public title as
label, a bucketed confidence, and the rule severity. Matched text is dropped
rather than redacted, because the message has no field that may carry
request-derived content at all.
Each of these cost a debugging cycle. Three of the four are documented, and it is worth being precise about which:
reason_codemust match^[a-z][a-z0-9_]{0,63}$— a dotted code such asatr.rule_matchis rejected; it isatr_rule_match. Documented in thereason_codefield comment.- At most 32 findings per stage. Overflow is truncated with an explicit
atr_findings_truncatedfinding rather than silently dropped. Documented in thefindingsfield comment. reasonis discarded above 4 KiB, so it is truncated on a UTF-8 boundary. Documented in thereasonfield comment.- Config arrives as
google.protobuf.Struct, which@grpc/proto-loaderdelivers as{fields: {k: {stringValue: v}}}. Passing that straight to a config parser makes every request look like it has one unknown key namedfields. Unit tests built from plain objects do not catch it; only driving the gRPC path does. Seesrc/struct.tsand its regression tests. Not documented, and not a gap in the contract — it is a language-binding behaviour, invisible from the Rust example, and the kind of thing only a second implementation in another language surfaces. Written up for upstream on this branch, which adds a short "Implement in a Language Other Than Rust" section todocs/extensibility/supervisor-middleware.mdx.
The honest summary is that the proto's field comments are good and I had not
read them closely enough. The one thing genuinely missing from the tree is the
Struct decoding shape, which is a documentation gap for non-Rust implementers
rather than a defect in the contract.
MIT. ATR is MIT and ruleset-neutral to this seam: the service is a demonstration that the middleware contract can carry a detection guard, not an argument that it should carry this one.