@emilia-protocol/gate 0.1.0 → 0.6.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/gcp.js +71 -0
- package/adapters/github-demo.mjs +57 -0
- package/adapters/github.js +110 -0
- package/adapters/k8s.js +71 -0
- package/adapters/stripe.js +82 -0
- package/adapters/supabase.js +100 -0
- package/adapters/terraform.js +60 -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 +20 -5
- package/reliance-packet.js +65 -0
- package/retention.js +85 -0
- package/store.js +75 -3
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EMILIA Gate × GitHub — the 60-second "oh shit" demo. Run: node adapters/github-demo.mjs
|
|
3
|
+
*
|
|
4
|
+
* An agent tries to delete a production repo. Without a receipt: refused, and the
|
|
5
|
+
* GitHub API is never called. With a valid human signoff bound to THIS repo: it
|
|
6
|
+
* runs and produces a reliance packet. Change the target repo after approval:
|
|
7
|
+
* refused (drift). Replay the receipt: refused. Uses a fake Octokit so it runs
|
|
8
|
+
* with no credentials; swap in `new Octokit({ auth })` for the real thing.
|
|
9
|
+
* @license Apache-2.0
|
|
10
|
+
*/
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import { createGate } from '../index.js';
|
|
13
|
+
import { createGithubManifest, guardGithubMutation } from './github.js';
|
|
14
|
+
|
|
15
|
+
const canon = (v) => (v == null ? JSON.stringify(v)
|
|
16
|
+
: Array.isArray(v) ? `[${v.map(canon).join(',')}]`
|
|
17
|
+
: typeof v === 'object' ? `{${Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canon(v[k])).join(',')}}`
|
|
18
|
+
: JSON.stringify(v));
|
|
19
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
|
20
|
+
const ISSUER = publicKey.export({ type: 'spki', format: 'der' }).toString('base64url');
|
|
21
|
+
let n = 0;
|
|
22
|
+
const mint = (extra = {}) => {
|
|
23
|
+
const payload = {
|
|
24
|
+
receipt_id: `gh_${++n}`, subject: 'agent:repo-bot', issuer: 'ep:org:demo', created_at: new Date().toISOString(),
|
|
25
|
+
claim: { action_type: 'github.repo.delete', owner: 'acme', repo: 'prod', outcome: 'allow_with_signoff', approver: 'ep:approver:cto', ...extra },
|
|
26
|
+
};
|
|
27
|
+
return { '@version': 'EP-RECEIPT-v1', payload, signature: { algorithm: 'Ed25519', value: crypto.sign(null, Buffer.from(canon(payload), 'utf8'), privateKey).toString('base64url') } };
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const octokit = {
|
|
31
|
+
repos: { delete: async (p) => { console.log(` !! GitHub API: repos.delete(${p.owner}/${p.repo}) EXECUTED`); return { status: 204 }; } },
|
|
32
|
+
};
|
|
33
|
+
const gate = createGate({ manifest: createGithubManifest(), trustedKeys: [ISSUER] });
|
|
34
|
+
const G = (s) => `\x1b[32m${s}\x1b[0m`; const R = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
35
|
+
const line = (s) => console.log(s);
|
|
36
|
+
|
|
37
|
+
async function attempt(label, params, receipt) {
|
|
38
|
+
try {
|
|
39
|
+
const { reliance } = await guardGithubMutation(gate, octokit, { op: 'repo.delete', params, receipt });
|
|
40
|
+
line(` ${label} -> ${G('ALLOWED')} reliance=${reliance.verdict.toUpperCase()}`);
|
|
41
|
+
} catch (e) {
|
|
42
|
+
line(` ${label} -> ${R(`REFUSED ${e.status || ''}`)} (${e.gate?.reason || e.message})`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
line('='.repeat(66));
|
|
47
|
+
line(' EMILIA Gate × GitHub — agent tries to delete the production repo');
|
|
48
|
+
line('='.repeat(66));
|
|
49
|
+
await attempt('1. delete acme/prod, no receipt ', { owner: 'acme', repo: 'prod' }, null);
|
|
50
|
+
const approval = mint();
|
|
51
|
+
await attempt('2. delete acme/prod, valid human signoff ', { owner: 'acme', repo: 'prod' }, approval);
|
|
52
|
+
await attempt('3. same receipt replayed ', { owner: 'acme', repo: 'prod' }, approval);
|
|
53
|
+
const reAppro = mint();
|
|
54
|
+
await attempt('4. approved acme/prod, but targets staging ', { owner: 'acme', repo: 'staging' }, reAppro);
|
|
55
|
+
line(' ' + '-'.repeat(62));
|
|
56
|
+
line(' No receipt, no mutation. The GitHub API is only reached on an allowed line.');
|
|
57
|
+
line('='.repeat(66));
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* EMILIA Gate — GitHub System-of-Record adapter.
|
|
4
|
+
*
|
|
5
|
+
* "Install this before your agent can touch production GitHub." Wraps destructive
|
|
6
|
+
* Octokit operations so the mutation NEVER reaches GitHub without a valid,
|
|
7
|
+
* sufficiently-assured, non-replayed human/quorum receipt bound to THIS repo —
|
|
8
|
+
* and on success returns a reliance packet proving exactly what was authorized
|
|
9
|
+
* and what executed.
|
|
10
|
+
*
|
|
11
|
+
* import { Octokit } from '@octokit/rest';
|
|
12
|
+
* import { createGate } from '@emilia-protocol/gate';
|
|
13
|
+
* import { createGithubManifest, guardGithubMutation } from '@emilia-protocol/gate/adapters/github';
|
|
14
|
+
*
|
|
15
|
+
* const gate = createGate({ manifest: createGithubManifest(), trustedKeys: [ISSUER] });
|
|
16
|
+
* const octokit = new Octokit({ auth });
|
|
17
|
+
* await guardGithubMutation(gate, octokit, {
|
|
18
|
+
* op: 'repo.delete', params: { owner, repo }, receipt, // throws if no valid receipt
|
|
19
|
+
* });
|
|
20
|
+
*
|
|
21
|
+
* The receipt's claim must carry the same owner/repo (and username/permission or
|
|
22
|
+
* branch) as the call — a receipt for repo A cannot authorize deleting repo B.
|
|
23
|
+
*/
|
|
24
|
+
import { createAdapter, manifestFromPack } from './_kit.js';
|
|
25
|
+
|
|
26
|
+
export const GITHUB_ACTION_PACK = Object.freeze([
|
|
27
|
+
Object.freeze({
|
|
28
|
+
id: 'github.repo.delete',
|
|
29
|
+
label: 'GitHub repo delete',
|
|
30
|
+
action_type: 'github.repo.delete',
|
|
31
|
+
risk: 'critical',
|
|
32
|
+
receipt_required: true,
|
|
33
|
+
assurance_class: 'class_a',
|
|
34
|
+
match: { protocol: 'github', tool: 'delete_repo' },
|
|
35
|
+
why: 'Destroys a repository and its history. Bind owner/repo to a named human approval.',
|
|
36
|
+
execution_binding: { required_fields: ['action_type', 'owner', 'repo'] },
|
|
37
|
+
}),
|
|
38
|
+
Object.freeze({
|
|
39
|
+
id: 'github.permission.change',
|
|
40
|
+
label: 'GitHub permission change',
|
|
41
|
+
action_type: 'github.permission.change',
|
|
42
|
+
risk: 'critical',
|
|
43
|
+
receipt_required: true,
|
|
44
|
+
assurance_class: 'quorum',
|
|
45
|
+
match: { protocol: 'github', tool: 'update_collaborator_permission' },
|
|
46
|
+
why: 'Changes who can act on the repo. Privilege change deserves the two-person rule.',
|
|
47
|
+
execution_binding: { required_fields: ['action_type', 'owner', 'repo', 'username', 'permission'] },
|
|
48
|
+
}),
|
|
49
|
+
Object.freeze({
|
|
50
|
+
id: 'github.branch_protection.remove',
|
|
51
|
+
label: 'GitHub branch-protection removal',
|
|
52
|
+
action_type: 'github.branch_protection.remove',
|
|
53
|
+
risk: 'critical',
|
|
54
|
+
receipt_required: true,
|
|
55
|
+
assurance_class: 'class_a',
|
|
56
|
+
match: { protocol: 'github', tool: 'delete_branch_protection' },
|
|
57
|
+
why: 'Removes the guard rails on a protected branch.',
|
|
58
|
+
execution_binding: { required_fields: ['action_type', 'owner', 'repo', 'branch'] },
|
|
59
|
+
}),
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
const OPS = {
|
|
63
|
+
'repo.delete': {
|
|
64
|
+
selector: { protocol: 'github', tool: 'delete_repo' },
|
|
65
|
+
observed: (p) => ({ action_type: 'github.repo.delete', owner: p.owner, repo: p.repo }),
|
|
66
|
+
perform: (octokit, p) => octokit.repos.delete({ owner: p.owner, repo: p.repo }),
|
|
67
|
+
},
|
|
68
|
+
'permission.change': {
|
|
69
|
+
selector: { protocol: 'github', tool: 'update_collaborator_permission' },
|
|
70
|
+
observed: (p) => ({
|
|
71
|
+
action_type: 'github.permission.change',
|
|
72
|
+
owner: p.owner, repo: p.repo, username: p.username, permission: p.permission,
|
|
73
|
+
}),
|
|
74
|
+
perform: (octokit, p) => octokit.repos.addCollaborator({
|
|
75
|
+
owner: p.owner, repo: p.repo, username: p.username, permission: p.permission,
|
|
76
|
+
}),
|
|
77
|
+
},
|
|
78
|
+
'branch_protection.remove': {
|
|
79
|
+
selector: { protocol: 'github', tool: 'delete_branch_protection' },
|
|
80
|
+
observed: (p) => ({ action_type: 'github.branch_protection.remove', owner: p.owner, repo: p.repo, branch: p.branch }),
|
|
81
|
+
perform: (octokit, p) => octokit.repos.deleteBranchProtection({ owner: p.owner, repo: p.repo, branch: p.branch }),
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const adapter = createAdapter({ system: 'github', ops: OPS });
|
|
86
|
+
export const GITHUB_OPS = adapter.OPS;
|
|
87
|
+
|
|
88
|
+
/** Build an action-risk manifest for the GitHub destructive ops (plus any extras). */
|
|
89
|
+
export function createGithubManifest(extraActions = []) {
|
|
90
|
+
return manifestFromPack(GITHUB_ACTION_PACK, extraActions);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Guard a destructive GitHub mutation behind the gate. The call never reaches
|
|
95
|
+
* GitHub unless a valid, sufficiently-assured, non-replayed receipt bound to
|
|
96
|
+
* THIS repo is present.
|
|
97
|
+
* @param {object} gate a gate built with createGithubManifest()
|
|
98
|
+
* @param {object} octokit an Octokit-like client (@octokit/rest or compatible)
|
|
99
|
+
* @param {object} o
|
|
100
|
+
* @param {string} o.op 'repo.delete' | 'permission.change' | 'branch_protection.remove'
|
|
101
|
+
* @param {object} o.params { owner, repo, [username], [permission], [branch] }
|
|
102
|
+
* @param {object} o.receipt the EMILIA receipt authorizing THIS exact op
|
|
103
|
+
* @returns {Promise<{ result:any, reliance:object, execution:object }>}
|
|
104
|
+
* @throws Error{code:'EMILIA_RECEIPT_REQUIRED'} if refused — the call never reaches GitHub
|
|
105
|
+
*/
|
|
106
|
+
export function guardGithubMutation(gate, octokit, args) {
|
|
107
|
+
return adapter.guard(gate, octokit, args);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export default { GITHUB_ACTION_PACK, GITHUB_OPS, createGithubManifest, guardGithubMutation };
|
package/adapters/k8s.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* EMILIA Gate — Kubernetes System-of-Record adapter.
|
|
4
|
+
*
|
|
5
|
+
* "Install this before your agent can touch the cluster." Guards the operations
|
|
6
|
+
* that take a cluster down or hand it away — namespace delete, workload delete,
|
|
7
|
+
* RBAC binding, secret delete — so they never reach the API server without a
|
|
8
|
+
* receipt bound to THIS namespace/resource. Client is injected (a @kubernetes/
|
|
9
|
+
* client-node wrapper or compatible).
|
|
10
|
+
*/
|
|
11
|
+
import { createAdapter, manifestFromPack } from './_kit.js';
|
|
12
|
+
|
|
13
|
+
export const K8S_ACTION_PACK = Object.freeze([
|
|
14
|
+
Object.freeze({
|
|
15
|
+
id: 'k8s.namespace.delete', label: 'Delete namespace', action_type: 'k8s.namespace.delete',
|
|
16
|
+
risk: 'critical', receipt_required: true, assurance_class: 'quorum',
|
|
17
|
+
match: { protocol: 'k8s', tool: 'delete_namespace' },
|
|
18
|
+
why: 'Deletes an entire namespace and everything in it. Quorum.',
|
|
19
|
+
execution_binding: { required_fields: ['action_type', 'namespace'] },
|
|
20
|
+
}),
|
|
21
|
+
Object.freeze({
|
|
22
|
+
id: 'k8s.workload.delete', label: 'Delete workload', action_type: 'k8s.workload.delete',
|
|
23
|
+
risk: 'high', receipt_required: true, assurance_class: 'class_a',
|
|
24
|
+
match: { protocol: 'k8s', tool: 'delete_workload' },
|
|
25
|
+
why: 'Deletes a deployment/statefulset/job. Bind namespace+kind+name.',
|
|
26
|
+
execution_binding: { required_fields: ['action_type', 'namespace', 'kind', 'name'] },
|
|
27
|
+
}),
|
|
28
|
+
Object.freeze({
|
|
29
|
+
id: 'k8s.rbac.bind', label: 'RBAC binding', action_type: 'k8s.rbac.bind',
|
|
30
|
+
risk: 'critical', receipt_required: true, assurance_class: 'quorum',
|
|
31
|
+
match: { protocol: 'k8s', tool: 'create_role_binding' },
|
|
32
|
+
why: 'Grants cluster permissions. Privilege change → two-person rule.',
|
|
33
|
+
execution_binding: { required_fields: ['action_type', 'subject', 'role', 'namespace'] },
|
|
34
|
+
}),
|
|
35
|
+
Object.freeze({
|
|
36
|
+
id: 'k8s.secret.delete', label: 'Delete secret', action_type: 'k8s.secret.delete',
|
|
37
|
+
risk: 'high', receipt_required: true, assurance_class: 'class_a',
|
|
38
|
+
match: { protocol: 'k8s', tool: 'delete_secret' },
|
|
39
|
+
why: 'Destroys a secret. Bind namespace+name.',
|
|
40
|
+
execution_binding: { required_fields: ['action_type', 'namespace', 'name'] },
|
|
41
|
+
}),
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
const OPS = {
|
|
45
|
+
'namespace.delete': {
|
|
46
|
+
selector: { protocol: 'k8s', tool: 'delete_namespace' },
|
|
47
|
+
observed: (p) => ({ action_type: 'k8s.namespace.delete', namespace: p.namespace }),
|
|
48
|
+
perform: (c, p) => c.deleteNamespace({ namespace: p.namespace }),
|
|
49
|
+
},
|
|
50
|
+
'workload.delete': {
|
|
51
|
+
selector: { protocol: 'k8s', tool: 'delete_workload' },
|
|
52
|
+
observed: (p) => ({ action_type: 'k8s.workload.delete', namespace: p.namespace, kind: p.kind, name: p.name }),
|
|
53
|
+
perform: (c, p) => c.deleteWorkload({ namespace: p.namespace, kind: p.kind, name: p.name }),
|
|
54
|
+
},
|
|
55
|
+
'rbac.bind': {
|
|
56
|
+
selector: { protocol: 'k8s', tool: 'create_role_binding' },
|
|
57
|
+
observed: (p) => ({ action_type: 'k8s.rbac.bind', subject: p.subject, role: p.role, namespace: p.namespace }),
|
|
58
|
+
perform: (c, p) => c.createRoleBinding({ subject: p.subject, role: p.role, namespace: p.namespace }),
|
|
59
|
+
},
|
|
60
|
+
'secret.delete': {
|
|
61
|
+
selector: { protocol: 'k8s', tool: 'delete_secret' },
|
|
62
|
+
observed: (p) => ({ action_type: 'k8s.secret.delete', namespace: p.namespace, name: p.name }),
|
|
63
|
+
perform: (c, p) => c.deleteSecret({ namespace: p.namespace, name: p.name }),
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const adapter = createAdapter({ system: 'k8s', ops: OPS });
|
|
68
|
+
export const K8S_OPS = adapter.OPS;
|
|
69
|
+
export function createK8sManifest(extraActions = []) { return manifestFromPack(K8S_ACTION_PACK, extraActions); }
|
|
70
|
+
export function guardK8sMutation(gate, client, args) { return adapter.guard(gate, client, args); }
|
|
71
|
+
export default { K8S_ACTION_PACK, K8S_OPS, createK8sManifest, guardK8sMutation };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* EMILIA Gate — Stripe / payments System-of-Record adapter.
|
|
4
|
+
*
|
|
5
|
+
* "Install this before your agent can move money." Wraps the destructive Stripe
|
|
6
|
+
* operations so a payout, refund, or payout-destination change never reaches
|
|
7
|
+
* Stripe without a valid, sufficiently-assured, non-replayed receipt bound to
|
|
8
|
+
* THIS amount/destination. A receipt for $100 to acct_A cannot authorize
|
|
9
|
+
* $10,000 to acct_B.
|
|
10
|
+
*
|
|
11
|
+
* import Stripe from 'stripe';
|
|
12
|
+
* import { createGate } from '@emilia-protocol/gate';
|
|
13
|
+
* import { createStripeManifest, guardStripeMutation } from '@emilia-protocol/gate/adapters/stripe';
|
|
14
|
+
*
|
|
15
|
+
* const gate = createGate({ manifest: createStripeManifest(), trustedKeys: [ISSUER] });
|
|
16
|
+
* await guardStripeMutation(gate, new Stripe(key), {
|
|
17
|
+
* op: 'payout.create', params: { amount: 40000, currency: 'usd', destination: 'acct_x' }, receipt,
|
|
18
|
+
* });
|
|
19
|
+
*/
|
|
20
|
+
import { createAdapter, manifestFromPack } from './_kit.js';
|
|
21
|
+
|
|
22
|
+
export const STRIPE_ACTION_PACK = Object.freeze([
|
|
23
|
+
Object.freeze({
|
|
24
|
+
id: 'stripe.payout.create', label: 'Stripe payout', action_type: 'stripe.payout.create',
|
|
25
|
+
risk: 'critical', receipt_required: true, assurance_class: 'class_a',
|
|
26
|
+
match: { protocol: 'stripe', tool: 'create_payout' },
|
|
27
|
+
why: 'Moves money out. Bind amount/currency/destination to a named human approval.',
|
|
28
|
+
execution_binding: { required_fields: ['action_type', 'amount', 'currency', 'destination'] },
|
|
29
|
+
}),
|
|
30
|
+
Object.freeze({
|
|
31
|
+
id: 'stripe.refund.create', label: 'Stripe refund', action_type: 'stripe.refund.create',
|
|
32
|
+
risk: 'high', receipt_required: true, assurance_class: 'class_a',
|
|
33
|
+
match: { protocol: 'stripe', tool: 'create_refund' },
|
|
34
|
+
why: 'Returns funds. Bind the payment and amount so a refund cannot be silently inflated.',
|
|
35
|
+
execution_binding: { required_fields: ['action_type', 'payment_intent', 'amount'] },
|
|
36
|
+
}),
|
|
37
|
+
Object.freeze({
|
|
38
|
+
id: 'stripe.bank_account.change', label: 'Stripe payout-destination change', action_type: 'stripe.bank_account.change',
|
|
39
|
+
risk: 'critical', receipt_required: true, assurance_class: 'quorum',
|
|
40
|
+
match: { protocol: 'stripe', tool: 'update_external_account' },
|
|
41
|
+
why: 'Changes WHERE future money flows. Quorum: the classic redirect-the-payouts attack.',
|
|
42
|
+
execution_binding: { required_fields: ['action_type', 'account', 'external_account'] },
|
|
43
|
+
}),
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const OPS = {
|
|
47
|
+
'payout.create': {
|
|
48
|
+
selector: { protocol: 'stripe', tool: 'create_payout' },
|
|
49
|
+
observed: (p) => ({ action_type: 'stripe.payout.create', amount: p.amount, currency: p.currency, destination: p.destination }),
|
|
50
|
+
perform: (stripe, p) => stripe.payouts.create({ amount: p.amount, currency: p.currency, destination: p.destination }),
|
|
51
|
+
},
|
|
52
|
+
'refund.create': {
|
|
53
|
+
selector: { protocol: 'stripe', tool: 'create_refund' },
|
|
54
|
+
observed: (p) => ({ action_type: 'stripe.refund.create', payment_intent: p.payment_intent, amount: p.amount }),
|
|
55
|
+
perform: (stripe, p) => stripe.refunds.create({ payment_intent: p.payment_intent, amount: p.amount }),
|
|
56
|
+
},
|
|
57
|
+
'bank_account.change': {
|
|
58
|
+
selector: { protocol: 'stripe', tool: 'update_external_account' },
|
|
59
|
+
observed: (p) => ({ action_type: 'stripe.bank_account.change', account: p.account, external_account: p.external_account }),
|
|
60
|
+
perform: (stripe, p) => stripe.accounts.updateExternalAccount(p.account, p.external_account, p.update || {}),
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const adapter = createAdapter({ system: 'stripe', ops: OPS });
|
|
65
|
+
export const STRIPE_OPS = adapter.OPS;
|
|
66
|
+
|
|
67
|
+
export function createStripeManifest(extraActions = []) {
|
|
68
|
+
return manifestFromPack(STRIPE_ACTION_PACK, extraActions);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Guard a destructive Stripe mutation behind the gate.
|
|
73
|
+
* @param {object} gate a gate built with createStripeManifest()
|
|
74
|
+
* @param {object} stripe a Stripe-like client (the official `stripe` SDK or compatible)
|
|
75
|
+
* @param {object} o { op:'payout.create'|'refund.create'|'bank_account.change', params, receipt }
|
|
76
|
+
* @throws Error{code:'EMILIA_RECEIPT_REQUIRED'} if refused — the call never reaches Stripe
|
|
77
|
+
*/
|
|
78
|
+
export function guardStripeMutation(gate, stripe, args) {
|
|
79
|
+
return adapter.guard(gate, stripe, args);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export default { STRIPE_ACTION_PACK, STRIPE_OPS, createStripeManifest, guardStripeMutation };
|
|
@@ -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 };
|
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));
|