@emilia-protocol/gate 0.6.0 → 0.8.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
@@ -210,6 +210,10 @@ challenge. The Gate composes that and adds the three things a firewall needs:
210
210
 
211
211
  - **Assurance tiers** — `software` < `class_a` (device signoff) < `quorum` (m-of-n). A `critical`
212
212
  action can demand `class_a` or `quorum`; a lower-assurance receipt is refused (`assurance_too_low`).
213
+ In the lightweight EP-RECEIPT-v1 gate, the tier is an issuer-attested claim
214
+ inside a receipt signed by a pinned issuer key. For independent verification
215
+ of every embedded device/quorum signature, use the EP §6.2 trust-receipt
216
+ verifier in `@emilia-protocol/verify`.
213
217
  - **One-time consumption** — a receipt authorizes one action, once. Replays are refused
214
218
  (`replay_refused`). Default store is in-memory; swap in Redis/DB for a fleet.
215
219
  - **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,55 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * EMILIA Gate — Cloudflare System-of-Record adapter.
4
+ * Guards DNS record delete, zone delete, and WAF/firewall disable so they never
5
+ * reach Cloudflare without a receipt bound to THIS zone. Client injected.
6
+ */
7
+ import { createAdapter, manifestFromPack } from './_kit.js';
8
+
9
+ export const CLOUDFLARE_ACTION_PACK = Object.freeze([
10
+ Object.freeze({
11
+ id: 'cloudflare.dns.delete', label: 'Delete DNS record', action_type: 'cloudflare.dns.delete',
12
+ risk: 'high', receipt_required: true, assurance_class: 'class_a',
13
+ match: { protocol: 'cloudflare', tool: 'delete_dns_record' },
14
+ why: 'Removing DNS can take a service offline or enable takeover. Bind zone+record.',
15
+ execution_binding: { required_fields: ['action_type', 'zone', 'record_id'] },
16
+ }),
17
+ Object.freeze({
18
+ id: 'cloudflare.zone.delete', label: 'Delete zone', action_type: 'cloudflare.zone.delete',
19
+ risk: 'critical', receipt_required: true, assurance_class: 'quorum',
20
+ match: { protocol: 'cloudflare', tool: 'delete_zone' },
21
+ why: 'Deletes an entire zone. Quorum.',
22
+ execution_binding: { required_fields: ['action_type', 'zone'] },
23
+ }),
24
+ Object.freeze({
25
+ id: 'cloudflare.firewall.disable', label: 'Disable firewall rule', action_type: 'cloudflare.firewall.disable',
26
+ risk: 'critical', receipt_required: true, assurance_class: 'quorum',
27
+ match: { protocol: 'cloudflare', tool: 'set_firewall_rule' },
28
+ why: 'Disabling WAF/firewall opens the perimeter. Quorum + bind zone+rule.',
29
+ execution_binding: { required_fields: ['action_type', 'zone', 'rule_id'] },
30
+ }),
31
+ ]);
32
+
33
+ const OPS = {
34
+ 'dns.delete': {
35
+ selector: { protocol: 'cloudflare', tool: 'delete_dns_record' },
36
+ observed: (p) => ({ action_type: 'cloudflare.dns.delete', zone: p.zone, record_id: p.record_id }),
37
+ perform: (c, p) => c.deleteDnsRecord({ zone: p.zone, recordId: p.record_id }),
38
+ },
39
+ 'zone.delete': {
40
+ selector: { protocol: 'cloudflare', tool: 'delete_zone' },
41
+ observed: (p) => ({ action_type: 'cloudflare.zone.delete', zone: p.zone }),
42
+ perform: (c, p) => c.deleteZone({ zone: p.zone }),
43
+ },
44
+ 'firewall.disable': {
45
+ selector: { protocol: 'cloudflare', tool: 'set_firewall_rule' },
46
+ observed: (p) => ({ action_type: 'cloudflare.firewall.disable', zone: p.zone, rule_id: p.rule_id }),
47
+ perform: (c, p) => c.setFirewallRule({ zone: p.zone, ruleId: p.rule_id, enabled: false }),
48
+ },
49
+ };
50
+
51
+ const adapter = createAdapter({ system: 'cloudflare', ops: OPS });
52
+ export const CLOUDFLARE_OPS = adapter.OPS;
53
+ export function createCloudflareManifest(extra = []) { return manifestFromPack(CLOUDFLARE_ACTION_PACK, extra); }
54
+ export function guardCloudflareMutation(gate, client, args) { return adapter.guard(gate, client, args); }
55
+ export default { CLOUDFLARE_ACTION_PACK, CLOUDFLARE_OPS, createCloudflareManifest, guardCloudflareMutation };
@@ -0,0 +1,55 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * EMILIA Gate — Jira System-of-Record adapter.
4
+ * Guards bulk issue delete, project delete, and permission grants so they never
5
+ * reach Jira without a receipt bound to THIS resource. Client injected.
6
+ */
7
+ import { createAdapter, manifestFromPack, hashCanonical } from './_kit.js';
8
+
9
+ export const JIRA_ACTION_PACK = Object.freeze([
10
+ Object.freeze({
11
+ id: 'jira.issue.bulk_delete', label: 'Bulk delete issues', action_type: 'jira.issue.bulk_delete',
12
+ risk: 'critical', receipt_required: true, assurance_class: 'class_a',
13
+ match: { protocol: 'jira', tool: 'bulk_delete_issues' },
14
+ why: 'Mass-destroys issues. Bind the exact JQL.',
15
+ execution_binding: { required_fields: ['action_type', 'project', 'jql_hash'] },
16
+ }),
17
+ Object.freeze({
18
+ id: 'jira.project.delete', label: 'Delete project', action_type: 'jira.project.delete',
19
+ risk: 'critical', receipt_required: true, assurance_class: 'quorum',
20
+ match: { protocol: 'jira', tool: 'delete_project' },
21
+ why: 'Deletes a project and its issues. Quorum.',
22
+ execution_binding: { required_fields: ['action_type', 'project_key'] },
23
+ }),
24
+ Object.freeze({
25
+ id: 'jira.permission.grant', label: 'Grant permission', action_type: 'jira.permission.grant',
26
+ risk: 'critical', receipt_required: true, assurance_class: 'quorum',
27
+ match: { protocol: 'jira', tool: 'grant_permission' },
28
+ why: 'Changes who can act. Quorum + bind project/principal/role.',
29
+ execution_binding: { required_fields: ['action_type', 'project', 'principal', 'role'] },
30
+ }),
31
+ ]);
32
+
33
+ const OPS = {
34
+ 'issue.bulk_delete': {
35
+ selector: { protocol: 'jira', tool: 'bulk_delete_issues' },
36
+ observed: (p) => ({ action_type: 'jira.issue.bulk_delete', project: p.project, jql_hash: hashCanonical(String(p.jql || '').trim()) }),
37
+ perform: (c, p) => c.bulkDeleteIssues({ project: p.project, jql: p.jql }),
38
+ },
39
+ 'project.delete': {
40
+ selector: { protocol: 'jira', tool: 'delete_project' },
41
+ observed: (p) => ({ action_type: 'jira.project.delete', project_key: p.project_key }),
42
+ perform: (c, p) => c.deleteProject({ projectKey: p.project_key }),
43
+ },
44
+ 'permission.grant': {
45
+ selector: { protocol: 'jira', tool: 'grant_permission' },
46
+ observed: (p) => ({ action_type: 'jira.permission.grant', project: p.project, principal: p.principal, role: p.role }),
47
+ perform: (c, p) => c.grantPermission({ project: p.project, principal: p.principal, role: p.role }),
48
+ },
49
+ };
50
+
51
+ const adapter = createAdapter({ system: 'jira', ops: OPS });
52
+ export const JIRA_OPS = adapter.OPS;
53
+ export function createJiraManifest(extra = []) { return manifestFromPack(JIRA_ACTION_PACK, extra); }
54
+ export function guardJiraMutation(gate, client, args) { return adapter.guard(gate, client, args); }
55
+ export default { JIRA_ACTION_PACK, JIRA_OPS, createJiraManifest, guardJiraMutation };
@@ -0,0 +1,55 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * EMILIA Gate — Linear System-of-Record adapter.
4
+ * Guards issue deletion, bulk delete, and team deletion so they never reach
5
+ * Linear without a receipt bound to THIS resource. Client injected.
6
+ */
7
+ import { createAdapter, manifestFromPack, hashCanonical } from './_kit.js';
8
+
9
+ export const LINEAR_ACTION_PACK = Object.freeze([
10
+ Object.freeze({
11
+ id: 'linear.issue.delete', label: 'Delete issue', action_type: 'linear.issue.delete',
12
+ risk: 'high', receipt_required: true, assurance_class: 'class_a',
13
+ match: { protocol: 'linear', tool: 'delete_issue' },
14
+ why: 'Destroys an issue. Bind the issue id.',
15
+ execution_binding: { required_fields: ['action_type', 'issue_id'] },
16
+ }),
17
+ Object.freeze({
18
+ id: 'linear.issue.bulk_delete', label: 'Bulk delete issues', action_type: 'linear.issue.bulk_delete',
19
+ risk: 'critical', receipt_required: true, assurance_class: 'class_a',
20
+ match: { protocol: 'linear', tool: 'bulk_delete_issues' },
21
+ why: 'Mass-destroys issues. Bind the exact query.',
22
+ execution_binding: { required_fields: ['action_type', 'team', 'query_hash'] },
23
+ }),
24
+ Object.freeze({
25
+ id: 'linear.team.delete', label: 'Delete team', action_type: 'linear.team.delete',
26
+ risk: 'critical', receipt_required: true, assurance_class: 'quorum',
27
+ match: { protocol: 'linear', tool: 'delete_team' },
28
+ why: 'Deletes a team and its issues. Quorum.',
29
+ execution_binding: { required_fields: ['action_type', 'team_id'] },
30
+ }),
31
+ ]);
32
+
33
+ const OPS = {
34
+ 'issue.delete': {
35
+ selector: { protocol: 'linear', tool: 'delete_issue' },
36
+ observed: (p) => ({ action_type: 'linear.issue.delete', issue_id: p.issue_id }),
37
+ perform: (c, p) => c.deleteIssue({ issueId: p.issue_id }),
38
+ },
39
+ 'issue.bulk_delete': {
40
+ selector: { protocol: 'linear', tool: 'bulk_delete_issues' },
41
+ observed: (p) => ({ action_type: 'linear.issue.bulk_delete', team: p.team, query_hash: hashCanonical(String(p.query || '').trim()) }),
42
+ perform: (c, p) => c.bulkDeleteIssues({ team: p.team, query: p.query }),
43
+ },
44
+ 'team.delete': {
45
+ selector: { protocol: 'linear', tool: 'delete_team' },
46
+ observed: (p) => ({ action_type: 'linear.team.delete', team_id: p.team_id }),
47
+ perform: (c, p) => c.deleteTeam({ teamId: p.team_id }),
48
+ },
49
+ };
50
+
51
+ const adapter = createAdapter({ system: 'linear', ops: OPS });
52
+ export const LINEAR_OPS = adapter.OPS;
53
+ export function createLinearManifest(extra = []) { return manifestFromPack(LINEAR_ACTION_PACK, extra); }
54
+ export function guardLinearMutation(gate, client, args) { return adapter.guard(gate, client, args); }
55
+ export default { LINEAR_ACTION_PACK, LINEAR_OPS, createLinearManifest, guardLinearMutation };
@@ -0,0 +1,55 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * EMILIA Gate — Salesforce System-of-Record adapter.
4
+ * Guards bulk record delete, data export, and permission-set assignment so they
5
+ * never reach Salesforce without a receipt bound to THIS object. Client injected.
6
+ */
7
+ import { createAdapter, manifestFromPack, hashCanonical } from './_kit.js';
8
+
9
+ export const SALESFORCE_ACTION_PACK = Object.freeze([
10
+ Object.freeze({
11
+ id: 'salesforce.records.bulk_delete', label: 'Bulk delete records', action_type: 'salesforce.records.bulk_delete',
12
+ risk: 'critical', receipt_required: true, assurance_class: 'class_a',
13
+ match: { protocol: 'salesforce', tool: 'bulk_delete' },
14
+ why: 'Mass-destroys CRM records. Bind object + the exact SOQL.',
15
+ execution_binding: { required_fields: ['action_type', 'object', 'soql_hash'] },
16
+ }),
17
+ Object.freeze({
18
+ id: 'salesforce.data.export', label: 'Bulk data export', action_type: 'salesforce.data.export',
19
+ risk: 'high', receipt_required: true, assurance_class: 'class_a',
20
+ match: { protocol: 'salesforce', tool: 'export' },
21
+ why: 'Exfiltrates CRM data. Bind object + recipient.',
22
+ execution_binding: { required_fields: ['action_type', 'object', 'recipient'] },
23
+ }),
24
+ Object.freeze({
25
+ id: 'salesforce.permission_set.assign', label: 'Assign permission set', action_type: 'salesforce.permission_set.assign',
26
+ risk: 'critical', receipt_required: true, assurance_class: 'quorum',
27
+ match: { protocol: 'salesforce', tool: 'assign_permission_set' },
28
+ why: 'Grants privileges. Quorum + bind user + permission set.',
29
+ execution_binding: { required_fields: ['action_type', 'user', 'permission_set'] },
30
+ }),
31
+ ]);
32
+
33
+ const OPS = {
34
+ 'records.bulk_delete': {
35
+ selector: { protocol: 'salesforce', tool: 'bulk_delete' },
36
+ observed: (p) => ({ action_type: 'salesforce.records.bulk_delete', object: p.object, soql_hash: hashCanonical(String(p.soql || '').trim()) }),
37
+ perform: (c, p) => c.bulkDelete({ object: p.object, soql: p.soql }),
38
+ },
39
+ 'data.export': {
40
+ selector: { protocol: 'salesforce', tool: 'export' },
41
+ observed: (p) => ({ action_type: 'salesforce.data.export', object: p.object, recipient: p.recipient }),
42
+ perform: (c, p) => c.export({ object: p.object, recipient: p.recipient }),
43
+ },
44
+ 'permission_set.assign': {
45
+ selector: { protocol: 'salesforce', tool: 'assign_permission_set' },
46
+ observed: (p) => ({ action_type: 'salesforce.permission_set.assign', user: p.user, permission_set: p.permission_set }),
47
+ perform: (c, p) => c.assignPermissionSet({ user: p.user, permissionSet: p.permission_set }),
48
+ },
49
+ };
50
+
51
+ const adapter = createAdapter({ system: 'salesforce', ops: OPS });
52
+ export const SALESFORCE_OPS = adapter.OPS;
53
+ export function createSalesforceManifest(extra = []) { return manifestFromPack(SALESFORCE_ACTION_PACK, extra); }
54
+ export function guardSalesforceMutation(gate, client, args) { return adapter.guard(gate, client, args); }
55
+ export default { SALESFORCE_ACTION_PACK, SALESFORCE_OPS, createSalesforceManifest, guardSalesforceMutation };
@@ -0,0 +1,56 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * EMILIA Gate — Vercel System-of-Record adapter.
4
+ * Guards promote-to-production, project delete, and env/secret changes so they
5
+ * never reach Vercel without a receipt bound to THIS project. Client injected
6
+ * (the Vercel REST client or a thin wrapper).
7
+ */
8
+ import { createAdapter, manifestFromPack } from './_kit.js';
9
+
10
+ export const VERCEL_ACTION_PACK = Object.freeze([
11
+ Object.freeze({
12
+ id: 'vercel.deploy.promote', label: 'Promote to production', action_type: 'vercel.deploy.promote',
13
+ risk: 'critical', receipt_required: true, assurance_class: 'quorum',
14
+ match: { protocol: 'vercel', tool: 'promote_deployment' },
15
+ why: 'Changes live production. Quorum for the prod cutover.',
16
+ execution_binding: { required_fields: ['action_type', 'project', 'deployment_id'] },
17
+ }),
18
+ Object.freeze({
19
+ id: 'vercel.project.delete', label: 'Delete project', action_type: 'vercel.project.delete',
20
+ risk: 'critical', receipt_required: true, assurance_class: 'class_a',
21
+ match: { protocol: 'vercel', tool: 'delete_project' },
22
+ why: 'Destroys a project and its deployments. Bind the project.',
23
+ execution_binding: { required_fields: ['action_type', 'project'] },
24
+ }),
25
+ Object.freeze({
26
+ id: 'vercel.env.set', label: 'Set env / secret', action_type: 'vercel.env.set',
27
+ risk: 'high', receipt_required: true, assurance_class: 'class_a',
28
+ match: { protocol: 'vercel', tool: 'upsert_env' },
29
+ why: 'Changes production secrets/config. Bind project+key+target.',
30
+ execution_binding: { required_fields: ['action_type', 'project', 'key', 'target'] },
31
+ }),
32
+ ]);
33
+
34
+ const OPS = {
35
+ 'deploy.promote': {
36
+ selector: { protocol: 'vercel', tool: 'promote_deployment' },
37
+ observed: (p) => ({ action_type: 'vercel.deploy.promote', project: p.project, deployment_id: p.deployment_id }),
38
+ perform: (c, p) => c.promoteDeployment({ project: p.project, deploymentId: p.deployment_id }),
39
+ },
40
+ 'project.delete': {
41
+ selector: { protocol: 'vercel', tool: 'delete_project' },
42
+ observed: (p) => ({ action_type: 'vercel.project.delete', project: p.project }),
43
+ perform: (c, p) => c.deleteProject({ project: p.project }),
44
+ },
45
+ 'env.set': {
46
+ selector: { protocol: 'vercel', tool: 'upsert_env' },
47
+ observed: (p) => ({ action_type: 'vercel.env.set', project: p.project, key: p.key, target: p.target }),
48
+ perform: (c, p) => c.upsertEnv({ project: p.project, key: p.key, value: p.value, target: p.target }),
49
+ },
50
+ };
51
+
52
+ const adapter = createAdapter({ system: 'vercel', ops: OPS });
53
+ export const VERCEL_OPS = adapter.OPS;
54
+ export function createVercelManifest(extra = []) { return manifestFromPack(VERCEL_ACTION_PACK, extra); }
55
+ export function guardVercelMutation(gate, client, args) { return adapter.guard(gate, client, args); }
56
+ export default { VERCEL_ACTION_PACK, VERCEL_OPS, createVercelManifest, guardVercelMutation };
@@ -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);
@@ -40,6 +40,8 @@ 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();
43
45
 
44
46
  // The default high-risk action EG-1 exercises: a Class-A money movement, which
45
47
  // the default gate manifest guards (selector { protocol:'mcp', tool:'release_payment' }).
@@ -70,9 +72,71 @@ export const EG1_CHECKS = Object.freeze([
70
72
  export function createEg1Harness({ now = Date.now, action = EG1_DEFAULT_ACTION, idPrefix = 'eg1' } = {}) {
71
73
  const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
72
74
  const pub = publicKey.export({ type: 'spki', format: 'der' }).toString('base64url');
75
+ const approverA = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
76
+ const approverB = crypto.generateKeyPairSync('ed25519');
77
+ const approverKeys = {
78
+ 'ep:key:eg1:class-a': {
79
+ public_key: approverA.publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'),
80
+ key_class: 'A',
81
+ },
82
+ 'ep:key:eg1:controller': {
83
+ public_key: approverB.publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'),
84
+ key_class: 'B',
85
+ },
86
+ };
73
87
  let counter = 0;
74
88
  const nowMs = () => (typeof now === 'function' ? now() : now);
75
89
 
90
+ function assuranceContext(payload) {
91
+ return {
92
+ '@version': 'EP-ASSURANCE-CONTEXT-v1',
93
+ receipt_id: payload.receipt_id,
94
+ claim_hash: `sha256:${sha256Hex(canon(payload.claim))}`,
95
+ };
96
+ }
97
+
98
+ function classASignoff(digest) {
99
+ const challenge = Buffer.from(digest).toString('base64url');
100
+ const clientDataJSON = Buffer.from(JSON.stringify({ type: 'webauthn.get', challenge, origin: 'https://www.emiliaprotocol.ai' }), 'utf8');
101
+ const rpIdHash = crypto.createHash('sha256').update('www.emiliaprotocol.ai').digest();
102
+ const authData = Buffer.concat([rpIdHash, Buffer.from([0x05]), Buffer.from([0, 0, 0, 0])]); // UP + UV
103
+ const signedData = Buffer.concat([authData, sha256Bytes(clientDataJSON)]);
104
+ return {
105
+ approver: 'ep:approver:eg1:cfo',
106
+ approver_key_id: 'ep:key:eg1:class-a',
107
+ key_class: 'A',
108
+ webauthn: {
109
+ authenticator_data: authData.toString('base64url'),
110
+ client_data_json: clientDataJSON.toString('base64url'),
111
+ signature: crypto.sign('sha256', signedData, approverA.privateKey).toString('base64url'),
112
+ },
113
+ };
114
+ }
115
+
116
+ function softwareSignoff(digest) {
117
+ return {
118
+ approver: 'ep:approver:eg1:controller',
119
+ approver_key_id: 'ep:key:eg1:controller',
120
+ key_class: 'B',
121
+ signature: crypto.sign(null, digest, approverB.privateKey).toString('base64url'),
122
+ };
123
+ }
124
+
125
+ function assuranceProof(payload, quorum) {
126
+ const context = assuranceContext(payload);
127
+ const contextHash = `sha256:${sha256Hex(canon(context))}`;
128
+ const digest = Buffer.from(contextHash.replace(/^sha256:/, ''), 'hex');
129
+ const threshold = Number(quorum?.threshold ?? quorum?.m ?? 1);
130
+ const signoffs = [classASignoff(digest)];
131
+ if (threshold >= 2) signoffs.push(softwareSignoff(digest));
132
+ return {
133
+ '@version': 'EP-ASSURANCE-PROOF-v1',
134
+ context_hash: contextHash,
135
+ threshold: threshold >= 2 ? threshold : 1,
136
+ signoffs,
137
+ };
138
+ }
139
+
76
140
  /**
77
141
  * Mint a scenario receipt.
78
142
  * @param {object} o
@@ -94,13 +158,16 @@ export function createEg1Harness({ now = Date.now, action = EG1_DEFAULT_ACTION,
94
158
  ...extra,
95
159
  },
96
160
  };
161
+ if (outcome === 'allow_with_signoff' || quorum) {
162
+ payload.assurance_proof = assuranceProof(payload, quorum);
163
+ }
97
164
  const value = crypto.sign(null, Buffer.from(canon(payload), 'utf8'), privateKey).toString('base64url');
98
165
  const receipt = { '@version': 'EP-RECEIPT-v1', payload, signature: { algorithm: 'Ed25519', value } };
99
166
  if (tamper) Object.assign(receipt.payload.claim, tamper); // tamper AFTER signing -> signature no longer binds
100
167
  return receipt;
101
168
  }
102
169
 
103
- return { publicKey: pub, mint, action, now: nowMs };
170
+ return { publicKey: pub, approverKeys, mint, action, now: nowMs };
104
171
  }
105
172
 
106
173
  /**
package/index.js CHANGED
@@ -27,6 +27,8 @@ const {
27
27
  receiptRequiredHeader,
28
28
  validateActionRiskManifest,
29
29
  findActionRequirement,
30
+ evaluateReceiptAssurance,
31
+ receiptAssuranceTier: receiptAssuranceTierFromProof,
30
32
  RECEIPT_REQUIRED_STATUS,
31
33
  RECEIPT_REQUIRED_HEADER,
32
34
  } = await import('@emilia-protocol/require-receipt').catch(() => import('../require-receipt/index.js'));
@@ -36,39 +38,44 @@ import { DEFAULT_GATE_MANIFEST, HIGH_RISK_ACTION_PACKS, createDefaultActionRiskM
36
38
  import { hashCanonical, verifyExecutionBinding } from './execution-binding.js';
37
39
  import { buildReliancePacket } from './reliance-packet.js';
38
40
  import { createEg1Harness, makeGateInvoke, runEg1, EG1_DEFAULT_SELECTOR } from './eg1-conformance.js';
41
+ import { CF1_VERSION, CF1_CHECKS, runCf1 } from './cf1-conformance.js';
39
42
  import { createKeyRegistry, asKeyRegistry } from './key-registry.js';
40
43
  import { classifyRetention, buildRetentionExport } from './retention.js';
44
+ import { createDefaultActionControlManifest, findActionControl, validateActionControlManifest } from './action-control-manifest.js';
41
45
 
42
46
  export { MemoryConsumptionStore, createEvidenceLog };
43
47
  export { createDurableConsumptionStore, createMemoryBackend } from './store.js';
44
48
  export { createKeyRegistry, asKeyRegistry } from './key-registry.js';
45
49
  export { classifyRetention, buildRetentionExport, RETENTION_EXPORT_VERSION } from './retention.js';
46
50
  export { DEFAULT_GATE_MANIFEST, HIGH_RISK_ACTION_PACKS, createDefaultActionRiskManifest };
51
+ export {
52
+ ACTION_CONTROL_MANIFEST_VERSION,
53
+ ACTION_CONTROL_SCHEMA_URL,
54
+ ACTION_CONTROL_CONFORMANCE_LEVEL,
55
+ ACTION_CONTROL_DEFAULTS,
56
+ ACTION_CONTROL_EVIDENCE_PROFILES,
57
+ ACTION_CONTROL_CONFORMANCE_CHECKS,
58
+ toActionControl,
59
+ createDefaultActionControlManifest,
60
+ findActionControl,
61
+ validateActionControlManifest,
62
+ } from './action-control-manifest.js';
47
63
  export { EXECUTION_BINDING_VERSION, canonicalize, hashCanonical, materialFieldsFor, verifyExecutionBinding } from './execution-binding.js';
48
64
  export { RELIANCE_PACKET_VERSION, buildReliancePacket } from './reliance-packet.js';
49
65
  export {
50
66
  EG1_VERSION, EG1_CHECKS, EG1_DEFAULT_ACTION, EG1_DEFAULT_SELECTOR,
51
67
  createEg1Harness, makeGateInvoke, runEg1,
52
68
  } from './eg1-conformance.js';
69
+ export { CF1_VERSION, CF1_CHECKS, runCf1 } from './cf1-conformance.js';
53
70
  export const ASSURANCE_TIERS = ['software', 'class_a', 'quorum'];
54
71
  const TIER_RANK = { software: 0, class_a: 1, quorum: 2 };
55
72
 
56
73
  /**
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).
74
+ * Return the proof-backed assurance tier. Without pinned approver keys or a
75
+ * caller-supplied verifier, higher tiers are not inferred from receipt fields.
63
76
  */
64
- export function receiptAssuranceTier(doc) {
65
- const p = doc?.payload || {};
66
- 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';
77
+ export function receiptAssuranceTier(doc, opts = {}) {
78
+ return receiptAssuranceTierFromProof(doc, opts);
72
79
  }
73
80
 
74
81
  /**
@@ -84,7 +91,7 @@ export function receiptAssuranceTier(doc) {
84
91
  * if given it supersedes trustedKeys — a receipt is verified only against keys valid (and not
85
92
  * revoked) at its issuance time.
86
93
  */
87
- export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900, store, log, allowInlineKey = false, allowEphemeralStore = false, strictEvidence = true, now = Date.now, keyRegistry = null } = {}) {
94
+ 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 } = {}) {
88
95
  // Production key custody: a registry (rotation + revocation) supersedes a flat
89
96
  // trustedKeys list. A flat list is coerced to an always-valid registry, so
90
97
  // existing callers are unchanged.
@@ -180,17 +187,28 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
180
187
  if (!v.ok) {
181
188
  return decide(false, RECEIPT_REQUIRED_STATUS, `receipt_rejected:${v.reason}`, { rejected: v });
182
189
  }
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 });
190
+ // Assurance tier: proof-backed only. A receipt's issuer may describe a human
191
+ // signoff, but high-tier enforcement requires pinned approver proof or an
192
+ // explicit verifier hook. Self-asserted `allow_with_signoff` / `quorum`
193
+ // fields are software-tier until proven.
194
+ const assurance = evaluateReceiptAssurance(receipt, requiredTier, {
195
+ approverKeys: approver_keys || approverKeys,
196
+ verifyAssurance,
197
+ });
198
+ const have = assurance.have;
199
+ if (!assurance.ok || (TIER_RANK[have] ?? 0) < (TIER_RANK[requiredTier] ?? 0)) {
200
+ return decide(false, RECEIPT_REQUIRED_STATUS, assurance.reason || 'assurance_too_low', {
201
+ have_tier: have,
202
+ need_tier: requiredTier,
203
+ assurance_tier_source: 'proof',
204
+ });
187
205
  }
188
206
  // The high-risk action packs define material fields that must be observed
189
207
  // by the executor from the system of record. A signed, harmless-looking
190
208
  // claim cannot authorize a different real mutation.
191
209
  const executionBinding = verifyExecutionBinding({ requirement, receipt, observedAction: observed });
192
210
  if (!executionBinding.ok) {
193
- return decide(false, RECEIPT_REQUIRED_STATUS, 'execution_binding_failed', { execution_binding: executionBinding, have_tier: have });
211
+ return decide(false, RECEIPT_REQUIRED_STATUS, 'execution_binding_failed', { execution_binding: executionBinding, have_tier: have, assurance_tier_source: 'proof' });
194
212
  }
195
213
  // One-time consumption (replay defense). Require a stable, issuer-generated
196
214
  // receipt_id — never fall back to a content hash, whose canonicalization can
@@ -214,7 +232,7 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
214
232
  if (!fresh) {
215
233
  return decide(false, RECEIPT_REQUIRED_STATUS, 'replay_refused', { consumption_key: receiptId });
216
234
  }
217
- return decide(true, 200, 'allow', { signer: v.signer, outcome: v.outcome, have_tier: have, execution_binding: executionBinding, consumption_mode: consumptionMode });
235
+ return decide(true, 200, 'allow', { signer: v.signer, outcome: v.outcome, have_tier: have, assurance_tier_source: 'proof', execution_binding: executionBinding, consumption_mode: consumptionMode });
218
236
  }
219
237
 
220
238
  /** Express/Connect middleware: refuse the route unless a sufficient receipt is present. */
@@ -378,10 +396,52 @@ export async function gateConformance({ gate, harness, action, selector = EG1_DE
378
396
  */
379
397
  export async function gateConformanceSelfTest({ now } = {}) {
380
398
  const harness = createEg1Harness({ now });
381
- const gate = createTrustedActionFirewall({ trustedKeys: [harness.publicKey], now });
399
+ const gate = createTrustedActionFirewall({ trustedKeys: [harness.publicKey], approverKeys: harness.approverKeys, now });
382
400
  return gateConformance({ gate, harness });
383
401
  }
384
402
 
403
+ /**
404
+ * CF-1 (Consequence Firewall) conformance for an existing gate. Runs the eight
405
+ * EG-1 runtime checks plus the three CF-1 category checks: the action is
406
+ * declared consequential by the manifest, a gate pinned to the WRONG issuer key
407
+ * refuses a valid receipt, and the allowed run emits offline-verifiable reliance
408
+ * evidence. The `gate` MUST trust `harness.publicKey`; `wrongGate` MUST trust a
409
+ * DIFFERENT key (otherwise wrong_authority_refused cannot be demonstrated).
410
+ * @param {object} o
411
+ * @param {object} o.gate an EMILIA Gate trusting harness.publicKey
412
+ * @param {object} [o.wrongGate] a sibling gate trusting a different (wrong) key
413
+ * @param {object} o.harness from createEg1Harness()
414
+ * @param {object} [o.manifest] the action-risk manifest (to resolve the requirement)
415
+ * @param {object} [o.selector] the manifest selector for the action
416
+ * @param {object} [o.action] the high-risk action to exercise
417
+ */
418
+ export async function cf1Conformance({ gate, wrongGate, harness, manifest = null, selector = EG1_DEFAULT_SELECTOR, action } = {}) {
419
+ if (!gate || typeof gate.run !== 'function') throw new Error('cf1Conformance requires a gate built trusting harness.publicKey');
420
+ if (!harness) throw new Error('cf1Conformance requires the harness whose key the gate trusts');
421
+ const act = action || harness.action;
422
+ const invoke = makeGateInvoke(gate, { selector, action: act });
423
+ const wrongInvoke = (wrongGate && typeof wrongGate.run === 'function')
424
+ ? makeGateInvoke(wrongGate, { selector, action: act }) : undefined;
425
+ const requirement = manifest ? findActionRequirement(manifest, selector) : null;
426
+ return runCf1({ invoke, wrongInvoke, harness, action: act, requirement });
427
+ }
428
+
429
+ /**
430
+ * Self-certify the reference gate against CF-1: a default Trusted Action
431
+ * Firewall trusting a fresh harness key, a sibling firewall trusting a DIFFERENT
432
+ * key (for wrong_authority_refused), and the default action-risk manifest (for
433
+ * consequential_action_declared). The canonical "reference gate earns CF-1"
434
+ * proof — runnable as a CLI (`cf1.mjs`).
435
+ */
436
+ export async function cf1ConformanceSelfTest({ now } = {}) {
437
+ const harness = createEg1Harness({ now });
438
+ const manifest = createDefaultActionRiskManifest();
439
+ const gate = createTrustedActionFirewall({ trustedKeys: [harness.publicKey], approverKeys: harness.approverKeys, now });
440
+ const wrongHarness = createEg1Harness({ now });
441
+ const wrongGate = createTrustedActionFirewall({ trustedKeys: [wrongHarness.publicKey], approverKeys: wrongHarness.approverKeys, now });
442
+ return cf1Conformance({ gate, wrongGate, harness, manifest, selector: EG1_DEFAULT_SELECTOR, action: harness.action });
443
+ }
444
+
385
445
  export default {
386
446
  createGate,
387
447
  createTrustedActionFirewall,
@@ -393,10 +453,18 @@ export default {
393
453
  HIGH_RISK_ACTION_PACKS,
394
454
  gateConformance,
395
455
  gateConformanceSelfTest,
456
+ cf1Conformance,
457
+ cf1ConformanceSelfTest,
458
+ CF1_VERSION,
459
+ CF1_CHECKS,
460
+ runCf1,
396
461
  createEg1Harness,
397
462
  runEg1,
398
463
  createKeyRegistry,
399
464
  asKeyRegistry,
400
465
  classifyRetention,
401
466
  buildRetentionExport,
467
+ createDefaultActionControlManifest,
468
+ findActionControl,
469
+ validateActionControlManifest,
402
470
  };
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.6.0",
3
+ "version": "0.8.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",
@@ -15,18 +16,67 @@
15
16
  "./adapters/k8s": "./adapters/k8s.js",
16
17
  "./adapters/terraform": "./adapters/terraform.js",
17
18
  "./adapters/gcp": "./adapters/gcp.js",
19
+ "./adapters/vercel": "./adapters/vercel.js",
20
+ "./adapters/cloudflare": "./adapters/cloudflare.js",
21
+ "./adapters/linear": "./adapters/linear.js",
22
+ "./adapters/jira": "./adapters/jira.js",
23
+ "./adapters/salesforce": "./adapters/salesforce.js",
18
24
  "./key-registry": "./key-registry.js",
19
25
  "./retention": "./retention.js",
20
26
  "./package.json": "./package.json"
21
27
  },
22
- "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", "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
+ ],
23
61
  "scripts": {
24
62
  "test": "node --test",
25
63
  "demo": "node demo.mjs",
26
- "eg1": "node eg1.mjs"
64
+ "eg1": "node eg1.mjs",
65
+ "cf1": "node cf1.mjs"
27
66
  },
28
67
  "dependencies": {
29
- "@emilia-protocol/require-receipt": "^0.4.1"
68
+ "@emilia-protocol/require-receipt": "^0.5.0"
30
69
  },
31
- "keywords": ["emilia", "authorization", "receipt", "firewall", "trusted-action-firewall", "agent", "mcp", "gate", "policy-enforcement-point", "ai-safety"]
70
+ "keywords": [
71
+ "emilia",
72
+ "authorization",
73
+ "receipt",
74
+ "firewall",
75
+ "trusted-action-firewall",
76
+ "agent",
77
+ "mcp",
78
+ "gate",
79
+ "policy-enforcement-point",
80
+ "ai-safety"
81
+ ]
32
82
  }