@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/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
- import {
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
- } from '../require-receipt/index.js';
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
- const v = verifyEmiliaReceipt(receipt, { trustedKeys, allowInlineKey, action, maxAgeSec });
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
- const fresh = await consumption.consume(receiptId);
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 out = await check({ selector, receipt: doc });
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 out = await check({ selector, receipt });
222
- if (!out.allow) {
223
- const e = new Error(`EMILIA Gate refused (${out.reason})`);
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
- return { check, recordExecution, middleware, guard, evidence, store: consumption };
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 { createGate, receiptAssuranceTier, MemoryConsumptionStore, createEvidenceLog, ASSURANCE_TIERS };
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
+ };
@@ -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.1.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), and a tamper-evident evidence log. Framework-agnostic, fails closed. Reference implementation, experimental.",
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
- "files": ["index.js", "store.js", "evidence.js", "README.md"],
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.0"
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 };