@emilia-protocol/gate 0.1.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 ADDED
@@ -0,0 +1,71 @@
1
+ # @emilia-protocol/gate — EMILIA Gate
2
+
3
+ **The Trusted Action Firewall.** Deny-by-default enforcement for consequential machine actions.
4
+
5
+ > If an agent cannot produce a valid receipt, it cannot change money, code, permissions, data,
6
+ > infrastructure, energy, or physical state.
7
+
8
+ A guarded action runs **only** if it arrives with a receipt that is **valid** (Ed25519 over
9
+ canonical JSON, signed by a pinned issuer), **in-scope** (bound to the exact action), **sufficiently
10
+ assured** (meets the action's required tier), **fresh**, and **unused** (not a replay). Otherwise it
11
+ is refused with a machine-readable `Receipt-Required` challenge (HTTP 428). Every decision — allow or
12
+ deny — is appended to a tamper-evident evidence log.
13
+
14
+ This is **not** authentication ("who are you") or permissions ("are you allowed here"). It is a
15
+ **policy-enforcement point** that requires portable proof a named human authorized *this exact
16
+ action* before the world is mutated.
17
+
18
+ ## Run it
19
+
20
+ ```bash
21
+ node --test # 10 tests
22
+ node demo.mjs # end-to-end: passthrough -> 428 -> too-low -> allow -> replay -> tamper
23
+ ```
24
+
25
+ ## Use it
26
+
27
+ ```js
28
+ import { createGate } from '@emilia-protocol/gate';
29
+
30
+ const gate = createGate({
31
+ manifest, // EP-ACTION-RISK-MANIFEST-v0.1: which actions are guarded + their tier
32
+ trustedKeys: [ISSUER_PUBKEY_B64U], // pin the issuers you trust
33
+ maxAgeSec: 900,
34
+ });
35
+
36
+ // 1) Framework-agnostic check
37
+ const out = await gate.check({ selector: { protocol: 'mcp', tool: 'release_payment' }, receipt });
38
+ if (!out.allow) throw out.challenge; // 428 Receipt-Required
39
+
40
+ // 2) Express / Connect middleware
41
+ app.post('/payments', gate.middleware({ action: 'payment.release' }), handler);
42
+
43
+ // 3) Wrap any function
44
+ const release = gate.guard(reallyRelease, { selector: () => ({ tool: 'release_payment', protocol: 'mcp' }), receipt: (args, r) => r });
45
+ ```
46
+
47
+ ## What it adds over a bare verifier
48
+
49
+ `@emilia-protocol/require-receipt` already does manifest matching, offline verification, and the 428
50
+ challenge. The Gate composes that and adds the three things a firewall needs:
51
+
52
+ - **Assurance tiers** — `software` < `class_a` (device signoff) < `quorum` (m-of-n). A `critical`
53
+ action can demand `class_a` or `quorum`; a lower-assurance receipt is refused (`assurance_too_low`).
54
+ - **One-time consumption** — a receipt authorizes one action, once. Replays are refused
55
+ (`replay_refused`). Default store is in-memory; swap in Redis/DB for a fleet.
56
+ - **Evidence log** — every decision is hash-chained (`evidence.verify()` detects any alteration).
57
+ This is the compliance / insurance artifact.
58
+
59
+ ## Boundary
60
+
61
+ EMILIA Gate does not stop every bad actor. It makes **legitimate infrastructure refuse unreceipted
62
+ consequential actions by default**, so the parties with leverage (clouds, payment rails, regulators,
63
+ insurers) can *require* a receipt — and "no receipt" becomes like "no TLS cert" or "unsigned binary":
64
+ not always illegal, just untrusted. Necessary, not sufficient.
65
+
66
+ ## Standards
67
+
68
+ The mechanism is specified in `draft-schrock-ep-enforcement-point` (the Receipt-Required rail) over
69
+ `draft-schrock-ep-authorization-receipts`. Earn the **RR-1** conformance level via
70
+ `receiptRequiredConformance()` in `@emilia-protocol/require-receipt`. Reference implementation;
71
+ experimental. Apache-2.0. Fails closed.
package/evidence.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @emilia-protocol/gate — evidence log (tamper-evident audit of decisions).
3
+ * @license Apache-2.0
4
+ *
5
+ * Every gate decision — allow or deny — appends a hash-chained record. Each
6
+ * record commits to the previous one, so removing or altering any decision
7
+ * breaks the chain and `verify()` catches it. This is the compliance and
8
+ * insurance artifact: a provable account of exactly which consequential actions
9
+ * were authorized, refused, and why. Default sink is in-memory; pass `sink` to
10
+ * stream records to durable/append-only storage.
11
+ *
12
+ * Hashing is over CANONICAL JSON (sorted keys) so the same logical record hashes
13
+ * identically across systems and languages — a plain JSON.stringify is
14
+ * insertion-order dependent and would make cross-system verification fragile.
15
+ *
16
+ * `strict` mode makes the log fail CLOSED: if a durable `sink` write fails, the
17
+ * record is NOT appended and `record()` throws. The gate uses this so it never
18
+ * authorizes an action it cannot durably account for. In non-strict (observe)
19
+ * mode a sink failure is best-effort and swallowed.
20
+ */
21
+ import crypto from 'node:crypto';
22
+
23
+ function sha256hex(s) {
24
+ return crypto.createHash('sha256').update(s).digest('hex');
25
+ }
26
+
27
+ /** Canonical JSON (recursive sorted keys) — matches @emilia-protocol/verify. */
28
+ function canonical(v) {
29
+ if (v === null || v === undefined) return JSON.stringify(v);
30
+ if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
31
+ if (typeof v === 'object') {
32
+ return `{${Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canonical(v[k])).join(',')}}`;
33
+ }
34
+ return JSON.stringify(v);
35
+ }
36
+
37
+ export function createEvidenceLog({ sink, strict = false } = {}) {
38
+ const records = [];
39
+ let prev = 'genesis';
40
+
41
+ return {
42
+ async record(entry) {
43
+ const body = { seq: records.length, prev_hash: prev, ...entry };
44
+ const hash = sha256hex(canonical(body));
45
+ const rec = { ...body, hash };
46
+ if (sink) {
47
+ try {
48
+ await sink(rec);
49
+ } catch (e) {
50
+ // Fail closed in strict mode: do NOT advance the chain, surface the
51
+ // failure so the caller (the gate) can refuse the action. The seq is
52
+ // left unconsumed so a retry produces a consistent chain.
53
+ if (strict) {
54
+ const err = new Error('evidence_sink_failed');
55
+ err.cause = e;
56
+ throw err;
57
+ }
58
+ /* non-strict (observe): best-effort, swallow — never break the gate */
59
+ }
60
+ }
61
+ records.push(rec);
62
+ prev = hash;
63
+ return rec;
64
+ },
65
+
66
+ all() {
67
+ return records.slice();
68
+ },
69
+
70
+ /** Recompute the chain; detects any altered or removed record. */
71
+ verify() {
72
+ let p = 'genesis';
73
+ for (const r of records) {
74
+ const { hash, ...body } = r;
75
+ if (body.prev_hash !== p) return { ok: false, at: r.seq, reason: 'prev_hash_mismatch' };
76
+ if (sha256hex(canonical(body)) !== hash) return { ok: false, at: r.seq, reason: 'hash_mismatch' };
77
+ p = hash;
78
+ }
79
+ return { ok: true, length: records.length, head: p === 'genesis' ? null : p };
80
+ },
81
+ };
82
+ }
83
+
84
+ export default { createEvidenceLog };
package/index.js ADDED
@@ -0,0 +1,243 @@
1
+ /**
2
+ * @emilia-protocol/gate — EMILIA Gate: the Trusted Action Firewall.
3
+ * @license Apache-2.0
4
+ *
5
+ * Deny-by-default enforcement for consequential machine actions. A guarded
6
+ * action runs ONLY if it arrives with a receipt that is:
7
+ * 1. valid — Ed25519 over canonical JSON, signed by a pinned issuer;
8
+ * 2. in-scope — bound to the exact action the manifest guards;
9
+ * 3. sufficiently — meets the action's required assurance tier
10
+ * assured (software / class_a device signoff / quorum);
11
+ * 4. fresh — within max age; and
12
+ * 5. unused — not a replay (one-time consumption).
13
+ * Otherwise it is refused with a machine-readable Receipt-Required challenge
14
+ * (HTTP 428). Every decision is appended to a tamper-evident evidence log.
15
+ *
16
+ * It is NOT authentication ("who are you") and NOT permissions ("are you
17
+ * allowed here"). It is a policy-enforcement point that requires portable proof
18
+ * a named human authorized THIS exact action before the world is mutated.
19
+ *
20
+ * Composes @emilia-protocol/require-receipt (manifest + verify + challenge) and
21
+ * adds the three things a firewall needs over a bare verifier: assurance-tier
22
+ * enforcement, replay defense, and the evidence log. Fails closed.
23
+ */
24
+ import {
25
+ verifyEmiliaReceipt,
26
+ receiptChallenge,
27
+ receiptRequiredHeader,
28
+ validateActionRiskManifest,
29
+ findActionRequirement,
30
+ RECEIPT_REQUIRED_STATUS,
31
+ RECEIPT_REQUIRED_HEADER,
32
+ } from '../require-receipt/index.js';
33
+ import { MemoryConsumptionStore } from './store.js';
34
+ import { createEvidenceLog } from './evidence.js';
35
+
36
+ export { MemoryConsumptionStore, createEvidenceLog };
37
+ export const ASSURANCE_TIERS = ['software', 'class_a', 'quorum'];
38
+ const TIER_RANK = { software: 0, class_a: 1, quorum: 2 };
39
+
40
+ /**
41
+ * The assurance tier a receipt demonstrably meets. Conservative / fail-closed:
42
+ * if a higher tier's structure is not present, the receipt only earns the lower
43
+ * tier, and a guard that needs more will refuse it.
44
+ * quorum — a quorum block with >= 2 distinct signers and threshold >= 2.
45
+ * class_a — a device signoff (or claim.outcome === 'allow_with_signoff').
46
+ * software — any otherwise-valid receipt (a software-held key).
47
+ */
48
+ export function receiptAssuranceTier(doc) {
49
+ const p = doc?.payload || {};
50
+ const q = p.quorum || p.claim?.quorum;
51
+ const signers = q && (q.signers || q.approvers);
52
+ const threshold = q && (q.m ?? q.threshold ?? (Array.isArray(signers) ? signers.length : 0));
53
+ if (q && Array.isArray(signers) && signers.length >= 2 && threshold >= 2) return 'quorum';
54
+ if (p.signoff || p.claim?.outcome === 'allow_with_signoff') return 'class_a';
55
+ return 'software';
56
+ }
57
+
58
+ /**
59
+ * Create a gate.
60
+ * @param {object} opts
61
+ * @param {object} [opts.manifest] EP-ACTION-RISK-MANIFEST-v0.1 (which actions are guarded, their tier)
62
+ * @param {string[]} [opts.trustedKeys] base64url SPKI-DER issuer keys you trust
63
+ * @param {number} [opts.maxAgeSec=900] reject receipts older than this
64
+ * @param {object} [opts.store] consumption store (default in-memory)
65
+ * @param {object} [opts.log] evidence log (default in-memory, hash-chained)
66
+ * @param {boolean} [opts.allowInlineKey=false] accept the receipt's own key (integrity, NOT trust)
67
+ */
68
+ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900, store, log, allowInlineKey = false, allowEphemeralStore = false, strictEvidence = true, now = Date.now } = {}) {
69
+ if (manifest) {
70
+ const m = validateActionRiskManifest(manifest);
71
+ if (!m.ok) throw new Error('EMILIA Gate: invalid action-risk manifest: ' + m.errors.join('; '));
72
+ }
73
+ if (allowInlineKey) {
74
+ // eslint-disable-next-line no-console
75
+ console.warn('EMILIA Gate: allowInlineKey=true accepts a receipt\'s OWN key. This proves INTEGRITY (the receipt was not tampered with) but NOT issuer TRUST (anyone can mint a receipt with their own key). Use for demos only; pin trustedKeys in production.');
76
+ }
77
+ // Replay defense is only sound if the consumption store is shared across every
78
+ // instance that can serve the action. The in-memory default is per-process, so
79
+ // a receipt consumed on one pod/lambda could be replayed on another. Fail
80
+ // CLOSED in production unless the operator explicitly accepts a single instance.
81
+ let consumption = store;
82
+ if (!consumption) {
83
+ const isProd = typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production';
84
+ if (isProd && !allowEphemeralStore) {
85
+ throw new Error('EMILIA Gate: no consumption store provided. The default in-memory store is per-process and is NOT safe for multi-instance or serverless deployments — a receipt consumed on one instance can be replayed on another, defeating one-time consumption. Provide a shared store ({ async consume(key) {...} }, e.g. Redis/DB-backed), or pass allowEphemeralStore:true to acknowledge a single-instance deployment.');
86
+ }
87
+ consumption = new MemoryConsumptionStore();
88
+ }
89
+ const evidence = log || createEvidenceLog({ strict: strictEvidence });
90
+
91
+ async function check({ selector = {}, receipt = null } = {}) {
92
+ const requirement = manifest ? findActionRequirement(manifest, selector) : null;
93
+ const action = requirement?.action_type || selector.action_type || selector.action || null;
94
+ const requiredTier = requirement?.assurance_class || selector.assurance_class || 'software';
95
+
96
+ async function decide(allow, status, reason, extra = {}) {
97
+ const entry = {
98
+ kind: 'decision',
99
+ at: new Date(typeof now === 'function' ? now() : now).toISOString(),
100
+ action,
101
+ allow,
102
+ status,
103
+ reason,
104
+ selector: { ...selector },
105
+ required_tier: requiredTier,
106
+ receipt_id: receipt?.payload?.receipt_id ?? null,
107
+ subject: receipt?.payload?.subject ?? null,
108
+ ...extra,
109
+ };
110
+ let record;
111
+ try {
112
+ record = await evidence.record(entry);
113
+ } catch (e) {
114
+ // The decision could not be durably recorded. Fail CLOSED: never
115
+ // authorize an action we cannot account for. Downgrade any allow to a
116
+ // refusal and best-effort note the downgrade (non-fatal if that fails too).
117
+ allow = false;
118
+ status = RECEIPT_REQUIRED_STATUS;
119
+ reason = 'evidence_log_failed';
120
+ try {
121
+ record = await evidence.record({ ...entry, allow: false, status, reason, evidence_error: String(e?.message ?? e) });
122
+ } catch {
123
+ record = null;
124
+ }
125
+ }
126
+ const out = { allow, status, reason, action, requirement, evidence: record };
127
+ if (!allow) {
128
+ out.challenge = receiptChallenge(action, reason, {
129
+ status: RECEIPT_REQUIRED_STATUS,
130
+ assuranceClass: requiredTier,
131
+ maxAgeSec,
132
+ manifest: selector.manifestUrl,
133
+ });
134
+ out.header = receiptRequiredHeader({ action, assuranceClass: requiredTier, maxAgeSec });
135
+ }
136
+ return out;
137
+ }
138
+
139
+ // Manifest present and this selector is not guarded (or explicitly not required): pass through.
140
+ if (manifest && (!requirement || requirement.receipt_required === false)) {
141
+ return decide(true, 200, 'not_guarded');
142
+ }
143
+ // Guarded, but no receipt was presented.
144
+ if (!receipt) {
145
+ return decide(false, RECEIPT_REQUIRED_STATUS, 'receipt_required');
146
+ }
147
+ // Signature / freshness / action-binding / outcome.
148
+ const v = verifyEmiliaReceipt(receipt, { trustedKeys, allowInlineKey, action, maxAgeSec });
149
+ if (!v.ok) {
150
+ return decide(false, RECEIPT_REQUIRED_STATUS, `receipt_rejected:${v.reason}`, { rejected: v });
151
+ }
152
+ // Assurance tier.
153
+ const have = receiptAssuranceTier(receipt);
154
+ if ((TIER_RANK[have] ?? 0) < (TIER_RANK[requiredTier] ?? 0)) {
155
+ return decide(false, RECEIPT_REQUIRED_STATUS, 'assurance_too_low', { have_tier: have, need_tier: requiredTier });
156
+ }
157
+ // One-time consumption (replay defense). Require a stable, issuer-generated
158
+ // receipt_id — never fall back to a content hash, whose canonicalization can
159
+ // differ across language implementations and silently break replay detection
160
+ // when services of different languages share a store.
161
+ const receiptId = receipt?.payload?.receipt_id;
162
+ if (!receiptId) {
163
+ return decide(false, RECEIPT_REQUIRED_STATUS, 'receipt_rejected:missing_receipt_id');
164
+ }
165
+ const fresh = await consumption.consume(receiptId);
166
+ if (!fresh) {
167
+ return decide(false, RECEIPT_REQUIRED_STATUS, 'replay_refused', { consumption_key: receiptId });
168
+ }
169
+ return decide(true, 200, 'allow', { signer: v.signer, outcome: v.outcome, have_tier: have });
170
+ }
171
+
172
+ /** Express/Connect middleware: refuse the route unless a sufficient receipt is present. */
173
+ function middleware(opts = {}) {
174
+ return async function emiliaGate(req, res, next) {
175
+ let selector = typeof opts.selector === 'function' ? opts.selector(req) : { ...(opts.selector || {}) };
176
+ if (opts.action && !selector.action_type) {
177
+ selector.action_type = typeof opts.action === 'function' ? opts.action(req) : opts.action;
178
+ }
179
+ let doc = null;
180
+ const hdr = req.headers?.['x-emilia-receipt'];
181
+ if (hdr) { try { doc = JSON.parse(Buffer.from(hdr, 'base64').toString('utf8')); } catch { /* fallthrough */ } }
182
+ if (!doc && req.body?.emilia_receipt) doc = req.body.emilia_receipt;
183
+ const out = await check({ selector, receipt: doc });
184
+ if (!out.allow) {
185
+ res.setHeader(RECEIPT_REQUIRED_HEADER, out.header);
186
+ return res.status(out.status).json(out.challenge);
187
+ }
188
+ req.emiliaGate = out;
189
+ return next();
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Emit a post-execution receipt bound to a prior authorization decision — the
195
+ * "execution emits proof" half of the loop (maps to the EP Commit seal). It
196
+ * commits to the exact authorization decision (`authorizes_decision` = that
197
+ * decision's evidence hash), so authorization and execution are one chain.
198
+ */
199
+ async function recordExecution({ authorization, outcome = 'executed', detail } = {}) {
200
+ const auth = authorization?.evidence || authorization || {};
201
+ return evidence.record({
202
+ kind: 'execution',
203
+ at: new Date(typeof now === 'function' ? now() : now).toISOString(),
204
+ authorizes_decision: auth.hash ?? null,
205
+ action: authorization?.action ?? auth.action ?? null,
206
+ receipt_id: auth.receipt_id ?? null,
207
+ outcome, // 'executed' | 'failed'
208
+ ...(detail !== undefined ? { detail } : {}),
209
+ });
210
+ }
211
+
212
+ /**
213
+ * Wrap any function so it runs only behind a passing gate check, and (unless
214
+ * disabled) emits an execution receipt after it runs — the full firewall loop:
215
+ * request -> check -> execute -> execution receipt. Framework-agnostic.
216
+ */
217
+ function guard(fn, opts = {}) {
218
+ return async function guarded(...args) {
219
+ const selector = typeof opts.selector === 'function' ? opts.selector(...args) : (opts.selector || {});
220
+ 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})`);
224
+ 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) });
235
+ throw e;
236
+ }
237
+ };
238
+ }
239
+
240
+ return { check, recordExecution, middleware, guard, evidence, store: consumption };
241
+ }
242
+
243
+ export default { createGate, receiptAssuranceTier, MemoryConsumptionStore, createEvidenceLog, ASSURANCE_TIERS };
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
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.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "index.js",
8
+ "files": ["index.js", "store.js", "evidence.js", "README.md"],
9
+ "scripts": {
10
+ "test": "node --test",
11
+ "demo": "node demo.mjs"
12
+ },
13
+ "dependencies": {
14
+ "@emilia-protocol/require-receipt": "^0.1.0"
15
+ },
16
+ "keywords": ["emilia", "authorization", "receipt", "firewall", "trusted-action-firewall", "agent", "mcp", "gate", "policy-enforcement-point", "ai-safety"]
17
+ }
package/store.js ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @emilia-protocol/gate — consumption store (replay defense).
3
+ * @license Apache-2.0
4
+ *
5
+ * A receipt authorizes ONE action, once. The gate consumes a receipt's
6
+ * identifier the first time it is used; any later presentation of the same
7
+ * receipt is a replay and is refused. The default store is in-memory; swap in a
8
+ * Redis/DB-backed store with the same `consume(key)` contract for a fleet.
9
+ */
10
+ export class MemoryConsumptionStore {
11
+ constructor() {
12
+ this.seen = new Set();
13
+ }
14
+
15
+ /** Returns true the FIRST time a key is seen, false on every replay. */
16
+ async consume(key) {
17
+ if (this.seen.has(key)) return false;
18
+ this.seen.add(key);
19
+ return true;
20
+ }
21
+
22
+ async has(key) {
23
+ return this.seen.has(key);
24
+ }
25
+
26
+ get size() {
27
+ return this.seen.size;
28
+ }
29
+ }
30
+
31
+ export default { MemoryConsumptionStore };