@xshieldai/aegis-guard 0.4.0 → 0.6.0
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/package.json +2 -2
- package/src/approval-token.ts +63 -0
- package/src/index.ts +7 -0
- package/src/signing.ts +22 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xshieldai/aegis-guard",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "AEGIS Guard SDK
|
|
3
|
+
"version": "0.6.0",
|
|
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>",
|
package/src/approval-token.ts
CHANGED
|
@@ -23,6 +23,7 @@ export interface ApprovalTokenPayload {
|
|
|
23
23
|
issued_by?: string;
|
|
24
24
|
nonce?: string;
|
|
25
25
|
status?: 'approved' | 'revoked' | 'denied';
|
|
26
|
+
action_digest?: string; // @rule:AEG-E-016 — binds the token to the CONCRETE act, not just its label
|
|
26
27
|
[key: string]: unknown;
|
|
27
28
|
}
|
|
28
29
|
|
|
@@ -31,6 +32,17 @@ const isText = (v: unknown): v is string => typeof v === 'string' && v.length >
|
|
|
31
32
|
const say = (v: unknown): string => (typeof v === 'string' ? v : typeof v).slice(0, 60);
|
|
32
33
|
const own = (o: object, k: string): boolean => Object.prototype.hasOwnProperty.call(o, k);
|
|
33
34
|
|
|
35
|
+
// @rule:AEG-E-016 — action-binding is the ENFORCED default (review point 4, 2026-10-07): a token
|
|
36
|
+
// that does not name the concrete act (no action_digest) is refused, so one approval cannot stand
|
|
37
|
+
// in for every instance. A caller that knowingly accepts the old label-only behaviour sets
|
|
38
|
+
// AEGIS_ALLOW_LABEL_ONLY_APPROVAL=1. (The full match — digest equals the act in hand — is still the
|
|
39
|
+
// job of verifyActionApprovalToken; this base check guarantees no loose token is accepted anywhere.)
|
|
40
|
+
export const LABEL_ONLY_ENV = 'AEGIS_ALLOW_LABEL_ONLY_APPROVAL';
|
|
41
|
+
const labelOnlyAllowed = (): boolean => {
|
|
42
|
+
const v = process.env[LABEL_ONLY_ENV];
|
|
43
|
+
return v === '1' || v === 'true' || v === 'yes';
|
|
44
|
+
};
|
|
45
|
+
|
|
34
46
|
// @rule:AEG-HG-2B-005 — SENSE stores proof reference, not proof secret.
|
|
35
47
|
// Returns first 24 hex chars of SHA-256 (96 bits) — sufficient for correlation, not reconstruction.
|
|
36
48
|
export function digestApprovalToken(token: string): string {
|
|
@@ -127,6 +139,16 @@ export function verifyApprovalToken(
|
|
|
127
139
|
if (payload.nonce !== undefined && !isText(payload.nonce)) {
|
|
128
140
|
throw new IrrNoApprovalError(cap, 'AEG-E-016: token nonce is not text');
|
|
129
141
|
}
|
|
142
|
+
// Action-binding is the enforced default (point 4): a label-only approval authorises every
|
|
143
|
+
// instance of the operation, so refuse it unless the caller opted into the old loose behaviour.
|
|
144
|
+
if (!isText(payload.action_digest) && !labelOnlyAllowed()) {
|
|
145
|
+
throw new IrrNoApprovalError(
|
|
146
|
+
cap,
|
|
147
|
+
`AEG-E-016: approval is not bound to a concrete action (no action_digest) — a label-only ` +
|
|
148
|
+
`approval authorises every instance. Mint with mintActionApprovalToken()/verify with ` +
|
|
149
|
+
`verifyActionApprovalToken(), or set ${LABEL_ONLY_ENV}=1 to accept label-only approvals.`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
130
152
|
|
|
131
153
|
emitAccReceipt({
|
|
132
154
|
receipt_id: `aegis-guard-verify-${digestApprovalToken(token)}`,
|
|
@@ -253,3 +275,44 @@ export function verifyScopedApprovalToken(
|
|
|
253
275
|
|
|
254
276
|
return payload;
|
|
255
277
|
}
|
|
278
|
+
|
|
279
|
+
// @rule:AEG-E-016 — bind an approval to the CONCRETE act, not just its label.
|
|
280
|
+
// An independent review (Oct 2026) showed a token scoped only to service/capability/operation
|
|
281
|
+
// approves ANY instance of that operation: one 'drop_table' approval drops any table. The fix is a
|
|
282
|
+
// digest of the concrete action (which table, which amount, which recipient) carried in the signed
|
|
283
|
+
// payload as action_digest; the gate recomputes it from the act in hand and refuses a mismatch.
|
|
284
|
+
// This rides the existing, hardened verifyScopedApprovalToken (a field with no token value refuses),
|
|
285
|
+
// so a token minted without an action_digest cannot pass the action-bound check at all.
|
|
286
|
+
|
|
287
|
+
// Deterministic JSON: keys sorted, so {a,b} and {b,a} digest the same.
|
|
288
|
+
function stableStringify(v: unknown): string {
|
|
289
|
+
if (v === null || typeof v !== 'object') return JSON.stringify(v) ?? 'null';
|
|
290
|
+
if (Array.isArray(v)) return '[' + v.map(stableStringify).join(',') + ']';
|
|
291
|
+
const o = v as Record<string, unknown>;
|
|
292
|
+
return '{' + Object.keys(o).sort().map((k) => JSON.stringify(k) + ':' + stableStringify(o[k])).join(',') + '}';
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// SHA-256 of the canonical action. The caller owns what 'the action' is; the SDK never names domain concepts.
|
|
296
|
+
export function actionDigest(action: unknown): string {
|
|
297
|
+
return createHash('sha256').update(stableStringify(action)).digest('hex');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Mint an approval already bound to a concrete action (adds action_digest to the signed payload).
|
|
301
|
+
export function mintActionApprovalToken(payload: ApprovalTokenPayload, action: unknown): string {
|
|
302
|
+
return mintApprovalToken({ ...payload, action_digest: actionDigest(action) });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Verify an approval against the concrete action in hand. The token MUST carry a matching action_digest;
|
|
306
|
+
// a label-only token (no action_digest) is refused here, closing "one approval fits every instance".
|
|
307
|
+
export function verifyActionApprovalToken(
|
|
308
|
+
token: string,
|
|
309
|
+
expectedServiceId: string,
|
|
310
|
+
expectedCapability: string,
|
|
311
|
+
expectedOperation: string,
|
|
312
|
+
action: unknown,
|
|
313
|
+
): ApprovalTokenPayload {
|
|
314
|
+
return verifyScopedApprovalToken(
|
|
315
|
+
token, expectedServiceId, expectedCapability, expectedOperation,
|
|
316
|
+
{ action_digest: actionDigest(action) },
|
|
317
|
+
);
|
|
318
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -46,6 +46,10 @@ export {
|
|
|
46
46
|
verifyApprovalToken,
|
|
47
47
|
verifyAndConsumeNonce,
|
|
48
48
|
verifyScopedApprovalToken,
|
|
49
|
+
actionDigest,
|
|
50
|
+
mintActionApprovalToken,
|
|
51
|
+
verifyActionApprovalToken,
|
|
52
|
+
LABEL_ONLY_ENV,
|
|
49
53
|
} from './approval-token.js';
|
|
50
54
|
|
|
51
55
|
// @rule:KGT-002 — KGT-T1.1 (v0.3.0): approval tokens are AEGIS-signed EdDSA JWTs.
|
|
@@ -59,6 +63,9 @@ export {
|
|
|
59
63
|
getPublicKeyPem,
|
|
60
64
|
signApprovalJwt,
|
|
61
65
|
verifyApprovalJwt,
|
|
66
|
+
mintAllowed,
|
|
67
|
+
MINT_AUTHORITY_ENV,
|
|
68
|
+
INSECURE_LOCAL_MINT_ENV,
|
|
62
69
|
} from './signing.js';
|
|
63
70
|
|
|
64
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(
|