@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xshieldai/aegis-guard",
3
- "version": "0.4.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.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>",
@@ -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(