@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 +11 -0
- package/action-control-manifest.js +237 -0
- package/cf1-conformance.js +107 -0
- package/cf1.mjs +45 -0
- package/demo.mjs +10 -1
- package/eg1-conformance.js +177 -12
- package/index.js +206 -24
- package/mcp.js +26 -18
- package/package.json +51 -5
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
|
|
package/eg1-conformance.js
CHANGED
|
@@ -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
|
|
80
|
-
*
|
|
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
|
|
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
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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.
|
|
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": [
|
|
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.
|
|
68
|
+
"@emilia-protocol/require-receipt": "^0.5.0",
|
|
69
|
+
"@emilia-protocol/verify": "^3.3.0"
|
|
35
70
|
},
|
|
36
|
-
"keywords": [
|
|
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
|
}
|