Skip to content

About

Agent Threat Rules as an operator-run OpenShell supervisor middleware service (openshell.middleware.v1)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

4 Commits

Folders and files

Repository files navigation

openshell-middleware-atr

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.

Status and disclosure

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.

Run

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"

Extension authentication

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.

Key freshness

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.

Configuration

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.

What it cannot do, measured

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.

Audit safety

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.

Contract details that bit

Each of these cost a debugging cycle. Three of the four are documented, and it is worth being precise about which:

  • reason_code must match ^[a-z][a-z0-9_]{0,63}$ — a dotted code such as atr.rule_match is rejected; it is atr_rule_match. Documented in the reason_code field comment.
  • At most 32 findings per stage. Overflow is truncated with an explicit atr_findings_truncated finding rather than silently dropped. Documented in the findings field comment.
  • reason is discarded above 4 KiB, so it is truncated on a UTF-8 boundary. Documented in the reason field comment.
  • Config arrives as google.protobuf.Struct, which @grpc/proto-loader delivers as {fields: {k: {stringValue: v}}}. Passing that straight to a config parser makes every request look like it has one unknown key named fields. Unit tests built from plain objects do not catch it; only driving the gRPC path does. See src/struct.ts and 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 to docs/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.

Licence

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.

About

Agent Threat Rules as an operator-run OpenShell supervisor middleware service (openshell.middleware.v1)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages