Security

Aegis exists to shrink the blast radius of a compromised or manipulated agent. This page is about what that means precisely — and where the limits are.

Threat model

ThreatDefence
A prompt-injected agent exfiltrates a keyThe key is never in the agent's context. It cannot send what it does not have.
The agent calls an unintended endpointDeny by default: host, path pattern and method must all match a configured target.
The agent reaches internal infrastructurePrivate ranges, loopback and cloud metadata addresses are blocked unconditionally — including for allow_any users and on every redirect hop.
An API reflects the token backResponses are scanned for every secret value of that user and replaced before the model sees them.
A runaway loop burns an API quotaPer-user, per-target token buckets.
A key leaks through transcripts or logsThe key never enters a transcript. The audit log records secret names, never values.
A stolen refresh token is replayedRefresh tokens rotate. Presenting a spent one invalidates the entire token family for that client and user.
You do not know what the agent didOne structured audit line per request, allowed or refused.

What aegis does not do

Stated plainly, because a security tool that oversells itself is worse than none.

Anything you explicitly allowed

If a target permits DELETE /v1/**, a manipulated agent can delete. Aegis constrains the blast radius; it does not read intent. Scope targets narrowly and prefer read-only.

Protect against a compromised host

Root on the machine running aegis means access to the config and to process memory. Aegis is a boundary between the model and the secret — not between an attacker and the machine.

Vet the client

Any client that completes the OAuth flow with valid credentials gets that user's capabilities. Password strength and who you hand credentials to remain yours.

Stop exfiltration through an allowed target

A writable target can in principle be used as a channel to send data out. Read-only targets where you can.

Secrets shorter than 8 bytes are not redacted. Below that the false-positive rate would corrupt legitimate response content. Aegis warns at startup naming each affected secret, but the gap is real: a short credential reflected by an upstream will reach the model. Use long credentials.
Per-IP rate limiting stops working behind a proxy. Aegis reads the client address from the TCP connection, so behind Traefik or Cloudflare every request appears to come from the proxy and the login limit becomes global. See the client IP problem in the deployment guide.

Network guard

Applied to every outbound request, for every user, and re-applied on every redirect hop. There is no override — a private-network target would defeat the purpose, so it is refused at config load time.

BlockedWhy
127.0.0.0/8, ::1Loopback — anything else the host runs.
10/8, 172.16/12, 192.168/16, fc00::/7Private networks.
169.254.0.0/16, fe80::/10Link-local, which includes 169.254.169.254 — the cloud metadata endpoint.
100.64.0.0/10, 192.0.0.0/24, 198.18.0.0/15Carrier-grade NAT, protocol assignments, benchmarking.
0.0.0.0/8, 224.0.0.0/4, 240.0.0.0/4Unspecified, multicast, reserved.
Any non-http(s) schemeNo file:, no gopher:, no redirect into one.

IPv4-mapped IPv6 addresses such as ::ffff:169.254.169.254 are normalised before the check, so a mapped address cannot slip past the IPv4 ranges.

DNS rebinding is handled. Aegis resolves the host, checks the resolved addresses, and then dials that IP directly through a custom dialer that re-checks at connect time. A name cannot resolve to a public address during the check and a private one when the connection is made.

Redaction

Injection alone is not enough. A debug echo endpoint, a verbose 401, a webhook inspector — plenty of upstreams will hand your token straight back. Every response body and header is searched for the calling user's secret values and each occurrence is replaced with [REDACTED:name].

  • Longest match first, so an overlapping pair yields the more specific name.
  • Redaction runs before truncation, so a secret cannot survive by straddling the cut.
  • Set-Cookie and Set-Cookie2 are dropped entirely.
  • The replacement count lands in the audit line — a non-zero value tells you which upstream reflects credentials.

Design decisions

Declarative injection only

The alternative — letting the model write ${secret} wherever it likes — was rejected because it hands placement back to the component that can be manipulated. A model that decides where a secret goes can, under injection, put it into a query parameter of an allowed host that echoes it back. Declaring the rule once, at the target, means the model's input can be wrong but never dangerous in that particular way.

In-memory state only

Registered clients, authorization codes and tokens exist only in RAM. Restarting means everyone signs in again — a real cost, accepted for a real benefit: a container full of credentials that writes nothing to disk leaves nothing behind. No token database to steal, to back up by accident, or to forget to encrypt. Hot-reloading the config is the mitigation, so restarts stay rare.

No consent screen

The user is the operator, and the targets were chosen by the same person who wrote the config. A click-through nobody reads is not a security control, so the login is the whole ceremony.

Two tools, not one per target

Generating a tool per configured target would improve the model's aim, but makes the tool list a function of the user and the config. list_targets recovers most of the discoverability at a fraction of the surface.

A small supply chain

Go, the standard library, and two external packages: gopkg.in/yaml.v3 and golang.org/x/crypto for bcrypt. For a process whose entire purpose is holding credentials, a dependency list short enough to read is a security property. The image is distroless and runs as a non-root user.

Hardening the deployment

  • Terminate TLS in front. Aegis speaks plain HTTP by design. The deployment guide has working setups for Traefik and Cloudflare.
  • Run the container read-only with --read-only --cap-drop ALL --security-opt no-new-privileges. Aegis never writes to disk, so nothing breaks.
  • Keep the config out of git unless every value is an env: or file: reference. The shipped .gitignore already excludes config.yaml.
  • Prefer file: references to real Docker or Podman secrets over inline values.
  • Give each agent its own user with the narrowest target set that works, and widen it by watching the audit log.
  • Set a rate limit on every target. The default is unlimited, which is the wrong default for a model in a loop.

How it is tested

The test suite covers path globs, target matching, every blocked network range, IPv4-mapped addresses, DNS rebinding, injection placement, header rejection, redaction across a truncation boundary, PKCE failure, code replay, refresh rotation and refresh reuse.

One test matters more than the rest. TestNoSecretEverLeaks drives a matrix of request shapes against upstreams that behave badly — echoing the token in the body, in a header, in an error message, or reflecting the entire request — and asserts that no secret value appears in any tool result or log line. Everything else checks a mechanism; that one checks the promise.

shell
cd src
go test -race ./...

Reporting a vulnerability

If you find a way to get a secret out of aegis, please report it privately rather than opening a public issue: use GitHub's private advisory form. A proof of concept — a config plus the request that leaks — is the most useful thing you can send.

Everything else is welcome as a normal issue.