@emilia-protocol/gate 0.7.0 → 0.9.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 CHANGED
@@ -75,6 +75,13 @@ Each pack also defines `execution_binding.required_fields`. The executor must pa
75
75
  fields from the real system of record. If the signed claim and observed mutation differ, the gate
76
76
  refuses with `execution_binding_failed` before consuming the receipt.
77
77
 
78
+ Execution-parameter binding is therefore a **Gate** guarantee that holds **only when you supply a
79
+ system-of-record `observedAction`**: it is the Gate — not receipt verification on its own — that
80
+ proves the executed parameters matched what was authorized. If a required field is declared but no
81
+ `observedAction` is provided, the check fails closed (`execution_binding_failed`), never silently
82
+ passes. A bare `@emilia-protocol/require-receipt` gate binds the action type/target only; reach for
83
+ this package when parameter drift (amount, beneficiary, commit, role, …) must be caught.
84
+
78
85
  Prefer `gate.run(...)` for mutations: it reserves the receipt, runs the side effect, commits
79
86
  one-time consumption only after success, releases the reservation if the action fails before
80
87
  mutation, and emits the execution receipt + reliance packet. Use lower-level `gate.check(...)` only
@@ -210,6 +217,10 @@ challenge. The Gate composes that and adds the three things a firewall needs:
210
217
 
211
218
  - **Assurance tiers** — `software` < `class_a` (device signoff) < `quorum` (m-of-n). A `critical`
212
219
  action can demand `class_a` or `quorum`; a lower-assurance receipt is refused (`assurance_too_low`).
220
+ In the lightweight EP-RECEIPT-v1 gate, the tier is an issuer-attested claim
221
+ inside a receipt signed by a pinned issuer key. For independent verification
222
+ of every embedded device/quorum signature, use the EP §6.2 trust-receipt
223
+ verifier in `@emilia-protocol/verify`.
213
224
  - **One-time consumption** — a receipt authorizes one action, once. Replays are refused
214
225
  (`replay_refused`). Default store is in-memory; swap in Redis/DB for a fleet.
215
226
  - **Evidence log** — every decision is hash-chained (`evidence.verify()` detects any alteration).
@@ -0,0 +1,237 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // EP Action Control Manifest: the missing control-plane waist between agent
4
+ // runtimes, receipt formats, transparency logs, and system-of-record adapters.
5
+ //
6
+ // A receipt proves an authorization event. This manifest tells an executor
7
+ // when a receipt is required, what assurance tier is required, which material
8
+ // fields must be observed from the system of record, and what evidence must be
9
+ // emitted after the effect boundary.
10
+
11
+ import { HIGH_RISK_ACTION_PACKS, DEFAULT_PASS_THROUGH_ACTIONS } from './action-packs.js';
12
+
13
+ export const ACTION_CONTROL_MANIFEST_VERSION = 'EP-ACTION-CONTROL-MANIFEST-v0.2';
14
+ export const ACTION_CONTROL_SCHEMA_URL = 'https://www.emiliaprotocol.ai/docs/schemas/agent-action-control-manifest-v0.2.schema.json';
15
+ export const ACTION_CONTROL_CONFORMANCE_LEVEL = 'EG-1';
16
+
17
+ const RISK_LEVELS = new Set(['low', 'medium', 'high', 'critical']);
18
+ const ASSURANCE_CLASSES = new Set(['software', 'class_a', 'quorum']);
19
+ const ENFORCEMENT_POINTS = new Set(['pre_execution', 'pre_effect_commit']);
20
+
21
+ export const ACTION_CONTROL_DEFAULTS = Object.freeze({
22
+ decision_point: 'pre_effect_commit',
23
+ missing_receipt: 'refuse',
24
+ invalid_receipt: 'refuse',
25
+ stale_receipt: 'refuse',
26
+ replay: 'one_time_consumption',
27
+ evidence_log: 'strict',
28
+ });
29
+
30
+ export const ACTION_CONTROL_EVIDENCE_PROFILES = Object.freeze({
31
+ authorization_receipt: 'EP-RECEIPT-v1',
32
+ execution_attestation: 'EP-EXECUTION-ATTESTATION-v1',
33
+ reliance_packet: 'EP-RELIANCE-PACKET-v1',
34
+ transparency: 'SCITT-compatible Signed Statement',
35
+ });
36
+
37
+ export const ACTION_CONTROL_CONFORMANCE_CHECKS = Object.freeze([
38
+ 'missing_receipt_refused',
39
+ 'software_on_classA_refused',
40
+ 'execution_drift_refused',
41
+ 'valid_classA_runs',
42
+ 'replay_refused',
43
+ 'tampered_refused',
44
+ 'execution_proof_binds',
45
+ 'reliance_packet_rely',
46
+ ]);
47
+
48
+ function cloneJson(value) {
49
+ return value == null ? value : JSON.parse(JSON.stringify(value));
50
+ }
51
+
52
+ function normalizeRisk(risk) {
53
+ return RISK_LEVELS.has(risk) ? risk : 'high';
54
+ }
55
+
56
+ function normalizeAssurance(value) {
57
+ return ASSURANCE_CLASSES.has(value) ? value : 'software';
58
+ }
59
+
60
+ function defaultControlForAction(action) {
61
+ const requiredFields = action.execution_binding?.required_fields || [];
62
+ if (!action.receipt_required) {
63
+ return {
64
+ enforcement_point: 'none',
65
+ authorization_receipt: { required: false },
66
+ evidence_output: { audit_event: true },
67
+ };
68
+ }
69
+ return {
70
+ enforcement_point: 'pre_effect_commit',
71
+ status: 428,
72
+ challenge_header: 'Receipt-Required',
73
+ proof_header: 'X-EMILIA-Receipt',
74
+ authorization_receipt: {
75
+ required: true,
76
+ profile: 'EP-RECEIPT-v1',
77
+ signature: 'Ed25519 over RFC 8785 canonical JSON',
78
+ verifier: 'offline',
79
+ },
80
+ replay: {
81
+ mode: 'one_time_consumption',
82
+ receipt_id_required: true,
83
+ },
84
+ execution_binding: {
85
+ required: true,
86
+ source: 'system_of_record',
87
+ required_fields: [...requiredFields],
88
+ },
89
+ transparency: {
90
+ mode: 'registerable',
91
+ profile: 'SCITT Signed Statement',
92
+ required: false,
93
+ },
94
+ evidence_output: {
95
+ audit_event: true,
96
+ execution_attestation: true,
97
+ reliance_packet: true,
98
+ blocked_attempts: true,
99
+ },
100
+ };
101
+ }
102
+
103
+ export function toActionControl(action) {
104
+ const out = {
105
+ id: action.id,
106
+ label: action.label || action.description || action.id,
107
+ action_type: action.action_type,
108
+ risk: normalizeRisk(action.risk || (action.receipt_required ? 'high' : 'low')),
109
+ receipt_required: !!action.receipt_required,
110
+ assurance_class: normalizeAssurance(action.assurance_class),
111
+ max_age_sec: action.max_age_sec || 900,
112
+ match: cloneJson(action.match || {}),
113
+ why: action.why || action.description || null,
114
+ control: cloneJson(action.control || defaultControlForAction(action)),
115
+ conformance: {
116
+ level: ACTION_CONTROL_CONFORMANCE_LEVEL,
117
+ checks: [...ACTION_CONTROL_CONFORMANCE_CHECKS],
118
+ ...(action.conformance || {}),
119
+ },
120
+ };
121
+ if (action.quorum) out.quorum = cloneJson(action.quorum);
122
+ return out;
123
+ }
124
+
125
+ export function createDefaultActionControlManifest({
126
+ service = {},
127
+ includePassThrough = true,
128
+ extraActions = [],
129
+ } = {}) {
130
+ const actions = [
131
+ ...HIGH_RISK_ACTION_PACKS.map(toActionControl),
132
+ ...(includePassThrough ? DEFAULT_PASS_THROUGH_ACTIONS.map(toActionControl) : []),
133
+ ...extraActions.map(toActionControl),
134
+ ];
135
+ return {
136
+ '@version': ACTION_CONTROL_MANIFEST_VERSION,
137
+ '$schema': ACTION_CONTROL_SCHEMA_URL,
138
+ profile: 'emilia.action-control',
139
+ service: {
140
+ name: service.name || 'EMILIA Gate default action controls',
141
+ issuer: service.issuer || 'https://www.emiliaprotocol.ai',
142
+ manifest_url: service.manifest_url || 'https://www.emiliaprotocol.ai/.well-known/agent-action-control.json',
143
+ ...service,
144
+ },
145
+ defaults: { ...ACTION_CONTROL_DEFAULTS },
146
+ evidence_profiles: { ...ACTION_CONTROL_EVIDENCE_PROFILES },
147
+ actions,
148
+ };
149
+ }
150
+
151
+ function selectorMatches(match = {}, selector = {}) {
152
+ if (!match || typeof match !== 'object') return false;
153
+ return Object.entries(match).every(([k, v]) => selector[k] === v);
154
+ }
155
+
156
+ export function findActionControl(manifest, selector = {}) {
157
+ if (!manifest || !Array.isArray(manifest.actions)) return null;
158
+ return manifest.actions.find((action) => {
159
+ if (selector.action_type && action.action_type === selector.action_type) return true;
160
+ return selectorMatches(action.match, selector);
161
+ }) || null;
162
+ }
163
+
164
+ export function validateActionControlManifest(manifest) {
165
+ const errors = [];
166
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
167
+ return { ok: false, errors: ['manifest must be an object'] };
168
+ }
169
+ if (manifest['@version'] !== ACTION_CONTROL_MANIFEST_VERSION) {
170
+ errors.push(`@version must be ${ACTION_CONTROL_MANIFEST_VERSION}`);
171
+ }
172
+ if (manifest.profile !== 'emilia.action-control') {
173
+ errors.push('profile must be emilia.action-control');
174
+ }
175
+ if (!manifest.service || typeof manifest.service !== 'object') {
176
+ errors.push('service object is required');
177
+ }
178
+ if (!manifest.defaults || typeof manifest.defaults !== 'object') {
179
+ errors.push('defaults object is required');
180
+ }
181
+ if (!Array.isArray(manifest.actions) || manifest.actions.length === 0) {
182
+ errors.push('actions must be a non-empty array');
183
+ }
184
+ for (const [idx, action] of (manifest.actions || []).entries()) {
185
+ const prefix = `actions[${idx}]`;
186
+ if (!action || typeof action !== 'object') {
187
+ errors.push(`${prefix} must be an object`);
188
+ continue;
189
+ }
190
+ if (!action.id || typeof action.id !== 'string') errors.push(`${prefix}.id is required`);
191
+ if (!action.action_type || typeof action.action_type !== 'string') errors.push(`${prefix}.action_type is required`);
192
+ if (!action.match || typeof action.match !== 'object') errors.push(`${prefix}.match is required`);
193
+ if (typeof action.receipt_required !== 'boolean') errors.push(`${prefix}.receipt_required must be boolean`);
194
+ if (!RISK_LEVELS.has(action.risk)) errors.push(`${prefix}.risk must be low|medium|high|critical`);
195
+ if (!ASSURANCE_CLASSES.has(action.assurance_class)) errors.push(`${prefix}.assurance_class must be software|class_a|quorum`);
196
+ if (action.receipt_required) {
197
+ if (!Number.isFinite(action.max_age_sec) || action.max_age_sec <= 0) errors.push(`${prefix}.max_age_sec must be positive`);
198
+ const control = action.control;
199
+ if (!control || typeof control !== 'object') {
200
+ errors.push(`${prefix}.control is required when receipt_required=true`);
201
+ continue;
202
+ }
203
+ if (!ENFORCEMENT_POINTS.has(control.enforcement_point)) {
204
+ errors.push(`${prefix}.control.enforcement_point must be pre_execution or pre_effect_commit`);
205
+ }
206
+ if (control.status !== 428) errors.push(`${prefix}.control.status must be 428`);
207
+ if (control.authorization_receipt?.required !== true) errors.push(`${prefix}.control.authorization_receipt.required must be true`);
208
+ if (control.authorization_receipt?.profile !== 'EP-RECEIPT-v1') errors.push(`${prefix}.control.authorization_receipt.profile must be EP-RECEIPT-v1`);
209
+ if (control.authorization_receipt?.verifier !== 'offline') errors.push(`${prefix}.control.authorization_receipt.verifier must be offline`);
210
+ if (control.replay?.mode !== 'one_time_consumption') errors.push(`${prefix}.control.replay.mode must be one_time_consumption`);
211
+ if (control.replay?.receipt_id_required !== true) errors.push(`${prefix}.control.replay.receipt_id_required must be true`);
212
+ const fields = control.execution_binding?.required_fields;
213
+ if (control.execution_binding?.required !== true) errors.push(`${prefix}.control.execution_binding.required must be true`);
214
+ if (control.execution_binding?.source !== 'system_of_record') errors.push(`${prefix}.control.execution_binding.source must be system_of_record`);
215
+ if (!Array.isArray(fields) || fields.length === 0 || fields.some((f) => typeof f !== 'string' || !f)) {
216
+ errors.push(`${prefix}.control.execution_binding.required_fields must be a non-empty string array`);
217
+ }
218
+ if (control.evidence_output?.execution_attestation !== true) errors.push(`${prefix}.control.evidence_output.execution_attestation must be true`);
219
+ if (control.evidence_output?.reliance_packet !== true) errors.push(`${prefix}.control.evidence_output.reliance_packet must be true`);
220
+ if (action.conformance?.level !== ACTION_CONTROL_CONFORMANCE_LEVEL) errors.push(`${prefix}.conformance.level must be ${ACTION_CONTROL_CONFORMANCE_LEVEL}`);
221
+ }
222
+ }
223
+ return { ok: errors.length === 0, errors };
224
+ }
225
+
226
+ export default {
227
+ ACTION_CONTROL_MANIFEST_VERSION,
228
+ ACTION_CONTROL_SCHEMA_URL,
229
+ ACTION_CONTROL_CONFORMANCE_LEVEL,
230
+ ACTION_CONTROL_DEFAULTS,
231
+ ACTION_CONTROL_EVIDENCE_PROFILES,
232
+ ACTION_CONTROL_CONFORMANCE_CHECKS,
233
+ toActionControl,
234
+ createDefaultActionControlManifest,
235
+ findActionControl,
236
+ validateActionControlManifest,
237
+ };
@@ -0,0 +1,107 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * CF-1 Consequence Firewall Conformance — the category badge above EG-1.
4
+ *
5
+ * EG-1 answers "does this integration ENFORCE the gate?" (eight runtime checks).
6
+ * CF-1 answers the category question "is this a Consequence Firewall?" — EG-1's
7
+ * eight checks PLUS the three that make the category claim honest:
8
+ *
9
+ * - consequential_action_declared: the action is declared high-risk /
10
+ * receipt-required by policy or manifest, not gated only by default-deny.
11
+ * - wrong_authority_refused: a gate pinned to the WRONG issuer key cannot be
12
+ * talked into authorizing — trust is pinned by the relying party, never
13
+ * taken from the receipt-carried signer.
14
+ * - evidence_verifies_offline: an allowed run emits a reliance packet a third
15
+ * party can verify offline (verdict "rely" + the execution proof binds the
16
+ * authorization decision, recomputable without trusting the operator).
17
+ *
18
+ * Pure module: composes eg1-conformance.js only (no import of index.js, so no
19
+ * cycle). `runCf1` is param-driven (an `invoke` for the gate under test, a
20
+ * `wrongInvoke` for a sibling gate pinned to the wrong key, the harness, and the
21
+ * resolved manifest requirement). `cf1Conformance` / `cf1ConformanceSelfTest`
22
+ * in index.js wire real gates to it.
23
+ */
24
+ import { EG1_CHECKS, runEg1 } from './eg1-conformance.js';
25
+
26
+ export const CF1_VERSION = 'CF-1';
27
+
28
+ // The nine CF-1 checks: the declaration bookend, the eight EG-1 runtime checks,
29
+ // then the two that distinguish a firewall from an enforced gate.
30
+ export const CF1_CHECKS = Object.freeze([
31
+ { id: 'consequential_action_declared', title: 'action declared consequential (receipt required by policy/manifest)' },
32
+ ...EG1_CHECKS.map((c) => ({ id: c.id, title: c.title })),
33
+ { id: 'wrong_authority_refused', title: 'gate pinned to the wrong authority cannot authorize' },
34
+ { id: 'evidence_verifies_offline', title: 'allowed run emits reliance evidence verifiable offline' },
35
+ ]);
36
+
37
+ /**
38
+ * Drive a subject through the CF-1 checks and return a JSON report.
39
+ * @param {object} o
40
+ * @param {(scenario:object)=>Promise<object>} o.invoke the gate under test
41
+ * @param {(scenario:object)=>Promise<object>} [o.wrongInvoke] a sibling gate pinned to a DIFFERENT (wrong) issuer key
42
+ * @param {object} o.harness from createEg1Harness()
43
+ * @param {object} [o.action] the high-risk action (defaults to the harness action)
44
+ * @param {object} [o.requirement] the manifest requirement resolved for this action (findActionRequirement)
45
+ */
46
+ export async function runCf1({ invoke, wrongInvoke, harness, action, requirement } = {}) {
47
+ if (typeof invoke !== 'function') throw new Error('runCf1 requires an invoke(scenario) function');
48
+ if (!harness || typeof harness.mint !== 'function') throw new Error('runCf1 requires a harness from createEg1Harness()');
49
+ const act = action || harness.action;
50
+
51
+ // The eight EG-1 runtime checks (missing / weak-assurance / execution-drift /
52
+ // valid-runs / replay / tamper / execution-proof / reliance-packet).
53
+ const eg1 = await runEg1({ invoke, harness, action: act });
54
+ const results = {};
55
+ for (const c of eg1.checks) results[c.id] = { pass: c.pass, observed: c.observed };
56
+
57
+ // consequential_action_declared — policy/manifest classifies the action as
58
+ // requiring a receipt (not merely caught by a default-deny fallback).
59
+ results.consequential_action_declared = {
60
+ pass: requirement ? requirement.receipt_required === true : false,
61
+ observed: {
62
+ receipt_required: requirement?.receipt_required ?? null,
63
+ assurance_class: requirement?.assurance_class ?? null,
64
+ action_type: requirement?.action_type ?? null,
65
+ },
66
+ };
67
+
68
+ // wrong_authority_refused — a gate pinned to a different key rejects a valid
69
+ // receipt. Proves trust is pinned by the relying party, not receipt-carried.
70
+ let wrongPass = false;
71
+ let wrongObserved = { skipped: true };
72
+ if (typeof wrongInvoke === 'function') {
73
+ const rr = await wrongInvoke({ receipt: harness.mint({ outcome: 'allow_with_signoff' }), observedAction: act });
74
+ wrongPass = rr.allowed === false;
75
+ wrongObserved = { allowed: !!rr.allowed, status: rr.status ?? null, reason: rr.reason ?? null };
76
+ }
77
+ results.wrong_authority_refused = { pass: wrongPass, observed: wrongObserved };
78
+
79
+ // evidence_verifies_offline — a fresh valid run yields a "rely" reliance
80
+ // packet whose execution proof binds the authorization decision. A third
81
+ // party recomputes that binding offline; no operator trust required.
82
+ const vr = await invoke({ receipt: harness.mint({ outcome: 'allow_with_signoff' }), observedAction: act });
83
+ const binds = !!vr.execution?.authorizes_decision && !!vr.decisionHash
84
+ && vr.execution.authorizes_decision === vr.decisionHash;
85
+ const offlineOk = vr.allowed === true
86
+ && String(vr.packet?.verdict || '').toLowerCase() === 'rely'
87
+ && binds;
88
+ results.evidence_verifies_offline = {
89
+ pass: offlineOk,
90
+ observed: { allowed: !!vr.allowed, verdict: vr.packet?.verdict ?? null, binds },
91
+ };
92
+
93
+ const checks = CF1_CHECKS.map((c) => ({ id: c.id, title: c.title, ...results[c.id] }));
94
+ const passedCount = checks.filter((c) => c.pass).length;
95
+ const passed = passedCount === checks.length;
96
+ return {
97
+ standard: CF1_VERSION,
98
+ passed,
99
+ badge: passed ? 'CF-1 Enforced' : 'CF-1 not earned',
100
+ summary: { passed: passedCount, total: checks.length },
101
+ eg1: { passed: eg1.passed, summary: eg1.summary },
102
+ checks,
103
+ generated_at: new Date(harness.now ? harness.now() : Date.now()).toISOString(),
104
+ };
105
+ }
106
+
107
+ export default { CF1_VERSION, CF1_CHECKS, runCf1 };
package/cf1.mjs ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * CF-1 Consequence Firewall Conformance — runnable proof. Run: node cf1.mjs [--json]
3
+ *
4
+ * Self-certifies the reference EMILIA Gate against the CF-1 checks: the eight
5
+ * EG-1 runtime checks plus the three category checks (action declared
6
+ * consequential, wrong-authority refused, evidence verifiable offline).
7
+ *
8
+ * An adopter earns CF-1 by pointing this at THEIR integration: build your gate
9
+ * trusting `harness.publicKey`, a sibling gate trusting a different key, and
10
+ * pass both to cf1Conformance():
11
+ *
12
+ * import { createEg1Harness, cf1Conformance, createTrustedActionFirewall,
13
+ * createDefaultActionRiskManifest } from '@emilia-protocol/gate';
14
+ * const harness = createEg1Harness();
15
+ * const gate = createTrustedActionFirewall({ trustedKeys: [harness.publicKey] });
16
+ * const wrongGate = createTrustedActionFirewall({ trustedKeys: [createEg1Harness().publicKey] });
17
+ * const report = await cf1Conformance({ gate, wrongGate, harness, manifest: createDefaultActionRiskManifest() });
18
+ *
19
+ * Exit code is 0 only if all checks pass — CI-friendly.
20
+ * @license Apache-2.0
21
+ */
22
+ import { cf1ConformanceSelfTest } from './index.js';
23
+
24
+ const report = await cf1ConformanceSelfTest();
25
+
26
+ if (process.argv.includes('--json')) {
27
+ // eslint-disable-next-line no-console
28
+ console.log(JSON.stringify(report, null, 2));
29
+ process.exit(report.passed ? 0 : 1);
30
+ }
31
+
32
+ const G = (s) => `\x1b[32m${s}\x1b[0m`;
33
+ const R = (s) => `\x1b[31m${s}\x1b[0m`;
34
+ const line = (s) => console.log(s);
35
+
36
+ line('='.repeat(66));
37
+ line(' CF-1 Conformance — is this integration a Consequence Firewall?');
38
+ line('='.repeat(66));
39
+ for (const c of report.checks) {
40
+ line(` ${c.pass ? G('PASS') : R('FAIL')} ${c.title}`);
41
+ }
42
+ line(' ' + '-'.repeat(62));
43
+ line(` ${report.passed ? G(`✓ ${report.badge}`) : R(`✗ ${report.badge}`)} (${report.summary.passed}/${report.summary.total})`);
44
+ line('='.repeat(66));
45
+ process.exit(report.passed ? 0 : 1);
package/demo.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  * @license Apache-2.0
6
6
  */
7
7
  import crypto from 'node:crypto';
8
- import { createTrustedActionFirewall } from './index.js';
8
+ import { createTrustedActionFirewall, mintDeviceSignoff } from './index.js';
9
9
 
10
10
  const canon = (v) => v == null ? JSON.stringify(v)
11
11
  : Array.isArray(v) ? `[${v.map(canon).join(',')}]`
@@ -21,8 +21,17 @@ const action = {
21
21
  payment_instruction_id: 'pi_demo_40000',
22
22
  beneficiary_account_hash: 'sha256:demo-beneficiary',
23
23
  };
24
+ // action_hash the CFO's device assertion is bound to.
25
+ const actionHash = crypto.createHash('sha256').update(canon(action), 'utf8').digest('hex');
24
26
  const receipt = (outcome, extra = {}) => {
25
27
  const payload = { receipt_id: `rcpt_${++n}`, subject: 'agent:finance-bot', issuer: 'ep:org:demo', created_at: new Date().toISOString(), claim: { ...action, outcome, approver: 'ep:approver:cfo', ...extra } };
28
+ // class_a is EARNED by a genuine WebAuthn device signoff, not by the outcome
29
+ // string. A software receipt (outcome 'allow') carries no signoff.
30
+ if (outcome === 'allow_with_signoff') {
31
+ const s = mintDeviceSignoff({ actionHash, approver: 'ep:approver:cfo' });
32
+ payload.signoff = s.signoff;
33
+ payload.approver_public_key = s.approver_public_key;
34
+ }
26
35
  return { '@version': 'EP-RECEIPT-v1', payload, signature: { algorithm: 'Ed25519', value: crypto.sign(null, Buffer.from(canon(payload), 'utf8'), privateKey).toString('base64url') } };
27
36
  };
28
37
 
@@ -40,6 +40,82 @@ const canon = (v) => (v == null ? JSON.stringify(v)
40
40
  : Array.isArray(v) ? `[${v.map(canon).join(',')}]`
41
41
  : typeof v === 'object' ? `{${Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canon(v[k])).join(',')}}`
42
42
  : JSON.stringify(v));
43
+ const sha256Hex = (v) => crypto.createHash('sha256').update(v, 'utf8').digest('hex');
44
+ const sha256Bytes = (v) => crypto.createHash('sha256').update(v).digest();
45
+
46
+ const RP_ID = 'emiliaprotocol.ai';
47
+
48
+ /**
49
+ * Mint a GENUINE WebAuthn ECDSA-P256 device signoff over an authorization
50
+ * context — the same structure @emilia-protocol/verify verifyWebAuthnSignoff
51
+ * checks. This is what earns a receipt its class_a tier: a real per-signer
52
+ * assertion, not a self-asserted `outcome` string. Used to build the Class-A and
53
+ * quorum evidence the EG-1 harness embeds so the Gate can CRYPTOGRAPHICALLY
54
+ * credit the tier.
55
+ */
56
+ export function mintDeviceSignoff({ actionHash, approver, issuedAtMs = Date.now(), nonce, prevContextHash = undefined } = {}) {
57
+ const signer = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
58
+ const context = {
59
+ ep_version: '1.0', context_type: 'ep.signoff.v1',
60
+ action_hash: actionHash,
61
+ policy: 'policy_eg1',
62
+ nonce: nonce || ('sig_' + crypto.randomBytes(16).toString('hex')),
63
+ approver,
64
+ initiator: 'ent_agent_eg1',
65
+ issued_at: new Date(issuedAtMs).toISOString(),
66
+ expires_at: new Date(issuedAtMs + 5 * 60_000).toISOString(),
67
+ ...(prevContextHash !== undefined ? { prev_context_hash: prevContextHash } : {}),
68
+ };
69
+ const challenge = crypto.createHash('sha256').update(canon(context), 'utf8').digest().toString('base64url');
70
+ const clientData = Buffer.from(JSON.stringify({ type: 'webauthn.get', challenge, origin: `https://www.${RP_ID}` }), 'utf8');
71
+ const authData = Buffer.concat([
72
+ crypto.createHash('sha256').update(RP_ID, 'utf8').digest(),
73
+ Buffer.from([0x05]), // UP | UV
74
+ Buffer.from([0, 0, 0, 1]),
75
+ ]);
76
+ const signed = Buffer.concat([authData, crypto.createHash('sha256').update(clientData).digest()]);
77
+ const signature = crypto.sign('sha256', signed, signer.privateKey).toString('base64url');
78
+ return {
79
+ signoff: {
80
+ '@type': 'ep.signoff',
81
+ context,
82
+ webauthn: {
83
+ authenticator_data: authData.toString('base64url'),
84
+ client_data_json: clientData.toString('base64url'),
85
+ signature,
86
+ },
87
+ approver_public_key: signer.publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'),
88
+ },
89
+ approver_public_key: signer.publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'),
90
+ context,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Mint a GENUINE EP-QUORUM-v1 evidence document: N distinct humans, each on a
96
+ * distinct device key, each with a real WebAuthn assertion bound to the SAME
97
+ * action_hash, within a window. verifyQuorum returns valid for it. This is what
98
+ * earns a receipt its `quorum` tier — never a bare {signers,threshold} block.
99
+ */
100
+ export function mintQuorumEvidence({ actionHash, threshold = 2, approvers, issuedAtMs = Date.now() } = {}) {
101
+ const people = approvers || Array.from({ length: threshold }, (_, i) => ({ role: `approver_${i + 1}`, approver: `ep:approver:eg1_${i + 1}` }));
102
+ const members = people.map((p, i) => {
103
+ const s = mintDeviceSignoff({ actionHash, approver: p.approver, issuedAtMs: issuedAtMs + i * 1000 });
104
+ return { role: p.role, approver_public_key: s.approver_public_key, signoff: { '@type': s.signoff['@type'], context: s.signoff.context, webauthn: s.signoff.webauthn } };
105
+ });
106
+ return {
107
+ '@type': 'ep.quorum',
108
+ action_hash: actionHash,
109
+ policy: {
110
+ mode: 'threshold',
111
+ required: threshold,
112
+ approvers: people,
113
+ distinct_humans: true,
114
+ window_sec: 900,
115
+ },
116
+ members,
117
+ };
118
+ }
43
119
 
44
120
  // The default high-risk action EG-1 exercises: a Class-A money movement, which
45
121
  // the default gate manifest guards (selector { protocol:'mcp', tool:'release_payment' }).
@@ -70,37 +146,126 @@ export const EG1_CHECKS = Object.freeze([
70
146
  export function createEg1Harness({ now = Date.now, action = EG1_DEFAULT_ACTION, idPrefix = 'eg1' } = {}) {
71
147
  const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
72
148
  const pub = publicKey.export({ type: 'spki', format: 'der' }).toString('base64url');
149
+ const approverA = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
150
+ const approverB = crypto.generateKeyPairSync('ed25519');
151
+ const approverKeys = {
152
+ 'ep:key:eg1:class-a': {
153
+ public_key: approverA.publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'),
154
+ key_class: 'A',
155
+ },
156
+ 'ep:key:eg1:controller': {
157
+ public_key: approverB.publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'),
158
+ key_class: 'B',
159
+ },
160
+ };
73
161
  let counter = 0;
74
162
  const nowMs = () => (typeof now === 'function' ? now() : now);
75
163
 
164
+ // The action_hash the self-contained human device assertions are bound to.
165
+ // Derived from the action so the signoff/quorum evidence is about THIS action.
166
+ const actionHash = crypto.createHash('sha256').update(canon(action), 'utf8').digest('hex');
167
+
168
+ function assuranceContext(payload) {
169
+ return {
170
+ '@version': 'EP-ASSURANCE-CONTEXT-v1',
171
+ receipt_id: payload.receipt_id,
172
+ claim_hash: `sha256:${sha256Hex(canon(payload.claim))}`,
173
+ };
174
+ }
175
+
176
+ function classASignoff(digest) {
177
+ const challenge = Buffer.from(digest).toString('base64url');
178
+ const clientDataJSON = Buffer.from(JSON.stringify({ type: 'webauthn.get', challenge, origin: 'https://www.emiliaprotocol.ai' }), 'utf8');
179
+ const rpIdHash = crypto.createHash('sha256').update('www.emiliaprotocol.ai').digest();
180
+ const authData = Buffer.concat([rpIdHash, Buffer.from([0x05]), Buffer.from([0, 0, 0, 0])]); // UP + UV
181
+ const signedData = Buffer.concat([authData, sha256Bytes(clientDataJSON)]);
182
+ return {
183
+ approver: 'ep:approver:eg1:cfo',
184
+ approver_key_id: 'ep:key:eg1:class-a',
185
+ key_class: 'A',
186
+ webauthn: {
187
+ authenticator_data: authData.toString('base64url'),
188
+ client_data_json: clientDataJSON.toString('base64url'),
189
+ signature: crypto.sign('sha256', signedData, approverA.privateKey).toString('base64url'),
190
+ },
191
+ };
192
+ }
193
+
194
+ function softwareSignoff(digest) {
195
+ return {
196
+ approver: 'ep:approver:eg1:controller',
197
+ approver_key_id: 'ep:key:eg1:controller',
198
+ key_class: 'B',
199
+ signature: crypto.sign(null, digest, approverB.privateKey).toString('base64url'),
200
+ };
201
+ }
202
+
203
+ function assuranceProof(payload, quorum) {
204
+ const context = assuranceContext(payload);
205
+ const contextHash = `sha256:${sha256Hex(canon(context))}`;
206
+ const digest = Buffer.from(contextHash.replace(/^sha256:/, ''), 'hex');
207
+ const threshold = Number(quorum?.threshold ?? quorum?.m ?? 1);
208
+ const signoffs = [classASignoff(digest)];
209
+ if (threshold >= 2) signoffs.push(softwareSignoff(digest));
210
+ return {
211
+ '@version': 'EP-ASSURANCE-PROOF-v1',
212
+ context_hash: contextHash,
213
+ threshold: threshold >= 2 ? threshold : 1,
214
+ signoffs,
215
+ };
216
+ }
217
+
76
218
  /**
77
219
  * Mint a scenario receipt.
78
220
  * @param {object} o
79
- * @param {'allow'|'allow_with_signoff'} [o.outcome] 'allow'=software, 'allow_with_signoff'=Class-A
80
- * @param {object} [o.quorum] a quorum block (signers/threshold) -> quorum tier
221
+ * @param {'allow'|'allow_with_signoff'} [o.outcome] 'allow'=software; 'allow_with_signoff'
222
+ * embeds a REAL WebAuthn device signoff so the receipt cryptographically earns class_a.
223
+ * @param {object|boolean} [o.quorum] request quorum-tier evidence. Truthy -> a REAL
224
+ * EP-QUORUM-v1 doc (distinct humans + distinct keys + per-signer assertions). If an
225
+ * object with `threshold`/`signers`, its size sets the quorum size.
226
+ * @param {boolean} [o.fakeQuorum] embed an UNVERIFIABLE self-asserted quorum block
227
+ * ({signers,threshold}) with no per-signer signatures — used to prove the Gate
228
+ * REFUSES it (must NOT be credited quorum). For adversarial tests only.
81
229
  * @param {object} [o.tamper] fields assigned to the claim AFTER signing (breaks the signature)
82
230
  */
83
- function mint({ outcome = 'allow_with_signoff', quorum = null, tamper = null, extra = {} } = {}) {
231
+ function mint({ outcome = 'allow_with_signoff', quorum = null, fakeQuorum = false, tamper = null, extra = {} } = {}) {
232
+ const claim = { ...action, outcome, approver: 'ep:approver:eg1', ...extra };
84
233
  const payload = {
85
234
  receipt_id: `${idPrefix}_${++counter}`,
86
235
  subject: 'agent:eg1-conformance',
87
236
  issuer: 'ep:org:eg1',
88
237
  created_at: new Date(nowMs()).toISOString(),
89
- claim: {
90
- ...action,
91
- outcome,
92
- approver: 'ep:approver:eg1',
93
- ...(quorum ? { quorum } : {}),
94
- ...extra,
95
- },
238
+ claim,
96
239
  };
240
+ if (fakeQuorum) {
241
+ // Self-asserted ONLY — NO members / NO signatures / NO pinned proof. The
242
+ // Gate must refuse to credit this as quorum (assurance_too_low). For
243
+ // adversarial tests that prove payload claims are never trusted.
244
+ payload.quorum = { signers: ['ep:a', 'ep:b'], threshold: 2 };
245
+ } else if (outcome === 'allow_with_signoff' || quorum) {
246
+ // Genuine, per-signer-verifiable evidence. The PRIMARY proof is the pinned
247
+ // EP-ASSURANCE-PROOF-v1 (verified against the harness's pinned approverKeys),
248
+ // which is what the EG-1 gate/custody path checks. We ALSO embed self-contained
249
+ // evidence (EP-QUORUM-v1 / WebAuthn device signoff) so a relying party that
250
+ // does NOT pin keys can still cryptographically credit the tier (DoD audit fix).
251
+ payload.assurance_proof = assuranceProof(payload, quorum);
252
+ if (quorum) {
253
+ const threshold = Number.isInteger(quorum.threshold) ? quorum.threshold
254
+ : (Array.isArray(quorum.signers) ? quorum.signers.length : 2);
255
+ payload.quorum = mintQuorumEvidence({ actionHash, threshold, issuedAtMs: nowMs() });
256
+ } else {
257
+ const s = mintDeviceSignoff({ actionHash, approver: 'ep:approver:eg1', issuedAtMs: nowMs() });
258
+ payload.signoff = s.signoff;
259
+ payload.approver_public_key = s.approver_public_key;
260
+ }
261
+ }
97
262
  const value = crypto.sign(null, Buffer.from(canon(payload), 'utf8'), privateKey).toString('base64url');
98
263
  const receipt = { '@version': 'EP-RECEIPT-v1', payload, signature: { algorithm: 'Ed25519', value } };
99
264
  if (tamper) Object.assign(receipt.payload.claim, tamper); // tamper AFTER signing -> signature no longer binds
100
265
  return receipt;
101
266
  }
102
267
 
103
- return { publicKey: pub, mint, action, now: nowMs };
268
+ return { publicKey: pub, approverKeys, mint, action, actionHash, now: nowMs };
104
269
  }
105
270
 
106
271
  /**
@@ -203,4 +368,4 @@ export async function runEg1({ invoke, harness, action } = {}) {
203
368
  };
204
369
  }
205
370
 
206
- export default { EG1_VERSION, EG1_CHECKS, EG1_DEFAULT_ACTION, EG1_DEFAULT_SELECTOR, createEg1Harness, makeGateInvoke, runEg1 };
371
+ export default { EG1_VERSION, EG1_CHECKS, EG1_DEFAULT_ACTION, EG1_DEFAULT_SELECTOR, createEg1Harness, makeGateInvoke, runEg1, mintDeviceSignoff, mintQuorumEvidence };
package/index.js CHANGED
@@ -6,8 +6,12 @@
6
6
  * action runs ONLY if it arrives with a receipt that is:
7
7
  * 1. valid — Ed25519 over canonical JSON, signed by a pinned issuer;
8
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);
9
+ * 3. sufficiently — meets the action's required assurance tier, and the
10
+ * assured credited tier is CRYPTOGRAPHICALLY VERIFIED, not read
11
+ * from self-asserted payload fields: class_a requires a
12
+ * valid WebAuthn device signoff, quorum requires a valid
13
+ * EP-QUORUM-v1 (distinct humans + distinct keys +
14
+ * threshold + per-signer assertions);
11
15
  * 4. fresh — within max age; and
12
16
  * 5. unused — not a replay (one-time consumption).
13
17
  * Otherwise it is refused with a machine-readable Receipt-Required challenge
@@ -27,48 +31,143 @@ const {
27
31
  receiptRequiredHeader,
28
32
  validateActionRiskManifest,
29
33
  findActionRequirement,
34
+ evaluateReceiptAssurance,
35
+ receiptAssuranceTier: receiptAssuranceTierFromProof,
30
36
  RECEIPT_REQUIRED_STATUS,
31
37
  RECEIPT_REQUIRED_HEADER,
32
38
  } = await import('@emilia-protocol/require-receipt').catch(() => import('../require-receipt/index.js'));
39
+ // The real per-signer verifiers (WebAuthn device-signoff + M-of-N quorum). The
40
+ // Gate MUST use these to CREDIT class_a / quorum — a receipt's self-asserted
41
+ // outcome string or a fabricated quorum block is NOT proof. Same resolution
42
+ // pattern as require-receipt: prefer the published package, fall back to the
43
+ // in-repo source so the monorepo test/build works without a node_modules link.
44
+ const { verifyWebAuthnSignoff, verifyQuorum } = await import('@emilia-protocol/verify')
45
+ .catch(() => import('../verify/index.js'));
33
46
  import { MemoryConsumptionStore } from './store.js';
34
47
  import { createEvidenceLog } from './evidence.js';
35
48
  import { DEFAULT_GATE_MANIFEST, HIGH_RISK_ACTION_PACKS, createDefaultActionRiskManifest } from './action-packs.js';
36
49
  import { hashCanonical, verifyExecutionBinding } from './execution-binding.js';
37
50
  import { buildReliancePacket } from './reliance-packet.js';
38
- import { createEg1Harness, makeGateInvoke, runEg1, EG1_DEFAULT_SELECTOR } from './eg1-conformance.js';
51
+ import { createEg1Harness, makeGateInvoke, runEg1, EG1_DEFAULT_SELECTOR, mintDeviceSignoff, mintQuorumEvidence } from './eg1-conformance.js';
52
+ import { CF1_VERSION, CF1_CHECKS, runCf1 } from './cf1-conformance.js';
39
53
  import { createKeyRegistry, asKeyRegistry } from './key-registry.js';
40
54
  import { classifyRetention, buildRetentionExport } from './retention.js';
55
+ import { createDefaultActionControlManifest, findActionControl, validateActionControlManifest } from './action-control-manifest.js';
41
56
 
42
57
  export { MemoryConsumptionStore, createEvidenceLog };
43
58
  export { createDurableConsumptionStore, createMemoryBackend } from './store.js';
44
59
  export { createKeyRegistry, asKeyRegistry } from './key-registry.js';
45
60
  export { classifyRetention, buildRetentionExport, RETENTION_EXPORT_VERSION } from './retention.js';
46
61
  export { DEFAULT_GATE_MANIFEST, HIGH_RISK_ACTION_PACKS, createDefaultActionRiskManifest };
62
+ export {
63
+ ACTION_CONTROL_MANIFEST_VERSION,
64
+ ACTION_CONTROL_SCHEMA_URL,
65
+ ACTION_CONTROL_CONFORMANCE_LEVEL,
66
+ ACTION_CONTROL_DEFAULTS,
67
+ ACTION_CONTROL_EVIDENCE_PROFILES,
68
+ ACTION_CONTROL_CONFORMANCE_CHECKS,
69
+ toActionControl,
70
+ createDefaultActionControlManifest,
71
+ findActionControl,
72
+ validateActionControlManifest,
73
+ } from './action-control-manifest.js';
47
74
  export { EXECUTION_BINDING_VERSION, canonicalize, hashCanonical, materialFieldsFor, verifyExecutionBinding } from './execution-binding.js';
48
75
  export { RELIANCE_PACKET_VERSION, buildReliancePacket } from './reliance-packet.js';
49
76
  export {
50
77
  EG1_VERSION, EG1_CHECKS, EG1_DEFAULT_ACTION, EG1_DEFAULT_SELECTOR,
51
- createEg1Harness, makeGateInvoke, runEg1,
78
+ createEg1Harness, makeGateInvoke, runEg1, mintDeviceSignoff, mintQuorumEvidence,
52
79
  } from './eg1-conformance.js';
80
+ export { CF1_VERSION, CF1_CHECKS, runCf1 } from './cf1-conformance.js';
53
81
  export const ASSURANCE_TIERS = ['software', 'class_a', 'quorum'];
54
82
  const TIER_RANK = { software: 0, class_a: 1, quorum: 2 };
55
83
 
56
84
  /**
57
- * The assurance tier a receipt demonstrably meets. Conservative / fail-closed:
58
- * if a higher tier's structure is not present, the receipt only earns the lower
59
- * tier, and a guard that needs more will refuse it.
60
- * quorum — a quorum block with >= 2 distinct signers and threshold >= 2.
61
- * class_a — a device signoff (or claim.outcome === 'allow_with_signoff').
62
- * software — any otherwise-valid receipt (a software-held key).
85
+ * The assurance tier a receipt has CRYPTOGRAPHICALLY EARNED.
86
+ *
87
+ * SECURITY: the credited tier is NEVER inferred from self-asserted payload
88
+ * fields. A bare `quorum:{signers,threshold}` block or an `outcome:
89
+ * 'allow_with_signoff'` string with no verifiable signature earns only
90
+ * `software`it will be refused `assurance_too_low` by any guard that needs
91
+ * more. Fail-closed by construction.
92
+ *
93
+ * Two independent cryptographic proof shapes are accepted; a receipt earns the
94
+ * HIGHEST tier any of them proves:
95
+ *
96
+ * (a) Pinned assurance proof (`payload.assurance_proof`, EP-ASSURANCE-PROOF-v1):
97
+ * per-signer signatures verified against PINNED approver keys (opts.approverKeys)
98
+ * or a caller-supplied verifier (opts.verifyAssurance). This is the primary,
99
+ * strongest model — the verifier never trusts a key that travels inside the
100
+ * receipt. Delegated to require-receipt's receiptAssuranceTierFromProof.
101
+ *
102
+ * (b) Self-contained embedded evidence (DoD audit fix): a full EP-QUORUM-v1
103
+ * document (payload.quorum) whose per-signer WebAuthn assertions verify via
104
+ * verifyQuorum (distinct humans + distinct keys + threshold + action-binding
105
+ * + window) earns `quorum`; a WebAuthn device signoff (payload.signoff =
106
+ * {context, webauthn}) that verifies against the approver's own key via
107
+ * verifyWebAuthnSignoff earns `class_a`. Used where the approver keys travel
108
+ * with the receipt rather than being pinned by the relying party.
109
+ *
110
+ * @param {object} doc the EP-RECEIPT-v1 document
111
+ * @param {object} [opts]
112
+ * @param {object} [opts.approverKeys] pinned approver keys for path (a)
113
+ * @param {function} [opts.verifyAssurance] custom assurance verifier for path (a)
114
+ * @param {string} [opts.rpId] bind embedded device assertions to this WebAuthn RP id (path b)
115
+ * @param {boolean} [opts.detail] return a {tier, quorum, signoff} object instead of the string
116
+ * @returns {'software'|'class_a'|'quorum'|object} the highest tier proven
63
117
  */
64
- export function receiptAssuranceTier(doc) {
118
+ export function receiptAssuranceTier(doc, opts = {}) {
119
+ const detail = { tier: 'software', quorum: null, signoff: null };
120
+
121
+ // --- Path (a): pinned assurance proof / caller-supplied verifier. ---
122
+ // Never inferred from receipt fields without a pinned key or explicit verifier.
123
+ let proofTier = 'software';
124
+ try {
125
+ proofTier = receiptAssuranceTierFromProof(doc, opts) || 'software';
126
+ } catch { proofTier = 'software'; }
127
+ if ((TIER_RANK[proofTier] ?? 0) > (TIER_RANK[detail.tier] ?? 0)) detail.tier = proofTier;
128
+
129
+ // --- Path (b): self-contained embedded per-signer evidence (DoD audit fix). ---
65
130
  const p = doc?.payload || {};
131
+ const verifyOpts = opts.rpId ? { rpId: opts.rpId } : {};
132
+
133
+ // quorum: a real, self-contained EP-QUORUM-v1 evidence document. Accept it
134
+ // under payload.quorum or payload.claim.quorum. It only counts if it is a full
135
+ // quorum document (policy + members with WebAuthn signoffs) AND verifyQuorum
136
+ // returns valid. A bare {signers,threshold} block has no members to verify and
137
+ // therefore CANNOT be credited quorum.
66
138
  const q = p.quorum || p.claim?.quorum;
67
- const signers = q && (q.signers || q.approvers);
68
- const threshold = q && (q.m ?? q.threshold ?? (Array.isArray(signers) ? signers.length : 0));
69
- if (q && Array.isArray(signers) && signers.length >= 2 && threshold >= 2) return 'quorum';
70
- if (p.signoff || p.claim?.outcome === 'allow_with_signoff') return 'class_a';
71
- return 'software';
139
+ if (detail.tier !== 'quorum' && isQuorumEvidence(q)) {
140
+ const qr = verifyQuorum(q, verifyOpts);
141
+ detail.quorum = { valid: qr.valid, checks: qr.checks };
142
+ if (qr.valid) detail.tier = 'quorum';
143
+ }
144
+
145
+ // class_a: a verifiable WebAuthn device signoff. The signoff evidence is
146
+ // {context, webauthn}; the approver key travels with it (signoff.approver_public_key)
147
+ // or alongside it (payload.approver_public_key).
148
+ if ((TIER_RANK[detail.tier] ?? 0) < TIER_RANK.class_a) {
149
+ const so = p.signoff || p.claim?.signoff;
150
+ if (isSignoffEvidence(so)) {
151
+ const key = so.approver_public_key || p.approver_public_key || p.claim?.approver_public_key;
152
+ if (key) {
153
+ const sr = verifyWebAuthnSignoff(so, key, verifyOpts);
154
+ detail.signoff = { valid: sr.valid, checks: sr.checks };
155
+ if (sr.valid && (TIER_RANK[detail.tier] ?? 0) < TIER_RANK.class_a) detail.tier = 'class_a';
156
+ }
157
+ }
158
+ }
159
+
160
+ return opts.detail ? detail : detail.tier;
161
+ }
162
+
163
+ /** A quorum evidence doc must carry members with per-signer signoffs to be verifiable. */
164
+ function isQuorumEvidence(q) {
165
+ return !!q && typeof q === 'object' && q.policy && Array.isArray(q.members) && q.members.length > 0
166
+ && typeof q.action_hash === 'string' && q.action_hash.length > 0;
167
+ }
168
+ /** A device signoff must carry the WebAuthn assertion material to be verifiable. */
169
+ function isSignoffEvidence(s) {
170
+ return !!s && typeof s === 'object' && s.context && s.webauthn;
72
171
  }
73
172
 
74
173
  /**
@@ -84,7 +183,7 @@ export function receiptAssuranceTier(doc) {
84
183
  * if given it supersedes trustedKeys — a receipt is verified only against keys valid (and not
85
184
  * revoked) at its issuance time.
86
185
  */
87
- export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900, store, log, allowInlineKey = false, allowEphemeralStore = false, strictEvidence = true, now = Date.now, keyRegistry = null } = {}) {
186
+ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900, store, log, allowInlineKey = false, allowEphemeralStore = false, strictEvidence = true, now = Date.now, keyRegistry = null, approverKeys = {}, approver_keys = null, verifyAssurance = null, rpId = null } = {}) {
88
187
  // Production key custody: a registry (rotation + revocation) supersedes a flat
89
188
  // trustedKeys list. A flat list is coerced to an always-valid registry, so
90
189
  // existing callers are unchanged.
@@ -180,17 +279,50 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
180
279
  if (!v.ok) {
181
280
  return decide(false, RECEIPT_REQUIRED_STATUS, `receipt_rejected:${v.reason}`, { rejected: v });
182
281
  }
183
- // Assurance tier.
184
- const have = receiptAssuranceTier(receipt);
185
- if ((TIER_RANK[have] ?? 0) < (TIER_RANK[requiredTier] ?? 0)) {
186
- return decide(false, RECEIPT_REQUIRED_STATUS, 'assurance_too_low', { have_tier: have, need_tier: requiredTier });
282
+ // Assurance tier. CRYPTOGRAPHICALLY VERIFIED — never inferred from
283
+ // self-asserted payload fields. The credited tier is the HIGHER of two
284
+ // independent proof paths:
285
+ // (a) pinned assurance proof (payload.assurance_proof verified against
286
+ // pinned approverKeys) or a caller-supplied verifyAssurance hook;
287
+ // (b) self-contained embedded per-signer evidence (EP-QUORUM-v1 /
288
+ // WebAuthn device signoff) re-verified via verifyQuorum /
289
+ // verifyWebAuthnSignoff (DoD audit fix).
290
+ // A receipt that only CLAIMS a higher tier earns 'software' and is refused.
291
+ const assurance = evaluateReceiptAssurance(receipt, requiredTier, {
292
+ approverKeys: approver_keys || approverKeys,
293
+ verifyAssurance,
294
+ });
295
+ const tierResult = receiptAssuranceTier(receipt, {
296
+ rpId, detail: true, approverKeys: approver_keys || approverKeys, verifyAssurance,
297
+ });
298
+ // Take the strongest tier either path proves.
299
+ const have = (TIER_RANK[assurance.have] ?? 0) >= (TIER_RANK[tierResult.tier] ?? 0)
300
+ ? assurance.have : tierResult.tier;
301
+ const needRank = TIER_RANK[requiredTier];
302
+ // Fail CLOSED on an unknown / mis-cased required tier: never silently treat
303
+ // it as 'software'. If a manifest asks for a tier this gate does not model,
304
+ // no receipt can satisfy it.
305
+ if (needRank === undefined) {
306
+ return decide(false, RECEIPT_REQUIRED_STATUS, 'unknown_required_tier', { have_tier: have, need_tier: requiredTier, assurance_tier_source: 'cryptographic_verification' });
307
+ }
308
+ if ((TIER_RANK[have] ?? 0) < needRank) {
309
+ // The credited tier (from either proof path) is below what the action
310
+ // requires. The canonical machine-readable reason is 'assurance_too_low';
311
+ // main's proof-path detail (e.g. 'assurance_proof_required') is surfaced
312
+ // separately so callers keep the diagnostic without changing the contract.
313
+ return decide(false, RECEIPT_REQUIRED_STATUS, 'assurance_too_low', {
314
+ have_tier: have, need_tier: requiredTier,
315
+ assurance_tier_source: 'cryptographic_verification',
316
+ assurance_detail: assurance.reason || null,
317
+ tier_evidence: { quorum: tierResult.quorum, signoff: tierResult.signoff },
318
+ });
187
319
  }
188
320
  // The high-risk action packs define material fields that must be observed
189
321
  // by the executor from the system of record. A signed, harmless-looking
190
322
  // claim cannot authorize a different real mutation.
191
323
  const executionBinding = verifyExecutionBinding({ requirement, receipt, observedAction: observed });
192
324
  if (!executionBinding.ok) {
193
- return decide(false, RECEIPT_REQUIRED_STATUS, 'execution_binding_failed', { execution_binding: executionBinding, have_tier: have });
325
+ return decide(false, RECEIPT_REQUIRED_STATUS, 'execution_binding_failed', { execution_binding: executionBinding, have_tier: have, assurance_tier_source: 'cryptographic_verification' });
194
326
  }
195
327
  // One-time consumption (replay defense). Require a stable, issuer-generated
196
328
  // receipt_id — never fall back to a content hash, whose canonicalization can
@@ -214,7 +346,7 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
214
346
  if (!fresh) {
215
347
  return decide(false, RECEIPT_REQUIRED_STATUS, 'replay_refused', { consumption_key: receiptId });
216
348
  }
217
- return decide(true, 200, 'allow', { signer: v.signer, outcome: v.outcome, have_tier: have, execution_binding: executionBinding, consumption_mode: consumptionMode });
349
+ return decide(true, 200, 'allow', { signer: v.signer, outcome: v.outcome, have_tier: have, assurance_tier_source: 'cryptographic_verification', execution_binding: executionBinding, consumption_mode: consumptionMode });
218
350
  }
219
351
 
220
352
  /** Express/Connect middleware: refuse the route unless a sufficient receipt is present. */
@@ -378,10 +510,52 @@ export async function gateConformance({ gate, harness, action, selector = EG1_DE
378
510
  */
379
511
  export async function gateConformanceSelfTest({ now } = {}) {
380
512
  const harness = createEg1Harness({ now });
381
- const gate = createTrustedActionFirewall({ trustedKeys: [harness.publicKey], now });
513
+ const gate = createTrustedActionFirewall({ trustedKeys: [harness.publicKey], approverKeys: harness.approverKeys, now });
382
514
  return gateConformance({ gate, harness });
383
515
  }
384
516
 
517
+ /**
518
+ * CF-1 (Consequence Firewall) conformance for an existing gate. Runs the eight
519
+ * EG-1 runtime checks plus the three CF-1 category checks: the action is
520
+ * declared consequential by the manifest, a gate pinned to the WRONG issuer key
521
+ * refuses a valid receipt, and the allowed run emits offline-verifiable reliance
522
+ * evidence. The `gate` MUST trust `harness.publicKey`; `wrongGate` MUST trust a
523
+ * DIFFERENT key (otherwise wrong_authority_refused cannot be demonstrated).
524
+ * @param {object} o
525
+ * @param {object} o.gate an EMILIA Gate trusting harness.publicKey
526
+ * @param {object} [o.wrongGate] a sibling gate trusting a different (wrong) key
527
+ * @param {object} o.harness from createEg1Harness()
528
+ * @param {object} [o.manifest] the action-risk manifest (to resolve the requirement)
529
+ * @param {object} [o.selector] the manifest selector for the action
530
+ * @param {object} [o.action] the high-risk action to exercise
531
+ */
532
+ export async function cf1Conformance({ gate, wrongGate, harness, manifest = null, selector = EG1_DEFAULT_SELECTOR, action } = {}) {
533
+ if (!gate || typeof gate.run !== 'function') throw new Error('cf1Conformance requires a gate built trusting harness.publicKey');
534
+ if (!harness) throw new Error('cf1Conformance requires the harness whose key the gate trusts');
535
+ const act = action || harness.action;
536
+ const invoke = makeGateInvoke(gate, { selector, action: act });
537
+ const wrongInvoke = (wrongGate && typeof wrongGate.run === 'function')
538
+ ? makeGateInvoke(wrongGate, { selector, action: act }) : undefined;
539
+ const requirement = manifest ? findActionRequirement(manifest, selector) : null;
540
+ return runCf1({ invoke, wrongInvoke, harness, action: act, requirement });
541
+ }
542
+
543
+ /**
544
+ * Self-certify the reference gate against CF-1: a default Trusted Action
545
+ * Firewall trusting a fresh harness key, a sibling firewall trusting a DIFFERENT
546
+ * key (for wrong_authority_refused), and the default action-risk manifest (for
547
+ * consequential_action_declared). The canonical "reference gate earns CF-1"
548
+ * proof — runnable as a CLI (`cf1.mjs`).
549
+ */
550
+ export async function cf1ConformanceSelfTest({ now } = {}) {
551
+ const harness = createEg1Harness({ now });
552
+ const manifest = createDefaultActionRiskManifest();
553
+ const gate = createTrustedActionFirewall({ trustedKeys: [harness.publicKey], approverKeys: harness.approverKeys, now });
554
+ const wrongHarness = createEg1Harness({ now });
555
+ const wrongGate = createTrustedActionFirewall({ trustedKeys: [wrongHarness.publicKey], approverKeys: wrongHarness.approverKeys, now });
556
+ return cf1Conformance({ gate, wrongGate, harness, manifest, selector: EG1_DEFAULT_SELECTOR, action: harness.action });
557
+ }
558
+
385
559
  export default {
386
560
  createGate,
387
561
  createTrustedActionFirewall,
@@ -393,10 +567,18 @@ export default {
393
567
  HIGH_RISK_ACTION_PACKS,
394
568
  gateConformance,
395
569
  gateConformanceSelfTest,
570
+ cf1Conformance,
571
+ cf1ConformanceSelfTest,
572
+ CF1_VERSION,
573
+ CF1_CHECKS,
574
+ runCf1,
396
575
  createEg1Harness,
397
576
  runEg1,
398
577
  createKeyRegistry,
399
578
  asKeyRegistry,
400
579
  classifyRetention,
401
580
  buildRetentionExport,
581
+ createDefaultActionControlManifest,
582
+ findActionControl,
583
+ validateActionControlManifest,
402
584
  };
package/mcp.js CHANGED
@@ -52,29 +52,37 @@ export function gateMcpTool(gate, o = {}, handler) {
52
52
  const { tool, protocol = 'mcp', action } = o;
53
53
  if (!tool) throw new Error('gateMcpTool requires { tool }');
54
54
 
55
+ const refused = (reason, body = null) => ({
56
+ isError: true,
57
+ content: [{
58
+ type: 'text',
59
+ text: `EMILIA Gate refused "${tool}": ${reason}. `
60
+ + 'This is a high-risk action; present a valid, sufficiently-assured, unused human/quorum receipt.',
61
+ }],
62
+ _emilia: {
63
+ gate: 'refused',
64
+ status: 428,
65
+ reason,
66
+ challenge: body,
67
+ },
68
+ });
69
+
55
70
  return async function gatedTool(args = {}, extra) {
56
71
  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);
72
+ let receipt;
73
+ let observedAction;
74
+ try {
75
+ receipt = resolveReceipt(args, o);
76
+ observedAction = typeof o.observedAction === 'function'
77
+ ? o.observedAction(args, extra)
78
+ : (o.observedAction ?? args);
79
+ } catch {
80
+ return refused('receipt_boundary_failed');
81
+ }
61
82
 
62
83
  const out = await gate.run({ selector, receipt, observedAction }, () => handler(args, extra));
63
84
  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
- };
85
+ return refused(out.authorization.reason, out.body);
78
86
  }
79
87
  const result = out.result;
80
88
  // Attach the proof without clobbering a structured tool result.
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@emilia-protocol/gate",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
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
9
  ".": "./index.js",
10
+ "./action-control-manifest": "./action-control-manifest.js",
10
11
  "./mcp": "./mcp.js",
11
12
  "./adapters/github": "./adapters/github.js",
12
13
  "./adapters/stripe": "./adapters/stripe.js",
@@ -24,14 +25,59 @@
24
25
  "./retention": "./retention.js",
25
26
  "./package.json": "./package.json"
26
27
  },
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"],
28
+ "files": [
29
+ "index.js",
30
+ "store.js",
31
+ "evidence.js",
32
+ "action-packs.js",
33
+ "action-control-manifest.js",
34
+ "execution-binding.js",
35
+ "reliance-packet.js",
36
+ "key-registry.js",
37
+ "retention.js",
38
+ "eg1-conformance.js",
39
+ "eg1.mjs",
40
+ "cf1-conformance.js",
41
+ "cf1.mjs",
42
+ "mcp.js",
43
+ "adapters/_kit.js",
44
+ "adapters/github.js",
45
+ "adapters/github-demo.mjs",
46
+ "adapters/stripe.js",
47
+ "adapters/supabase.js",
48
+ "adapters/aws.js",
49
+ "adapters/k8s.js",
50
+ "adapters/terraform.js",
51
+ "adapters/gcp.js",
52
+ "adapters/vercel.js",
53
+ "adapters/cloudflare.js",
54
+ "adapters/linear.js",
55
+ "adapters/jira.js",
56
+ "adapters/salesforce.js",
57
+ "demo.mjs",
58
+ "custody-demo.mjs",
59
+ "README.md"
60
+ ],
28
61
  "scripts": {
29
62
  "test": "node --test",
30
63
  "demo": "node demo.mjs",
31
- "eg1": "node eg1.mjs"
64
+ "eg1": "node eg1.mjs",
65
+ "cf1": "node cf1.mjs"
32
66
  },
33
67
  "dependencies": {
34
- "@emilia-protocol/require-receipt": "^0.4.1"
68
+ "@emilia-protocol/require-receipt": "^0.5.0",
69
+ "@emilia-protocol/verify": "^3.3.0"
35
70
  },
36
- "keywords": ["emilia", "authorization", "receipt", "firewall", "trusted-action-firewall", "agent", "mcp", "gate", "policy-enforcement-point", "ai-safety"]
71
+ "keywords": [
72
+ "emilia",
73
+ "authorization",
74
+ "receipt",
75
+ "firewall",
76
+ "trusted-action-firewall",
77
+ "agent",
78
+ "mcp",
79
+ "gate",
80
+ "policy-enforcement-point",
81
+ "ai-safety"
82
+ ]
37
83
  }