@emilia-protocol/gate 0.1.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +218 -13
- package/action-packs.js +141 -0
- package/adapters/_kit.js +63 -0
- package/adapters/aws.js +98 -0
- package/adapters/cloudflare.js +55 -0
- package/adapters/gcp.js +71 -0
- package/adapters/github-demo.mjs +57 -0
- package/adapters/github.js +110 -0
- package/adapters/jira.js +55 -0
- package/adapters/k8s.js +71 -0
- package/adapters/linear.js +55 -0
- package/adapters/salesforce.js +55 -0
- package/adapters/stripe.js +82 -0
- package/adapters/supabase.js +100 -0
- package/adapters/terraform.js +60 -0
- package/adapters/vercel.js +56 -0
- package/custody-demo.mjs +41 -0
- package/demo.mjs +57 -0
- package/eg1-conformance.js +206 -0
- package/eg1.mjs +40 -0
- package/execution-binding.js +98 -0
- package/index.js +184 -25
- package/key-registry.js +103 -0
- package/mcp.js +100 -0
- package/package.json +25 -5
- package/reliance-packet.js +65 -0
- package/retention.js +85 -0
- package/store.js +75 -3
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* EMILIA Gate — Supabase / Postgres System-of-Record adapter.
|
|
4
|
+
*
|
|
5
|
+
* "Install this before your agent can run destructive SQL." Wraps the dangerous
|
|
6
|
+
* database operations — destructive SQL (DELETE/DROP/TRUNCATE/UPDATE), bulk
|
|
7
|
+
* export, and RLS-policy change — so they never execute without a valid,
|
|
8
|
+
* sufficiently-assured, non-replayed receipt bound to THIS statement/table. The
|
|
9
|
+
* exact statement is bound by hash, so an approval for one DELETE cannot be
|
|
10
|
+
* swapped for a DROP.
|
|
11
|
+
*
|
|
12
|
+
* import { createGate } from '@emilia-protocol/gate';
|
|
13
|
+
* import { createSupabaseManifest, guardSupabaseMutation, isDestructiveSql } from '@emilia-protocol/gate/adapters/supabase';
|
|
14
|
+
*
|
|
15
|
+
* const gate = createGate({ manifest: createSupabaseManifest(), trustedKeys: [ISSUER] });
|
|
16
|
+
* // client: anything with .query(sql) (node-postgres / a Supabase RPC wrapper).
|
|
17
|
+
* await guardSupabaseMutation(gate, client, {
|
|
18
|
+
* op: 'sql.destructive', params: { sql: 'DELETE FROM payments WHERE id=1', table: 'payments' }, receipt,
|
|
19
|
+
* });
|
|
20
|
+
*/
|
|
21
|
+
import { createAdapter, manifestFromPack, hashCanonical } from './_kit.js';
|
|
22
|
+
|
|
23
|
+
const DESTRUCTIVE = /\b(delete|drop|truncate|alter\s+table)\b/i;
|
|
24
|
+
const UPDATE_NO_WHERE = /\bupdate\b(?:(?!\bwhere\b).)*$/is;
|
|
25
|
+
|
|
26
|
+
/** Heuristic: is this SQL destructive (DELETE/DROP/TRUNCATE/ALTER, or UPDATE without WHERE)? */
|
|
27
|
+
export function isDestructiveSql(sql) {
|
|
28
|
+
const s = String(sql || '');
|
|
29
|
+
return DESTRUCTIVE.test(s) || UPDATE_NO_WHERE.test(s.trim());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Canonical hash of a SQL statement, whitespace-collapsed and lowercased. */
|
|
33
|
+
export function statementHash(sql) {
|
|
34
|
+
return hashCanonical(String(sql || '').replace(/\s+/g, ' ').trim().toLowerCase());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const SUPABASE_ACTION_PACK = Object.freeze([
|
|
38
|
+
Object.freeze({
|
|
39
|
+
id: 'supabase.sql.destructive', label: 'Destructive SQL', action_type: 'supabase.sql.destructive',
|
|
40
|
+
risk: 'critical', receipt_required: true, assurance_class: 'class_a',
|
|
41
|
+
match: { protocol: 'supabase', tool: 'execute_sql' },
|
|
42
|
+
why: 'DELETE/DROP/TRUNCATE/ALTER destroys or rewrites system-of-record state. Bind the exact statement.',
|
|
43
|
+
execution_binding: { required_fields: ['action_type', 'statement_hash'] },
|
|
44
|
+
}),
|
|
45
|
+
Object.freeze({
|
|
46
|
+
id: 'supabase.data.export', label: 'Bulk data export', action_type: 'supabase.data.export',
|
|
47
|
+
risk: 'high', receipt_required: true, assurance_class: 'class_a',
|
|
48
|
+
match: { protocol: 'supabase', tool: 'export_table' },
|
|
49
|
+
why: 'Moves data out of its system of record. Bind table + recipient to the approval.',
|
|
50
|
+
execution_binding: { required_fields: ['action_type', 'table', 'recipient'] },
|
|
51
|
+
}),
|
|
52
|
+
Object.freeze({
|
|
53
|
+
id: 'supabase.rls.change', label: 'RLS policy change', action_type: 'supabase.rls.change',
|
|
54
|
+
risk: 'critical', receipt_required: true, assurance_class: 'quorum',
|
|
55
|
+
match: { protocol: 'supabase', tool: 'alter_policy' },
|
|
56
|
+
why: 'Changes who can read/write rows. Row-Level-Security changes deserve the two-person rule.',
|
|
57
|
+
execution_binding: { required_fields: ['action_type', 'table', 'policy'] },
|
|
58
|
+
}),
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const OPS = {
|
|
62
|
+
'sql.destructive': {
|
|
63
|
+
selector: { protocol: 'supabase', tool: 'execute_sql' },
|
|
64
|
+
observed: (p) => ({ action_type: 'supabase.sql.destructive', statement_hash: statementHash(p.sql) }),
|
|
65
|
+
perform: (client, p) => client.query(p.sql),
|
|
66
|
+
},
|
|
67
|
+
'data.export': {
|
|
68
|
+
selector: { protocol: 'supabase', tool: 'export_table' },
|
|
69
|
+
observed: (p) => ({ action_type: 'supabase.data.export', table: p.table, recipient: p.recipient }),
|
|
70
|
+
perform: (client, p) => client.export(p.table, p.recipient),
|
|
71
|
+
},
|
|
72
|
+
'rls.change': {
|
|
73
|
+
selector: { protocol: 'supabase', tool: 'alter_policy' },
|
|
74
|
+
observed: (p) => ({ action_type: 'supabase.rls.change', table: p.table, policy: p.policy }),
|
|
75
|
+
perform: (client, p) => client.alterPolicy(p.table, p.policy, p.definition),
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const adapter = createAdapter({ system: 'supabase', ops: OPS });
|
|
80
|
+
export const SUPABASE_OPS = adapter.OPS;
|
|
81
|
+
|
|
82
|
+
export function createSupabaseManifest(extraActions = []) {
|
|
83
|
+
return manifestFromPack(SUPABASE_ACTION_PACK, extraActions);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Guard a destructive Supabase/Postgres mutation behind the gate.
|
|
88
|
+
* @param {object} gate a gate built with createSupabaseManifest()
|
|
89
|
+
* @param {object} client a client exposing { query(sql), export(table,recipient), alterPolicy(table,policy,def) }
|
|
90
|
+
* @param {object} o { op:'sql.destructive'|'data.export'|'rls.change', params, receipt }
|
|
91
|
+
* @throws Error{code:'EMILIA_RECEIPT_REQUIRED'} if refused — the statement never executes
|
|
92
|
+
*/
|
|
93
|
+
export function guardSupabaseMutation(gate, client, args) {
|
|
94
|
+
return adapter.guard(gate, client, args);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export default {
|
|
98
|
+
SUPABASE_ACTION_PACK, SUPABASE_OPS, createSupabaseManifest, guardSupabaseMutation,
|
|
99
|
+
isDestructiveSql, statementHash,
|
|
100
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* EMILIA Gate — Terraform System-of-Record adapter.
|
|
4
|
+
*
|
|
5
|
+
* "Install this before your agent can destroy infrastructure." Guards the
|
|
6
|
+
* irreversible Terraform operations — destroy, state rm, workspace delete — so
|
|
7
|
+
* they never run without a receipt bound to THIS workspace/plan. The destroy
|
|
8
|
+
* plan is bound by hash, so an approved small destroy cannot be swapped for a
|
|
9
|
+
* full teardown. Runner is injected (a `terraform`/`tofu` CLI wrapper or Cloud
|
|
10
|
+
* API client).
|
|
11
|
+
*/
|
|
12
|
+
import { createAdapter, manifestFromPack } from './_kit.js';
|
|
13
|
+
|
|
14
|
+
export const TERRAFORM_ACTION_PACK = Object.freeze([
|
|
15
|
+
Object.freeze({
|
|
16
|
+
id: 'terraform.apply.destroy', label: 'Terraform destroy', action_type: 'terraform.apply.destroy',
|
|
17
|
+
risk: 'critical', receipt_required: true, assurance_class: 'quorum',
|
|
18
|
+
match: { protocol: 'terraform', tool: 'destroy' },
|
|
19
|
+
why: 'Tears down real infrastructure. Bind the workspace + plan hash; quorum.',
|
|
20
|
+
execution_binding: { required_fields: ['action_type', 'workspace', 'plan_hash'] },
|
|
21
|
+
}),
|
|
22
|
+
Object.freeze({
|
|
23
|
+
id: 'terraform.state.rm', label: 'Terraform state rm', action_type: 'terraform.state.rm',
|
|
24
|
+
risk: 'high', receipt_required: true, assurance_class: 'class_a',
|
|
25
|
+
match: { protocol: 'terraform', tool: 'state_rm' },
|
|
26
|
+
why: 'Detaches a resource from state (orphans real infra). Bind workspace+address.',
|
|
27
|
+
execution_binding: { required_fields: ['action_type', 'workspace', 'address'] },
|
|
28
|
+
}),
|
|
29
|
+
Object.freeze({
|
|
30
|
+
id: 'terraform.workspace.delete', label: 'Terraform workspace delete', action_type: 'terraform.workspace.delete',
|
|
31
|
+
risk: 'high', receipt_required: true, assurance_class: 'class_a',
|
|
32
|
+
match: { protocol: 'terraform', tool: 'workspace_delete' },
|
|
33
|
+
why: 'Deletes a workspace and its state. Bind the workspace.',
|
|
34
|
+
execution_binding: { required_fields: ['action_type', 'workspace'] },
|
|
35
|
+
}),
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const OPS = {
|
|
39
|
+
'apply.destroy': {
|
|
40
|
+
selector: { protocol: 'terraform', tool: 'destroy' },
|
|
41
|
+
observed: (p) => ({ action_type: 'terraform.apply.destroy', workspace: p.workspace, plan_hash: p.plan_hash }),
|
|
42
|
+
perform: (r, p) => r.destroy({ workspace: p.workspace, plan_hash: p.plan_hash }),
|
|
43
|
+
},
|
|
44
|
+
'state.rm': {
|
|
45
|
+
selector: { protocol: 'terraform', tool: 'state_rm' },
|
|
46
|
+
observed: (p) => ({ action_type: 'terraform.state.rm', workspace: p.workspace, address: p.address }),
|
|
47
|
+
perform: (r, p) => r.stateRm({ workspace: p.workspace, address: p.address }),
|
|
48
|
+
},
|
|
49
|
+
'workspace.delete': {
|
|
50
|
+
selector: { protocol: 'terraform', tool: 'workspace_delete' },
|
|
51
|
+
observed: (p) => ({ action_type: 'terraform.workspace.delete', workspace: p.workspace }),
|
|
52
|
+
perform: (r, p) => r.workspaceDelete({ workspace: p.workspace }),
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const adapter = createAdapter({ system: 'terraform', ops: OPS });
|
|
57
|
+
export const TERRAFORM_OPS = adapter.OPS;
|
|
58
|
+
export function createTerraformManifest(extraActions = []) { return manifestFromPack(TERRAFORM_ACTION_PACK, extraActions); }
|
|
59
|
+
export function guardTerraformMutation(gate, runner, args) { return adapter.guard(gate, runner, args); }
|
|
60
|
+
export default { TERRAFORM_ACTION_PACK, TERRAFORM_OPS, createTerraformManifest, guardTerraformMutation };
|
|
@@ -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 };
|
package/custody-demo.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EMILIA Gate — production custody demo. Run: node custody-demo.mjs
|
|
3
|
+
*
|
|
4
|
+
* Shows the three custody controls a serious buyer asks for:
|
|
5
|
+
* 1. ROTATION — a new issuer key takes over without breaking the old one.
|
|
6
|
+
* 2. REVOCATION — a compromised issuer key is rejected immediately, live.
|
|
7
|
+
* 3. RETENTION — the evidence log classifies hot/cold/expired + exports.
|
|
8
|
+
* @license Apache-2.0
|
|
9
|
+
*/
|
|
10
|
+
import { createGate, createKeyRegistry, createEg1Harness } from './index.js';
|
|
11
|
+
import { DEFAULT_GATE_MANIFEST } from './action-packs.js';
|
|
12
|
+
|
|
13
|
+
const SEL = { protocol: 'mcp', tool: 'release_payment' };
|
|
14
|
+
const k1 = createEg1Harness({ idPrefix: 'k1' });
|
|
15
|
+
const k2 = createEg1Harness({ idPrefix: 'k2' });
|
|
16
|
+
const registry = createKeyRegistry([{ kid: 'issuer-1', key: k1.publicKey }]);
|
|
17
|
+
const gate = createGate({ manifest: DEFAULT_GATE_MANIFEST, keyRegistry: registry });
|
|
18
|
+
|
|
19
|
+
const G = (s) => `\x1b[32m${s}\x1b[0m`; const R = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
20
|
+
const line = (s) => console.log(s);
|
|
21
|
+
async function pay(label, harness) {
|
|
22
|
+
const out = await gate.run({ selector: SEL, receipt: harness.mint({ outcome: 'allow_with_signoff' }), observedAction: harness.action }, async () => ({ wire: 'ok' }));
|
|
23
|
+
line(` ${label} -> ${out.ok ? G('ALLOWED') : R(`REFUSED (${out.authorization.reason})`)}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
line('='.repeat(64));
|
|
27
|
+
line(' EMILIA Gate — production custody: rotate, revoke, retain');
|
|
28
|
+
line('='.repeat(64));
|
|
29
|
+
await pay('1. payout, issuer-1 (current) ', k1);
|
|
30
|
+
registry.revoke('issuer-1');
|
|
31
|
+
line(' ↳ issuer-1 reported COMPROMISED — registry.revoke("issuer-1")');
|
|
32
|
+
await pay('2. payout, issuer-1 (revoked) ', k1);
|
|
33
|
+
registry.add({ kid: 'issuer-2', key: k2.publicKey });
|
|
34
|
+
line(' ↳ rotated in issuer-2 — registry.add(issuer-2)');
|
|
35
|
+
await pay('3. payout, issuer-2 (rotated in) ', k2);
|
|
36
|
+
|
|
37
|
+
const exp = gate.retentionExport();
|
|
38
|
+
line(' ' + '-'.repeat(60));
|
|
39
|
+
line(` retention: ${exp.counts.total} evidence entries (hot ${exp.counts.hot} / cold ${exp.counts.cold} / expired ${exp.counts.expired}), head ${String(exp.evidence_head).slice(0, 12)}…`);
|
|
40
|
+
line(' Revoke a leaked issuer key live. No redeploy. Every decision retained + provable.');
|
|
41
|
+
line('='.repeat(64));
|
package/demo.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EMILIA Gate — end-to-end demo. Run: node demo.mjs
|
|
3
|
+
* Shows the Trusted Action Firewall refusing and allowing a consequential
|
|
4
|
+
* action, defeating replay and tampering, and emitting a verifiable audit log.
|
|
5
|
+
* @license Apache-2.0
|
|
6
|
+
*/
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import { createTrustedActionFirewall } from './index.js';
|
|
9
|
+
|
|
10
|
+
const canon = (v) => v == null ? JSON.stringify(v)
|
|
11
|
+
: Array.isArray(v) ? `[${v.map(canon).join(',')}]`
|
|
12
|
+
: typeof v === 'object' ? `{${Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canon(v[k])).join(',')}}`
|
|
13
|
+
: JSON.stringify(v);
|
|
14
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
|
15
|
+
const pub = publicKey.export({ type: 'spki', format: 'der' }).toString('base64url');
|
|
16
|
+
let n = 0;
|
|
17
|
+
const action = {
|
|
18
|
+
action_type: 'payment.release',
|
|
19
|
+
amount_usd: 40000,
|
|
20
|
+
currency: 'USD',
|
|
21
|
+
payment_instruction_id: 'pi_demo_40000',
|
|
22
|
+
beneficiary_account_hash: 'sha256:demo-beneficiary',
|
|
23
|
+
};
|
|
24
|
+
const receipt = (outcome, extra = {}) => {
|
|
25
|
+
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 } };
|
|
26
|
+
return { '@version': 'EP-RECEIPT-v1', payload, signature: { algorithm: 'Ed25519', value: crypto.sign(null, Buffer.from(canon(payload), 'utf8'), privateKey).toString('base64url') } };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const gate = createTrustedActionFirewall({ trustedKeys: [pub] });
|
|
30
|
+
const PAY = { protocol: 'mcp', tool: 'release_payment' };
|
|
31
|
+
const line = (s) => console.log(s);
|
|
32
|
+
const r = (o) => o.allow ? `\x1b[32mALLOW\x1b[0m` : `\x1b[31mREFUSE ${o.status}\x1b[0m (${o.reason})`;
|
|
33
|
+
|
|
34
|
+
line('='.repeat(64));
|
|
35
|
+
line(' EMILIA Gate — Trusted Action Firewall (release_payment, critical)');
|
|
36
|
+
line('='.repeat(64));
|
|
37
|
+
line(`\n 1. read_status (not guarded) -> ${r(await gate.check({ selector: { protocol: 'mcp', tool: 'read_status' } }))}`);
|
|
38
|
+
line(` 2. release_payment, no receipt -> ${r(await gate.check({ selector: PAY }))}`);
|
|
39
|
+
line(` 3. release_payment, software receipt -> ${r(await gate.check({ selector: PAY, receipt: receipt('allow'), observedAction: action }))} (needs class_a)`);
|
|
40
|
+
const good = receipt('allow_with_signoff');
|
|
41
|
+
line(` 4. release_payment, observed drift -> ${r(await gate.check({ selector: PAY, receipt: good, observedAction: { ...action, amount_usd: 999999 } }))}`);
|
|
42
|
+
const run = await gate.run({ selector: PAY, receipt: good, observedAction: action }, async () => ({ released: true, id: 'wire_123' }));
|
|
43
|
+
const a4 = run.authorization;
|
|
44
|
+
line(` 5. release_payment, class_a + bound -> ${r(a4)}`);
|
|
45
|
+
const exec = run.execution;
|
|
46
|
+
line(` ↳ executed -> execution receipt bound to decision ${exec.authorizes_decision.slice(0, 12)}…`);
|
|
47
|
+
line(` 6. release_payment, SAME receipt again -> ${r(await gate.check({ selector: PAY, receipt: good, observedAction: action }))}`);
|
|
48
|
+
const bad = receipt('allow_with_signoff'); bad.payload.claim.amount_usd = 9_999_999;
|
|
49
|
+
line(` 7. release_payment, tampered amount -> ${r(await gate.check({ selector: PAY, receipt: bad, observedAction: action }))}`);
|
|
50
|
+
|
|
51
|
+
const chain = gate.evidence.verify();
|
|
52
|
+
const packet = run.packet;
|
|
53
|
+
line(`\n reliance packet: ${packet.verdict.toUpperCase()} decision=${packet.summary.decision_hash.slice(0, 12)}… execution=${packet.summary.execution_hash.slice(0, 12)}…`);
|
|
54
|
+
line(` evidence log: ${gate.evidence.all().length} decisions, chain ${chain.ok ? '\x1b[32mINTACT\x1b[0m' : '\x1b[31mBROKEN\x1b[0m'}`);
|
|
55
|
+
line(' ' + '-'.repeat(60));
|
|
56
|
+
line(' Deny by default. No receipt, no execution. Every decision is provable.');
|
|
57
|
+
line('='.repeat(64));
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* EG-1 Conformance — the binary artifact that turns "we adopted EMILIA Gate"
|
|
4
|
+
* from a claim into a test result.
|
|
5
|
+
*
|
|
6
|
+
* The question EG-1 answers: *does your integration actually ENFORCE the gate,
|
|
7
|
+
* or are you only claiming it?* An integration earns EG-1 only if it
|
|
8
|
+
* demonstrably:
|
|
9
|
+
* 1. refuses a high-risk action with NO receipt (428);
|
|
10
|
+
* 2. refuses a software-tier receipt on a Class-A action;
|
|
11
|
+
* 3. refuses when the observed execution drifts from the authorized fields;
|
|
12
|
+
* 4. RUNS the action for a valid Class-A/quorum receipt;
|
|
13
|
+
* 5. refuses a replay of the same receipt;
|
|
14
|
+
* 6. refuses a tampered receipt;
|
|
15
|
+
* 7. emits an execution proof bound to the authorization decision;
|
|
16
|
+
* 8. produces a reliance packet whose verdict is "rely".
|
|
17
|
+
*
|
|
18
|
+
* This module is pure (no dependency on index.js, so no import cycle): it owns a
|
|
19
|
+
* throwaway issuer keypair, mints the scenario receipts, and drives any
|
|
20
|
+
* "subject" through the eight checks. A subject is an async `invoke` function
|
|
21
|
+
* representing one attempt at the guarded dangerous action:
|
|
22
|
+
*
|
|
23
|
+
* invoke({ receipt, observedAction }) -> {
|
|
24
|
+
* allowed: boolean, status: number, reason: string,
|
|
25
|
+
* // present only on an allowed run:
|
|
26
|
+
* decisionHash?: string, execution?: { authorizes_decision }, packet?: { verdict }
|
|
27
|
+
* }
|
|
28
|
+
*
|
|
29
|
+
* For integrations built on @emilia-protocol/gate, `makeGateInvoke(gate, ...)`
|
|
30
|
+
* produces a conformant `invoke` from `gate.run()`. Custom integrations (an HTTP
|
|
31
|
+
* service, a different language) implement `invoke` themselves and configure
|
|
32
|
+
* their gate to trust `harness.publicKey` for the run.
|
|
33
|
+
*/
|
|
34
|
+
import crypto from 'node:crypto';
|
|
35
|
+
|
|
36
|
+
export const EG1_VERSION = 'EG-1';
|
|
37
|
+
|
|
38
|
+
// Same sorted-key canonical JSON the receipt signature is computed over.
|
|
39
|
+
const canon = (v) => (v == null ? JSON.stringify(v)
|
|
40
|
+
: Array.isArray(v) ? `[${v.map(canon).join(',')}]`
|
|
41
|
+
: typeof v === 'object' ? `{${Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canon(v[k])).join(',')}}`
|
|
42
|
+
: JSON.stringify(v));
|
|
43
|
+
|
|
44
|
+
// The default high-risk action EG-1 exercises: a Class-A money movement, which
|
|
45
|
+
// the default gate manifest guards (selector { protocol:'mcp', tool:'release_payment' }).
|
|
46
|
+
export const EG1_DEFAULT_SELECTOR = Object.freeze({ protocol: 'mcp', tool: 'release_payment' });
|
|
47
|
+
export const EG1_DEFAULT_ACTION = Object.freeze({
|
|
48
|
+
action_type: 'payment.release',
|
|
49
|
+
amount_usd: 40000,
|
|
50
|
+
currency: 'USD',
|
|
51
|
+
payment_instruction_id: 'pi_eg1_40000',
|
|
52
|
+
beneficiary_account_hash: 'sha256:eg1-beneficiary',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export const EG1_CHECKS = Object.freeze([
|
|
56
|
+
{ id: 'missing_receipt_refused', title: 'missing receipt → 428' },
|
|
57
|
+
{ id: 'software_on_classA_refused', title: 'software receipt on Class-A action → refused' },
|
|
58
|
+
{ id: 'execution_drift_refused', title: 'observed execution drift → refused' },
|
|
59
|
+
{ id: 'valid_classA_runs', title: 'valid Class-A/quorum receipt → runs' },
|
|
60
|
+
{ id: 'replay_refused', title: 'same receipt replay → refused' },
|
|
61
|
+
{ id: 'tampered_refused', title: 'tampered receipt → refused' },
|
|
62
|
+
{ id: 'execution_proof_binds', title: 'execution proof binds to authorization decision' },
|
|
63
|
+
{ id: 'reliance_packet_rely', title: 'reliance packet returns verdict "rely"' },
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Create an EG-1 harness: a throwaway issuer key + a receipt minter for the
|
|
68
|
+
* scenarios. Configure the subject's gate to trust `publicKey` for the run.
|
|
69
|
+
*/
|
|
70
|
+
export function createEg1Harness({ now = Date.now, action = EG1_DEFAULT_ACTION, idPrefix = 'eg1' } = {}) {
|
|
71
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
|
72
|
+
const pub = publicKey.export({ type: 'spki', format: 'der' }).toString('base64url');
|
|
73
|
+
let counter = 0;
|
|
74
|
+
const nowMs = () => (typeof now === 'function' ? now() : now);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Mint a scenario receipt.
|
|
78
|
+
* @param {object} o
|
|
79
|
+
* @param {'allow'|'allow_with_signoff'} [o.outcome] 'allow'=software, 'allow_with_signoff'=Class-A
|
|
80
|
+
* @param {object} [o.quorum] a quorum block (signers/threshold) -> quorum tier
|
|
81
|
+
* @param {object} [o.tamper] fields assigned to the claim AFTER signing (breaks the signature)
|
|
82
|
+
*/
|
|
83
|
+
function mint({ outcome = 'allow_with_signoff', quorum = null, tamper = null, extra = {} } = {}) {
|
|
84
|
+
const payload = {
|
|
85
|
+
receipt_id: `${idPrefix}_${++counter}`,
|
|
86
|
+
subject: 'agent:eg1-conformance',
|
|
87
|
+
issuer: 'ep:org:eg1',
|
|
88
|
+
created_at: new Date(nowMs()).toISOString(),
|
|
89
|
+
claim: {
|
|
90
|
+
...action,
|
|
91
|
+
outcome,
|
|
92
|
+
approver: 'ep:approver:eg1',
|
|
93
|
+
...(quorum ? { quorum } : {}),
|
|
94
|
+
...extra,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
const value = crypto.sign(null, Buffer.from(canon(payload), 'utf8'), privateKey).toString('base64url');
|
|
98
|
+
const receipt = { '@version': 'EP-RECEIPT-v1', payload, signature: { algorithm: 'Ed25519', value } };
|
|
99
|
+
if (tamper) Object.assign(receipt.payload.claim, tamper); // tamper AFTER signing -> signature no longer binds
|
|
100
|
+
return receipt;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { publicKey: pub, mint, action, now: nowMs };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Adapt an @emilia-protocol/gate instance into an EG-1 `invoke`. The gate must
|
|
108
|
+
* have been built trusting the harness public key. Uses gate.run() so the
|
|
109
|
+
* execution proof + reliance packet are produced on the allowed path.
|
|
110
|
+
*/
|
|
111
|
+
export function makeGateInvoke(gate, { selector = EG1_DEFAULT_SELECTOR, action = EG1_DEFAULT_ACTION } = {}) {
|
|
112
|
+
if (!gate || typeof gate.run !== 'function') {
|
|
113
|
+
throw new Error('makeGateInvoke requires an EMILIA Gate instance (with .run)');
|
|
114
|
+
}
|
|
115
|
+
return async ({ receipt, observedAction }) => {
|
|
116
|
+
const out = await gate.run(
|
|
117
|
+
{ selector, receipt, observedAction: observedAction ?? action },
|
|
118
|
+
async () => ({ eg1: 'side-effect-ran' }),
|
|
119
|
+
);
|
|
120
|
+
if (!out.ok) {
|
|
121
|
+
return { allowed: false, status: out.status, reason: out.authorization?.reason ?? 'refused' };
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
allowed: true,
|
|
125
|
+
status: 200,
|
|
126
|
+
reason: out.authorization?.reason ?? 'allow',
|
|
127
|
+
decisionHash: out.authorization?.evidence?.hash ?? null,
|
|
128
|
+
execution: out.execution ?? null,
|
|
129
|
+
packet: out.packet ?? null,
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const pick = (r) => ({ allowed: !!r.allowed, status: r.status ?? null, reason: r.reason ?? null });
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Drive a subject through the eight EG-1 checks and return a JSON report.
|
|
138
|
+
* @param {object} o
|
|
139
|
+
* @param {(scenario:object)=>Promise<object>} o.invoke the integration under test
|
|
140
|
+
* @param {object} o.harness from createEg1Harness()
|
|
141
|
+
* @param {object} [o.action] the high-risk action (defaults to the harness action)
|
|
142
|
+
*/
|
|
143
|
+
export async function runEg1({ invoke, harness, action } = {}) {
|
|
144
|
+
if (typeof invoke !== 'function') throw new Error('runEg1 requires an invoke(scenario) function');
|
|
145
|
+
if (!harness || typeof harness.mint !== 'function') throw new Error('runEg1 requires a harness from createEg1Harness()');
|
|
146
|
+
const act = action || harness.action || EG1_DEFAULT_ACTION;
|
|
147
|
+
const observed = act;
|
|
148
|
+
const drift = { ...act, amount_usd: Number(act.amount_usd ?? 0) + 1 };
|
|
149
|
+
|
|
150
|
+
const results = {};
|
|
151
|
+
const set = (id, pass, observed_) => { results[id] = { pass: !!pass, observed: observed_ }; };
|
|
152
|
+
|
|
153
|
+
// 1. missing receipt → 428
|
|
154
|
+
let r = await invoke({ receipt: null, observedAction: observed });
|
|
155
|
+
set('missing_receipt_refused', !r.allowed && r.status === 428, pick(r));
|
|
156
|
+
|
|
157
|
+
// 2. software receipt on a Class-A action → refused
|
|
158
|
+
r = await invoke({ receipt: harness.mint({ outcome: 'allow' }), observedAction: observed });
|
|
159
|
+
set('software_on_classA_refused', !r.allowed && /assurance/i.test(r.reason || ''), pick(r));
|
|
160
|
+
|
|
161
|
+
// 3. observed execution drift → refused
|
|
162
|
+
r = await invoke({ receipt: harness.mint({ outcome: 'allow_with_signoff' }), observedAction: drift });
|
|
163
|
+
set('execution_drift_refused', !r.allowed && /binding/i.test(r.reason || ''), pick(r));
|
|
164
|
+
|
|
165
|
+
// 4. valid Class-A receipt → runs (capture for 5/7/8)
|
|
166
|
+
const valid = harness.mint({ outcome: 'allow_with_signoff' });
|
|
167
|
+
r = await invoke({ receipt: valid, observedAction: observed });
|
|
168
|
+
const validAllowed = r.allowed === true;
|
|
169
|
+
set('valid_classA_runs', validAllowed, pick(r));
|
|
170
|
+
|
|
171
|
+
// 7. execution proof binds to the authorization decision
|
|
172
|
+
const boundOk = validAllowed
|
|
173
|
+
&& !!r.execution && !!r.execution.authorizes_decision
|
|
174
|
+
&& !!r.decisionHash && r.execution.authorizes_decision === r.decisionHash;
|
|
175
|
+
set('execution_proof_binds', boundOk, {
|
|
176
|
+
authorizes_decision: r.execution?.authorizes_decision ?? null,
|
|
177
|
+
decision_hash: r.decisionHash ?? null,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// 8. reliance packet verdict "rely"
|
|
181
|
+
set('reliance_packet_rely', validAllowed && String(r.packet?.verdict || '').toLowerCase() === 'rely', {
|
|
182
|
+
verdict: r.packet?.verdict ?? null,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// 5. same receipt replay → refused
|
|
186
|
+
r = await invoke({ receipt: valid, observedAction: observed });
|
|
187
|
+
set('replay_refused', !r.allowed && /replay/i.test(r.reason || ''), pick(r));
|
|
188
|
+
|
|
189
|
+
// 6. tampered receipt → refused
|
|
190
|
+
r = await invoke({ receipt: harness.mint({ outcome: 'allow_with_signoff', tamper: { amount_usd: 9_999_999 } }), observedAction: observed });
|
|
191
|
+
set('tampered_refused', !r.allowed && r.status === 428, pick(r));
|
|
192
|
+
|
|
193
|
+
const checks = EG1_CHECKS.map((c) => ({ id: c.id, title: c.title, ...results[c.id] }));
|
|
194
|
+
const passedCount = checks.filter((c) => c.pass).length;
|
|
195
|
+
const passed = passedCount === checks.length;
|
|
196
|
+
return {
|
|
197
|
+
standard: EG1_VERSION,
|
|
198
|
+
passed,
|
|
199
|
+
badge: passed ? 'EG-1 Enforced' : 'EG-1 not earned',
|
|
200
|
+
summary: { passed: passedCount, total: checks.length },
|
|
201
|
+
checks,
|
|
202
|
+
generated_at: new Date(harness.now ? harness.now() : Date.now()).toISOString(),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export default { EG1_VERSION, EG1_CHECKS, EG1_DEFAULT_ACTION, EG1_DEFAULT_SELECTOR, createEg1Harness, makeGateInvoke, runEg1 };
|
package/eg1.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EG-1 Conformance — runnable proof. Run: node eg1.mjs [--json]
|
|
3
|
+
*
|
|
4
|
+
* Self-certifies the reference EMILIA Gate against the eight EG-1 checks. An
|
|
5
|
+
* adopter earns EG-1 by pointing this harness at THEIR integration: build your
|
|
6
|
+
* gate trusting `harness.publicKey`, pass it to gateConformance(), and ship the
|
|
7
|
+
* JSON report + the "EG-1 Enforced" badge.
|
|
8
|
+
*
|
|
9
|
+
* import { createEg1Harness, gateConformance } from '@emilia-protocol/gate';
|
|
10
|
+
* const harness = createEg1Harness();
|
|
11
|
+
* const gate = createTrustedActionFirewall({ trustedKeys: [harness.publicKey] });
|
|
12
|
+
* const report = await gateConformance({ gate, harness });
|
|
13
|
+
*
|
|
14
|
+
* Exit code is 0 only if all eight checks pass — CI-friendly.
|
|
15
|
+
* @license Apache-2.0
|
|
16
|
+
*/
|
|
17
|
+
import { gateConformanceSelfTest } from './index.js';
|
|
18
|
+
|
|
19
|
+
const report = await gateConformanceSelfTest();
|
|
20
|
+
|
|
21
|
+
if (process.argv.includes('--json')) {
|
|
22
|
+
// eslint-disable-next-line no-console
|
|
23
|
+
console.log(JSON.stringify(report, null, 2));
|
|
24
|
+
process.exit(report.passed ? 0 : 1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const G = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
28
|
+
const R = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
29
|
+
const line = (s) => console.log(s);
|
|
30
|
+
|
|
31
|
+
line('='.repeat(64));
|
|
32
|
+
line(' EG-1 Conformance — does this integration ENFORCE EMILIA Gate?');
|
|
33
|
+
line('='.repeat(64));
|
|
34
|
+
for (const c of report.checks) {
|
|
35
|
+
line(` ${c.pass ? G('PASS') : R('FAIL')} ${c.title}`);
|
|
36
|
+
}
|
|
37
|
+
line(' ' + '-'.repeat(60));
|
|
38
|
+
line(` ${report.passed ? G(`✓ ${report.badge}`) : R(`✗ ${report.badge}`)} (${report.summary.passed}/${report.summary.total})`);
|
|
39
|
+
line('='.repeat(64));
|
|
40
|
+
process.exit(report.passed ? 0 : 1);
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Execution-field binding for EMILIA Gate. This is the executor-side control
|
|
3
|
+
// that prevents "the signed claim said X, the system mutated Y".
|
|
4
|
+
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
export const EXECUTION_BINDING_VERSION = 'EP-GATE-EXECUTION-BINDING-v1';
|
|
8
|
+
|
|
9
|
+
export function canonicalize(v) {
|
|
10
|
+
if (v === null || v === undefined) return JSON.stringify(v);
|
|
11
|
+
if (Array.isArray(v)) return `[${v.map(canonicalize).join(',')}]`;
|
|
12
|
+
if (typeof v === 'object') {
|
|
13
|
+
return `{${Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canonicalize(v[k])).join(',')}}`;
|
|
14
|
+
}
|
|
15
|
+
return JSON.stringify(v);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function hashCanonical(v) {
|
|
19
|
+
return crypto.createHash('sha256').update(canonicalize(v)).digest('hex');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function hasValue(v) {
|
|
23
|
+
return v !== undefined && v !== null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalize(v) {
|
|
27
|
+
if (Array.isArray(v)) return [...new Set(v.map((x) => String(x)))].sort();
|
|
28
|
+
if (v && typeof v === 'object') {
|
|
29
|
+
return Object.fromEntries(Object.keys(v).sort().map((k) => [k, normalize(v[k])]));
|
|
30
|
+
}
|
|
31
|
+
return v;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function equalValue(a, b) {
|
|
35
|
+
return hashCanonical(normalize(a)) === hashCanonical(normalize(b));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function materialFieldsFor(requirement) {
|
|
39
|
+
const fields = requirement?.execution_binding?.required_fields;
|
|
40
|
+
return Array.isArray(fields) ? [...new Set(fields.filter(Boolean))] : [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Verifies that the signed claim and the executor-observed mutation fields
|
|
45
|
+
* match for the action pack. The executor must pass `observedAction` from the
|
|
46
|
+
* system of record, not from the agent request body.
|
|
47
|
+
*/
|
|
48
|
+
export function verifyExecutionBinding({ requirement, receipt, observedAction } = {}) {
|
|
49
|
+
const requiredFields = materialFieldsFor(requirement);
|
|
50
|
+
if (requiredFields.length === 0) {
|
|
51
|
+
return { ok: true, required: false, required_fields: [], signed_hash: null, observed_hash: null };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const signed = receipt?.payload?.claim || {};
|
|
55
|
+
const observed = observedAction || {};
|
|
56
|
+
const missingSigned = [];
|
|
57
|
+
const missingObserved = [];
|
|
58
|
+
const mismatched = [];
|
|
59
|
+
const signedValues = {};
|
|
60
|
+
const observedValues = {};
|
|
61
|
+
|
|
62
|
+
for (const field of requiredFields) {
|
|
63
|
+
const expected = signed[field];
|
|
64
|
+
const actual = observed[field];
|
|
65
|
+
if (!hasValue(expected)) {
|
|
66
|
+
missingSigned.push(field);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
signedValues[field] = normalize(expected);
|
|
70
|
+
if (!hasValue(actual)) {
|
|
71
|
+
missingObserved.push(field);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
observedValues[field] = normalize(actual);
|
|
75
|
+
if (!equalValue(expected, actual)) mismatched.push(field);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
'@version': EXECUTION_BINDING_VERSION,
|
|
80
|
+
ok: missingSigned.length === 0 && missingObserved.length === 0 && mismatched.length === 0,
|
|
81
|
+
required: true,
|
|
82
|
+
required_fields: requiredFields,
|
|
83
|
+
missing_signed_fields: missingSigned,
|
|
84
|
+
missing_observed_fields: missingObserved,
|
|
85
|
+
mismatched_fields: mismatched,
|
|
86
|
+
signed_hash: hashCanonical(signedValues),
|
|
87
|
+
observed_hash: hashCanonical(observedValues),
|
|
88
|
+
note: 'Executor MUST provide observedAction from the system of record; request-body fields are not a trust source.',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export default {
|
|
93
|
+
EXECUTION_BINDING_VERSION,
|
|
94
|
+
canonicalize,
|
|
95
|
+
hashCanonical,
|
|
96
|
+
materialFieldsFor,
|
|
97
|
+
verifyExecutionBinding,
|
|
98
|
+
};
|