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
| Threat | Defence |
|---|---|
| A prompt-injected agent exfiltrates a key | The key is never in the agent's context. It cannot send what it does not have. |
| The agent calls an unintended endpoint | Deny by default: host, path pattern and method must all match a configured target. |
| The agent reaches internal infrastructure | Private ranges, loopback and cloud metadata addresses are blocked unconditionally — including for allow_any users and on every redirect hop. |
| An API reflects the token back | Responses are scanned for every secret value of that user and replaced before the model sees them. |
| A runaway loop burns an API quota | Per-user, per-target token buckets. |
| A key leaks through transcripts or logs | The key never enters a transcript. The audit log records secret names, never values. |
| A stolen refresh token is replayed | Refresh tokens rotate. Presenting a spent one invalidates the entire token family for that client and user. |
| You do not know what the agent did | One 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.
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.
| Blocked | Why |
|---|---|
127.0.0.0/8, ::1 | Loopback — anything else the host runs. |
10/8, 172.16/12, 192.168/16, fc00::/7 | Private networks. |
169.254.0.0/16, fe80::/10 | Link-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/15 | Carrier-grade NAT, protocol assignments, benchmarking. |
0.0.0.0/8, 224.0.0.0/4, 240.0.0.0/4 | Unspecified, multicast, reserved. |
Any non-http(s) scheme | No 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.
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-CookieandSet-Cookie2are 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:orfile:reference. The shipped.gitignorealready excludesconfig.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.
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.
aegis