Changelog
Format: Keep a Changelog. Versioning: semantic, with the caveat that nothing here is a production release. Until an external assessor signs off, every version is a demonstrator.
An entry is added for anything that changes a trust boundary, a signed artefact, a failure mode or a reproduction command. Refactors that change none of those belong in the git history, not here.
[0.15.0] — 2026-09-07
Another program's evidence, anchored here. AEGIS gains one route and one artefact type so that MAIVAR — a read-only Windows posture scanner kept in a separate repository on purpose — can deposit the Merkle root of each relevé in the transparency log. What crosses the boundary is a digest and four small fields; the attestation is exactly as narrow. Nothing in the kernel changed, and the demo's captured values are still those of the 0.14.1 run.
Added
- A posture root can be anchored in the transparency log.
POST /v1/posture/rootstakes the Merkle root of one MAIVAR relevé — host, time, root, previous root, finding count, and nothing else — wraps it in a statement AEGIS signs under the new artefact typeposture-root, appends it to the log and returns the inclusion proof. MAIVAR is a separate program in a separate repository on purpose; what crosses the boundary is a digest. The attestation is deliberately narrow: AEGIS says it received this root at this time from this collector, and nothing about whether the findings behind it are true. It never saw the machine. The route requires an identified collector like every evidence route, refuses unknown fields, and registering the same record twice returns the first entry. TransparencyService.registerArtifactgeneralisesregister: any byte artefact the log agrees to attest, under its declared type. The SCRAPI route is unchanged and delegates to it.
Noted
- The log's Merkle tree is RFC 6962's tree, confirmed by an independent
implementation.
merkle.tsbuilds level by level and carries an odd node up unchanged; the RFC defines the tree recursively, splitting at the largest power of two. Those are two descriptions of one tree — the construction that differs is Bitcoin's, which duplicates the odd node. MAIVAR's verifier, written in Python from the RFC's recursive definition, reproduces this log's roots and inclusion paths byte for byte on vectors this code produced for sizes 1 to 13. Recorded because an earlier draft of this entry claimed the opposite, and a test written to prove the difference proved the equality.
[0.14.2] — 2026-09-02
The public site, as a product surface. Nothing in the kernel changed: the demo's captured values are still those of the 0.14.1 run, and the demo page says so. Until the DNS record for the Byss subdomain exists, the site is served from a Vercel alias, and its canonical links say where it actually lives.
Added
- A public site, assembled from the repository, at
aegis.byss-group.com(Vercel, built on every push bynpm run site:build). It carries the replayed demo, twelve documents chosen one by one, the changelog, the security policy and the verify page. It is not written; it is generated, so it cannot say what the repository does not — the hand-written page elsewhere had drifted within a day. Two rules are enforced at build time and were mutation-tested: every internal link resolves, and every page carries the demonstrator line. Links into the private repository are kept and labelled rather than dropped, so a reader knows a document exists and that they cannot currently read it. - The site as a dossier. The landing is now the console's own world pushed
as far as a public page goes: the three self-hosted typefaces (Anybody on
its width axis for the thesis, Commissioner for reading, Azeret Mono for
what attests), the five gates on a serrated ticket, measured figures
including the zero independent audits, and seven numbered drawers that open
onto paper — problem, mechanism, what you receive, why believe this page,
what it does not prove, for whom, how to start. The footer is a publication
receipt for the page itself (version, commit, date, page count, what was
checked); the page count is compared with the build by the test. Documents
get a table of contents built from their real headings. A browser spec runs
axe on eight pages at two viewports, opens the drawers with the keyboard,
and proves the three families load from this origin rather than falling back
silently; the demo page gained a
mainlandmark and a valid definition list on the way. Every new check was mutation-tested.
Changed
- The site loads nothing from any other origin. The demo page asked Google
Fonts for the three families; the generator now drops that request and
serves the same files from
/fonts/, and the content-security policy invercel.jsonno longer names a third-party host:font-src 'self'.
[0.14.1] — 2026-09-02
The console catches up with 0.14.0. The kernel had gained a second action, a standard receipt format and a queryable log; the console hard-coded pod quarantine and called nine routes, all older than any of that.
Fixed
- A mission allowing the second action was refused at the HTTP boundary.
parseMissionstill admittedQuarantinePodFor300Secondsalone. The cordon was proposable, executable and covered end to end through the kernel, and every one of those tests bypassed the parser — so all of them were green while no HTTP client could ever have used it. The console found it on its first attempt. A mission may now allow any contracted action and only those, and a test refuses an uncontracted name; disarming the check fails two tests.
Added
- The operator chooses the action before signing the mission — isolate a pod, or take a node out of scheduling — with native radios, so keyboard and screen-reader behaviour come for free and the choice is carried by words rather than by colour. It locks once the mission is signed, because from then on the choice is the kernel's. The demo session seeds a node beside the pod, with four schedulable siblings so the safety floor is a floor and not the thing under test.
- The SCITT capsule in the expert drawer:
capsule_idrecomputed in the browser from the JSON with WebCrypto rather than believed; the effect status; which build issued it, or the sentence saying no release did; the signed.coseas a download built from a blob, not a link back to the server. - Registration in the transparency log, from the console, and the RFC 6962
inclusion proof recomputed with WebCrypto — leaf and node prefixes, the odd
node carried up — against the published root. The entry id is checked to be
the digest of the bytes that were sent.
/entriesnow answers JSON to a client that asks for it, because a browser with no CBOR decoder — on purpose — still has to check the proof; the COSE receipt is unchanged for everything else./v1/capsules/{id}carries the signed statement so a holder can register it. - Six browser journeys for the new surface, in French and English, on desktop
and mobile. Two of them found real defects before the code shipped: the
parseMissiongap above, and a<fieldset>assertion that Playwright's enabled/disabled notion does not cover. - The public demo page recaptured from a real run of this version, now showing the capsule, its transparency entry, and — honestly — the absence of build provenance on a development checkout.
Changed
- A proof that does not apply is dimmed by its dot and said in words, not by
washing out the whole row: the earlier
opacity: .58put its text below WCAG AA contrast, which axe reported the first time an item was not-applicable at page load. - The mobile actuator's disabled label now clears 4.5:1 on both backgrounds it can sit on — 5.65:1 on the disabled grey, 4.93:1 on the enabled blue, computed rather than eyeballed. The old colour cleared the grey only; the button flips state at every gate, and axe-core, which measures in batches that yield to the event loop, caught the disabled text over the enabled blue. A human can see the same frame.
hiddenmeans hidden on the capsule panel, the download link and the register button: an authordisplayoutranked the attribute, so a bare link and a disabled button were on screen before there was a capsule to download.
[0.14.0] — 2026-09-02
The release where AEGIS stops being one action, one format and one log nobody can ask. Thirty-two commits since 0.13.1; what follows is the part that changes a trust boundary, a signed artefact, a failure mode or a reproduction command. The rest is in the git history.
Two disciplines ran through all of it and are worth naming once. Every new test was mutation-tested — the guard it protects was removed and the test had to fall, and several did not on first writing. And every hand-written encoder is read back by something that is not AEGIS.
Security
- The "no executor performs this" guard ran after
prepare, not before. Contracting a second action made it reachable: the kernel asked the executor to reach for the target of an action the executor never claimed, which against a real cluster is an API call on behalf of a decision that was never going to be honoured. Moved ahead ofprepare. It used to be dormant behind three earlier gates; implementing cordoning opened all three, which is precisely the change it was kept for.test/kernel.test.tsnow exercises it with a live capability. - A capability names the executor that may spend it, not a router.
RoutingExecutorlets a deployment hold both real executors; bound to the router's own name, every executor behind it would have been a valid audience and the binding would have meant "some AEGIS executor".audienceFordelegates, the kernel derives the audience from the intent, and two executors claiming the same action are refused at construction — otherwise the one that loses is the one an operator believed they had deployed. - The container now runs read-only, with no capabilities and no path to
privilege escalation, and the
containerCI job asserts each of those took effect — including by attempting a write to the root filesystem and requiring it to fail — rather than trusting the flags.lab/hardening/security-context.yamlis the same confinement as a pod spec. This bounds the blast radius of a compromised executor; it does not sandbox it, anddocs/owasp-agentic-mapping.mdkeeps ASI05 at bounds. - Emergency stop must not freeze restoration. Article 14 asks for a halt
"in a safe state"; a halt that also blocked restoration would leave every
contained workload isolated indefinitely — the safety device become the
outage. The code did the right thing,
executepassing through the stop guard andrestorenot, but nothing guaranteed it and the two calls are one word apart. A test pins it; routingrestorethrough the guard fails exactly one test. - The Splunk connector refuses an alert whose age it cannot establish.
Splunk sends no envelope timestamp, so
alert.result._timeis required rather than optional: accepting receipt time in its place would be fabricating the missing datum.results_linkis validated and deliberately not carried into the evidence — it identifies the deployment. Sentinel'sIncidentNumberis accepted as the two documented shapes and nothing else;String(value)had already turned["vault:v1"]into a valid Transit prefix once in this repository. - Cordoning a node needs cluster-scoped RBAC, and the manifest says so. A
node is not in a namespace, so the boundary that bounds the quarantine
executor does not exist.
lab/kubernetes/cordon-executor-rbac.yamlis deliberately absent fromkustomization.yaml;resourceNamesconfinespatchto named nodes and cannot confinelist, which the safety floor needs in order to see the whole set. A deployment that does not cordon should not grant it at all: the kernel refuses a cordon withaction_not_implementedbefore issuing any authority. capsule_idwas not what the draft says. It was the AEGIS receipt hash — 64 hex characters that commit to the receipt, not to the capsule. Anyone recomputingHEX(SHA-256(JCS(capsule without capsule_id)))as draft-mih-scitt-agent-action-capsule-02 describes got a different answer and concluded the capsule was tampered with. Computed per the draft now, and the Rust verifier recomputes it independently.
Added
- A second reversible action.
CordonNodeFor900Seconds:ActionTargetandTargetStateare discriminated unions, the simulator carries a shape table soprepare/execute/restorestay one implementation each, the policy reads the contract registry instead of literals, andtargetKeyis compatible so no schema migration is needed. Three modules narrow rather than widen — the NetworkPolicy executor, the admission webhook, the independent observer — because they are legitimately pod-specific, and each refuses with a typed code.KubernetesNodeCordonExecutorperforms it for real: JSON Patch withteston UID andresourceVersion, an ownership label so restoration can prove the change was ours,addrather thanreplaceon/spec/unschedulablebecause the field is absent on a never-cordoned node. The safety floor counts other ready, schedulable, not-being-deleted nodes — a draining node reportsReadyuntil the moment it stops. Exercised end to end through the kernel, against a fake API server that enforcestestoperations and movingresourceVersion, and against a real three-node kind cluster in CI, which then asserts no node was left cordoned. - SCITT, both halves. Capsules are now tagged COSE_Sign1 envelopes with the
§3.1 protected header —
algEdDSA, content type,kid, and CWT claims (RFC 9597) with the closedcapsule_*set enforced at both ends. The CBOR and COSE encoding is hand-written in a strict subset (encode only, no floats, no indefinite lengths, no decoder); it is checked against RFC 8949 Appendix A, and every envelope is decoded by the Rust verifier withcosetandciborium— third-party implementations that have never seen the encoder — which must refuse five tampered variants. And a transparency service with the SCRAPI endpoints:/.well-known/scitt-keysas a COSE Key Set,/entriesto register and resolve. Unauthenticated on purpose. It does not decode what it is given: it bounds, commits to the digest, and claims nothing about meaning — the only CBOR parser in the project is a third party's, in the verifier.docs/scitt-transparency.mdtabulates what is conformant and what is not. - Which build issued this receipt. A
build_provenanceblock in the capsule payload, field names from §5.9 of draft-marques-asqav-compliance-receipts-08 verbatim. Every field is derived from an artefact that shipped or is absent: the release hashes the SBOM file rather than accepting a digest, then re-derives it and fails if the two disagree.slsa_provenance_pointerstays absent — what ships is an unsigned in-toto Statement, not the envelope the profile asks for — and the recorder refuses the pointer without--attested, passed under the same condition that decides whether to attest at all. - Merkle inclusion proofs (RFC 6962,
0x00/0x01prefixes, odd node carried up unchanged) over the transparency log, verified by the Rust side from a corpus chosen to hit odd totals and last positions. - An adversarial agent, over HTTP, holding only an operator identity. Eighteen routes; the positive control runs first so a scorecard of refusals cannot come from a harness that never reached the product; a breach is judged by the world, not the HTTP status. The first version reported 14/14 refused and was entirely vacuous — no capability was ever issued — which the positive control caught.
- AgentDojo's injection corpus against the admission boundary.
- An MCP server, no SDK, exposing
aegis.propose_action,aegis.execute_action,aegis.get_capsuleand nothing else. - A reusable GitHub Action (
.github/actions/verify): signature, inclusion proof, and now a SCITT capsule with the key taken from the issuer's published key set rather than from whoever handed over the capsule.release:takes the prebuilt verifier and checks it againstSHA256SUMS; on any other runner than Linux x86_64 it fails rather than silently building. A pinned older binary says plainly which check it is too old for. Its README states what "anyone can check this" does not yet mean while the repository is private. - The Rust verifier gains
inclusion,capsuleandscitt-keys, plus the two crates that make the hand-written encoder defensible.SECURITY.mdrecords why they sit on the verifier side: the parser facing untrusted bytes is a third party's. - Two invariants in the formal model — no authority for an action nothing performs, and nothing mutates unless the party that did is the party the capability named — reached through dedicated actions so recorded implementation traces stay replayable. Both mutations produce a counterexample. The first version had the invariants written but never added to the conjunction, and both mutations passed green; the mutation found it.
- Splunk and Microsoft Sentinel connectors, with the vendors' documented schemas.
docs/owasp-agentic-mapping.md— four verdicts, never ten out of ten.- A compliance mapping — which artefact is evidence for which obligation, and the list of what it does not cover.
.github/allowed_signers, sogit log --show-signaturenames a principal on a fresh clone instead of "No principal matched".- The console in English (106 strings, French keys, machine identifiers never
translated), and a standalone demo page replaying a real
npm run demotransaction, including the one assurance claim it does not satisfy. - Live Kubernetes tests in CI against a disposable kind cluster: pod quarantine and node cordon, followed by an assertion on the cluster's state rather than on the tests' report of themselves.
Changed
parseActionIntentadmits two target shapes, each closed on its own keys. A cordon carrying a straynamespacewould be scoped as a node by the authority layer while a reader of the JSON saw a namespace and assumed it honoured.docs/openapi.yamlcarries one intent variant per action with its own duration ceiling, and a conformance test now compares the spec's action vocabulary against the contract registry — it was possible for the spec to understate what the server accepted with every test green.- The regulatory calendar: Regulation (EU) 2026/1744 defers high-risk obligations to 2 December 2027; the earlier claim of enforceability since 2 August 2026 was wrong and is corrected where it was published.
- The verifier's usage text is a list checked against the dispatch table.
Fixed
- The simulator's node precondition was vacuous —
scheduledWorkloads >= 0— while its comment promised to refuse the last node with room.NodeStatecarriesschedulableSiblingsand the floor is>= 1. - The transparency log's duplicate check compared statement hashes, which carry a fresh UUID per call, so re-registering identical bytes grew the log while lookups kept answering with the first entry. Identity is the artefact digest, decided inside the serialised append.
- The verifier's usage text did not list
capsuleorscitt-keys: the binary could do things it did not admit to, which is exactly what a caller reads that text to find out. - The release step's SBOM glob died under
pipefailbefore its own error message could print. - In the action:
head -1behind a pipe turned a SIGPIPE into a failed step with more than one key; the canonicalisation corpus was resolved from a path that does not exist on the release path.
Not claimed
- The transparency receipt body is not draft-ietf-cose-merkle-tree-proofs. It carries its own content type so no reader can mistake it for the standard.
slsa_provenance_pointeris absent until the repository can carry an attestation.- The action is usable only from repositories that can read this one, and the
verifier crate is deliberately unpublished (
publish = false). - Cordoning is not shown to protect anything: it evicts nothing, and nothing here schedules a workload to observe the scheduler avoiding the node.
- Coverage, corrected for Node 22 counting test files: 83.9 / 88.1 / 92.9 (lines / branches / functions).
Reproduce: npm run verify, npm run adversary,
bash scripts/verifier-check.sh, npm run formal:verify.
[0.13.1] — 2026-09-01
Dependency updates, and the two defects they exposed. Nothing here changes a trust boundary; two things restore a gate that had stopped guarding anything.
Fixed
- The
enginesfloor was no longer enforced by anything.package.jsondeclares"node": ">=22", but@types/nodehad been bumped to 26, so the type checker described Node 26. Code calling an API absent from Node 22 compiled cleanly and would have thrown at run time, on the very floor the project promises to support. Confirmed rather than assumed: under@types/node26,new URLPattern({ pathname: "/x" })— a Node 24 global — type-checked without a word; under 22 it isTS2304.@types/nodenow tracks the floor, and its major bumps are excluded from Dependabot so the next proposal cannot undo this silently. Minor and patch updates within 22 are still wanted. - A dead assignment inside signature verification, found by ESLint 10's
no-useless-assignment:signatureValidinsiem-connectors.tswas initialised tofalseand then reassigned on every path. Not a bug, but the defensive initialiser would have silently absorbed a future edit that dropped an assignment. TypeScript's definite-assignment analysis now enforces it, so such an edit fails to compile instead of defaulting; theinvariantstill fails closed at run time. package-lock.jsonwas a textual git merge, not an npm resolution. Dependabot rewrote a branch between fetch and merge, and git reconciled two lockfiles line by line, leaving two copies ofjs-yamland duplicate nested@types/nodeandundici-types. Regenerated. This matters beyond tidiness: the SBOM ships with the release, and a tree carrying phantom duplicates describes badly what is actually installed.
Added
test/kubeconfig-admission.test.ts— eleven cases onfromExplicitKubeconfig, the gate deciding whether a kubeconfig may become an executor holding authority over a cluster. It refuses plaintext transport, disabled TLS verification,execand auth-provider plugins, basic authentication and impersonation, and nothing checked that it refuses any of them. Each case was mutation-tested: removing any one of the four invariants fails exactly one test. It is also the only place the code constructs@kubernetes/client-nodeitself, so the major bump below is no longer gated by the type checker alone.- Release notes now carry the two commands that check a release without running
AEGIS:
sha256sum --check SHA256SUMSand./aegis-verify-linux-x86_64 vectors canonical-vectors.json.
Changed
@kubernetes/client-node1.4.0 → 2.0.0,eslintand@eslint/js9 → 10,actions/checkout4 → 7.0.1,actions/setup-node4 → 7.0.0,actions/cachewithin v4.SECURITY.mdno longer claims every commit carries an SSH signature. Merges made through the GitHub interface are signed by GitHub's web-flow key instead, which verifies by different means — an assessor runninggit log --show-signatureshould know why one line readsCan't check signature.
[0.13.0] — 2026-09-01
Security
- Canonical JSON was not JCS-conformant.
canonicalJsonsorted object entries and then rebuilt the object withObject.fromEntries, which ECMAScript re-orders: integer-index properties come first, numerically. The sort was silently undone, so{"2":a,"10":b}serialised in that order where RFC 8785 requires{"10":b,"2":a}. Reachable with real data —admission-verifier.tshashes arbitrary Kubernetes objects and a label key of"2"is valid — and it meant any correct verifier in another language would have refused a genuine proof. The profile is now namedaegis-jcs-1, pinned by a 27-vector corpus and reproduced byte for byte by an independent Rust implementation. - A
Datecanonicalised to{}, so two different instants produced the same signature. Non-plain objects are now refused. - Deeply nested input caused a stack overflow rather than a typed refusal. Bounded at 256 levels.
- The target of a containment was written to the log stream.
kubernetes-executor.tslogged up to 500 characters of the Kubernetes API error body, which names the pod and namespace of the workload being contained. Replaced by a correlation digest. - Authentication ran after request-body parsing on five routes, so an anonymous caller reached the hostile-input parsers and received the list of expected fields. Identity is now established before any body is read.
- A non-finite
durationSecondsproduced HTTP 500 instead of a typed 400.1e999is valid JSON and parses toInfinity, which passed the parser and then threw a rawTypeErrorinside canonicalisation. - A non-string
signaturePrefixpassed key-bundle validation.String(["vault:v1"])is"vault:v1", so a JSON array satisfied the Transit prefix pattern and the bundle was reported valid. Verification then failed closed, so no signature was forgeable, but a configuration error became an unexplained runtime denial instead of a startup refusal. - The rate limiter grew without bound. One permanent entry per source address meant address rotation from an IPv6 prefix could exhaust the memory of the process that carries authority. Replaced by a sliding window with an explicit LRU ceiling.
Added
- Independent Rust verifier (
verifier/) for canonical bytes and detached Ed25519 signatures, sharing no code with AEGIS. - Trace conformance: real kernel traces, read from the signed audit log, replayed
against the Quint model by
quint test, plus six traces the model must refuse. - Adversarial suite: parser fuzzing, property-based kernel interleavings, and crash injection at ten durable boundaries.
- Structured logging with a strict field allowlist, eight Prometheus alert rules, and performance budgets enforced in CI.
- POSIX dispatch for the lab commands, so a reviewer on Linux or macOS can run the parts of the audit package that do not depend on Windows provisioning.
Changed
server.tsreduced from 807 to 179 lines; the HTTP surface is now testable and covered.npm run formal:checksplit intoformal:sample(random simulation) andformal:conformance(implementation traces), because the sampled result had been presented as a formal-model result.
Added, after the report
- Idempotency keys on the mutating routes, bound to the caller and the exact request hash so a key can never be used to read somebody else's receipt.
- The restoration workflow is versioned (
patched()), its retry budget is the same five the kernel enforces, and exhausting it escalates into a state an operator can query and resume rather than a failed workflow that disappears. - A differential harness between the embedded evaluator and OPA. It found 47
disagreements on 15 000 boundary cases, all one cause: an empty
faultDomainfell back tosourceRootin Rego, so two records from one domain looked independent and OPA allowed what the kernel denied. Fixed in the policy; the denying reading is the safe one. docs/restoration-conflict-runbook.md, for the person paged because a containment did not lift.- Signed commits and tags,
CODEOWNERS, and a release that carries the SBOM, the shared vector corpus, the independent verifier binary andSHA256SUMS. Build provenance is described in aprovenance.jsonthe workflow generates, not attested: GitHub does not persist attestations for a user-owned private repository. The signed tag binds the release to a commit and the checksums bind that commit's build to the bytes; nothing signs the builder's identity. The attestation step is gated on the repository being public and starts working by itself if that ever changes. - The supply-chain scan now covers the commit history, not only the working tree: a secret committed and later deleted used to pass for ever.
Known gaps
Unchanged and still true: no independent audit, no production credentials, one
cluster/CNI combination, local key custody. See docs/threat-model.md and
docs/production-roadmap.md, which are the authoritative statements of what is
not proven.
[0.12.0] — 2026-08-29
Imported as the repository's first commit. Signed missions, evidence provenance,
one-shot capabilities, ExecutionProof, signed receipts, PostgreSQL authority,
Temporal restoration, OpenBao Transit signing, the typed mTLS executor, the proof
admission webhook, the independent observer and the S3 Object Lock sink.