@xshieldai/aegis-guard 0.6.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 +37 -3
- package/package.json +1 -1
- package/src/approval-token.ts +53 -8
package/README.md
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# @xshieldai/aegis-guard
|
|
2
2
|
|
|
3
|
-
> **π Verification status (2026-10-
|
|
4
|
-
> - **Tests:** β
**
|
|
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
|
-
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xshieldai/aegis-guard",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
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",
|
package/src/approval-token.ts
CHANGED
|
@@ -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';
|
|
@@ -285,21 +286,65 @@ export function verifyScopedApprovalToken(
|
|
|
285
286
|
// so a token minted without an action_digest cannot pass the action-bound check at all.
|
|
286
287
|
|
|
287
288
|
// Deterministic JSON: keys sorted, so {a,b} and {b,a} digest the same.
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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');
|
|
293
336
|
}
|
|
294
337
|
|
|
295
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).
|
|
296
340
|
export function actionDigest(action: unknown): string {
|
|
297
|
-
return
|
|
341
|
+
return digestFor(action, 'unknown');
|
|
298
342
|
}
|
|
299
343
|
|
|
300
344
|
// Mint an approval already bound to a concrete action (adds action_digest to the signed payload).
|
|
301
345
|
export function mintActionApprovalToken(payload: ApprovalTokenPayload, action: unknown): string {
|
|
302
|
-
|
|
346
|
+
const cap = payload !== null && typeof payload === 'object' && isText(payload.capability) ? payload.capability : 'unknown';
|
|
347
|
+
return mintApprovalToken({ ...payload, action_digest: digestFor(action, cap) });
|
|
303
348
|
}
|
|
304
349
|
|
|
305
350
|
// Verify an approval against the concrete action in hand. The token MUST carry a matching action_digest;
|
|
@@ -313,6 +358,6 @@ export function verifyActionApprovalToken(
|
|
|
313
358
|
): ApprovalTokenPayload {
|
|
314
359
|
return verifyScopedApprovalToken(
|
|
315
360
|
token, expectedServiceId, expectedCapability, expectedOperation,
|
|
316
|
-
{ action_digest:
|
|
361
|
+
{ action_digest: digestFor(action, isText(expectedCapability) ? expectedCapability : 'unknown') },
|
|
317
362
|
);
|
|
318
363
|
}
|