@xshieldai/aegis-guard 0.5.0 β†’ 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,11 +1,12 @@
1
1
  # @xshieldai/aegis-guard
2
2
 
3
- > **πŸ” Verification status (2026-10-05 IST β€” v0.4.0)**
4
- > - **Tests:** βœ… **177/177 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](https://github.com/rocketlang/aegis/blob/master/packages/aegis-guard/tests/aegis-guard.test.ts) and 107 in [tests/hardening.test.ts](https://github.com/rocketlang/aegis/blob/master/packages/aegis-guard/tests/hardening.test.ts) for key trust, malformed tokens, nonces, the duplicate check, the audit event, the quality bar, the envelope helpers and the stated limits. No test contacts a network or touches `~/.aegis`.
3
+ > **πŸ” Verification status (2026-10-11 IST β€” v0.6.1)**
4
+ > - **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](https://github.com/rocketlang/aegis/blob/master/packages/aegis-guard/tests/aegis-guard.test.ts) and 114 in [tests/hardening.test.ts](https://github.com/rocketlang/aegis/blob/master/packages/aegis-guard/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`.
5
5
  > - **Examples:** βœ… runnable quickstart, in the repository (not in the npm package): [examples/quickstart.ts](https://github.com/rocketlang/aegis/blob/master/packages/aegis-guard/examples/quickstart.ts) β€” `bun run examples/quickstart.ts` shows all 5 Locks in action with receipts
6
6
  > - **Live demo:** ⚠️ planned (Tier 3, see [PROOF-STACK.md](https://github.com/rocketlang/aegis/blob/master/PROOF-STACK.md))
7
7
  > - **Limits:** see "What this does not do" below, and the v0.2.0 ACC section
8
8
  > - **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".
9
+ > - **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".
9
10
 
10
11
  AEGIS Guard SDK β€” reusable approval-token, nonce, idempotency, SENSE, and quality-evidence primitives for AEGIS-governed services.
11
12
 
@@ -53,7 +54,12 @@ An approval token is an EdDSA-signed JWT. One box signs; every other box only ve
53
54
 
54
55
  `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.
55
56
 
56
- Mint with `issued_at` and `expires_at` in milliseconds: `mintApprovalToken({ service_id, capability, operation, nonce, issued_at: Date.now(), expires_at: Date.now() + 60_000 })`.
57
+ Since 0.6.0 two things are refused by default, and the two calls below are therefore not enough on their own:
58
+
59
+ - **Minting is an authority action.** `mintApprovalToken()` and `ensureSigningKeypair()` throw unless the process declares itself the approving authority with `AEGIS_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.
60
+ - **An approval must name the concrete action.** A token without an `action_digest` is 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=1` brings back the old label-only behaviour for a caller that knowingly accepts it.
61
+
62
+ 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)`.
57
63
 
58
64
  ```typescript
59
65
  import { verifyApprovalToken, verifyScopedApprovalToken } from '@xshieldai/aegis-guard';
@@ -70,6 +76,25 @@ const payload = verifyScopedApprovalToken(
70
76
 
71
77
  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.
72
78
 
79
+ ### One approval, one action
80
+
81
+ ```typescript
82
+ import { mintActionApprovalToken, verifyActionApprovalToken } from '@xshieldai/aegis-guard';
83
+
84
+ // On the authority (AEGIS_MINT_AUTHORITY=1): the approval names what was approved.
85
+ const action = { table: 'audit_log_2023', run_at: '2026-10-11T06:40:00.000Z' };
86
+ const token = mintActionApprovalToken({ service_id, capability, operation, nonce, issued_at, expires_at }, action);
87
+
88
+ // In the service, with the action it is about to perform:
89
+ const payload = verifyActionApprovalToken(token, 'my-service', 'schema', 'drop_table', actionInHand);
90
+ ```
91
+
92
+ 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.
93
+
94
+ **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.
95
+
96
+ 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.
97
+
73
98
  ### LOCK_3 β€” observability (SENSE)
74
99
 
75
100
  ```typescript
@@ -176,6 +201,13 @@ const redisNonceStore: NonceStore = {
176
201
  - `quality_drift_score`: bits 12–15, `aegis-quality-16bit-v1`
177
202
  - AEG-Q-003 invariant: bits 12–15 must **never** be set in `quality_mask_at_promotion`
178
203
 
204
+ ## Upgrading to 0.6.x
205
+
206
+ - **0.5.0** added `actionDigest()`, `mintActionApprovalToken()` and `verifyActionApprovalToken()`.
207
+ - **0.6.0: set `AEGIS_MINT_AUTHORITY=1` for the one process that mints**, and for no other. Without it minting and key generation throw. `AEGIS_ALLOW_INSECURE_LOCAL_MINT=1` is the stated opt-out for a single-process development box.
208
+ - **0.6.0: mint and verify with the action-bound pair.** A token with no `action_digest` is refused by `verifyApprovalToken()` and `verifyScopedApprovalToken()` too, unless `AEGIS_ALLOW_LABEL_ONLY_APPROVAL=1`.
209
+ - **0.6.1: an action must be plain data.** A `Date`, `URL`, `Map`, `Set`, `RegExp`, `Buffer`, class instance, `NaN`, `Β±Infinity`, `undefined` or `bigint` anywhere 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. A `Buffer` was digested correctly before and is refused now as well: write it as hex or base64 text.
210
+
179
211
  ## Upgrading from 0.3.x
180
212
 
181
213
  - **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.
@@ -191,6 +223,8 @@ const redisNonceStore: NonceStore = {
191
223
  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.
192
224
 
193
225
  - **No upper limit on a token's life.** The signer chooses `expires_at`.
226
+ - **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.
227
+ - **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()`.
194
228
  - **An empty scope checks nothing more** than `verifyApprovalToken()`. A scope value that is an object never matches.
195
229
  - **`verifyAndConsumeNonce()` checks no signature and no status.** It trusts the payload it is given.
196
230
  - **A key file swapped under a running process is not noticed until restart.** A change of `AEGIS_APPROVAL_PUBKEY_PEM` is.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xshieldai/aegis-guard",
3
- "version": "0.5.0",
4
- "description": "AEGIS Guard SDK β€” Five Locks primitives (approval-token, nonce, idempotency, SENSE, quality-evidence) + opt-in Agentic Control Center event bus for AEGIS-governed services",
3
+ "version": "0.6.1",
4
+ "description": "AEGIS Guard SDK \u2014 Five Locks primitives (approval-token, nonce, idempotency, SENSE, quality-evidence) + opt-in Agentic Control Center event bus for AEGIS-governed services",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
7
7
  "author": "Capt. Anil Sharma <capt.anil.sharma@powerpbox.org>",
@@ -4,6 +4,7 @@
4
4
  // @rule:AEG-HG-2B-008 β€” every field a decision rests on is checked for its type before it is
5
5
  // compared; anything the gate cannot read is a refusal, with the
6
6
  // package's own error, never a pass and never a TypeError
7
+ // @rule:AEG-HG-2B-010 β€” an action is plain data or it is refused; the digest never guesses a spelling
7
8
 
8
9
  import { createHash } from 'crypto';
9
10
  import { IrrNoApprovalError } from './errors.js';
@@ -32,6 +33,17 @@ const isText = (v: unknown): v is string => typeof v === 'string' && v.length >
32
33
  const say = (v: unknown): string => (typeof v === 'string' ? v : typeof v).slice(0, 60);
33
34
  const own = (o: object, k: string): boolean => Object.prototype.hasOwnProperty.call(o, k);
34
35
 
36
+ // @rule:AEG-E-016 β€” action-binding is the ENFORCED default (review point 4, 2026-10-07): a token
37
+ // that does not name the concrete act (no action_digest) is refused, so one approval cannot stand
38
+ // in for every instance. A caller that knowingly accepts the old label-only behaviour sets
39
+ // AEGIS_ALLOW_LABEL_ONLY_APPROVAL=1. (The full match β€” digest equals the act in hand β€” is still the
40
+ // job of verifyActionApprovalToken; this base check guarantees no loose token is accepted anywhere.)
41
+ export const LABEL_ONLY_ENV = 'AEGIS_ALLOW_LABEL_ONLY_APPROVAL';
42
+ const labelOnlyAllowed = (): boolean => {
43
+ const v = process.env[LABEL_ONLY_ENV];
44
+ return v === '1' || v === 'true' || v === 'yes';
45
+ };
46
+
35
47
  // @rule:AEG-HG-2B-005 β€” SENSE stores proof reference, not proof secret.
36
48
  // Returns first 24 hex chars of SHA-256 (96 bits) β€” sufficient for correlation, not reconstruction.
37
49
  export function digestApprovalToken(token: string): string {
@@ -128,6 +140,16 @@ export function verifyApprovalToken(
128
140
  if (payload.nonce !== undefined && !isText(payload.nonce)) {
129
141
  throw new IrrNoApprovalError(cap, 'AEG-E-016: token nonce is not text');
130
142
  }
143
+ // Action-binding is the enforced default (point 4): a label-only approval authorises every
144
+ // instance of the operation, so refuse it unless the caller opted into the old loose behaviour.
145
+ if (!isText(payload.action_digest) && !labelOnlyAllowed()) {
146
+ throw new IrrNoApprovalError(
147
+ cap,
148
+ `AEG-E-016: approval is not bound to a concrete action (no action_digest) β€” a label-only ` +
149
+ `approval authorises every instance. Mint with mintActionApprovalToken()/verify with ` +
150
+ `verifyActionApprovalToken(), or set ${LABEL_ONLY_ENV}=1 to accept label-only approvals.`,
151
+ );
152
+ }
131
153
 
132
154
  emitAccReceipt({
133
155
  receipt_id: `aegis-guard-verify-${digestApprovalToken(token)}`,
@@ -264,21 +286,65 @@ export function verifyScopedApprovalToken(
264
286
  // so a token minted without an action_digest cannot pass the action-bound check at all.
265
287
 
266
288
  // Deterministic JSON: keys sorted, so {a,b} and {b,a} digest the same.
267
- function stableStringify(v: unknown): string {
268
- if (v === null || typeof v !== 'object') return JSON.stringify(v) ?? 'null';
269
- if (Array.isArray(v)) return '[' + v.map(stableStringify).join(',') + ']';
270
- const o = v as Record<string, unknown>;
271
- return '{' + Object.keys(o).sort().map((k) => JSON.stringify(k) + ':' + stableStringify(o[k])).join(',') + '}';
289
+ //
290
+ // @rule:AEG-HG-2B-010 β€” an action is PLAIN DATA or it is refused: text, finite numbers, booleans,
291
+ // null, arrays of these, and plain objects of these. Through 0.6.0 every other value was given a
292
+ // guessed spelling, and the guesses collided: a Date, a URL, a Set, a Map, a RegExp and any class
293
+ // instance were all written "{}", and NaN, Infinity and undefined were all written "null". An
294
+ // approval minted for one such act then verified for a different one (found 11 October 2026 on
295
+ // the published 0.6.0). The digest does not guess any more. A caller writes such a value as text
296
+ // itself (a date as its ISO text, a URL as its href, a set as a sorted array), so that what was
297
+ // approved is what a person could read.
298
+ //
299
+ // For an action that was plain data already, the digest is the same as in 0.5.0 and 0.6.0.
300
+ function stableStringify(v: unknown, cap: string, at: string, seen: Set<object>): string {
301
+ const refuse = (what: string): never => {
302
+ throw new IrrNoApprovalError(
303
+ cap,
304
+ `AEG-E-016: the action cannot be bound β€” ${at} is ${what}. An action must be plain data ` +
305
+ `(text, finite numbers, booleans, null, arrays and plain objects); write other values as text.`,
306
+ );
307
+ };
308
+ if (v === null || typeof v === 'string' || typeof v === 'boolean') return JSON.stringify(v);
309
+ if (typeof v === 'number') return Number.isFinite(v) ? JSON.stringify(v) : refuse(`the number ${String(v)}`);
310
+ if (typeof v !== 'object') return refuse(v === undefined ? 'undefined' : `a ${typeof v}`);
311
+ if (seen.has(v)) return refuse('a value that contains itself');
312
+ seen.add(v);
313
+ let out: string;
314
+ if (Array.isArray(v)) {
315
+ const parts: string[] = [];
316
+ // By index, not map(): map() skips a hole, and a hole is a missing value.
317
+ for (let i = 0; i < v.length; i++) parts.push(stableStringify(v[i], cap, `${at}[${i}]`, seen));
318
+ out = '[' + parts.join(',') + ']';
319
+ } else {
320
+ const proto = Object.getPrototypeOf(v);
321
+ if (proto !== Object.prototype && proto !== null) {
322
+ const name = (proto as { constructor?: { name?: unknown } })?.constructor?.name;
323
+ refuse(`a ${typeof name === 'string' && name ? name.slice(0, 40) : 'class instance'}, not a plain object`);
324
+ }
325
+ if (Object.getOwnPropertySymbols(v).length > 0) refuse('an object with a symbol key');
326
+ const o = v as Record<string, unknown>;
327
+ out = '{' + Object.keys(o).sort().map((k) =>
328
+ JSON.stringify(k) + ':' + stableStringify(o[k], cap, `${at}.${k.slice(0, 40)}`, seen)).join(',') + '}';
329
+ }
330
+ seen.delete(v);
331
+ return out;
332
+ }
333
+
334
+ function digestFor(action: unknown, cap: string): string {
335
+ return createHash('sha256').update(stableStringify(action, cap, 'action', new Set())).digest('hex');
272
336
  }
273
337
 
274
338
  // SHA-256 of the canonical action. The caller owns what 'the action' is; the SDK never names domain concepts.
339
+ // Throws IrrNoApprovalError for an action that is not plain data (see AEG-HG-2B-010 above).
275
340
  export function actionDigest(action: unknown): string {
276
- return createHash('sha256').update(stableStringify(action)).digest('hex');
341
+ return digestFor(action, 'unknown');
277
342
  }
278
343
 
279
344
  // Mint an approval already bound to a concrete action (adds action_digest to the signed payload).
280
345
  export function mintActionApprovalToken(payload: ApprovalTokenPayload, action: unknown): string {
281
- return mintApprovalToken({ ...payload, action_digest: actionDigest(action) });
346
+ const cap = payload !== null && typeof payload === 'object' && isText(payload.capability) ? payload.capability : 'unknown';
347
+ return mintApprovalToken({ ...payload, action_digest: digestFor(action, cap) });
282
348
  }
283
349
 
284
350
  // Verify an approval against the concrete action in hand. The token MUST carry a matching action_digest;
@@ -292,6 +358,6 @@ export function verifyActionApprovalToken(
292
358
  ): ApprovalTokenPayload {
293
359
  return verifyScopedApprovalToken(
294
360
  token, expectedServiceId, expectedCapability, expectedOperation,
295
- { action_digest: actionDigest(action) },
361
+ { action_digest: digestFor(action, isText(expectedCapability) ? expectedCapability : 'unknown') },
296
362
  );
297
363
  }
package/src/index.ts CHANGED
@@ -49,6 +49,7 @@ export {
49
49
  actionDigest,
50
50
  mintActionApprovalToken,
51
51
  verifyActionApprovalToken,
52
+ LABEL_ONLY_ENV,
52
53
  } from './approval-token.js';
53
54
 
54
55
  // @rule:KGT-002 β€” KGT-T1.1 (v0.3.0): approval tokens are AEGIS-signed EdDSA JWTs.
@@ -62,6 +63,9 @@ export {
62
63
  getPublicKeyPem,
63
64
  signApprovalJwt,
64
65
  verifyApprovalJwt,
66
+ mintAllowed,
67
+ MINT_AUTHORITY_ENV,
68
+ INSECURE_LOCAL_MINT_ENV,
65
69
  } from './signing.js';
66
70
 
67
71
  export {
package/src/signing.ts CHANGED
@@ -27,6 +27,25 @@ export const APPROVAL_JWT_ALG = 'EdDSA';
27
27
  const KEY_FILE = 'approval-signing.key'; // PKCS8 PEM (private) β€” mode 600
28
28
  const PUB_FILE = 'approval-signing.pub'; // SPKI PEM (public) β€” mode 644
29
29
 
30
+ // @rule:KGT-002 β€” minting is an AUTHORITY action; separation is the default (review point 1,
31
+ // key leg, 2026-10-07). An ordinary agent process VERIFIES; it does not mint, and it does not
32
+ // make a signing key. Only the designated authority (the AEGIS dashboard, ideally under its own
33
+ // uid) mints β€” it declares itself with AEGIS_MINT_AUTHORITY=1. A single-process dev box that
34
+ // knowingly accepts the risk sets AEGIS_ALLOW_INSECURE_LOCAL_MINT=1 (explicit opt-out).
35
+ //
36
+ // HONEST CEILING: on one uid a determined agent can set the env itself. The real boundary is the
37
+ // private key living under a DIFFERENT uid the agent cannot read; this gate stops the lazy path
38
+ // (an agent that mints just by calling the SDK), and the OS stops the determined one.
39
+ export const MINT_AUTHORITY_ENV = 'AEGIS_MINT_AUTHORITY';
40
+ export const INSECURE_LOCAL_MINT_ENV = 'AEGIS_ALLOW_INSECURE_LOCAL_MINT';
41
+ const envTrue = (v?: string): boolean => v === '1' || v === 'true' || v === 'yes';
42
+ export const mintAllowed = (): boolean =>
43
+ envTrue(process.env[MINT_AUTHORITY_ENV]) || envTrue(process.env[INSECURE_LOCAL_MINT_ENV]);
44
+ const MINT_REFUSAL =
45
+ 'KGT-002: minting is an authority action β€” this process is not the approval authority. Agents verify, ' +
46
+ 'they do not mint. Run the authority (the AEGIS dashboard, ideally under its own uid) with ' +
47
+ 'AEGIS_MINT_AUTHORITY=1, or set AEGIS_ALLOW_INSECURE_LOCAL_MINT=1 to accept single-process minting.';
48
+
30
49
  function aegisDir(): string {
31
50
  return process.env.AEGIS_DIR ?? join(process.env.HOME ?? homedir(), '.aegis');
32
51
  }
@@ -102,6 +121,8 @@ function loadKeys(): Keys {
102
121
  // replace the authority's key with the box's own, and the box would then accept approvals
103
122
  // it signed itself.
104
123
  export function ensureSigningKeypair(): { publicKeyPem: string } {
124
+ // Making a signing key is an authority action, not something an agent does by calling the SDK.
125
+ if (!mintAllowed()) throw new Error(MINT_REFUSAL);
105
126
  const dir = aegisDir();
106
127
  const keyPath = join(dir, KEY_FILE);
107
128
  const pubPath = join(dir, PUB_FILE);
@@ -145,6 +166,7 @@ function b64url(buf: Buffer | string): string {
145
166
  // It never makes a key: before v0.4.0 it did, on any box, at the first mint. The authority
146
167
  // calls ensureSigningKeypair() once, at boot.
147
168
  export function signApprovalJwt(payload: Record<string, unknown>): string {
169
+ if (!mintAllowed()) throw new Error(MINT_REFUSAL);
148
170
  const { priv, conflict } = loadKeys();
149
171
  if (!priv) {
150
172
  throw new Error(