@emilia-protocol/gate 0.1.0 → 0.7.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/README.md +218 -13
- package/action-packs.js +141 -0
- package/adapters/_kit.js +63 -0
- package/adapters/aws.js +98 -0
- package/adapters/cloudflare.js +55 -0
- package/adapters/gcp.js +71 -0
- package/adapters/github-demo.mjs +57 -0
- package/adapters/github.js +110 -0
- package/adapters/jira.js +55 -0
- package/adapters/k8s.js +71 -0
- package/adapters/linear.js +55 -0
- package/adapters/salesforce.js +55 -0
- package/adapters/stripe.js +82 -0
- package/adapters/supabase.js +100 -0
- package/adapters/terraform.js +60 -0
- package/adapters/vercel.js +56 -0
- package/custody-demo.mjs +41 -0
- package/demo.mjs +57 -0
- package/eg1-conformance.js +206 -0
- package/eg1.mjs +40 -0
- package/execution-binding.js +98 -0
- package/index.js +184 -25
- package/key-registry.js +103 -0
- package/mcp.js +100 -0
- package/package.json +25 -5
- package/reliance-packet.js +65 -0
- package/retention.js +85 -0
- package/store.js +75 -3
package/index.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* adds the three things a firewall needs over a bare verifier: assurance-tier
|
|
22
22
|
* enforcement, replay defense, and the evidence log. Fails closed.
|
|
23
23
|
*/
|
|
24
|
-
|
|
24
|
+
const {
|
|
25
25
|
verifyEmiliaReceipt,
|
|
26
26
|
receiptChallenge,
|
|
27
27
|
receiptRequiredHeader,
|
|
@@ -29,11 +29,27 @@ import {
|
|
|
29
29
|
findActionRequirement,
|
|
30
30
|
RECEIPT_REQUIRED_STATUS,
|
|
31
31
|
RECEIPT_REQUIRED_HEADER,
|
|
32
|
-
}
|
|
32
|
+
} = await import('@emilia-protocol/require-receipt').catch(() => import('../require-receipt/index.js'));
|
|
33
33
|
import { MemoryConsumptionStore } from './store.js';
|
|
34
34
|
import { createEvidenceLog } from './evidence.js';
|
|
35
|
+
import { DEFAULT_GATE_MANIFEST, HIGH_RISK_ACTION_PACKS, createDefaultActionRiskManifest } from './action-packs.js';
|
|
36
|
+
import { hashCanonical, verifyExecutionBinding } from './execution-binding.js';
|
|
37
|
+
import { buildReliancePacket } from './reliance-packet.js';
|
|
38
|
+
import { createEg1Harness, makeGateInvoke, runEg1, EG1_DEFAULT_SELECTOR } from './eg1-conformance.js';
|
|
39
|
+
import { createKeyRegistry, asKeyRegistry } from './key-registry.js';
|
|
40
|
+
import { classifyRetention, buildRetentionExport } from './retention.js';
|
|
35
41
|
|
|
36
42
|
export { MemoryConsumptionStore, createEvidenceLog };
|
|
43
|
+
export { createDurableConsumptionStore, createMemoryBackend } from './store.js';
|
|
44
|
+
export { createKeyRegistry, asKeyRegistry } from './key-registry.js';
|
|
45
|
+
export { classifyRetention, buildRetentionExport, RETENTION_EXPORT_VERSION } from './retention.js';
|
|
46
|
+
export { DEFAULT_GATE_MANIFEST, HIGH_RISK_ACTION_PACKS, createDefaultActionRiskManifest };
|
|
47
|
+
export { EXECUTION_BINDING_VERSION, canonicalize, hashCanonical, materialFieldsFor, verifyExecutionBinding } from './execution-binding.js';
|
|
48
|
+
export { RELIANCE_PACKET_VERSION, buildReliancePacket } from './reliance-packet.js';
|
|
49
|
+
export {
|
|
50
|
+
EG1_VERSION, EG1_CHECKS, EG1_DEFAULT_ACTION, EG1_DEFAULT_SELECTOR,
|
|
51
|
+
createEg1Harness, makeGateInvoke, runEg1,
|
|
52
|
+
} from './eg1-conformance.js';
|
|
37
53
|
export const ASSURANCE_TIERS = ['software', 'class_a', 'quorum'];
|
|
38
54
|
const TIER_RANK = { software: 0, class_a: 1, quorum: 2 };
|
|
39
55
|
|
|
@@ -64,8 +80,15 @@ export function receiptAssuranceTier(doc) {
|
|
|
64
80
|
* @param {object} [opts.store] consumption store (default in-memory)
|
|
65
81
|
* @param {object} [opts.log] evidence log (default in-memory, hash-chained)
|
|
66
82
|
* @param {boolean} [opts.allowInlineKey=false] accept the receipt's own key (integrity, NOT trust)
|
|
83
|
+
* @param {object} [opts.keyRegistry] a key registry (createKeyRegistry) for rotation + revocation;
|
|
84
|
+
* if given it supersedes trustedKeys — a receipt is verified only against keys valid (and not
|
|
85
|
+
* revoked) at its issuance time.
|
|
67
86
|
*/
|
|
68
|
-
export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900, store, log, allowInlineKey = false, allowEphemeralStore = false, strictEvidence = true, now = Date.now } = {}) {
|
|
87
|
+
export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900, store, log, allowInlineKey = false, allowEphemeralStore = false, strictEvidence = true, now = Date.now, keyRegistry = null } = {}) {
|
|
88
|
+
// Production key custody: a registry (rotation + revocation) supersedes a flat
|
|
89
|
+
// trustedKeys list. A flat list is coerced to an always-valid registry, so
|
|
90
|
+
// existing callers are unchanged.
|
|
91
|
+
const registry = keyRegistry ? asKeyRegistry(keyRegistry) : (trustedKeys.length ? asKeyRegistry(trustedKeys) : null);
|
|
69
92
|
if (manifest) {
|
|
70
93
|
const m = validateActionRiskManifest(manifest);
|
|
71
94
|
if (!m.ok) throw new Error('EMILIA Gate: invalid action-risk manifest: ' + m.errors.join('; '));
|
|
@@ -88,10 +111,11 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
88
111
|
}
|
|
89
112
|
const evidence = log || createEvidenceLog({ strict: strictEvidence });
|
|
90
113
|
|
|
91
|
-
async function check({ selector = {}, receipt = null } = {}) {
|
|
114
|
+
async function check({ selector = {}, receipt = null, observedAction = null, consumptionMode = 'consume' } = {}) {
|
|
92
115
|
const requirement = manifest ? findActionRequirement(manifest, selector) : null;
|
|
93
116
|
const action = requirement?.action_type || selector.action_type || selector.action || null;
|
|
94
117
|
const requiredTier = requirement?.assurance_class || selector.assurance_class || 'software';
|
|
118
|
+
const observed = observedAction || selector.observedAction || selector.actionDetails || null;
|
|
95
119
|
|
|
96
120
|
async function decide(allow, status, reason, extra = {}) {
|
|
97
121
|
const entry = {
|
|
@@ -105,6 +129,7 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
105
129
|
required_tier: requiredTier,
|
|
106
130
|
receipt_id: receipt?.payload?.receipt_id ?? null,
|
|
107
131
|
subject: receipt?.payload?.subject ?? null,
|
|
132
|
+
observed_action_hash: observed ? hashCanonical(observed) : null,
|
|
108
133
|
...extra,
|
|
109
134
|
};
|
|
110
135
|
let record;
|
|
@@ -144,8 +169,14 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
144
169
|
if (!receipt) {
|
|
145
170
|
return decide(false, RECEIPT_REQUIRED_STATUS, 'receipt_required');
|
|
146
171
|
}
|
|
147
|
-
// Signature / freshness / action-binding / outcome.
|
|
148
|
-
|
|
172
|
+
// Signature / freshness / action-binding / outcome. Production key custody:
|
|
173
|
+
// resolve the issuer keys valid (and not revoked) at THIS receipt's issuance
|
|
174
|
+
// time. A revoked or out-of-window key is excluded, so its signature does not
|
|
175
|
+
// verify and the action is refused (fail closed).
|
|
176
|
+
const effectiveKeys = registry
|
|
177
|
+
? registry.keysValidAt(receipt?.payload?.created_at)
|
|
178
|
+
: trustedKeys;
|
|
179
|
+
const v = verifyEmiliaReceipt(receipt, { trustedKeys: effectiveKeys, allowInlineKey, action, maxAgeSec });
|
|
149
180
|
if (!v.ok) {
|
|
150
181
|
return decide(false, RECEIPT_REQUIRED_STATUS, `receipt_rejected:${v.reason}`, { rejected: v });
|
|
151
182
|
}
|
|
@@ -154,6 +185,13 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
154
185
|
if ((TIER_RANK[have] ?? 0) < (TIER_RANK[requiredTier] ?? 0)) {
|
|
155
186
|
return decide(false, RECEIPT_REQUIRED_STATUS, 'assurance_too_low', { have_tier: have, need_tier: requiredTier });
|
|
156
187
|
}
|
|
188
|
+
// The high-risk action packs define material fields that must be observed
|
|
189
|
+
// by the executor from the system of record. A signed, harmless-looking
|
|
190
|
+
// claim cannot authorize a different real mutation.
|
|
191
|
+
const executionBinding = verifyExecutionBinding({ requirement, receipt, observedAction: observed });
|
|
192
|
+
if (!executionBinding.ok) {
|
|
193
|
+
return decide(false, RECEIPT_REQUIRED_STATUS, 'execution_binding_failed', { execution_binding: executionBinding, have_tier: have });
|
|
194
|
+
}
|
|
157
195
|
// One-time consumption (replay defense). Require a stable, issuer-generated
|
|
158
196
|
// receipt_id — never fall back to a content hash, whose canonicalization can
|
|
159
197
|
// differ across language implementations and silently break replay detection
|
|
@@ -162,11 +200,21 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
162
200
|
if (!receiptId) {
|
|
163
201
|
return decide(false, RECEIPT_REQUIRED_STATUS, 'receipt_rejected:missing_receipt_id');
|
|
164
202
|
}
|
|
165
|
-
|
|
203
|
+
let fresh;
|
|
204
|
+
if (consumptionMode === 'reserve') {
|
|
205
|
+
if (typeof consumption.reserve !== 'function') {
|
|
206
|
+
return decide(false, RECEIPT_REQUIRED_STATUS, 'consumption_store_lacks_reserve', { consumption_key: receiptId });
|
|
207
|
+
}
|
|
208
|
+
fresh = await consumption.reserve(receiptId);
|
|
209
|
+
} else if (consumptionMode === 'none') {
|
|
210
|
+
fresh = true;
|
|
211
|
+
} else {
|
|
212
|
+
fresh = await consumption.consume(receiptId);
|
|
213
|
+
}
|
|
166
214
|
if (!fresh) {
|
|
167
215
|
return decide(false, RECEIPT_REQUIRED_STATUS, 'replay_refused', { consumption_key: receiptId });
|
|
168
216
|
}
|
|
169
|
-
return decide(true, 200, 'allow', { signer: v.signer, outcome: v.outcome, have_tier: have });
|
|
217
|
+
return decide(true, 200, 'allow', { signer: v.signer, outcome: v.outcome, have_tier: have, execution_binding: executionBinding, consumption_mode: consumptionMode });
|
|
170
218
|
}
|
|
171
219
|
|
|
172
220
|
/** Express/Connect middleware: refuse the route unless a sufficient receipt is present. */
|
|
@@ -180,7 +228,10 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
180
228
|
const hdr = req.headers?.['x-emilia-receipt'];
|
|
181
229
|
if (hdr) { try { doc = JSON.parse(Buffer.from(hdr, 'base64').toString('utf8')); } catch { /* fallthrough */ } }
|
|
182
230
|
if (!doc && req.body?.emilia_receipt) doc = req.body.emilia_receipt;
|
|
183
|
-
const
|
|
231
|
+
const observedAction = typeof opts.observedAction === 'function'
|
|
232
|
+
? opts.observedAction(req)
|
|
233
|
+
: (opts.observedAction || req.emiliaObservedAction || null);
|
|
234
|
+
const out = await check({ selector, receipt: doc, observedAction });
|
|
184
235
|
if (!out.allow) {
|
|
185
236
|
res.setHeader(RECEIPT_REQUIRED_HEADER, out.header);
|
|
186
237
|
return res.status(out.status).json(out.challenge);
|
|
@@ -196,7 +247,7 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
196
247
|
* commits to the exact authorization decision (`authorizes_decision` = that
|
|
197
248
|
* decision's evidence hash), so authorization and execution are one chain.
|
|
198
249
|
*/
|
|
199
|
-
async function recordExecution({ authorization, outcome = 'executed', detail } = {}) {
|
|
250
|
+
async function recordExecution({ authorization, outcome = 'executed', detail, observedAction = null, executionBinding = null } = {}) {
|
|
200
251
|
const auth = authorization?.evidence || authorization || {};
|
|
201
252
|
return evidence.record({
|
|
202
253
|
kind: 'execution',
|
|
@@ -205,10 +256,47 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
205
256
|
action: authorization?.action ?? auth.action ?? null,
|
|
206
257
|
receipt_id: auth.receipt_id ?? null,
|
|
207
258
|
outcome, // 'executed' | 'failed'
|
|
259
|
+
observed_action_hash: observedAction ? hashCanonical(observedAction) : null,
|
|
260
|
+
execution_binding: executionBinding || authorization?.evidence?.execution_binding || authorization?.execution_binding || null,
|
|
208
261
|
...(detail !== undefined ? { detail } : {}),
|
|
209
262
|
});
|
|
210
263
|
}
|
|
211
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Recommended end-to-end path. Reserves the receipt, runs the side effect,
|
|
267
|
+
* commits one-time consumption only after success, and records execution.
|
|
268
|
+
* If the side effect throws, the reservation is released so the approval can
|
|
269
|
+
* be retried safely.
|
|
270
|
+
*/
|
|
271
|
+
async function run({ selector = {}, receipt = null, observedAction = null } = {}, fn, opts = {}) {
|
|
272
|
+
if (typeof fn !== 'function') throw new Error('EMILIA Gate run(): fn is required');
|
|
273
|
+
const authorization = await check({ selector, receipt, observedAction, consumptionMode: 'reserve' });
|
|
274
|
+
if (!authorization.allow) {
|
|
275
|
+
return { ok: false, status: authorization.status, body: authorization.challenge, authorization };
|
|
276
|
+
}
|
|
277
|
+
const receiptId = authorization.evidence?.receipt_id;
|
|
278
|
+
let actionRan = false;
|
|
279
|
+
try {
|
|
280
|
+
const result = await fn(authorization);
|
|
281
|
+
actionRan = true;
|
|
282
|
+
if (typeof consumption.commit === 'function') await consumption.commit(receiptId);
|
|
283
|
+
if (opts.recordExecution === false) return { ok: true, result, authorization, execution: null, packet: null };
|
|
284
|
+
const execution = await recordExecution({ authorization, outcome: 'executed', observedAction });
|
|
285
|
+
return { ok: true, result, authorization, execution, packet: reliancePacket({ authorization, execution }) };
|
|
286
|
+
} catch (e) {
|
|
287
|
+
if (!actionRan && typeof consumption.release === 'function') await consumption.release(receiptId);
|
|
288
|
+
if (opts.recordExecution !== false) {
|
|
289
|
+
await recordExecution({
|
|
290
|
+
authorization,
|
|
291
|
+
outcome: 'failed',
|
|
292
|
+
detail: actionRan ? `post_execution_failure:${String(e?.message ?? e)}` : String(e?.message ?? e),
|
|
293
|
+
observedAction,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
throw e;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
212
300
|
/**
|
|
213
301
|
* Wrap any function so it runs only behind a passing gate check, and (unless
|
|
214
302
|
* disabled) emits an execution receipt after it runs — the full firewall loop:
|
|
@@ -218,26 +306,97 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
218
306
|
return async function guarded(...args) {
|
|
219
307
|
const selector = typeof opts.selector === 'function' ? opts.selector(...args) : (opts.selector || {});
|
|
220
308
|
const receipt = typeof opts.receipt === 'function' ? opts.receipt(...args) : (opts.receipt ?? null);
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
309
|
+
const observedAction = typeof opts.observedAction === 'function'
|
|
310
|
+
? opts.observedAction(...args)
|
|
311
|
+
: (opts.observedAction || selector.observedAction || null);
|
|
312
|
+
const out = await run({ selector, receipt, observedAction }, () => fn(...args), { recordExecution: opts.recordExecution });
|
|
313
|
+
if (!out.ok) {
|
|
314
|
+
const e = new Error(`EMILIA Gate refused (${out.authorization.reason})`);
|
|
224
315
|
e.code = 'EMILIA_RECEIPT_REQUIRED';
|
|
225
|
-
e.gate = out;
|
|
226
|
-
throw e;
|
|
227
|
-
}
|
|
228
|
-
if (opts.recordExecution === false) return fn(...args);
|
|
229
|
-
try {
|
|
230
|
-
const result = await fn(...args);
|
|
231
|
-
await recordExecution({ authorization: out, outcome: 'executed' });
|
|
232
|
-
return result;
|
|
233
|
-
} catch (e) {
|
|
234
|
-
await recordExecution({ authorization: out, outcome: 'failed', detail: String(e?.message ?? e) });
|
|
316
|
+
e.gate = out.authorization;
|
|
235
317
|
throw e;
|
|
236
318
|
}
|
|
319
|
+
return out.result;
|
|
237
320
|
};
|
|
238
321
|
}
|
|
239
322
|
|
|
240
|
-
|
|
323
|
+
function reliancePacket({ authorization, execution = null, binding = null } = {}) {
|
|
324
|
+
return buildReliancePacket({
|
|
325
|
+
decision: authorization,
|
|
326
|
+
execution,
|
|
327
|
+
evidence,
|
|
328
|
+
manifest,
|
|
329
|
+
binding,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Retention classification over this gate's evidence log (hot/cold/expired/legal-hold). */
|
|
334
|
+
function retention(opts = {}) {
|
|
335
|
+
return classifyRetention(evidence.all(), opts);
|
|
336
|
+
}
|
|
337
|
+
/** The auditor/SIEM export manifest for this gate's evidence log. */
|
|
338
|
+
function retentionExport(opts = {}) {
|
|
339
|
+
return buildRetentionExport(evidence.all(), opts);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
check, run, recordExecution, middleware, guard, reliancePacket, evidence,
|
|
344
|
+
store: consumption, keyRegistry: registry, retention, retentionExport,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function createTrustedActionFirewall(opts = {}) {
|
|
349
|
+
const { manifest = createDefaultActionRiskManifest(), ...rest } = opts;
|
|
350
|
+
return createGate({ ...rest, manifest });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* EG-1 conformance for an existing gate. The gate MUST have been built trusting
|
|
355
|
+
* `harness.publicKey` (otherwise every valid receipt is rejected and the gate
|
|
356
|
+
* cannot earn EG-1). Returns the EG-1 JSON report.
|
|
357
|
+
* @param {object} o
|
|
358
|
+
* @param {object} o.gate an EMILIA Gate (createGate/createTrustedActionFirewall)
|
|
359
|
+
* @param {object} o.harness the harness whose key the gate trusts (createEg1Harness)
|
|
360
|
+
* @param {object} [o.action] the high-risk action to exercise
|
|
361
|
+
* @param {object} [o.selector] the manifest selector for that action
|
|
362
|
+
*/
|
|
363
|
+
export async function gateConformance({ gate, harness, action, selector = EG1_DEFAULT_SELECTOR } = {}) {
|
|
364
|
+
if (!gate || typeof gate.run !== 'function') {
|
|
365
|
+
throw new Error('gateConformance requires a gate built trusting harness.publicKey');
|
|
366
|
+
}
|
|
367
|
+
if (!harness) throw new Error('gateConformance requires the harness whose key the gate trusts');
|
|
368
|
+
const act = action || harness.action;
|
|
369
|
+
const invoke = makeGateInvoke(gate, { selector, action: act });
|
|
370
|
+
return runEg1({ invoke, harness, action: act });
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Self-certify the reference gate: build a default Trusted Action Firewall that
|
|
375
|
+
* trusts a fresh EG-1 harness key, then run all eight checks. This is the
|
|
376
|
+
* canonical "EMILIA Gate earns EG-1" proof — runnable as a CLI (`eg1.mjs`),
|
|
377
|
+
* shown on /gate, and the template an adopter copies for their integration.
|
|
378
|
+
*/
|
|
379
|
+
export async function gateConformanceSelfTest({ now } = {}) {
|
|
380
|
+
const harness = createEg1Harness({ now });
|
|
381
|
+
const gate = createTrustedActionFirewall({ trustedKeys: [harness.publicKey], now });
|
|
382
|
+
return gateConformance({ gate, harness });
|
|
241
383
|
}
|
|
242
384
|
|
|
243
|
-
export default {
|
|
385
|
+
export default {
|
|
386
|
+
createGate,
|
|
387
|
+
createTrustedActionFirewall,
|
|
388
|
+
receiptAssuranceTier,
|
|
389
|
+
MemoryConsumptionStore,
|
|
390
|
+
createEvidenceLog,
|
|
391
|
+
ASSURANCE_TIERS,
|
|
392
|
+
DEFAULT_GATE_MANIFEST,
|
|
393
|
+
HIGH_RISK_ACTION_PACKS,
|
|
394
|
+
gateConformance,
|
|
395
|
+
gateConformanceSelfTest,
|
|
396
|
+
createEg1Harness,
|
|
397
|
+
runEg1,
|
|
398
|
+
createKeyRegistry,
|
|
399
|
+
asKeyRegistry,
|
|
400
|
+
classifyRetention,
|
|
401
|
+
buildRetentionExport,
|
|
402
|
+
};
|
package/key-registry.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* EMILIA Gate — issuer key registry (production key custody for the VERIFIER).
|
|
4
|
+
*
|
|
5
|
+
* A flat `trustedKeys: string[]` cannot express the two things a production
|
|
6
|
+
* deployment needs:
|
|
7
|
+
* 1. ROTATION — an issuer key is valid only for a window; a new key overlaps
|
|
8
|
+
* the old one, then the old one retires, without rejecting receipts that
|
|
9
|
+
* were legitimately issued while it was current.
|
|
10
|
+
* 2. REVOCATION — a COMPROMISED issuer key must be rejected immediately, for
|
|
11
|
+
* every receipt, regardless of the issuance time the (now-untrusted) key
|
|
12
|
+
* claims. Fail closed: once revoked, the key signs nothing the gate accepts.
|
|
13
|
+
*
|
|
14
|
+
* The registry resolves, for a given receipt issuance time, the set of public
|
|
15
|
+
* keys the gate should verify against — excluding revoked keys entirely and
|
|
16
|
+
* windowed keys whose [not_before, not_after] does not contain that time. The
|
|
17
|
+
* gate passes that resolved set to the receipt verifier, so an excluded key's
|
|
18
|
+
* signature simply does not verify and the action is refused.
|
|
19
|
+
*
|
|
20
|
+
* A key with no window and no revocation behaves exactly like a flat trustedKeys
|
|
21
|
+
* entry (back-compatible).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
function toMs(t) {
|
|
25
|
+
if (t == null) return null;
|
|
26
|
+
const ms = typeof t === 'number' ? t : Date.parse(t);
|
|
27
|
+
return Number.isFinite(ms) ? ms : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {Array<object>} entries each: {
|
|
32
|
+
* kid?: string, key: string (base64url SPKI-DER public key),
|
|
33
|
+
* not_before?: string|number, not_after?: string|number, revoked_at?: string|number
|
|
34
|
+
* }
|
|
35
|
+
*/
|
|
36
|
+
export function createKeyRegistry(entries = []) {
|
|
37
|
+
const list = [];
|
|
38
|
+
function normalize(e) {
|
|
39
|
+
if (!e || !e.key || typeof e.key !== 'string') throw new Error('key registry entry requires a base64url SPKI key');
|
|
40
|
+
return {
|
|
41
|
+
kid: e.kid || e.key.slice(0, 16),
|
|
42
|
+
key: e.key,
|
|
43
|
+
not_before: toMs(e.not_before),
|
|
44
|
+
not_after: toMs(e.not_after),
|
|
45
|
+
revoked_at: toMs(e.revoked_at),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
for (const e of entries) list.push(normalize(e));
|
|
49
|
+
|
|
50
|
+
/** Is this entry usable to verify a receipt issued at `atMs`? */
|
|
51
|
+
function entryActiveAt(entry, atMs) {
|
|
52
|
+
if (entry.revoked_at != null) return false; // HARD revocation: never trust a revoked key
|
|
53
|
+
if (entry.not_before != null && atMs != null && atMs < entry.not_before) return false;
|
|
54
|
+
if (entry.not_after != null && atMs != null && atMs > entry.not_after) return false;
|
|
55
|
+
// A windowed key with an unknown issuance time cannot be safely placed in
|
|
56
|
+
// its window — fail closed and exclude it. An unwindowed key still applies.
|
|
57
|
+
if ((entry.not_before != null || entry.not_after != null) && atMs == null) return false;
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
/** The base64url public keys to verify a receipt issued at `at` (ISO or ms). */
|
|
63
|
+
keysValidAt(at) {
|
|
64
|
+
const atMs = toMs(at);
|
|
65
|
+
return list.filter((e) => entryActiveAt(e, atMs)).map((e) => e.key);
|
|
66
|
+
},
|
|
67
|
+
/** Mark a kid revoked as of `at` (default: now-as-supplied). Fail-closed thereafter. */
|
|
68
|
+
revoke(kid, at) {
|
|
69
|
+
let n = 0;
|
|
70
|
+
for (const e of list) {
|
|
71
|
+
if (e.kid === kid && e.revoked_at == null) { e.revoked_at = toMs(at) ?? 0; n += 1; }
|
|
72
|
+
}
|
|
73
|
+
if (n === 0) throw new Error(`key registry: no active key with kid "${kid}" to revoke`);
|
|
74
|
+
return n;
|
|
75
|
+
},
|
|
76
|
+
/** Add/rotate in a new key. */
|
|
77
|
+
add(entry) { list.push(normalize(entry)); return this; },
|
|
78
|
+
/** Operational snapshot (no private material; keys are public). */
|
|
79
|
+
status(at) {
|
|
80
|
+
const atMs = toMs(at);
|
|
81
|
+
return list.map((e) => ({
|
|
82
|
+
kid: e.kid,
|
|
83
|
+
revoked: e.revoked_at != null,
|
|
84
|
+
active: entryActiveAt(e, atMs),
|
|
85
|
+
not_before: e.not_before,
|
|
86
|
+
not_after: e.not_after,
|
|
87
|
+
revoked_at: e.revoked_at,
|
|
88
|
+
}));
|
|
89
|
+
},
|
|
90
|
+
get size() { return list.length; },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Coerce a flat trustedKeys[] OR a registry into a registry (back-compat). */
|
|
95
|
+
export function asKeyRegistry(trustedKeysOrRegistry) {
|
|
96
|
+
if (trustedKeysOrRegistry && typeof trustedKeysOrRegistry.keysValidAt === 'function') {
|
|
97
|
+
return trustedKeysOrRegistry;
|
|
98
|
+
}
|
|
99
|
+
const keys = Array.isArray(trustedKeysOrRegistry) ? trustedKeysOrRegistry : [];
|
|
100
|
+
return createKeyRegistry(keys.map((key) => ({ key })));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export default { createKeyRegistry, asKeyRegistry };
|
package/mcp.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* EMILIA Gate — MCP drop-in. The lowest-friction place to put the firewall:
|
|
4
|
+
* agents already live at the MCP tool-call boundary, and a single wrapper turns
|
|
5
|
+
* a dangerous MCP tool into a receipt-required one.
|
|
6
|
+
*
|
|
7
|
+
* import { createTrustedActionFirewall } from '@emilia-protocol/gate';
|
|
8
|
+
* import { gateMcpTool } from '@emilia-protocol/gate/mcp';
|
|
9
|
+
* const gate = createTrustedActionFirewall({ trustedKeys: [ISSUER] });
|
|
10
|
+
*
|
|
11
|
+
* server.tool('release_payment',
|
|
12
|
+
* gateMcpTool(gate, { tool: 'release_payment' }, async (args) => actuallyPay(args)));
|
|
13
|
+
*
|
|
14
|
+
* A guarded call with no valid, sufficiently-assured, non-replayed receipt
|
|
15
|
+
* returns a structured MCP error (isError) carrying the Receipt-Required
|
|
16
|
+
* challenge, so the agent knows to go get a human/quorum to authorize THIS exact
|
|
17
|
+
* action. On success the tool runs and an execution proof + reliance packet are
|
|
18
|
+
* attached under `_emilia`.
|
|
19
|
+
*
|
|
20
|
+
* Receipt resolution order (override with opts.receipt): args._emilia_receipt,
|
|
21
|
+
* args.emilia_receipt, then a base64 string in args._emilia_receipt_b64.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
function resolveReceipt(args, opts) {
|
|
25
|
+
if (typeof opts.receipt === 'function') return opts.receipt(args);
|
|
26
|
+
if (opts.receipt) return opts.receipt;
|
|
27
|
+
if (args && typeof args === 'object') {
|
|
28
|
+
if (args._emilia_receipt) return args._emilia_receipt;
|
|
29
|
+
if (args.emilia_receipt) return args.emilia_receipt;
|
|
30
|
+
if (typeof args._emilia_receipt_b64 === 'string') {
|
|
31
|
+
try { return JSON.parse(Buffer.from(args._emilia_receipt_b64, 'base64').toString('utf8')); } catch { return null; }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Wrap a single MCP tool handler so it runs only behind a passing gate check.
|
|
39
|
+
* @param {object} gate an EMILIA Gate (createGate/createTrustedActionFirewall)
|
|
40
|
+
* @param {object} o
|
|
41
|
+
* @param {string} o.tool the MCP tool name (matched against the manifest)
|
|
42
|
+
* @param {string} [o.protocol='mcp']
|
|
43
|
+
* @param {string} [o.action] explicit action_type (else resolved by the manifest from {protocol,tool})
|
|
44
|
+
* @param {object|function} [o.observedAction] the system-of-record facts to bind (default: the tool args)
|
|
45
|
+
* @param {object|function} [o.receipt] override receipt resolution
|
|
46
|
+
* @param {function} handler the real tool implementation (args, extra) => result
|
|
47
|
+
* @returns {function} a gated MCP tool handler
|
|
48
|
+
*/
|
|
49
|
+
export function gateMcpTool(gate, o = {}, handler) {
|
|
50
|
+
if (!gate || typeof gate.run !== 'function') throw new Error('gateMcpTool requires an EMILIA Gate (with .run)');
|
|
51
|
+
if (typeof handler !== 'function') throw new Error('gateMcpTool requires a tool handler function');
|
|
52
|
+
const { tool, protocol = 'mcp', action } = o;
|
|
53
|
+
if (!tool) throw new Error('gateMcpTool requires { tool }');
|
|
54
|
+
|
|
55
|
+
return async function gatedTool(args = {}, extra) {
|
|
56
|
+
const selector = { protocol, tool, ...(action ? { action_type: action } : {}) };
|
|
57
|
+
const receipt = resolveReceipt(args, o);
|
|
58
|
+
const observedAction = typeof o.observedAction === 'function'
|
|
59
|
+
? o.observedAction(args, extra)
|
|
60
|
+
: (o.observedAction ?? args);
|
|
61
|
+
|
|
62
|
+
const out = await gate.run({ selector, receipt, observedAction }, () => handler(args, extra));
|
|
63
|
+
if (!out.ok) {
|
|
64
|
+
return {
|
|
65
|
+
isError: true,
|
|
66
|
+
content: [{
|
|
67
|
+
type: 'text',
|
|
68
|
+
text: `EMILIA Gate refused "${tool}": ${out.authorization.reason}. `
|
|
69
|
+
+ 'This is a high-risk action; present a valid, sufficiently-assured, unused human/quorum receipt.',
|
|
70
|
+
}],
|
|
71
|
+
_emilia: {
|
|
72
|
+
gate: 'refused',
|
|
73
|
+
status: out.status,
|
|
74
|
+
reason: out.authorization.reason,
|
|
75
|
+
challenge: out.body,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const result = out.result;
|
|
80
|
+
// Attach the proof without clobbering a structured tool result.
|
|
81
|
+
if (result && typeof result === 'object' && !Array.isArray(result)) {
|
|
82
|
+
return { ...result, _emilia: { gate: 'allowed', execution: out.execution, reliance: out.packet } };
|
|
83
|
+
}
|
|
84
|
+
return { result, _emilia: { gate: 'allowed', execution: out.execution, reliance: out.packet } };
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Convenience: wrap a map of { toolName: handler } in one call. Tools not named
|
|
90
|
+
* in the manifest still pass through the gate (which lets non-guarded tools run).
|
|
91
|
+
*/
|
|
92
|
+
export function gateMcpTools(gate, handlers = {}, opts = {}) {
|
|
93
|
+
const wrapped = {};
|
|
94
|
+
for (const [tool, handler] of Object.entries(handlers)) {
|
|
95
|
+
wrapped[tool] = gateMcpTool(gate, { ...opts, tool }, handler);
|
|
96
|
+
}
|
|
97
|
+
return wrapped;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export default { gateMcpTool, gateMcpTools };
|
package/package.json
CHANGED
|
@@ -1,17 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@emilia-protocol/gate",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "EMILIA Gate — the Trusted Action Firewall. Deny-by-default enforcement for consequential machine actions: an action runs only with a valid, in-scope, sufficiently-assured, non-replayed EMILIA authorization receipt (proof a named human authorized this exact action). Composes require-receipt + verify; adds assurance-tier enforcement, one-time consumption (replay defense),
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "EMILIA Gate — the Trusted Action Firewall. Deny-by-default enforcement for consequential machine actions: an action runs only with a valid, in-scope, sufficiently-assured, non-replayed EMILIA authorization receipt (proof a named human authorized this exact action). Composes require-receipt + verify; adds assurance-tier enforcement, one-time consumption (replay defense), a tamper-evident evidence log, default high-risk action packs, execution-field binding, reliance packets, an MCP drop-in, a GitHub system-of-record adapter, and EG-1 conformance. Framework-agnostic, fails closed. Reference implementation, experimental.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "index.js",
|
|
8
|
-
"
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./index.js",
|
|
10
|
+
"./mcp": "./mcp.js",
|
|
11
|
+
"./adapters/github": "./adapters/github.js",
|
|
12
|
+
"./adapters/stripe": "./adapters/stripe.js",
|
|
13
|
+
"./adapters/supabase": "./adapters/supabase.js",
|
|
14
|
+
"./adapters/aws": "./adapters/aws.js",
|
|
15
|
+
"./adapters/k8s": "./adapters/k8s.js",
|
|
16
|
+
"./adapters/terraform": "./adapters/terraform.js",
|
|
17
|
+
"./adapters/gcp": "./adapters/gcp.js",
|
|
18
|
+
"./adapters/vercel": "./adapters/vercel.js",
|
|
19
|
+
"./adapters/cloudflare": "./adapters/cloudflare.js",
|
|
20
|
+
"./adapters/linear": "./adapters/linear.js",
|
|
21
|
+
"./adapters/jira": "./adapters/jira.js",
|
|
22
|
+
"./adapters/salesforce": "./adapters/salesforce.js",
|
|
23
|
+
"./key-registry": "./key-registry.js",
|
|
24
|
+
"./retention": "./retention.js",
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"files": ["index.js", "store.js", "evidence.js", "action-packs.js", "execution-binding.js", "reliance-packet.js", "key-registry.js", "retention.js", "eg1-conformance.js", "eg1.mjs", "mcp.js", "adapters/_kit.js", "adapters/github.js", "adapters/github-demo.mjs", "adapters/stripe.js", "adapters/supabase.js", "adapters/aws.js", "adapters/k8s.js", "adapters/terraform.js", "adapters/gcp.js", "adapters/vercel.js", "adapters/cloudflare.js", "adapters/linear.js", "adapters/jira.js", "adapters/salesforce.js", "demo.mjs", "custody-demo.mjs", "README.md"],
|
|
9
28
|
"scripts": {
|
|
10
29
|
"test": "node --test",
|
|
11
|
-
"demo": "node demo.mjs"
|
|
30
|
+
"demo": "node demo.mjs",
|
|
31
|
+
"eg1": "node eg1.mjs"
|
|
12
32
|
},
|
|
13
33
|
"dependencies": {
|
|
14
|
-
"@emilia-protocol/require-receipt": "^0.1
|
|
34
|
+
"@emilia-protocol/require-receipt": "^0.4.1"
|
|
15
35
|
},
|
|
16
36
|
"keywords": ["emilia", "authorization", "receipt", "firewall", "trusted-action-firewall", "agent", "mcp", "gate", "policy-enforcement-point", "ai-safety"]
|
|
17
37
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Auditor / insurer-facing reliance packet for an EMILIA Gate decision.
|
|
3
|
+
|
|
4
|
+
export const RELIANCE_PACKET_VERSION = 'EP-GATE-RELIANCE-PACKET-v1';
|
|
5
|
+
|
|
6
|
+
function evidenceStatus(evidence) {
|
|
7
|
+
if (!evidence) return { ok: null, length: null, head: null };
|
|
8
|
+
if (typeof evidence.verify === 'function') return evidence.verify();
|
|
9
|
+
return evidence;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function check(id, ok, detail = null) {
|
|
13
|
+
return { id, ok, ...(detail ? { detail } : {}) };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function buildReliancePacket({
|
|
17
|
+
decision,
|
|
18
|
+
execution = null,
|
|
19
|
+
evidence = null,
|
|
20
|
+
manifest = null,
|
|
21
|
+
binding = null,
|
|
22
|
+
verifier = '@emilia-protocol/gate',
|
|
23
|
+
} = {}) {
|
|
24
|
+
const evidenceCheck = evidenceStatus(evidence);
|
|
25
|
+
const decisionHash = decision?.evidence?.hash || decision?.hash || null;
|
|
26
|
+
const executionBound = !execution || (execution.kind === 'execution' && execution.authorizes_decision === decisionHash);
|
|
27
|
+
const bindingCheck = binding || decision?.evidence?.execution_binding || decision?.execution_binding || null;
|
|
28
|
+
const allowed = decision?.allow === true;
|
|
29
|
+
const evidenceOk = evidenceCheck.ok !== false;
|
|
30
|
+
const bindingOk = bindingCheck ? bindingCheck.ok === true : true;
|
|
31
|
+
const verdict = allowed && executionBound && evidenceOk && bindingOk ? 'rely' : 'do_not_rely';
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
'@version': RELIANCE_PACKET_VERSION,
|
|
35
|
+
product: 'EMILIA Gate',
|
|
36
|
+
verifier,
|
|
37
|
+
verdict,
|
|
38
|
+
summary: {
|
|
39
|
+
action: decision?.action || null,
|
|
40
|
+
receipt_id: decision?.evidence?.receipt_id || decision?.receipt_id || null,
|
|
41
|
+
subject: decision?.evidence?.subject || null,
|
|
42
|
+
required_tier: decision?.evidence?.required_tier || decision?.required_tier || null,
|
|
43
|
+
observed_tier: decision?.evidence?.have_tier || decision?.have_tier || null,
|
|
44
|
+
decision_hash: decisionHash,
|
|
45
|
+
execution_hash: execution?.hash || null,
|
|
46
|
+
evidence_head: evidenceCheck.head || null,
|
|
47
|
+
},
|
|
48
|
+
checks: [
|
|
49
|
+
check('receipt_present_and_valid', allowed && !String(decision?.reason || '').startsWith('receipt_rejected'), decision?.reason || null),
|
|
50
|
+
check('assurance_sufficient', allowed || decision?.reason !== 'assurance_too_low', decision?.reason === 'assurance_too_low' ? 'receipt tier below action requirement' : null),
|
|
51
|
+
check('receipt_one_time_consumed', allowed || decision?.reason === 'replay_refused' ? decision?.reason !== 'replay_refused' : null),
|
|
52
|
+
check('execution_fields_bound', bindingCheck ? bindingCheck.ok === true : null, bindingCheck ? { missing_observed_fields: bindingCheck.missing_observed_fields || [], mismatched_fields: bindingCheck.mismatched_fields || [] } : 'no material execution-field binding required by this action'),
|
|
53
|
+
check('execution_attests_decision', execution ? executionBound : null, execution ? null : 'no execution record supplied'),
|
|
54
|
+
check('evidence_log_intact', evidenceCheck.ok === undefined ? null : evidenceCheck.ok, evidenceCheck.reason || null),
|
|
55
|
+
],
|
|
56
|
+
manifest_version: manifest?.['@version'] || null,
|
|
57
|
+
limitations: [
|
|
58
|
+
'The packet proves the gate verified a receipt and enforced its configured policy; it does not prove the human made a wise decision.',
|
|
59
|
+
'Identity, authority enrollment, and key custody remain external trust roots that must be operated correctly.',
|
|
60
|
+
'For execution-field binding, observedAction must come from the system of record, not from attacker-controlled request input.',
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export default { RELIANCE_PACKET_VERSION, buildReliancePacket };
|