Changelog¶
What shipped when. The canonical source is the project-root CHANGELOG.md, kept in
Keep a Changelog format.
Changelog¶
All notable changes to Aegis will be documented here. This project follows Keep a Changelog and Semantic Versioning.
Unreleased¶
Added¶
-
Real SDKs —
aegis-sdk-scala+aegis-sdk-javanow work (closes #98, ROADMAP 2.2.a). Both published SDK artifacts previously threw on their only entry point (NotImplementedError/UnsupportedOperationException). The CLI's tested blocking client (AegisHttpClient+WireFormats+HttpPort) moved down intoaegis-sdk-scalaunderdev.aegiskms.sdk, gaining bearer-token auth (Authorization: Bearer <jwt>) alongside the devX-Aegis-Userheader.AegisClient.https(baseUrl, token)/AegisClient.dev(baseUrl, principal)return a working client with full REST coverage (key lifecycle, sign/verify, encrypt/decrypt, wrap/unwrap, rotate/compromise, agent issuance, audit read, advisor).aegis-sdk-java'sAegisClientJis now a thin pure-Java delegate over the newjavadsl.AegisJavaClient:java.utilcollections in, wire DTOs out, failures as oneAegisClientException. The CLI consumes the SDK client, so the wire code exists exactly once. -
Production preflight (closes #99, ROADMAP 2.2.b).
Server.bootstep 0 cross-checks the bind address against dev-grade settings (auth.kind=dev,policy.kind=dev,crypto.kind=in-memory,journal.kind=in-memory). Loopback binds always pass. On a network-reachable bind,aegis.security.preflight=warn(default) prints one unmissable banner listing each finding and its risk;enforce(setAEGIS_SECURITY_PREFLIGHT=enforce— recommended for production) refuses to boot before any resource is acquired.
Changed¶
- CLI wire DTOs moved package.
dev.aegiskms.cli.{AegisHttpClient, WireFormats, HttpPort}are nowdev.aegiskms.sdk.*(the CLI re-uses them from the SDK). Source-breaking only for code that imported the CLI's internals — the CLI itself is unchanged on the command line.
0.2.1 — 2026-06-06¶
Added¶
aegis advisor explain <agent-id>— agent-session timeline + LLM narration (closes #29, ROADMAP 2.1.b/2.1.d). The last piece of the v0.2.1 LLM-advisor wedge: assembles one agent's audit timeline (chronological events, risk scores, anomaly flags + a deterministic summary) and — when an LLM provider is configured (#30) — narrates it in plain language. Read-only and safe by construction (2.1.d): the model is handed only the structured timeline under a strict read-only system prompt, the prompt is bounded by amaxEventscap, and any LLM failure degrades gracefully to the bare deterministic timeline rather than erroring. Withaegis.advisor.llm.provider=none(the default) there are no outbound calls at all.AdvisorService.explain(aegis-agent-ai): pages the audit log filtered to the agent, builds the timeline via the pureAdvisorExplain.timeline, then optionally narrates via the injectedLlmClient.GET /v1/advisor/explain/{agentId}(aegis-http): human-only,501when no advisor is wired; knobslookbackDays/maxEvents.aegis advisor explain <agent-id>(aegis-cli): prints the narrative (when present) followed by the event timeline.-
Wiring:
Server.bootnow builds the LLM provider fromaegis.advisor.llm.*and threads it into the advisor — the integration point for #30. Newapplication.confaegis.advisor.llmblock (provider / api-key / base-url / model / max-tokens, all env-overridable; defaults toprovider=none). -
Pluggable LLM provider SPI + adapters (#30, ROADMAP 2.1.c). The
LlmClient[F]SPI inaegis-agent-ainow has three bundled, config-selected adapters: Anthropic (POST /v1/messages) and OpenAI (POST /v1/chat/completions) for the "pair with your existing AI vendor" path, and Ollama (POST /api/generate) for local/private use — audit data never leaves the host.LlmClient.fromConfigselects by name (none→ disabled, returns no client; unknown name fails fast). Adapters call through a tiny testableLlmHttpseam (JDK-backed default), so each provider's request shape and response parsing are unit-tested with no network or API key. The contract stays read-only — the model only ever describes/recommends, never executes a crypto op. (Bedrock is the remaining fast-follow — it needs AWS SigV4; the SPI is provider-shaped for it. Wired into a running feature byadvisor explain, #29.) -
aegis advisor scan— read-only audit triage (closes #28, ROADMAP 2.1.a). The first slice of the v0.2.1 LLM-advisor wedge, deliberately deterministic (no LLM, no mutation): it aggregates the audit log into a bounded operator summary — idle keys, broad-scope agents, active anomalies, and the riskiest agents. Keeping the analysis deterministic means the headline demo runs in CI with no API key and the numbers are reproducible. The pluggableLlmClient(#30) layers natural-language narration over these same facts later. AdvisorService[F]SPI +AdvisorScananalyzer (aegis-agent-ai): the heuristics live in a pureAdvisorScan.analyze(property-testable against synthetic records);AdvisorService.deterministicpages the audit log via the existingAuditQuerySPI up to a 50k-row cap and marks the reporttruncatedwhen the window exceeds it (no silent under-reporting).GET /v1/advisor/scan(aegis-http): human-principals-only (Service/Agent get 403),501when the server has no queryable audit sink (audit kind ≠ postgres) — same access model asGET /v1/audit. Tuning knobslookbackDays/unusedDays/broadScope/topfall back to defaults (90 / 30 / 5 / 5).aegis advisor scan(aegis-cli): replaces the long-standing stub; flags--lookback-days,--unused-days,--broad-scope,--top; sectioned terminal output that prints "none" for empty findings.- Wiring:
Server.bootbuilds the advisor over the sameAuditQuerythat backs the audit-read endpoint, so it's available exactly when audit-read is.aegis-httpnow depends onaegis-agent-ai(both server-tier; no Pekko added to the library tier).
Fixed¶
-
Docker (main)workflow flaked on a transient GHCR push. The:mainimage build/push intermittently red-X'd withunknown blobeven though the image built and every layer uploaded — a registry-side manifest race, not a build failure. The publish step now retries up to 3× with backoff so a flaky push no longer fails an otherwise-goodmainbuild. (release.ymlhas the same single-shot publish and would benefit from the same treatment.) -
Honey-key auto-revoke never fired (regression in #26). Two gaps meant a honey-key touch produced the
HoneyKey/High recommendation but noRevokeaction: (1)AutoResponder.DefaultRuleswas built from only the five baseline detector names —"HoneyKey"was missing, so the rule lookup returnedNone; (2)AutoResponder.extractKeyIdrequired akey:<id>resource prefix, but op-on-key audit records carry the bareKeyId, so even with a matching rule the revoke failed with "resource is not a key reference." Added"HoneyKey"toDefaultRulesand taughtextractKeyIdto accept a bareKeyId(still rejectingname:/pattern:create/locate resources). The canary now actually revokes the key on first agent touch. Caught by a new end-to-end test (HoneyKeyAutoRevokeE2ESpec) that assembles the real detector → auto-responder → actor stack rather than hand-built recommendation fixtures.
Changed¶
- Doobie
1.0.0-RC5→1.0.0-RC12(latest RC;1.0.0final is not yet released). - Docker release publishing now also pushes the floating
:MAJOR.MINORand:latestaliases for stablevX.Y.Ztags (pre-release tags get the exact tag only). - MkDocs
strict: trueto match the CI--strictbuild; fixed several intra-page anchor links. - Maven Central publishing migrated to the Sonatype Central Portal. Bumped sbt
1.10.2→1.12.11andsbt-ci-release1.6.1→1.11.2, and removed thesonatypeCredentialHost := "s01.oss.sonatype.org"setting — the legacy OSSRH host was sunset 2025-06-30, andsbt-ci-release1.11+ targetscentral.sonatype.comautomatically.RELEASING.mdupdated with the Central Portal namespace + user-token setup. (Publishing still requires thePGP_*/SONATYPE_*secrets to be configured.)
0.2.0 — 2026-05-31¶
Added¶
- Honey keys (canary keys) with auto-revoke on agent touch (closes #26). Operator-marked
KeyIds that fire aSeverity.Highrecommendation any time an agent principal touches them;AutoResponder.DefaultRulesthen translate High → Revoke, killing both the key and the agent's JWT via the wiredRevocationList. The first touch is the trip wire by design — there's no cold-start guard like the other detectors have. HoneyKeyRegistrySPI (aegis-agent-ai): tiny —isHoney(KeyId): Boolean+snapshot: Set[KeyId]. Three impls:empty(production-safe default),fromSetfor HOCON-backed wiring, and the ctor-default onBaselineDetector.makeso embedders compile unchanged.BaselineDetectorgains a 6th detector ("HoneyKey") that runs after the five existing ones. Restricted to agent principals only — humans validating that a canary is still alive don't trigger auto-revoke. Skips Create / Locate audit resources (theirname:.../pattern:...shapes don't yield a parseableKeyId).Server.bootwiring:aegis.security.honey-keysHOCON list parsed into aSet[KeyId]and threaded intoBaselineDetector.make(honeyKeys = ...). Supports both HOCON-list syntax and theAEGIS_HONEY_KEYScomma-separated env-var override. Boot fails fast on malformedKeyIdstrings (a typo'd canary that doesn't catch the agent is the opposite of what an operator wanted).-
Tests:
HoneyKeyRegistrySpec(4 cases —empty,fromSetsemantics, snapshot fidelity,fromSet(Set.empty)equivalence).BaselineDetectorSpec(+6 cases — happy-path fire, first-touch trip wire, human-touch no-fire, non-honey no-fire, empty-registry inert, Create/Locate skip). -
Kafka + NATS JetStream audit fan-out sinks (closes #22, closes #23). Two new streaming audit destinations alongside the existing SIEM webhook (#21). Both follow the same shape — bounded
Queue+ background drain fiber + retry + JSONL dead-letter — and compose with the primary durable sink viaFanOutAuditSink, so operators can run e.g.postgres + kafka + natssimultaneously. KafkaAuditSink(aegis-server) uses Pekko-Connectors-Kafka'sSendProducerwith an idempotent producer config (acks=all,enable.idempotence=true,max.in.flight.requests.per.connection=5,retries=Int.MaxValue,compression=lz4). Each record's Kafka key is itscorrelationIdso messages from the same KMS request land on the same partition, preserving order for downstream consumers.NatsAuditSink(aegis-server) usesio.nats:jnats2.21 with JetStream'spublishAsync+PubAckfor durability — the drain fiber only ack's a record once JetStream has durably persisted it. OptionalautoCreateStream=trueprovisions the stream on boot if missing (idempotent — existing streams are left untouched). Supports optional NATS credentials file (.credsfromnsc add user --csv).Server.bootfan-out wiring:kafkaAuditSinkResourceandnatsAuditSinkResourceare added alongsidewebhookAuditSinkResource. All three return an empty list when disabled, so the default path stays zero-cost. Fail-fast at boot on emptybootstrap-servers/topic/servers/stream/subject.- New HOCON blocks:
aegis.audit.kafka.{enabled, bootstrap-servers, topic, client-id, max-retries, initial-backoff-ms, max-backoff-ms, dead-letter-file, queue-capacity}andaegis.audit.nats.{enabled, servers, stream, subject, auto-create-stream, credentials-file, max-retries, initial-backoff-ms, max-backoff-ms, dead-letter-file, queue-capacity}. All overridable viaAEGIS_AUDIT_KAFKA_*/AEGIS_AUDIT_NATS_*. - New dependencies:
pekko-connectors-kafka1.1.0 (pairs with pekko 1.1.x) andio.nats:jnats2.21.1. Test-only Testcontainers modules for Kafka and NATS. - Tests:
KafkaAuditSinkSpec(4 cases — happy-path consume, canonical-JSON round-trip, transport failure → DLQ, Config validation) andNatsAuditSinkSpec(5 cases — same coverage plus idempotentautoCreateStream). Integration tests use shared containers viaBeforeAndAfterAllper the workflow-speed pattern established in PR #86. Skip cleanly on machines without Docker. -
Doc updates: ROADMAP audit-sink table (Kafka + NATS both ✅ v0.2.0); also flipped several stale
🔜 v0.2.0rows that had drifted from reality (risk scorer, OIDC, agent-token endpoint, Redis revocation). ARCHITECTURE.md mermaid diagrams + v0.2.0 status table updated. -
MySQL + SQLite event journal adapters (closes #49, closes #50). The persistence SPI now ships three relational backends instead of one. Operators pick via the existing
aegis.persistence.journal.kindHOCON key. MysqlEventJournalmirrorsPostgresEventJournalagainst MySQL 8.x via themysql-connector-jdriver (already inDependencies.persistence). Schema deltas vs. Postgres:BIGSERIAL→BIGINT AUTO_INCREMENT,JSONB→JSON,TIMESTAMPTZ→ UTC ISO-8601 inVARCHAR(40)(MySQLDATETIMEhas no TZ;TIMESTAMPhas a 2038 problem). Bootstrap catchesERROR 1061 Duplicate key nameso the migration is idempotent without MySQL's missingCREATE INDEX IF NOT EXISTS.SqliteEventJournalfor embedded / single-node / CI use viaorg.xerial:sqlite-jdbc(newly added). Schema uses SQLite's affinity types (INTEGER PRIMARY KEY AUTOINCREMENT,TEXTfor everything else).poolSizeis forced to 1 because SQLite serialises writes internally — a larger pool just produces SQLITE_BUSY errors. JSON payloads round-trip as strings (no native JSON type pre-3.45).- New HOCON blocks
aegis.persistence.journal.mysqlandaegis.persistence.journal.sqlitewith env-var overrides (AEGIS_MYSQL_*,AEGIS_SQLITE_JDBC_URL).Server.journalResourcedispatches acrossin-memory | postgres | mysql | sqlite; the kind-unknown branch fails fast at boot with a clear error. -
Tests:
MysqlEventJournalSpec(3 cases, Testcontainersmysql:8.4, gated on Docker via the sameassume(dockerAvailable)pattern as the Postgres spec).SqliteEventJournalSpec(4 cases) needs no Docker — uses per-test temp files. The fourth SQLite case asserts durability across connection churn (the property that makes SQLite usable for embedded deployments). -
RoleBasedPolicyEnginewired inServer.boot(closes #77). The role-based engine has shipped inaegis-iamsince v0.1.0 with full tests, butServer.bootalways instantiatedDevPolicyEngineregardless ofaegis.auth.kind. As a result, the "alice can sign but not revoke" human-RBAC story we sell as the wedge's safety net was a no-op for humans in production HMAC / OIDC mode — only the agent-scope recursion still fired. - New
aegis.policyHOCON block:kind = dev | role-based(defaultdev), plusrole-based.role-bindings(group → list of KMIP operation names) androle-based.subject-bindings(subject → list of operation names). Env-var overrides viaAEGIS_POLICY_KIND. Server.buildPolicyEngineparses the HOCON, validates every operation name againstOperation.values(typos like"Sgn"fail fast at boot, not silently never-match), and rejectskind=role-basedwhen both binding maps are empty — silent allow-all on misconfiguration would defeat the purpose of opting in.- Per-builder warn logs when either auth or policy is in dev mode replace the old unconditional "starting in DEV MODE" startup warning so the message reflects what's actually configured.
-
Tests: new
PolicyEngineResourceSpec(7 cases) mirrorsRootOfTrustResourceSpec— exercises each branch including the fail-fast paths and validates the bindings produce the expected Allow/Deny decisions. -
Generic SIEM webhook audit sink — closes the last
priority/highaudit row for v0.2.0 (closes #21, ROADMAP 2.0.i). Pluggable HTTPS POST sink with HMAC signing, exponential backoff retry, and dead-letter to disk. Fan-out alongside the primary durable sink (Postgres or stdout) so operators getpostgres + webhooksimultaneously. AuditRecordJson(inaegis-audit, library tier): canonical circe encoder forAuditRecord. Distinct fromaegis-http'sAuditRecordDto(which is the REST audit-read endpoint's wire format and may evolve with the API) so downstream SIEM consumers pin to a stable schema.FanOutAuditSink(inaegis-audit): composes one primary + N secondaries. Asymmetric semantics by design — primary failures propagate (durability contract); secondary failures are logged at WARN and swallowed (best-effort).FanOutAuditSink.ofis a pass-through when the secondaries list is empty so the boot composition stays zero-cost on the default path.WebhookAuditSink(inaegis-server): bounded async queue + background drain fiber. POSTs one record per request as JSON withX-Aegis-Signature: sha256=<hex>(GitHub-webhook convention). 2xx acks. 4xx → DLQ immediately (no retry — auth/malformed are not transient). 5xx and transport errors retry up tomax-retrieswith exponential backoff capped atmax-backoff. Records exceeding the retry budget land in a JSONL dead-letter file (parent directory auto-created on first write). Reuses the bootActorSystem's pekkoHttpextension — no new connection pool.Server.bootfan-out wiring: whenaegis.audit.webhook.enabled=true, the primary sink is wrapped viaFanOutAuditSink.of(primary, List(webhook))before being passed to the auto- responder andHttpRoutes. The audit-readAuditQuerylookup matches onprimarySink(not the wrapped sink) soGET /v1/auditstill works when fan-out is enabled.- New
aegis.audit.webhookHOCON block:enabled,url,secret,max-retries,initial-backoff-ms,max-backoff-ms,dead-letter-file,queue-capacity, all overridable viaAEGIS_AUDIT_WEBHOOK_*. Boot fails fast on empty URL or empty secret when enabled. -
Tests:
FanOutAuditSinkSpec(5 cases) covers the asymmetric failure semantics + identity collapse on empty secondaries.WebhookAuditSinkSpec(6 cases) is an integration suite that spins up a real pekko-http server on127.0.0.1:0per test and validates: 2xx ack, HMAC signature matches an independent recompute, 4xx → immediate DLQ, 5xx retries to DLQ, transport failure retries to DLQ, dead-letter file is well-formed JSONL. -
Source-IP plumbed into audit records — activates
SourceIpBaseline(closes #78). The detector has been inert since v0.1.0 becausesource.ipnever landed inAuditRecord.context. This change wires the HTTP transport's remote address through to every audit row produced byAuditingKeyService. RequestContextSPI inaegis-audit: a tinyIOLocal-backed side-channel withcurrent: IO[Map[String, String]]andset(Map): IO[Unit]. Two impls ship:RequestContext.empty(no-op, the default so existing callers compile unchanged) andRequestContext.fromIOLocal(local)(the real wire-up). Lives inaegis-auditso the library tier stays Pekko-free — only cats-effect is on the classpath.AuditingKeyServicegains an optionalrequestContextctor param. The decorator callsrequestContext.currentinsideinstrument/locateandpreflightContextmerges the per-request bag last so a transport-supplied key (e.g.source.ip) wins over a same-key value from the scorer/engine.Endpoints.scalaintroduces a server-onlyextractFromRequest-basedsourceIpInput. It does NOT appear in the OpenAPI document (the wire shape is unchanged for clients), but every keys / agents / audit endpoint gains an internalOption[String]slot for the remote IP. The extractor reads fromreq.underlyingcast to pekko'sRequestContextbecause Tapir's pekko adapter hardcodesServerRequest.connectionInfoto(None, None, None)— naive use ofconnectionInfo.remotewould always yieldNone.application.confsetspekko.http.server.remote-address-attribute = onso pekko populatesAttributeKeys.remoteAddresson every incoming request (the default isofffor backwards compatibility).HttpRoutes.runIO(clientIp)(io)sets the IOLocal as the first IO step of every request, before any user-facing work runs. Setting it inside the IO chain (rather than on the calling thread) is what makes the value visible to deeper IO consumers — every subsequentflatMapin the fiber inherits it, including the read insideAuditingKeyService.preflightContext.Server.bootconstructs oneIOLocal[Map[String,String]]and hands the sameRequestContext.fromIOLocal(local)to bothAuditingKeyServiceandHttpRoutes— separate locals would leave the read empty. No new HOCON keys; the wiring is automatic when the server runs.- Tests: 4 new
AuditingKeyServiceSpeccases covering the back-compat empty path, the source.ip stamp on a state-changing op, the source.ip stamp on the read-onlylocatepath, and the three-way coexistence withrisk.score+outcome.decisionon the same record. Plus a newHttpRoutesSourceIpSpecintegration suite (3 cases) that drives requests through the fullRouteand asserts the audit log carriessource.ipend-to-end — covering criterion #4 of the issue. -
BaselineDetectordoc cleanup. Removed the three "inert until the HTTP plumbing PR populates this key" comments now that this PR is that plumbing. -
CLI
agent issue+audit tailsubcommands (closes #79). The two demo-critical CLI surfaces moved from stubs ((planned — PR A1)/(planned — PR F2.b)) to real implementations against the v0.2.0 backends. aegis agent issue --label … --scopes Op,Op,… --ttl <seconds> [--parent <subject>]— POSTs to/v1/agents/issueand printsagentId,jti,expiresAt, and the bearer JWT on separate lines so a shell user can copy individual values or grep them out.aegis audit tail [--since] [--until] [--actor] [--key] [--op] [--limit] [--offset] [--watch]— GETs/v1/auditwith the supplied filters URL-encoded. Default mode prints one page;--watchpolls every 2 s, advancing--sinceto the highestat:seen so far (basictail -fUX without dragging cats-effect into the CLI's startup path).IssueAgentRequestDto/IssueAgentResponseDto/AuditRecordDto/AuditQueryResponseDtomirrored intoaegis-cli/WireFormats.scala(duplicated fromaegis-httpon purpose —aegis-clidoes not depend on Tapir + pekko-http to keep its boot time low).AegisHttpClientgainsissueAgentandqueryAuditmethods with the sameEither[ClientError, A]shape as the other endpoint wrappers, including the standardPermissionDenied → exit 5/ItemNotFound → exit 4mapping.-
Tests: 11 new
CliSpeccases covering required-flag errors, scope splitting/trimming, URL encoding of query params (e.g.actor=alice%40org), the empty-page footer, and the--watchboolean-flag parsing. Existing "agent/audit placeholder" assertions inCommandsSpecandCliSpecupdated —advisor scanis now the only remaining stub. -
Redis-backed JWT revocation list — JTI blacklist (closes #24). The kill-switch primitive the auto-responder's "Revoke" action will use to invalidate an agent's bearer token before its natural expiry.
RevocationList[F[_]]SPI inaegis-iam:isRevoked(jti)+revoke(jti, expiresAt). Three impls ship:RevocationList.noop(never revoked),RevocationList.inMemory(process-localRef[Map[jti, expiresAt]], lazy TTL evict on read + prune on revoke), andRedisRevocationList(inaegis-server, Lettuce-backed, key-per-jti withPEXPIREAT).RevocationAwareJwtVerifierdecorator wraps any innerJwtVerifierand consults the list after signature + claims validation passes. Inner rejections (Expired, SignatureInvalid, …) short-circuit before the lookup — saves a Redis round-trip on already-bad tokens. Tokens without ajti(legacy / non-Aegis-issued) pass through unchecked.JwtError.Revoked(jti)variant.PrincipalResolver.jwtmaps it toAuthenticationNotSuccessfulwith a"JWT revoked (jti=…)"message so operators can distinguish "expired naturally" from "killed by auto-responder / admin revoke".- Fail-open on Redis outage.
isRevokedreturnsfalseif the Redis call throws, logging awarn. Bounded security gap = token TTL; the alternative (fail-closed) would create a global outage on a partial-store failure. Operators who need fail-closed semantics can wrap the impl. Server.bootwiring. Newaegis.iam.revocation.kindHOCON key (none|in-memory(default) |redis) +AEGIS_REVOCATION_KIND/AEGIS_REVOCATION_REDIS_URI/AEGIS_REVOCATION_REDIS_KEY_PREFIXenv vars. Empty Redis URI whenkind=redisfails fast at boot — no silent fallback.buildResolvernow takes theRevocationListand wraps the constructed verifier (HMAC / OIDC) withRevocationAwareJwtVerifierautomatically.- Lettuce-based Redis client (
io.lettuce:lettuce-core6.4.0). Single-jar dep, synchronousRedisCommandswrapped inIO.blocking— lighter on the Docker image than redis4cats (which pulls all of cats-effect-redis + Reactor). -
Tests: 7 unit cases in
RevocationListSpec(noop, in-memory revoke + lookup, expired-on-read, idempotent revoke, prune-on-revoke, no-store-on-past-expiry, helper); 5 decorator cases inRevocationAwareJwtVerifierSpec(passthrough, Revoked outcome, no-jti bypass, inner-rejection short-circuit, idempotent double-wrap); 8 Testcontainers cases inRedisRevocationListSpec(Docker-gated — basic write/read, TTL eviction, idempotency, prefix isolation, multi-jti lookups, fail-open on closed connection, …). -
OIDC verifier + JWKS rotation + RS256/ES256 (closes #25). Production-grade auth path. Previously the server could only accept HS256 JWTs signed with a single shared secret (
aegis.auth.kind=hmac) — disqualifying for any real evaluator. Newaegis.auth.kind=oidcmode verifies tokens against an OIDC provider's JWKS endpoint (RS256 / RS384 / RS512 / ES256 / ES384 / ES512). Components shipped: JwksProviderSPI inaegis-iamwithhttp(uri, ttl)(production) +static(set)(tests). The HTTP impl caches theJwkSetwith a configurable TTL and refreshes lazily onkidmiss — handles provider key rotation without operator action or Aegis restart. Usesjava.net.http.HttpClientso the library tier doesn't pick up Pekko / sttp / http4s as a transitive dep.JwkSetvalue type holding parsedjava.security.PublicKeyvalues keyed bykid, parsed from RFC 7517 JWKS JSON via jjwt'sJwks.parser()(gives RSA / EC support for free).OidcJwtVerifierimplements the existingJwtVerifiertrait. Per-verify flow: readkidfrom the token header, look up the key in the cached JWKS (refresh on miss), verify the signature with the resolved public key viaJwts.parser().keyLocator(...), validateissagainstexpectedIssuer(defends against token substitution across shared cloud-IDP key sets), validateaudagainstexpectedAudienceif configured, extract theaegis_kind/aegis_groups/aegis_*extension claims into the existingJwtClaims.Human/JwtClaims.Agentshape.- Algorithm-confusion defence. Passing a
PublicKeytoverifyWithmakes jjwt refuseHS256tokens forged against the public key bytes — the classic "alg=RS256→alg=HS256 with public key as HMAC secret" attack is impossible.parseSignedClaimsalso refusesalg=none. OidcJwtVerifier.fromIssuer(issuerUri, audience, ttl)— one-step factory that fetches/.well-known/openid-configuration, extractsjwks_uri, builds the verifier.Server.bootwiresaegis.auth.kind=oidcwith the new HOCON keysaegis.auth.oidc.issuer-uri(AEGIS_AUTH_OIDC_ISSUER_URI),aegis.auth.oidc.audience(AEGIS_AUTH_OIDC_AUDIENCE, optional), andaegis.auth.oidc.jwks-cache-ttl-seconds(AEGIS_AUTH_OIDC_JWKS_CACHE_TTL_SECONDS, default 3600). Empty / missingissuer-urifails fast at boot — never silently falls back to dev.buildResolveris nowIO[PrincipalResolver](was synchronous) so the OIDC path can hit the network during boot.-
Tests: 11 cases in
OidcJwtVerifierSpeccovering RS256 + ES256 happy paths, Agent-claim round-trip, audience-None opt-out, issuer mismatch, audience mismatch, expired token, kid-not-in-JWKS, wrong-key-same-kid signature mismatch, malformed garbage, missingaegis_kind. Keycloak Testcontainers integration is deferred to a follow-up — the unit tests with hand-rolled RSA/EC keypairs exercise the verification logic comprehensively without the CI cost of a Keycloak container. -
AwsKmsRootOfTrustwired intoServer.boot. Closes a doc-vs-code gap surfaced during the v0.2.0 readiness audit. The AWS adapter has shipped in theaegis-cryptolibrary since v0.1.x (17 passing tests), butServer.bootwas constructingActorBackedKeyService(system)with the defaultRootOfTrust.inMemory(deterministic-MAC dev backend) — meaning the published Docker image used the dev backend regardless of how it was configured. The status / comparison docs claimed "AWS KMS — Shipped" while the running server signed with HMAC. Fix: - New
aegis.crypto.kindHOCON key (in-memory(default) |aws-kms) withAEGIS_CRYPTO_KINDenv-var override. - New
aegis.crypto.aws-kms.region+aegis.crypto.aws-kms.kek-arnkeys (AEGIS_CRYPTO_AWS_KMS_REGION/AEGIS_CRYPTO_AWS_KMS_KEK_ARN); missing config fails fast at boot, never silently falls back to dev. - New
AwsKmsRootOfTrust.resource(cfg): Resource[IO, AwsKmsRootOfTrust]factory wraps theKmsClientin a cats-effectResourceso the SDK's connection pool / metric publisher / background threads are released on SIGTERM. The pre-existingfromConfigis kept (with a docstring warning about the leaked client) so single-shot scripts and embedder code that manages its own KMS-client lifecycle stay supported. Server.bootgains arootOfTrustResource(config)builder parallelingjournalResource; the in-memory branch logs awarnflagging "NOT a real KMS, set kind=aws-kms for production" so operators can't accidentally rely on the dev backend.
Changed¶
- Doc drift cleanup, v0.2.0 readiness audit.
CHANGELOG.mdremoved duplicate### Addedheading in the Unreleased section (merged into a single Added block).docs/about/status.mdcorrected GCP KMS / Azure Key Vault / HashiCorp Vault Transit rows from "v0.2.0" to "v0.3.0" (matches ROADMAP §3.0.a–c).docs/about/status.mdcorrected the OIDC / JWKS row from "WIP" to "Designed" (no commits yet; the trait is in place but no implementation).docs/about/status.mdAWS KMS row now explicitly states theServer.bootwiring path instead of leaving it implicit.docs/about/status.mdKMIP and MCP rows now reference v0.4.0 (matches ROADMAP §4.0.*) instead of the previous v0.2.0 overclaim.-
docs/about/comparison.mdKMIP and MCP-native rows corrected from "Designed (v0.2.0)" to "Designed (v0.4.0)". -
Audit-read REST API:
GET /v1/audit(closes #20). NewAuditQuery[F[_]]SPI inaegis-auditwithFilter(since / until / actor / resource / operation / limit / offset) andPage(records / limit / offset / hasMore).PostgresAuditSinknow implements bothAuditSinkANDAuditQuery— one impl, two responsibilities. The query composes optional filters withAND, uses the "LIMIT n+1" trick to derivehasMorewithout a separateCOUNT(*), clampslimitto[1, 1000]andoffsetto>= 0defensively, and rebuildsAuditRecordinstances from the row (including JSONBcontextround-trip back toMap[String, String]). New Tapir endpointGET /v1/audit?since&until&actor&key&op&limit&offsetreturns{records, limit, offset, hasMore}. Authz: human principals only — agents and services are refused with403 PermissionDenied(v0.2.0 simplification of the future "audit:read permission via policy engine" model). Unknownopnames reject with400 InvalidFieldrather than silently producing an empty result. When the server isn't wired with a query-capable sink (aegis.audit.kind=stdout), the endpoint returns501 FeatureNotSupported— same pattern as/v1/agents/issuewhen its dependency is missing. New tests: 8 Postgres-backed cases inPostgresAuditSinkSpec(filter by actor / resource / op / since-until, AND composition, pagination withhasMore, defensive clamping, JSONB round-trip) + 8 HTTP-level cases inHttpRoutesAuditQuerySpec(response shape, record round-trip, filter parsing, op-name validation, human JWT happy path, agent 403, no-reader 501, pagination propagation). Backs theaegis audit tailCLI stub that ships next. -
Postgres audit table with indexed schema + retention (closes #19). New
PostgresAuditSinkinaegis-auditwrites everyAuditRecordinto a Doobie-backedaegis_audit_eventstable with one composite index per #20 audit-read filter (actor_subject,resource,operation, each paired withoccurred_at DESC) plus a plainoccurred_atindex for retention scans.contextis stored as JSONB so the existingrisk.score/outcome.decision/agent.jti/ etc. context keys survive without DDL churn — SIEM consumers query individual fields viacontext->>'risk.score'. Schema bootstraps on startup viaCREATE TABLE IF NOT EXISTS+CREATE INDEX IF NOT EXISTS(idempotent; no Flyway dep yet).actor_kindcolumn denormalises thePrincipalADT discriminator (Human/Service/Agent) for fast filter scans. Retention via apruneBefore(cutoff)method on the sink;Server.bootstarts a background fiber that runs once daily and deletes rows older thanaegis.audit.retention.days(default 365). SetAEGIS_AUDIT_KIND=postgresto enable; the existingAEGIS_JDBC_URL/_USERNAME/_PASSWORDenv vars are reused (audit table lives in the same database as the event journal — operators configure one set of credentials). NewPostgresAuditSinkSpeccovers 8 Testcontainers cases (idempotent bootstrap, every-column write fidelity, principal-kind discrimination, empty-context default, insertion-order preservation, retentionpruneBeforewith strict<cutoff semantics + empty-table case, JSONB round-trip with newlines / quotes / unicode / long strings), Docker-gated via the existingassume(dockerAvailable, ...)pattern. -
Agent-token issuance endpoint
POST /v1/agents/issue(closes #18). Exposes the existingJwtIssuerover REST so operators (and the upcomingaegis agent issueCLI) can mint short-lived agent JWTs programmatically. NewAgentTokenIssuerinaegis-iamwraps the issuer with three concerns the rawJwtIssuerdoesn't carry: authz (onlyPrincipal.Humancan issue —ServiceandAgentcallers are refused with403 PermissionDenied, enforcing the "agents cannot issue agents" rule from the spec), validation (label must be non-empty, scopes must parse asOperationnames, TTL must be> 0and≤ 24 hby default), and identity generation (agentId = agent-<uuid>,jti = <uuid>). Wire body:{label, scopes, ttlSeconds, parent?}— when present,parentmust equal the authenticated caller's subject (cross-principal issuance is rejected in v0.2.0; delegated issuance lands with a future release). Response:{agentId, jwt, jti, expiresAt}. The JWT carries the same claims the verifier already understands (kind=agent,aegis_parent,aegis_purpose,aegis_ops) plus a newjticlaim for the future revocation list (#24). Wired intoServer.bootfor both auth modes:hmacreusesaegis.auth.hmac.secret;devmints a per-boot ephemeral 48-byte secret (logged with a warning so operators don't confuse dev tokens with production). -
POST /v1/agents/issueis audited. Every call to the agent-issue endpoint — success AND failure — now produces anAuditRecordwritten to the configured sink (operation =Create, resource =agent:<id>, outcome carrying the agentId/jti/ttl/scopes). The JWT itself is NEVER recorded in the audit row (it's a bearer credential; recording it in plaintext would defeat its purpose). Test coverage asserts the JWT is absent from both outcome and context.HttpRoutesgained an optionalauditSink: Option[AuditSink[IO]]constructor arg (no-op when not wired — the keys surface stays covered byAuditingKeyService);Server.bootwires the samestdoutSinkalready used for the keys-surface audit. -
jti(RFC 7519 token ID) on all Aegis-issued JWTs.JwtClaims.HumanandJwtClaims.Agentgained a requiredjti: Stringfield;JwtIssuersets thejticlaim viabuilder.id(...);JwtVerifierextracts it on parse (tolerating absentjtias empty string for backwards-compatibility with externally-minted tokens). The JTI blacklist consumer ships with #24 (Redis-backed revocation list). 7 test sites updated to passjtito the case-class constructors. -
CI publishes
ghcr.io/<owner>/aegis-server:mainon every push tomain. New.github/workflows/docker-main.ymlbuilds and publishes a floating:mainimage (and an immutable:main-<short-sha>) so the v0.2.0 wedge demo in the quickstart can be exercised without waiting for a tagged release. The workflow skips on docs-only commits (paths filter) to avoid burning CI minutes for typo fixes.release.yml(tagged releases) is untouched —:0.1.x,:0.2.0, … continue to be published fromv*tags.
Changed¶
-
deploy/docker/docker-compose.ymldefault image bumped0.1.0→0.1.1. The compose default now pulls the latest stable tagged release; the embedded comment documents the three alternatives (:mainfor v0.2.0 preview,:main-<sha>for immutability, local build viasbt 'server / Docker / publishLocal'). -
Docs + roadmap refresh reflecting the W2 + W3 wedge work on
main.ROADMAP.md2.0.a / 2.0.b / 2.0.c marked ✅ Shipped.docs/index.mdfeature table now lists the risk scorer, decision adapter, and auto-responder under "shipped (v0.2.0)" with explicit thresholds + default rules.docs/about/status.mdupdated per-capability snapshot (Agent-AI plane → Shipped; risk scorer / decision adapter / auto-responder rows replaced their WIP entries).docs/about/comparison.mdgained explicit rows for "Risk-scored decisions" and "Auto-response to anomalies" (each marked as a first-class Aegis capability vs. DIY-on-CloudTrail-or-audit-devices elsewhere).docs/ARCHITECTURE.mdrequest-lifecycle Mermaid diagram now shows theRiskScorer,DecisionEngine,BaselineDetector, andAutoResponderboxes wired throughAuditingKeyService+TappedAuditSink, with explanatory prose covering the additive-risk-overlay-vs-policy-floor semantics and the "below-the-audit-decorator" no-recursion routing.docs/getting-started/quickstart.mdextended from 14 to 18 steps: Step 14 switches the running stack from:0.1.1to:main(so the wedge demo actually exercises the new code — fixes the "I ran the quickstart and the auto-revoke didn't fire" report where Steps 15–17 silently produced no risk context against the pre-W2 image); Steps 15–17 are the wedge demo itself, tripping aRateSpike, showing the operator-grep-ableaegis-systemauto-revoke audit row, and exercising the decision adapter'sPermissionDeniedpath. Time estimate updated 10 → 15 min, the introductory "What you'll have when you're done" callout spells out the wedge-demo deliverable so newcomers know what makes Aegis different before they start. -
Auto-responder — recommendations become actions (closes #17). New
AutoResponderinaegis-agent-aiis itself aRecommendationSink: it decorates the existing in-memory store, so everyAgentRecommendationis persisted first, then matched against a configuredList[AutoResponseRule], then executed if the rule fires and the per-(actor, action)cooldown allows.AutoResponseActionenum models the four execution actions:Alert(audit-only annotation),Revoke(callsKeyService.revokeon the target key extracted fromdetails("resource")),Deactivate(mapped toRevokefor v0.2.0), andFreeze(records intent; full enforcement arrives with #24's JTI blacklist). Action audit rows are written withactor = Principal.Service("aegis-system", TenantId("system"))and the outcome stringAnomalyAlert(detector=…, severity=…, rec=<id>, action=…) Success|Failed …so operators can grep the responder's timeline. The responder calls a "below the audit decorator"KeyServiceon purpose: routing through the outerAuditingKeyServicewould feed every auto-response back into the detector → recommendation pipeline, causing recursion. Default rule set covers all five baseline detectors atHigh → RevokeandMedium → Alert;Lowis intentionally absent (too much noise — operators opt in). Wired intoServer.bootbetween the recommendation store and the tapped audit sink. Failure modes (missing target key, invalid keyId, KMS error) are captured in the audit row, never thrown —publishis total. Operator-tuned rules via HOCON land in a follow-up. -
Risk scorer with reasoning (closes #15). New
RiskScorer[F[_]]SPI inaegis-corereturns a numeric score in[0.0, 1.0]plus a list ofRiskFactorevidence rows (name + weight + human-readable evidence string) for every request.BaselineRiskScorerinaegis-agent-aicombines the five baseline detectors (scope, rate-spike, op-histogram, time-of-day, source-IP) with four contextual signals (AgentPrincipal,CredentialAgepast 80 % of TTL,BroadScope5 allowed ops,
DestructiveOpfor Rotate / Compromise / Destroy / Revoke).AuditingKeyServicetakes an optional scorer constructor arg and stampsrisk.score(two-decimal-place string) andrisk.factors(semicolon-separatedname:weightlist) into everyAuditRecord.context— for successful, denied, and failed calls alike, so post-incident review can answer "did the scoring engine already know this was risky?". Wired intoServer.bootagainst the sameBaselineDetectorinstance the tapped sink writes into. The decision adapter that acts on the score is #16 below. -
Decision adapter — risk score becomes a verdict (closes #16). New
Decisionenum inaegis-core(Allow/Deny(reason)/StepUpRequired(reason)) consolidated withaegis-iam's pre-existing policy-decision type so the boolean policy gate and the risk overlay now speak the same vocabulary. NewDecisionEngine[F[_]]SPI translates a(RiskScore, Principal, Operation)triple into aDecision.ThresholdDecisionEngineinaegis-agent-aiships the default two-threshold implementation (denyAt=0.85,stepUpAt=0.60) with a per-op irreversibility tax — destructive ops (Rotate,Compromise,Destroy,Revoke) drop both thresholds by 0.15.AuditingKeyServicegained an optionalengine: Option[DecisionEngine[IO]]arg and now short-circuits the innerKeyServiceonDeny(returnsLeft(KmsError(PermissionDenied, "risk: …"))) andStepUpRequired(returns the newLeft(KmsError(StepUpRequired, reason))). Every audit row stampsoutcome.decision(Allow/StepUp/Deny) plus anoutcome.decision.reasonwhen the decision was non-Allow. NewErrorCode.StepUpRequiredis an Aegis-specific extension (KMIP has no equivalent — the wire codec maps it toOperationCanceledByRequester); the HTTP layer translates it to401 Unauthorizedwith the reason in the JSON body.locateis intentionally never gated by the engine — filtering directory results would leak existence-or-not signal and isn't a useful security primitive; the policy gate handles discovery authorization separately. Wired intoServer.bootwith default thresholds; HOCON-configurable thresholds and a dedicatedWWW-Authenticate: aegis-stepupresponse header land in follow-ups.
0.1.1 — 2026-05-09¶
First public, taggable release. Everything below shipped between the
v0.1.0-rc.2 candidate and this tag — the full key-lifecycle and
crypto surface (sign / verify / encrypt / decrypt / wrap / unwrap /
rotate / compromise), JWT bearer auth, Postgres event journal,
Prometheus + OpenTelemetry observability, anomaly-detector baselines,
and the OpenAPI / Swagger UI documentation surface. v0.1.0 final was
never cut — what we'd planned as v0.1.0 is folded into this release.
Changed¶
Servernow boots inside aResource[IO, Unit](closes #12). Refactored the entry point fromdef main+unsafeRunSynctoIOApp.Simple+ a single composedResourcechain. Each piece of the boot — Prometheus meter registry, journal connection pool, PekkoActorSystem, HTTP binding — is acquired with a matching finalizer, so SIGTERM / SIGINT now unwinds the stack in reverse: HTTP unbind (5 s grace) → actor system terminate → journal pool close → meter registry close. v0.1.0's boot calledPostgresEventJournal.make(...).allocated.unsafeRunSync()._1and discarded the finalizer, leaking the connection pool until JVM exit; that's gone. NewBootResourceSpecacquires the full stack against a free local port, hits the listener, and verifies that releasing the resource closes the binding (no 200 on a subsequent connect).
Added¶
- Anomaly detector expansion: time-of-day, source-IP, op-histogram baselines (closes #13).
BaselineDetectornow ships five detectors instead of two — addsOpHistogramBaseline(actor performed anOperationit has never used),TimeOfDayBaseline(actor active in a UTC hour outside their seen set), andSourceIpBaseline(request from a new IP, read fromAuditRecord.context("source.ip")). Each detector has a cold-start guard: it requires the actor to have at least one prior observation in that dimension, so the first call doesn't alert. A single anomalous record can fire multiple detectors at once (compound anomalies — see the README's "Claude goes rogue" path).ActorBaselinegainedhoursSeen: Set[Int]andsourceIpsSeen: Set[String].AuditRecordgained an additivecontext: Map[String, String] = Map.emptyfield; theSourceIpBaselinedetector readsBaselineDetector.SourceIpContextKey("source.ip") from it. The HTTP layer doesn't yet populate the context — that's a follow-up; until then the SourceIp detector is shape-complete and tested but inert in production. - OpenAPI 3.1 spec + Swagger UI on the REST plane (closes #52).
HttpRoutesnow generates an OpenAPI document from the liveEndpoints.alllist and mounts the standard Swagger UI bundle at/docs/, with the raw YAML at/docs/docs.yaml. Because the spec is derived from the same Tapir endpoint definitions the routes interpret, drift between the docs and the wire shape is impossible by construction. Thetapir-openapi-docsandtapir-swagger-ui-bundledeps were already inDependencies.scalatapir; this PR is purely the route plumbing + a regression test that asserts every shipped path appears in the rendered spec. - Maven Central publishing — POM metadata + operator runbook (closes #14).
Each library module (
aegis-core,aegis-persistence,aegis-crypto,aegis-iam,aegis-audit,aegis-sdk-scala,aegis-sdk-java,aegis-kmip,aegis-http,aegis-agent-ai,aegis-mcp-server) now declares its own one-linedescriptionso Sonatype's POM-validation staging gate accepts the artifact.aegis-serverandaegis-clikeeppublish / skip := truesince they ship as a Docker image and a Universal tarball respectively. AThisBuild / descriptionfallback prevents an unnamed jar from regressing the gate. NewRELEASING.mddocuments the one-time maintainer setup (Sonatype OSSRH account, GPG key generation + keyserver publication, the four GitHub Action secretsPGP_SECRET/PGP_PASSPHRASE/SONATYPE_USERNAME/SONATYPE_PASSWORD) plus the per-release workflow (CHANGELOG bump,git tag v0.1.1 && git push origin v0.1.1, what to expect on the Actions page) and a troubleshooting matrix. The existingrelease.ymlworkflow already gates the Maven publish step onPGP_SECRET != '', so a release without secrets ships Docker + CLI only with a clear::notice. - OpenTelemetry tracing — application-level spans + autoconfigured SDK (closes #11). New
TracingKeyServicedecorator wraps eachKeyService[IO]call in an OTel span namedkms.<operation>with attributesaegis.operation,aegis.key.id(when applicable),aegis.principal.subject,aegis.principal.kind(humanoragent), andaegis.outcome(success/error_<code>). Span status is set toERRORwith theKmsErrormessage on failure. NewTracingRegistrybootstraps the OTel SDK viaAutoConfiguredOpenTelemetrySdk— configuration is driven entirely by the standardOTEL_*env vars / system properties (OTEL_SERVICE_NAME,OTEL_TRACES_EXPORTER,OTEL_EXPORTER_OTLP_ENDPOINT,OTEL_TRACES_SAMPLER,OTEL_RESOURCE_ATTRIBUTES). The decorator slots betweenMeteredKeyServiceandAuditingKeyService. For full request-graph coverage (pekko-http server spans, JDBC client spans, AWS SDK client spans), attach the OpenTelemetry Java Agent at JVM start (-javaagent:opentelemetry-javaagent.jar) — the agent and the SDK both read the sameOTEL_*env vars, so configuration is unchanged and our manual spans become children of the agent's via W3C trace-context propagation. NewTracingKeyServiceSpecuses the OTelInMemorySpanExporterto assert span names, attributes, status codes, and the locate-specificaegis.locate.hitsattribute. Adds theopentelemetry-api+-sdk+-exporter-otlp+-sdk-extension-autoconfiguredeps (server-tier only — library modules unaffected) plusopentelemetry-sdk-testingat test scope. - Docker Compose hardening: no default Postgres password (closes #51).
deploy/docker/docker-compose.ymlno longer ships theaegis-dev-password-change-medefault. Both the Postgres container and theaegis-serverJDBC password now reference${POSTGRES_PASSWORD:?...}— Compose fails fast with a clear error if the operator hasn't exported the variable.SECURITY.mdgains a new "Deploy-time configuration" section enumerating the env vars that must be supplied (POSTGRES_PASSWORD,AEGIS_AUTH_HMAC_SECRETwhen JWT auth is on, AWS creds when the KMS root-of-trust is configured) and noting that TLS termination is the fronting proxy's responsibility until the v0.4.0 KMIP plane ships native mTLS. - Prometheus
/metricsendpoint (closes #10). NewMeteredKeyServicedecorator slots betweenAuditingKeyServiceandAuthorizingKeyServicein the boot wiring and records three series perKeyServiceoperation:aegis_keys_op_total{operation}(counter),aegis_keys_op_duration_seconds{operation, outcome}(timer with percentile histogram so dashboards can compute p50/p95/p99), andaegis_keys_op_errors_total{operation, code}(counter tagged by theKmsError.code, so denies surface ascode="PermissionDenied"). The metrics layer sits outside auth so denies are countable; audit stays the outermost decorator so the audit row still reflects the true outcome. NewMetricsRegistry.make()builds aPrometheusMeterRegistryand binds the standard JVM/GC/threads/classloader/processor/uptime collectors. NewMetricsRoutes.routeexposesGET /metricsin Prometheus exposition format (text/plain; version=0.0.4) on the same pekko-http port as the application routes — it lives inaegis-serverrather thanaegis-httpso the Tapir API module stays Micrometer-free.Server.scalabuilds the registry once at boot and stitches the metrics route into the application route viaconcat(...). Adds themicrometer-core+micrometer-registry-prometheusdeps (server-tier only — library modules unaffected).
Fixed¶
- Server boot hung on first launch.
aegis-serverused a Pekko user-guardian + Promise pattern to expose theKeyOpsActor'sActorRefto the main thread. On some JDK + sbt + Pekko combinations, the guardian'sBehaviors.setupblock was never dispatched, soAwait.result(initialized.future, …)hung past every reasonable timeout. The fix makes the user guardian be theKeyOpsActordirectly (ActorSystem[T] <: ActorRef[T]in Pekko Typed) and removes the Promise/Await dance entirely. This affected thesbt 'server / run'README quickstart and the Docker image's startup. - CLI launcher script was named
bin/aegis-cli, notbin/aegis. sbt-native-packager defaults to the project name; we now setexecutableScriptName := "aegis"so the published tarball matches the README's./aegis-cli-0.1.0/bin/aegis versioninstructions. Server.scalaran sbt'sruntask in-process (no fork). Addedrun / fork := truefor theservermodule so the run task gets an isolated JVM. Previously this entangled Pekko's dispatcher with sbt's classloader.
Added¶
- Sign / verify across the whole stack (closes #5). New
sign(id, message, alg, by)andverify(id, message, signature, by)methods onKeyService[F[_]]inaegis-core, withOperation.Sign/Operation.Verifyadded to the IAM allowlist enum, a newSignaturetype +SigAlgorithmenum (RsaPssSha256,EcdsaSha256for v0.1.1), and matchingAuditingKeyServicedecorator records that capture the algorithm andvalid=true|falseoutcome. TheRootOfTrustSPI gained the same operations;AwsKmsRootOfTrustimplements them via the AWS KMSSign/VerifyAPIs (mappingRsaPssSha256→RSASSA_PSS_SHA_256,EcdsaSha256→ECDSA_SHA_256). On the wire:POST /v1/keys/{id}/sign(request:{messageBase64, algorithm}, response:{signatureBase64, algorithm}) andPOST /v1/keys/{id}/verify(request addssignatureBase64, response is{valid, algorithm}). The CLI gainedaegis keys sign --id <id> --message <text|@file> [--alg RsaPssSha256]andaegis keys verify --id <id> --message <text|@file> --signature <base64> [--alg RsaPssSha256]; verify exits 0 forvalid:true, 3 forvalid:false. The in-memoryKeyServiceuses a deterministic HMAC-SHA-256 keyed by the KeyId so the dev REST surface has a working round-trip without a real KMS. Sign requires the key to be inKeyState.Active; calls against PreActive keys returnKmsError(IllegalOperation, ...)and produce aFailedaudit record. ReadmeQuickstartSpecinaegis-core. Compiles + runs the embedded-library example fromREADME.mdso that snippet can never silently bitrot. If you change the README's "Quickstart — embedding as a library" Scala block, mirror the change in this test.- Rotate(id, policy) across the whole stack (closes #8). New
rotate(id, policy, by)method onKeyService[F[_]].ManagedKeygainscurrentVersion: Int = 1(additive — defaulted for back-compat); rotation increments it by one. Legal source state isActiveonly; rotating from any other state returnsKmsError(IllegalOperation, ...). The new value typeRotationPolicy(Manual | TimeBased(FiniteDuration) | OpCountBased(Long)) is recorded on the rotation event and audit row —Manualfor explicit calls today, the auto variants reserved for the v0.2.0 scheduler. NewKeyEvent.Rotated(newVersion, policy)journal event with circe codec so replays restorecurrentVersiondeterministically. The "old version stays verifiable/decryptable after rotation" contract fromdocs/ARCHITECTURE.md§3 is preserved without per-version material storage: the in-memory dev backend keys its deterministic MAC byKeyIdonly (so byte output is version-stable), and AWS KMS handles per-version material internally — the same CMK decrypts both pre- and post-rotation ciphertexts. AddedOperation.Rotateto the IAM allowlist enum;AuthorizingKeyServiceguards via the policy engine;AuditingKeyServicerecordsnewVersion=N policy=...;ActorBackedKeyService.rotateroutes through the actor mailbox for journal-serialized state changes;PostgresEventJournallearns the new event kind. On the wire:POST /v1/keys/{id}/rotate(request{policy?}, response fullManagedKeyDtowith the bumpedcurrentVersion). The CLI gainedaegis keys rotate --id <id> [--policy Manual|TimeBased:7days|OpCountBased:N].ManagedKeyDto(HTTP + CLI wire shapes) gained thecurrentVersionfield; existing JSON without the field decodes ascurrentVersion=1via the case-class default. - Compromise operator override across the whole stack (closes #9). New
compromise(id, reason, by)method onKeyService[F[_]]. Marks the key asCompromised; from this state every cryptographic operation — includingverify— refuses withKmsError(IllegalOperation, ...). (Note:verifywas previously permitted on any state; this PR tightens it to refuseCompromisedandDestroyed, matching the lock-down semantics described indocs/ARCHITECTURE.md§3.) Compromise is one-way: from{PreActive, Active, Deactivated}→Compromised;Destroyedkeys cannot be compromised. The mandatoryreasonis a non-empty human-readable justification (e.g. "discovered in S3 audit leak 2026-05-08") and ends up on the audit row atseverity=Critical. AddedOperation.Compromiseto the IAM allowlist enum and a newKeyEvent.Compromisedjournal event with circe codec so the journal replays the state transition deterministically. The state-mutating call routes throughKeyOpsActorso the journal append + state transition are serialized with the rest of the lifecycle. On the wire:POST /v1/keys/{id}/compromise(request:{reason}, response: fullManagedKeyDto); blank reasons are rejected with 400InvalidField. The CLI gainedaegis keys compromise --id <id> --reason "<text>". - Wrap / unwrap across the whole stack (closes #7). New
wrap(id, dek, by)andunwrap(id, wrappedDek, by)methods onKeyService[F[_]]for KMIP-style envelope encryption, withOperation.Wrap/Operation.Unwrapadded to the IAM allowlist enum and a newWrappedDekvalue type. TheRootOfTrustSPI gainedwrap/unwrapDek;AwsKmsRootOfTrustimplements them by delegating to the existing AWS KMSEncrypt/Decryptcalls with an emptyEncryptionContext(AWS doesn't expose separate Wrap/Unwrap APIs for symmetric CMKs — this is the conventional wire-up). On the wire:POST /v1/keys/{id}/wrap(request:{dekBase64}, response:{wrappedDekBase64}) andPOST /v1/keys/{id}/unwrap(request:{wrappedDekBase64}, response:{dekBase64}). The CLI gainedaegis keys wrap --id <id> --dek <text|@file>andaegis keys unwrap --id <id> --wrapped <b64>. Same state-gate as encrypt/decrypt: wrap requiresActive; unwrap is permitted onActive+Deactivatedso historical wrapped DEKs remain recoverable across rotations, refused onCompromised/Destroyed. TheAuditingKeyServicedecorator recordsdekLen(not the bytes) so audit logs show what was protected without leaking key material. - Encrypt / decrypt across the whole stack (closes #6). New
encrypt(id, plaintext, context, by)anddecrypt(id, ciphertext, context, by)methods onKeyService[F[_]], withOperation.Encrypt/Operation.Decryptadded to the IAM allowlist enum and a newCiphertextvalue type. Encryption context (theMap[String, String]AAD) is carried as a separate parameter — not embedded in the ciphertext — so the same context must be supplied to both sides, mirroring AWS KMS semantics. A context mismatch on decrypt returnsKmsError(CryptographicFailure, ...). TheRootOfTrustSPI gained the same operations;AwsKmsRootOfTrustimplements them via the AWS KMSEncrypt/DecryptAPIs withEncryptionContextplumbed throughAwsKmsPort. On the wire:POST /v1/keys/{id}/encrypt(request:{plaintextBase64, context}, response:{ciphertextBase64, context}) andPOST /v1/keys/{id}/decrypt(request:{ciphertextBase64, context}, response:{plaintextBase64, context}). The CLI gainedaegis keys encrypt --id <id> --plaintext <text|@file> [--context k=v,k2=v2]andaegis keys decrypt --id <id> --ciphertext <b64> [--context k=v,k2=v2]. The in-memoryKeyServiceuses a deterministic HMAC-keyed XOR-keystream layout (HMAC(id, ctx) || pt XOR keystream(id, ctx)) so the dev REST surface has a working round-trip without a real KMS. Encrypt requires the key to be inKeyState.Active; decrypt is permitted onActiveandDeactivatedkeys (so existing ciphertexts remain readable after a future rotation lands), but refused onCompromised/Destroyed. TheAuditingKeyServicedecorator records the context keys (not values) and the plaintext length on success, so audit logs surface what was protected without leaking the AAD's payload.
Documentation¶
- README accuracy pass. Each section that described future capabilities is now explicitly
marked 🚧 WIP (status column in tables, design-preview callouts above example/demo transcripts).
The "Modules" table now lists per-module v0.1.0 status. The library-embedding example was rewritten
to actually compile (the previous version used
KeyService.inMemory[IO]which doesn't typecheck —KeyService.inMemoryreturnsIO[KeyService[IO]]). Added a callout under "Docker Compose quickstart" telling users how to build the image locally before v0.1.0 hits GHCR.
0.1.0 — 2026-04-29¶
The first tagged release. Pre-alpha — interfaces will change before 1.0.
What ships¶
Library tier (no Pekko, embeddable in any JVM app):
aegis-core—KeyService[F[_]]algebra, typed domain ADTs (Principal,KeyId,KeySpec,OperationResult,KeyEvent), in-memory reference implementation, circe codecs forKeyEvent.aegis-iam—RoleBasedPolicyEngine(allowlist with recursive parent-check that blocks agent-scope escalation),AuthorizingKeyServicedecorator, JWT bearer auth (JwtVerifier/JwtIssuer— HMAC-SHA256),PrincipalResolverSPI (dev / jwt).aegis-audit—AuditingKeyServicedecorator that writes oneAuditRecordper call (including denied/failed),InMemoryAuditSinkandStdoutAuditSinkreference impls.aegis-persistence—EventJournalSPI with two implementations:InMemoryEventJournal(dev) andPostgresEventJournal(Doobie/Hikari) with idempotent schema bootstrap.aegis-crypto—RootOfTrustSPI plusAwsKmsRootOfTrustadapter for layered-mode deployments fronting an existing AWS KMS CMK.aegis-sdk-scala/aegis-sdk-java— skeleton clients (REST surface; further polish in 0.2.0).
Server tier (Pekko-based):
aegis-http— Tapir + pekko-http REST endpoints forPOST/GET/POST-activate/DELETE /v1/keys.aegis-server— boot wiring tying it all together: REST routes → audit fan-out (StdoutAuditSink + W1 anomaly detector) → authorization → PekkoKeyOpsActor(single-actor key state) → durableEventJournal. Configurable journal (in-memory|postgres) and auth (dev|hmac) via HOCON.aegis-agent-ai— W1 anomaly detector MVP (BaselineDetectorwith scope + rate-spike heuristics),AgentRecommendationevents,RecommendationSinkSPI + in-memory impl,TappedAuditSink.aegis-cli—aegisadmin CLI withversion,login,keys create/get/activate/destroy. Stubs printing "not yet wired up" foragent issue,audit tail,advisor scan(back-ends in 0.2.0).
Operator-facing knobs¶
aegis.persistence.journal.kind—"in-memory"(default) or"postgres"(env:AEGIS_JOURNAL_KIND).aegis.persistence.journal.postgres.{jdbc-url, username, password, pool-size}— env-overridable.aegis.auth.kind—"dev"(default) or"hmac".aegis.auth.hmac.secret— required whenkind=hmac; ≥32 bytes (env:AEGIS_AUTH_HMAC_SECRET).aegis.http.{host, port}— env-overridable.
Distribution¶
- Docker image:
ghcr.io/sharma-bhaskar/aegis-server:0.1.0. - Library jars:
dev.aegiskms:aegis-{core,iam,audit,crypto,persistence,sdk-scala,sdk-java}:0.1.0on Maven Central. - CLI tarball: attached to the GitHub Release for v0.1.0.
Known limitations (deferred)¶
- No live OIDC / JWKS verification. v0.1.0 ships HS256 only — operators issue self-signed tokens to themselves. RSA / ES256 + JWKS rotation are scoped for v0.2.0.
- No agent-token issuance HTTP endpoint.
aegis agent issuein the CLI prints a clear "not yet wired up" message; the trait (JwtIssuer) is in place. Endpoint lands in v0.2.0 (PR A1). - No MCP server, no KMIP server. Module skeletons exist in
aegis-mcp-serverandaegis-kmipso they can land additively in v0.2.0+. aegis-serverPostgres path leaks the connection pool until JVM exit. A properResource[IO, Unit]boot scope is on the F1.b follow-up.- GCP / Azure / Vault / PKCS#11 root-of-trust adapters are not yet shipped. AWS KMS only.
- Audit fan-out to Postgres / Kafka / SIEM webhooks is not yet shipped. Stdout sink only.
- Risk scorer (W2), auto-responder (W3), LLM advisor (W4) are not yet shipped. The W1 anomaly detector
emits
AgentRecommendationevents; consuming them is manual. - No Helm chart yet.
deploy/helm/aegis-kms/is a placeholder;deploy/docker/docker-compose.ymlbrings the server up against a local Postgres for hands-on testing.
Repository scaffolding (already in main before this release)¶
- sbt multi-project layout, Apache-2.0 license, CI workflow (
ci.yml), contribution and security policies, scalafmt + scalafix configured. apply-pr-backlog.shfor splitting working-tree changes into one commit per PR.