@xshieldai/aegis-guard
v0.6.2
Published
AEGIS Guard SDK — Five Locks primitives (approval-token, nonce, idempotency, SENSE, quality-evidence) + opt-in Agentic Control Center event bus for AEGIS-governed services
Maintainers
Readme
@xshieldai/aegis-guard
🔍 Verification status (2026-10-14 IST — v0.6.2)
- New in 0.6.2: plain Node can import it. Until 0.6.1 the entry was the TypeScript source, so it loaded under Bun only. It now ships compiled JavaScript with type declarations in
dist/. Nothing else changed.- Tests: ✅ 184/184 passing in the repository; the npm package ships
src/only, so run them from a clone (bun test): 70 in tests/aegis-guard.test.ts and 114 in tests/hardening.test.ts for key trust, malformed tokens, nonces, the duplicate check, the audit event, the quality bar, the envelope helpers, the stated limits and the action digest. No test contacts a network or touches~/.aegis.- Examples: ✅ runnable quickstart, in the repository (not in the npm package): examples/quickstart.ts —
bun run examples/quickstart.tsshows all 5 Locks in action with receipts- Live demo: ⚠️ planned (Tier 3, see PROOF-STACK.md)
- Limits: see "What this does not do" below, and the v0.2.0 ACC section
- Upgrading from 0.3.x: some calls that passed are now refused, and minting no longer makes a key. See "Upgrading from 0.3.x".
- Upgrading from 0.4.x, 0.5.0 or 0.6.0: minting needs
AEGIS_MINT_AUTHORITY=1, an approval must name the concrete action, and since 0.6.1 an action must be plain data. See "Upgrading to 0.6.x".
AEGIS Guard SDK — reusable approval-token, nonce, idempotency, SENSE, and quality-evidence primitives for AEGIS-governed services.
Carbonx proved the locks. Batch 93 makes the locks reusable.
The Five Locks were proven across 13 batches (62–74) of carbonx-backend. This package extracts them into a service-agnostic SDK so any AEGIS-governed service can adopt them without copy-pasting bespoke logic.
Five Locks
| Lock | Primitive | Rule |
|---|---|---|
| LOCK_1 — decision | verifyApprovalToken | AEG-E-016 |
| LOCK_2 — identity | verifyScopedApprovalToken | AEG-E-016 |
| LOCK_3 — observability | emitAegisSenseEvent | CA-003, AEG-HG-2B-003/005 |
| LOCK_4 — rollback | checkIdempotency | AEG-HG-2B-006 |
| LOCK_5 — idempotency | verifyAndConsumeNonce | AEG-HG-2B-006 |
Install
bun add @xshieldai/aegis-guard
# or
npm install @xshieldai/aegis-guardKeys — who signs, who verifies
An approval token is an EdDSA-signed JWT. One box signs; every other box only verifies.
| Box | Holds | Set up by |
|---|---|---|
| The approving authority (AEGIS) | ~/.aegis/approval-signing.key and .pub | ensureSigningKeypair(), once, at boot |
| Every service that checks approvals | the authority's public key | the file ~/.aegis/approval-signing.pub, or the PEM in AEGIS_APPROVAL_PUBKEY_PEM |
AEGIS_DIR replaces ~/.aegis. The rules the package enforces (AEG-HG-2B-007):
- Minting never makes a key.
mintApprovalToken()throws on a box with no private key. - A box that was given a public key never makes its own.
ensureSigningKeypair()throws there and writes nothing. - Keys that disagree are not trusted. If the private key, the public file and the environment PEM are not all the same Ed25519 key, every verification is refused and the reason names the conflict.
- No key, no approval. With no key material at all, verification is refused.
Usage
LOCK_1 + LOCK_2 — decision + identity
verifyApprovalToken() checks, in order: the signature; that service, capability and operation match exactly; that expires_at is a number and not past; that issued_at, when present, is a number, not more than 60 seconds ahead of the clock and not after expires_at; that status, when present, is exactly approved. Anything else throws IrrNoApprovalError (code: 'IRR-NOAPPROVAL') — including a token that is not text.
Since 0.6.0 two things are refused by default, and the two calls below are therefore not enough on their own:
- Minting is an authority action.
mintApprovalToken()andensureSigningKeypair()throw unless the process declares itself the approving authority withAEGIS_MINT_AUTHORITY=1. An agent process verifies; it does not mint. On one user account an agent can set that variable itself: the real boundary is a private key under a different account that the agent cannot read. - An approval must name the concrete action. A token without an
action_digestis refused by every verify call, because an approval scoped only to service, capability and operation fits every instance of that operation. Use the action-bound pair described in the next section.AEGIS_ALLOW_LABEL_ONLY_APPROVAL=1brings back the old label-only behaviour for a caller that knowingly accepts it.
Mint with issued_at and expires_at in milliseconds: mintActionApprovalToken({ service_id, capability, operation, nonce, issued_at: Date.now(), expires_at: Date.now() + 60_000 }, action).
import { verifyApprovalToken, verifyScopedApprovalToken } from '@xshieldai/aegis-guard';
// LOCK_1 — base token verification (service_id + capability + operation)
const payload = verifyApprovalToken(token, 'my-service', 'settle', 'record_settle');
// LOCK_2 — scoped verification (add service-specific field bindings)
const payload = verifyScopedApprovalToken(
token, 'my-service', 'settle', 'record_settle',
{ vessel_id: args.vesselId, amount: args.amount },
);Each scope field must be the token's own field and strictly equal (100 is not "100"). A field the caller has no value for (undefined or null) is refused: it would bind the token to nothing.
One approval, one action
import { mintActionApprovalToken, verifyActionApprovalToken } from '@xshieldai/aegis-guard';
// On the authority (AEGIS_MINT_AUTHORITY=1): the approval names what was approved.
const action = { table: 'audit_log_2023', run_at: '2026-10-11T06:40:00.000Z' };
const token = mintActionApprovalToken({ service_id, capability, operation, nonce, issued_at, expires_at }, action);
// In the service, with the action it is about to perform:
const payload = verifyActionApprovalToken(token, 'my-service', 'schema', 'drop_table', actionInHand);The token carries a SHA-256 digest of the action; the service recomputes it from the action in hand and a mismatch is refused. Keys are sorted at every depth, so the order they were written in does not matter.
An action must be plain data: text, finite numbers, booleans, null, arrays and plain objects of these. Anything else is refused with IrrNoApprovalError, at mint and at verify, and the message says where in the action it is (action.pay.to[0].when is a Date, not a plain object). That covers Date, URL, Map, Set, RegExp, Buffer, any class instance, NaN, ±Infinity, undefined (also as a field's value, and a hole in an array), bigint, functions, symbols, and an action that contains itself. Write such a value as text yourself: a date as its ISO text, a URL as its href, a set as a sorted array, a large integer as digits.
Through 0.6.0 the digest gave those values a guessed spelling and the guesses collided: every Date, URL, Map, Set, RegExp and class instance was written {}, and NaN, Infinity and undefined were written null. An approval minted for one such action verified for a different one. If a service on 0.5.0 or 0.6.0 passes any of those as part of an action, upgrade. For an action that was plain data already, the digest is unchanged, so tokens minted before the upgrade still verify.
LOCK_3 — observability (SENSE)
import { emitAegisSenseEvent, digestApprovalToken, configureSenseTransport } from '@xshieldai/aegis-guard';
// Wire your logger (default: process.stdout JSON)
configureSenseTransport((event) => logger.info(event, `SENSE:${event.event_type}`));
emitAegisSenseEvent({
event_type: 'allowance.settle',
service_id: 'my-service',
capability: 'settle',
operation: 'record_settle',
before_snapshot: { status: 'pending' },
after_snapshot: { status: 'settled' },
delta: { status: 'pending→settled' },
emitted_at: new Date().toISOString(),
irreversible: true,
correlation_id: req.headers['x-correlation-id'],
approval_token_ref: digestApprovalToken(token), // 24-hex digest, never raw token
});If approval_token_ref is not already a 24-hex digest, the SDK digests it before the event reaches the transport or the bus, so a raw token passed by mistake is not written to the log.
LOCK_4 — rollback guard (idempotency check)
import { checkIdempotency, buildIdempotencyFingerprint } from '@xshieldai/aegis-guard';
const existing = await db.findByExternalRef(args.externalRef);
const fp = buildIdempotencyFingerprint({ amount: args.amount, vessel_id: args.vesselId });
const { isDuplicate, safeNoOp } = checkIdempotency(args.externalRef, existing, fp, existing?.fingerprint);
if (isDuplicate && safeNoOp) return existing; // safe no-op
if (isDuplicate && !safeNoOp) throw new Error('duplicate externalRef: payload differs or cannot be compared');A duplicate is a safe no-op only when both fingerprints are present and equal (AEG-HG-2B-009). If the existing record has no stored fingerprint, the result is safeNoOp: false, comparable: false: store the fingerprint with the record.
buildIdempotencyFingerprint() sorts keys at every depth and keeps NaN, ±Infinity, bigint, Map and Set distinct. The fingerprint is the payload, base64-encoded — not a hash. Anyone who can read a stored fingerprint can read the payload; keep secrets out of it or hash it before storing.
LOCK_5 — nonce replay prevention
import { verifyAndConsumeNonce } from '@xshieldai/aegis-guard';
// Requires nonce in payload; throws IrrNoApprovalError on missing or replayed nonce
await verifyAndConsumeNonce(payload, redisNonceStore);The nonce must be non-empty text and the payload must be in date. Only a store that answers exactly true has consumed the nonce; a store that throws propagates its error (fail closed). This call takes a payload and checks no signature: pass it what verifyApprovalToken() returned.
Quality evidence
import { buildQualityMaskAtPromotion, meetsHgQualityRequirement } from '@xshieldai/aegis-guard';
const mask = buildQualityMaskAtPromotion({
tests_passed: true,
rollback_tested: true,
audit_artifact_produced: true,
});
const ready = meetsHgQualityRequirement('HG-2B-financial', mask);
// false here: the financial bar needs all twelve promotion bitsmeetsHgQualityRequirement() returns false for a group that is not in the table and for a mask that is not a whole number made of bits 0–11 (-1, "4095", 4095.9, or a mask with a drift bit set). HG_REQUIRED_MASKS is frozen.
Session envelope
import { issueEnvelope, verifyEnvelope } from '@xshieldai/aegis-guard';
const env = await issueEnvelope({ service_key: 'my-service', declared_caps: ['read'] });
const audit = await verifyEnvelope(env.session_id);
if (!audit.verified || audit.drift_detected) { /* quarantine the session */ }Both call the Aegis dashboard (aegis_url, or AEGIS_URL, or http://localhost:4850; http and https only) and read its answer strictly (ASE-016): verified is true only when the answer says exactly true for the session asked about; an answer with no session id or seal throws. They trust that endpoint and the connection to it — they do not recompute the seal.
NonceStore — production wiring
The default defaultNonceStore is in-memory (single-process only). Multi-instance deployments must provide a Redis-backed store:
import { type NonceStore } from '@xshieldai/aegis-guard';
const redisNonceStore: NonceStore = {
async consumeNonce(nonce, ttlMs) {
const key = `aegis:nonce:${nonce}`;
const result = await redis.set(key, '1', 'NX', 'PX', ttlMs);
if (result === null) return false; // already consumed
return true;
// throws propagate to callers → fail CLOSED (AEG-HG-2B-006)
},
};Schema
quality_mask_at_promotion: bits 0–11,aegis-quality-16bit-v1quality_drift_score: bits 12–15,aegis-quality-16bit-v1- AEG-Q-003 invariant: bits 12–15 must never be set in
quality_mask_at_promotion
Upgrading to 0.6.x
- 0.5.0 added
actionDigest(),mintActionApprovalToken()andverifyActionApprovalToken(). - 0.6.0: set
AEGIS_MINT_AUTHORITY=1for the one process that mints, and for no other. Without it minting and key generation throw.AEGIS_ALLOW_INSECURE_LOCAL_MINT=1is the stated opt-out for a single-process development box. - 0.6.0: mint and verify with the action-bound pair. A token with no
action_digestis refused byverifyApprovalToken()andverifyScopedApprovalToken()too, unlessAEGIS_ALLOW_LABEL_ONLY_APPROVAL=1. - 0.6.1: an action must be plain data. A
Date,URL,Map,Set,RegExp,Buffer, class instance,NaN,±Infinity,undefinedorbigintanywhere in an action is now refused, where 0.6.0 digested it (wrongly, see "One approval, one action"). Convert it to text on both sides, the minting side and the verifying side. ABufferwas digested correctly before and is refused now as well: write it as hex or base64 text.
Upgrading from 0.3.x
- Call
ensureSigningKeypair()once on the authority box before minting. Minting no longer makes a key. On a verifier box, do not call it at all. - Mint with a numeric
expires_at. A token without one is refused (it used to never expire). verifyApprovalToken()refuses astatusother thanapproved. Before, only the scoped call looked at it.checkIdempotency()without a stored fingerprint is no longer a safe no-op. Read the newcomparablefield.- Fingerprints of nested payloads change (inner keys are now sorted). A flat payload of text, finite numbers, booleans and null fingerprints as before. A retry that straddles the upgrade with a nested payload is reported as a mismatch.
- A stale
approval-signing.pubnext to the private key is now a conflict, and verification is refused until the two agree. verifyEnvelope()throws when the audit is for another session;issueEnvelope()throws on an answer with no session id or seal.
What this does not do
Each of these is pinned by a test in tests/hardening.test.ts, so it cannot change unnoticed — except the two about what is absent (a shared nonce store, a revocation list), which no test can show.
- No upper limit on a token's life. The signer chooses
expires_at. - The package does not decide what "the action" is. The caller builds it. A field the caller leaves out of the action is not bound, and two services that describe the same act differently do not match.
- Verifying an action-bound token does not use it up. The same token verifies again until it expires; a second use is stopped only by
verifyAndConsumeNonce(). - An empty scope checks nothing more than
verifyApprovalToken(). A scope value that is an object never matches. verifyAndConsumeNonce()checks no signature and no status. It trusts the payload it is given.- A key file swapped under a running process is not noticed until restart. A change of
AEGIS_APPROVAL_PUBKEY_PEMis. - The default nonce store is one process's memory. Two instances each accept the same nonce once.
- The fingerprint is not a hash, a field set to
undefinedis the same as the field absent, and aDateis the same as its ISO text. - Only the
approval_token_reffield is digested. A raw token placed anywhere else in a SENSE event reaches the transport. - A SENSE transport that throws reaches the caller, and the three snapshots are not checked for presence.
- Revocation is a field in the token. There is no revocation list: a token signed as
approvedstays valid until it expires.
License
AGPL-3.0 — Capt. Anil Sharma, powerpbox.org
v0.2.0 — Opt-in Agentic Control Center (ACC) event bus
Added 2026-05-17. Each Five Locks primitive now emits an AccReceipt on
success or failure, but only when you wire a bus. Without setEventBus,
v0.2.0 behaves identically to v0.1.0 — no emission, no state, no side effect.
Wire it in 3 lines
import { setEventBus, type EventBus, type AccReceipt } from '@xshieldai/aegis-guard';
const myBus: EventBus = {
emit: (r: AccReceipt) => console.log(`[ACC] ${r.event_type} verdict=${r.verdict} ${r.summary}`),
};
setEventBus(myBus);Now every primitive call emits a receipt. Pass null to setEventBus to detach.
Receipt events emitted
| Primitive | event_type on success | event_type on failure |
|---|---|---|
| verifyApprovalToken | lock.approval.verified (PASS) | lock.approval.rejected (FAIL) |
| verifyAndConsumeNonce | lock.nonce.consumed (PASS) | lock.nonce.rejected (FAIL) |
| checkIdempotency | lock.idempotency.duplicate (PASS), lock.idempotency.mismatch (WARN) or lock.idempotency.unverifiable (WARN, no fingerprint to compare) | (no event for non-duplicate path) |
| emitAegisSenseEvent | lock.sense.emitted (PASS or WARN if irreversible) | — |
Receipt shape
interface AccReceipt {
receipt_id: string; // primitive-prefixed identifier
primitive: string; // always 'aegis-guard' for this package
event_type: string; // lock.*
emitted_at: string; // ISO 8601
agent_id?: string; // reserved — not yet populated by aegis-guard
verdict?: string; // PASS | FAIL | WARN
rules_fired?: string[]; // e.g. ['AEG-E-016']
summary?: string; // ≤200 chars
payload?: Record<string, unknown>;
}The shape is a strict subset of the EE PRAMANA receipt format. EE consumers ingest these events without translation.
Phase-1 limits (v0.2.0)
- agent_id is not yet populated — primitives don't receive an agent
context as parameter. Future versions may add an optional
agent_idargument to each primitive; today you can post-process receipts in the bus to add agent context from your own tracking. buildIdempotencyFingerprint,digestApprovalToken,mintApprovalTokendo NOT emit — they're pure helpers called many times per operation. Emitting from them would flood the bus.buildQualityMaskAtPromotion,buildQualityDriftScore,meetsHgQualityRequirementdo NOT emit — quality computation is scoring, not a governance decision. They're called during promotion decisions; the calling code emits the governance event.- Default bus is in-process only. Multi-process buses (Redis-backed,
etc.) are a consumer choice — implement the
EventBusinterface and callsetEventBus(yourBus).
Use with @xshieldai/aegis-suite
If you installed the meta-package, you can wire all 6 primitives in one call:
import { wireAllToBus } from '@xshieldai/aegis-suite'; // available in suite v0.2.0+
wireAllToBus(); // default: in-memory bus + SQLite writer to ~/.aegis/acc-events.dbThis sets up the bus on aegis-guard + chitta-detect + lakshmanrekha + hanumang-mandate all at once, and persists events for the Agentic Control Center page.
Discipline
- Stateless contract preserved. Primitives hold no state beyond a
module-private bus reference. Pass
nulltosetEventBusto detach. - Emission must never throw. If your bus implementation throws, the primitive's caller is unaffected — the receipt is silently dropped. This is intentional; observability must not break the governed path.
