@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
package/README.md
CHANGED
|
@@ -18,32 +18,191 @@ action* before the world is mutated.
|
|
|
18
18
|
## Run it
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
|
-
node --test
|
|
22
|
-
node demo.mjs
|
|
21
|
+
node --test # Gate + red-team + EG-1 + MCP + adapter tests
|
|
22
|
+
node demo.mjs # end-to-end: passthrough -> 428 -> too-low -> drift -> allow -> replay -> tamper -> reliance packet
|
|
23
|
+
node eg1.mjs # EG-1 conformance: 8/8 -> "EG-1 Enforced"
|
|
24
|
+
node adapters/github-demo.mjs # an agent tries to delete a prod repo (refused without a receipt)
|
|
25
|
+
node custody-demo.mjs # rotate, revoke a compromised issuer key live, retention export
|
|
23
26
|
```
|
|
24
27
|
|
|
25
28
|
## Use it
|
|
26
29
|
|
|
27
30
|
```js
|
|
28
|
-
import {
|
|
31
|
+
import { createTrustedActionFirewall } from '@emilia-protocol/gate';
|
|
29
32
|
|
|
30
|
-
const gate =
|
|
31
|
-
|
|
32
|
-
trustedKeys: [ISSUER_PUBKEY_B64U], // pin the issuers you trust
|
|
33
|
+
const gate = createTrustedActionFirewall({
|
|
34
|
+
trustedKeys: [ISSUER_PUBKEY_B64U], // pin the issuers you trust
|
|
33
35
|
maxAgeSec: 900,
|
|
34
36
|
});
|
|
35
37
|
|
|
36
|
-
//
|
|
37
|
-
const
|
|
38
|
-
|
|
38
|
+
// Facts from the system of record, not from attacker-controlled request input.
|
|
39
|
+
const observedAction = {
|
|
40
|
+
action_type: 'payment.release',
|
|
41
|
+
amount_usd: 40000,
|
|
42
|
+
currency: 'USD',
|
|
43
|
+
payment_instruction_id: 'pi_123',
|
|
44
|
+
beneficiary_account_hash: 'sha256:...',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const out = await gate.run({
|
|
48
|
+
selector: { protocol: 'mcp', tool: 'release_payment' },
|
|
49
|
+
receipt,
|
|
50
|
+
observedAction,
|
|
51
|
+
}, async () => {
|
|
52
|
+
// Only reached after receipt verification, assurance enforcement, field
|
|
53
|
+
// binding, and one-time reservation.
|
|
54
|
+
return releasePayment(observedAction);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (!out.ok) throw out.body; // 428 Receipt Required
|
|
58
|
+
console.log(out.packet.verdict); // "rely"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Default action packs
|
|
62
|
+
|
|
63
|
+
`createTrustedActionFirewall()` ships with high-risk defaults. These are category-based, not just
|
|
64
|
+
amount-based:
|
|
65
|
+
|
|
66
|
+
- `payment.release` — money movement, `class_a`
|
|
67
|
+
- `payment.bank_details.change` — bank-detail / beneficiary change, `class_a`
|
|
68
|
+
- `deploy.production` — production deploy, `quorum`
|
|
69
|
+
- `permission.admin.change` — permission / admin change, `quorum`
|
|
70
|
+
- `data.export` — bulk sensitive-data export, `class_a`
|
|
71
|
+
- `record.delete` — destructive record deletion, `class_a`
|
|
72
|
+
- `regulated.decision.override` — regulated decision override, `quorum`
|
|
73
|
+
|
|
74
|
+
Each pack also defines `execution_binding.required_fields`. The executor must pass those observed
|
|
75
|
+
fields from the real system of record. If the signed claim and observed mutation differ, the gate
|
|
76
|
+
refuses with `execution_binding_failed` before consuming the receipt.
|
|
77
|
+
|
|
78
|
+
Prefer `gate.run(...)` for mutations: it reserves the receipt, runs the side effect, commits
|
|
79
|
+
one-time consumption only after success, releases the reservation if the action fails before
|
|
80
|
+
mutation, and emits the execution receipt + reliance packet. Use lower-level `gate.check(...)` only
|
|
81
|
+
when your framework has to separate authorization from execution.
|
|
82
|
+
|
|
83
|
+
Use your own manifest when you need custom policy:
|
|
84
|
+
|
|
85
|
+
```js
|
|
86
|
+
import { createGate } from '@emilia-protocol/gate';
|
|
87
|
+
|
|
88
|
+
const gate = createGate({ manifest, trustedKeys: [ISSUER_PUBKEY_B64U] });
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Framework adapters
|
|
92
|
+
|
|
93
|
+
```js
|
|
94
|
+
// 1) Express / Connect middleware
|
|
95
|
+
app.post(
|
|
96
|
+
'/payments',
|
|
97
|
+
gate.middleware({
|
|
98
|
+
selector: { protocol: 'http', method: 'POST', path: '/payments' },
|
|
99
|
+
observedAction: (req) => req.paymentFromSystemOfRecord,
|
|
100
|
+
}),
|
|
101
|
+
handler,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// 2) Wrap any function
|
|
105
|
+
const release = gate.guard(reallyRelease, {
|
|
106
|
+
selector: () => ({ tool: 'release_payment', protocol: 'mcp' }),
|
|
107
|
+
receipt: (_amount, r) => r,
|
|
108
|
+
observedAction: (amount) => ({
|
|
109
|
+
action_type: 'payment.release',
|
|
110
|
+
amount_usd: amount,
|
|
111
|
+
currency: 'USD',
|
|
112
|
+
payment_instruction_id: 'pi_123',
|
|
113
|
+
beneficiary_account_hash: 'sha256:...',
|
|
114
|
+
}),
|
|
115
|
+
});
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## MCP drop-in
|
|
119
|
+
|
|
120
|
+
Agents live at the MCP tool-call boundary. One wrapper turns a dangerous tool into a
|
|
121
|
+
receipt-required one:
|
|
39
122
|
|
|
40
|
-
|
|
41
|
-
|
|
123
|
+
```js
|
|
124
|
+
import { createTrustedActionFirewall } from '@emilia-protocol/gate';
|
|
125
|
+
import { gateMcpTool } from '@emilia-protocol/gate/mcp';
|
|
126
|
+
|
|
127
|
+
const gate = createTrustedActionFirewall({ trustedKeys: [ISSUER_PUBKEY_B64U] });
|
|
42
128
|
|
|
43
|
-
|
|
44
|
-
|
|
129
|
+
server.tool('release_payment', gateMcpTool(
|
|
130
|
+
gate,
|
|
131
|
+
{ tool: 'release_payment', observedAction: (args) => paymentSystem.describe(args) },
|
|
132
|
+
async (args) => paymentSystem.release(args),
|
|
133
|
+
));
|
|
134
|
+
// No valid receipt -> a structured MCP error ({ isError, _emilia.challenge }).
|
|
135
|
+
// On success -> the tool result with { _emilia: { execution, reliance } } attached.
|
|
45
136
|
```
|
|
46
137
|
|
|
138
|
+
## System-of-record adapters
|
|
139
|
+
|
|
140
|
+
Adoption happens where the mutation happens — *"install this before your agent can touch
|
|
141
|
+
production."* Each adapter guards the destructive operations of a real system so the mutation never
|
|
142
|
+
reaches it without a receipt bound to **this** resource (a receipt for resource A cannot authorize
|
|
143
|
+
mutating B). All share one fail-closed contract (`adapters/_kit.js`).
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
import { createGate } from '@emilia-protocol/gate';
|
|
147
|
+
import { createGithubManifest, guardGithubMutation } from '@emilia-protocol/gate/adapters/github';
|
|
148
|
+
|
|
149
|
+
const gate = createGate({ manifest: createGithubManifest(), trustedKeys: [ISSUER_PUBKEY_B64U] });
|
|
150
|
+
await guardGithubMutation(gate, octokit, {
|
|
151
|
+
op: 'repo.delete', // | 'permission.change' | 'branch_protection.remove'
|
|
152
|
+
params: { owner: 'acme', repo: 'prod' },
|
|
153
|
+
receipt, // throws EMILIA_RECEIPT_REQUIRED if absent/invalid/replayed/drifted
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
| Adapter | Import | Guarded ops (assurance) |
|
|
158
|
+
|---|---|---|
|
|
159
|
+
| **GitHub** | `@emilia-protocol/gate/adapters/github` | repo.delete `class_a`, permission.change `quorum`, branch_protection.remove `class_a` |
|
|
160
|
+
| **Stripe** | `@emilia-protocol/gate/adapters/stripe` | payout.create `class_a`, refund.create `class_a`, bank_account.change `quorum` |
|
|
161
|
+
| **Supabase / Postgres** | `@emilia-protocol/gate/adapters/supabase` | sql.destructive `class_a`, data.export `class_a`, rls.change `quorum` |
|
|
162
|
+
| **AWS (IAM + network)** | `@emilia-protocol/gate/adapters/aws` | iam.attach_policy `quorum`, iam.create_access_key `class_a`, iam.delete_user `class_a`, ec2.authorize_ingress `quorum` |
|
|
163
|
+
|
|
164
|
+
```js
|
|
165
|
+
import { createStripeManifest, guardStripeMutation } from '@emilia-protocol/gate/adapters/stripe';
|
|
166
|
+
const gate = createGate({ manifest: createStripeManifest(), trustedKeys: [ISSUER_PUBKEY_B64U] });
|
|
167
|
+
await guardStripeMutation(gate, stripe, { op: 'payout.create', params: { amount: 40000, currency: 'usd', destination: 'acct_x' }, receipt });
|
|
168
|
+
// Supabase: guardSupabaseMutation(gate, db, { op: 'sql.destructive', params: { sql }, receipt }) // binds the exact statement
|
|
169
|
+
// AWS: guardAwsMutation(gate, client, { op: 'iam.attach_policy', params: { user, policy_arn }, receipt })
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Clients are injected (the real `@octokit/rest`, `stripe`, a `pg`/Supabase client, or the AWS SDK), so
|
|
173
|
+
the adapters are testable without credentials. Adding an adapter is ~40 lines: a frozen action pack
|
|
174
|
+
(selectors + tiers + `execution_binding.required_fields`) and an op map (`selector`, `observed(params)`,
|
|
175
|
+
`perform(client, params)`) passed to `createAdapter()`.
|
|
176
|
+
|
|
177
|
+
## Earn EG-1
|
|
178
|
+
|
|
179
|
+
**EG-1 conformance** answers the only question that matters for adoption: *does your integration
|
|
180
|
+
actually enforce the gate, or are you just claiming it?* An integration earns **EG-1 Enforced** only
|
|
181
|
+
if it demonstrably passes all eight checks:
|
|
182
|
+
|
|
183
|
+
1. missing receipt → 428
|
|
184
|
+
2. software receipt on a Class-A action → refused
|
|
185
|
+
3. observed execution drift → refused
|
|
186
|
+
4. valid Class-A / quorum receipt → runs
|
|
187
|
+
5. same receipt replay → refused
|
|
188
|
+
6. tampered receipt → refused
|
|
189
|
+
7. execution proof binds to the authorization decision
|
|
190
|
+
8. reliance packet returns verdict `rely`
|
|
191
|
+
|
|
192
|
+
```js
|
|
193
|
+
import { createTrustedActionFirewall, createEg1Harness, gateConformance } from '@emilia-protocol/gate';
|
|
194
|
+
|
|
195
|
+
const harness = createEg1Harness();
|
|
196
|
+
const gate = createTrustedActionFirewall({ trustedKeys: [harness.publicKey] });
|
|
197
|
+
const report = await gateConformance({ gate, harness });
|
|
198
|
+
// report.passed === true; report.badge === 'EG-1 Enforced'
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
For a custom integration (an HTTP service, another language), provide your own `invoke` to
|
|
202
|
+
`runEg1({ invoke, harness })` — it drives the same eight scenarios. `node eg1.mjs` self-certifies the
|
|
203
|
+
reference gate and exits non-zero on any failure, so it drops straight into CI. This turns an open PR
|
|
204
|
+
into a crisp claim: *"this PR makes `delete_row` earn EG-1."*
|
|
205
|
+
|
|
47
206
|
## What it adds over a bare verifier
|
|
48
207
|
|
|
49
208
|
`@emilia-protocol/require-receipt` already does manifest matching, offline verification, and the 428
|
|
@@ -55,6 +214,52 @@ challenge. The Gate composes that and adds the three things a firewall needs:
|
|
|
55
214
|
(`replay_refused`). Default store is in-memory; swap in Redis/DB for a fleet.
|
|
56
215
|
- **Evidence log** — every decision is hash-chained (`evidence.verify()` detects any alteration).
|
|
57
216
|
This is the compliance / insurance artifact.
|
|
217
|
+
- **Execution-field binding** — for high-risk packs, the signed claim must match the executor's
|
|
218
|
+
observed mutation fields (`amount_usd`, `commit_sha`, `principal_id`, `record_id`, etc.). This
|
|
219
|
+
closes "approved harmless X, executed dangerous Y."
|
|
220
|
+
- **Reliance packet** — `gate.reliancePacket()` turns the decision, execution receipt, field binding,
|
|
221
|
+
and evidence head into the compact artifact an auditor, insurer, or investigator can review.
|
|
222
|
+
|
|
223
|
+
## Production custody
|
|
224
|
+
|
|
225
|
+
The three things a serious buyer (CISO, auditor, insurer) asks after the demo:
|
|
226
|
+
|
|
227
|
+
**Issuer key rotation + revocation.** A flat `trustedKeys` list can't revoke a leaked key
|
|
228
|
+
or rotate without downtime. A key registry can — a receipt is verified only against keys
|
|
229
|
+
valid (and not revoked) at its issuance time. Revocation is fail-closed and immediate.
|
|
230
|
+
|
|
231
|
+
```js
|
|
232
|
+
import { createGate, createKeyRegistry } from '@emilia-protocol/gate';
|
|
233
|
+
|
|
234
|
+
const registry = createKeyRegistry([
|
|
235
|
+
{ kid: 'issuer-1', key: KEY1 },
|
|
236
|
+
{ kid: 'issuer-2', key: KEY2, not_before: '2026-07-01T00:00:00Z' }, // rotation window
|
|
237
|
+
]);
|
|
238
|
+
const gate = createGate({ manifest, keyRegistry: registry });
|
|
239
|
+
registry.revoke('issuer-1'); // compromised — refused immediately, live, no redeploy
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
**Fleet-safe replay defense.** The in-memory store is per-process. In production, back the
|
|
243
|
+
consumption store with a shared key-value store whose insert-if-absent is atomic:
|
|
244
|
+
|
|
245
|
+
```js
|
|
246
|
+
import { createDurableConsumptionStore } from '@emilia-protocol/gate';
|
|
247
|
+
const store = createDurableConsumptionStore(redisBackend); // backend.addIfAbsent MUST be atomic (Redis SET NX)
|
|
248
|
+
const gate = createGate({ manifest, keyRegistry, store });
|
|
249
|
+
// A receipt consumed on one pod cannot be replayed on another.
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
**Evidence retention.** Classify the evidence log into hot/cold/expired with legal hold, and
|
|
253
|
+
export the auditor/SIEM manifest (tied to the evidence head). `EP_AUDIT_HOT_DAYS` /
|
|
254
|
+
`EP_AUDIT_COLD_DAYS` set the horizons.
|
|
255
|
+
|
|
256
|
+
```js
|
|
257
|
+
gate.retention({ hotDays: 365, coldDays: 2190, legalHold: ['<evidence-hash>'] });
|
|
258
|
+
gate.retentionExport(); // EP-GATE-RETENTION-EXPORT-v1 manifest
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Issuer-side **KMS/HSM signing custody** (production mode refuses dev-local private keys) lives in
|
|
262
|
+
EP core (`lib/key-custody.js`, `assertProductionKeyCustody` / `createExternalCustodySigner`).
|
|
58
263
|
|
|
59
264
|
## Boundary
|
|
60
265
|
|
package/action-packs.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// EMILIA Gate default action packs: the high-risk families that should require
|
|
3
|
+
// pre-execution human authorization before a machine mutates the world.
|
|
4
|
+
|
|
5
|
+
export const ACTION_RISK_MANIFEST_VERSION = 'EP-ACTION-RISK-MANIFEST-v0.1';
|
|
6
|
+
|
|
7
|
+
export const HIGH_RISK_ACTION_PACKS = Object.freeze([
|
|
8
|
+
Object.freeze({
|
|
9
|
+
id: 'money_movement.release',
|
|
10
|
+
label: 'Money movement',
|
|
11
|
+
action_type: 'payment.release',
|
|
12
|
+
risk: 'critical',
|
|
13
|
+
receipt_required: true,
|
|
14
|
+
assurance_class: 'class_a',
|
|
15
|
+
match: { protocol: 'mcp', tool: 'release_payment' },
|
|
16
|
+
why: 'Moves funds or releases value. Requires a named human signoff, not an agent-only key.',
|
|
17
|
+
execution_binding: {
|
|
18
|
+
required_fields: ['action_type', 'amount_usd', 'currency', 'payment_instruction_id', 'beneficiary_account_hash'],
|
|
19
|
+
},
|
|
20
|
+
}),
|
|
21
|
+
Object.freeze({
|
|
22
|
+
id: 'money_movement.bank_details_change',
|
|
23
|
+
label: 'Bank-detail change',
|
|
24
|
+
action_type: 'payment.bank_details.change',
|
|
25
|
+
risk: 'critical',
|
|
26
|
+
receipt_required: true,
|
|
27
|
+
assurance_class: 'class_a',
|
|
28
|
+
match: { protocol: 'mcp', tool: 'change_bank_details' },
|
|
29
|
+
why: 'Changes where future money flows. Treats payee, beneficiary, vendor, and payroll account changes as high-risk by category.',
|
|
30
|
+
execution_binding: {
|
|
31
|
+
required_fields: ['action_type', 'account_holder_id', 'payment_instruction_id', 'bank_account_hash'],
|
|
32
|
+
},
|
|
33
|
+
}),
|
|
34
|
+
Object.freeze({
|
|
35
|
+
id: 'production.deploy',
|
|
36
|
+
label: 'Production deploy',
|
|
37
|
+
action_type: 'deploy.production',
|
|
38
|
+
risk: 'critical',
|
|
39
|
+
receipt_required: true,
|
|
40
|
+
assurance_class: 'quorum',
|
|
41
|
+
match: { protocol: 'mcp', tool: 'deploy_production' },
|
|
42
|
+
why: 'Changes live production behavior. Quorum is the cryptographic two-person rule for hard operational cuts.',
|
|
43
|
+
execution_binding: {
|
|
44
|
+
required_fields: ['action_type', 'repo', 'commit_sha', 'environment', 'artifact_digest'],
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
Object.freeze({
|
|
48
|
+
id: 'permissions.admin_change',
|
|
49
|
+
label: 'Permission or admin change',
|
|
50
|
+
action_type: 'permission.admin.change',
|
|
51
|
+
risk: 'critical',
|
|
52
|
+
receipt_required: true,
|
|
53
|
+
assurance_class: 'quorum',
|
|
54
|
+
match: { protocol: 'mcp', tool: 'change_permissions' },
|
|
55
|
+
why: 'Changes who can act next. Privilege changes deserve stronger proof than the session that requested them.',
|
|
56
|
+
execution_binding: {
|
|
57
|
+
required_fields: ['action_type', 'principal_id', 'role', 'scope'],
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
Object.freeze({
|
|
61
|
+
id: 'data.bulk_export',
|
|
62
|
+
label: 'Bulk data export',
|
|
63
|
+
action_type: 'data.export',
|
|
64
|
+
risk: 'high',
|
|
65
|
+
receipt_required: true,
|
|
66
|
+
assurance_class: 'class_a',
|
|
67
|
+
match: { protocol: 'mcp', tool: 'export_customer_data' },
|
|
68
|
+
why: 'Moves sensitive data out of its system of record. The recipient and purpose must be bound to the approval.',
|
|
69
|
+
execution_binding: {
|
|
70
|
+
required_fields: ['action_type', 'dataset', 'recipient', 'purpose', 'row_count_max'],
|
|
71
|
+
},
|
|
72
|
+
}),
|
|
73
|
+
Object.freeze({
|
|
74
|
+
id: 'records.delete',
|
|
75
|
+
label: 'Record deletion',
|
|
76
|
+
action_type: 'record.delete',
|
|
77
|
+
risk: 'high',
|
|
78
|
+
receipt_required: true,
|
|
79
|
+
assurance_class: 'class_a',
|
|
80
|
+
match: { protocol: 'mcp', tool: 'delete_record' },
|
|
81
|
+
why: 'Destroys or hides state. The record identity and pre-state must be bound before deletion.',
|
|
82
|
+
execution_binding: {
|
|
83
|
+
required_fields: ['action_type', 'record_type', 'record_id', 'before_state_hash'],
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
Object.freeze({
|
|
87
|
+
id: 'regulated.decision_override',
|
|
88
|
+
label: 'Regulated decision override',
|
|
89
|
+
action_type: 'regulated.decision.override',
|
|
90
|
+
risk: 'critical',
|
|
91
|
+
receipt_required: true,
|
|
92
|
+
assurance_class: 'quorum',
|
|
93
|
+
match: { protocol: 'mcp', tool: 'override_regulated_decision' },
|
|
94
|
+
why: 'Changes a decision with legal, benefit, credit, clinical, or safety impact. Requires named accountability.',
|
|
95
|
+
execution_binding: {
|
|
96
|
+
required_fields: ['action_type', 'case_id', 'decision_id', 'subject_id', 'override_reason'],
|
|
97
|
+
},
|
|
98
|
+
}),
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
export const DEFAULT_PASS_THROUGH_ACTIONS = Object.freeze([
|
|
102
|
+
Object.freeze({
|
|
103
|
+
id: 'observe.read_status',
|
|
104
|
+
label: 'Read-only status',
|
|
105
|
+
action_type: 'read.status',
|
|
106
|
+
receipt_required: false,
|
|
107
|
+
match: { protocol: 'mcp', tool: 'read_status' },
|
|
108
|
+
}),
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
export function createDefaultActionRiskManifest({ includePassThrough = true, extraActions = [] } = {}) {
|
|
112
|
+
return {
|
|
113
|
+
'@version': ACTION_RISK_MANIFEST_VERSION,
|
|
114
|
+
actions: [
|
|
115
|
+
...HIGH_RISK_ACTION_PACKS.map((a) => ({
|
|
116
|
+
...a,
|
|
117
|
+
match: { ...a.match },
|
|
118
|
+
execution_binding: a.execution_binding
|
|
119
|
+
? { ...a.execution_binding, required_fields: [...a.execution_binding.required_fields] }
|
|
120
|
+
: undefined,
|
|
121
|
+
})),
|
|
122
|
+
...(includePassThrough ? DEFAULT_PASS_THROUGH_ACTIONS.map((a) => ({ ...a, match: { ...a.match } })) : []),
|
|
123
|
+
...extraActions,
|
|
124
|
+
],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const DEFAULT_GATE_MANIFEST = Object.freeze(createDefaultActionRiskManifest());
|
|
129
|
+
|
|
130
|
+
export function highRiskActionTypes(actions = HIGH_RISK_ACTION_PACKS) {
|
|
131
|
+
return actions.filter((a) => a.receipt_required).map((a) => a.action_type);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export default {
|
|
135
|
+
ACTION_RISK_MANIFEST_VERSION,
|
|
136
|
+
HIGH_RISK_ACTION_PACKS,
|
|
137
|
+
DEFAULT_PASS_THROUGH_ACTIONS,
|
|
138
|
+
DEFAULT_GATE_MANIFEST,
|
|
139
|
+
createDefaultActionRiskManifest,
|
|
140
|
+
highRiskActionTypes,
|
|
141
|
+
};
|
package/adapters/_kit.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* EMILIA Gate — System-of-Record adapter kit. One enforcement contract shared by
|
|
4
|
+
* every adapter (GitHub, Stripe, Supabase, AWS, ...): map a destructive op to a
|
|
5
|
+
* gate selector + the observed system-of-record fields, run it through
|
|
6
|
+
* gate.run(), and FAIL CLOSED — if the gate refuses, the real client call is
|
|
7
|
+
* never made and we throw EMILIA_RECEIPT_REQUIRED.
|
|
8
|
+
*
|
|
9
|
+
* An op spec is: { selector, observed(params) -> {fields...}, perform(client, params) -> result }.
|
|
10
|
+
* `observed` must return the same material fields the action pack binds, drawn
|
|
11
|
+
* from the call params (the system-of-record facts), so a receipt for resource A
|
|
12
|
+
* cannot authorize a mutation of resource B.
|
|
13
|
+
*/
|
|
14
|
+
import { hashCanonical } from '../execution-binding.js';
|
|
15
|
+
|
|
16
|
+
export { hashCanonical };
|
|
17
|
+
|
|
18
|
+
/** Build an EP-ACTION-RISK-MANIFEST-v0.1 from a frozen action pack (deep-copied). */
|
|
19
|
+
export function manifestFromPack(pack, extraActions = []) {
|
|
20
|
+
return {
|
|
21
|
+
'@version': 'EP-ACTION-RISK-MANIFEST-v0.1',
|
|
22
|
+
actions: [
|
|
23
|
+
...pack.map((a) => ({
|
|
24
|
+
...a,
|
|
25
|
+
match: { ...a.match },
|
|
26
|
+
execution_binding: a.execution_binding
|
|
27
|
+
? { ...a.execution_binding, required_fields: [...a.execution_binding.required_fields] }
|
|
28
|
+
: undefined,
|
|
29
|
+
})),
|
|
30
|
+
...extraActions,
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create an adapter from a system name + op map.
|
|
37
|
+
* @returns {{ ops:object, OPS:string[], guard:(gate,client,{op,params,receipt})=>Promise<{result,reliance,execution}> }}
|
|
38
|
+
*/
|
|
39
|
+
export function createAdapter({ system, ops }) {
|
|
40
|
+
if (!system || !ops || typeof ops !== 'object') throw new Error('createAdapter requires { system, ops }');
|
|
41
|
+
const OPS = Object.freeze(Object.keys(ops));
|
|
42
|
+
|
|
43
|
+
async function guard(gate, client, { op, params = {}, receipt = null } = {}) {
|
|
44
|
+
if (!gate || typeof gate.run !== 'function') throw new Error(`${system} adapter requires an EMILIA Gate (with .run)`);
|
|
45
|
+
const spec = ops[op];
|
|
46
|
+
if (!spec) throw new Error(`${system} adapter: unknown op "${op}" (expected one of: ${OPS.join(', ')})`);
|
|
47
|
+
const observedAction = spec.observed(params);
|
|
48
|
+
const out = await gate.run({ selector: spec.selector, receipt, observedAction }, () => spec.perform(client, params));
|
|
49
|
+
if (!out.ok) {
|
|
50
|
+
const e = new Error(`EMILIA Gate refused ${system}:${op} — ${out.authorization.reason}`);
|
|
51
|
+
e.code = 'EMILIA_RECEIPT_REQUIRED';
|
|
52
|
+
e.status = out.status;
|
|
53
|
+
e.gate = out.authorization;
|
|
54
|
+
e.challenge = out.body;
|
|
55
|
+
throw e;
|
|
56
|
+
}
|
|
57
|
+
return { result: out.result, reliance: out.packet, execution: out.execution };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { ops, OPS, guard };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export default { createAdapter, manifestFromPack, hashCanonical };
|
package/adapters/aws.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* EMILIA Gate — AWS System-of-Record adapter (IAM + network).
|
|
4
|
+
*
|
|
5
|
+
* "Install this before your agent can change cloud permissions or open the
|
|
6
|
+
* network." Wraps the high-blast-radius AWS operations — attach IAM policy,
|
|
7
|
+
* create access key, delete user, and open a security-group ingress — so they
|
|
8
|
+
* never reach AWS without a valid, sufficiently-assured, non-replayed receipt
|
|
9
|
+
* bound to THIS principal/policy/group. Privilege and network changes default
|
|
10
|
+
* to quorum (the two-person rule).
|
|
11
|
+
*
|
|
12
|
+
* import { IAMClient, EC2Client } from '@aws-sdk/...';
|
|
13
|
+
* import { createGate } from '@emilia-protocol/gate';
|
|
14
|
+
* import { createAwsManifest, guardAwsMutation } from '@emilia-protocol/gate/adapters/aws';
|
|
15
|
+
*
|
|
16
|
+
* const gate = createGate({ manifest: createAwsManifest(), trustedKeys: [ISSUER] });
|
|
17
|
+
* // client: { iam: { attachUserPolicy, createAccessKey, deleteUser }, ec2: { authorizeSecurityGroupIngress } }
|
|
18
|
+
* await guardAwsMutation(gate, client, {
|
|
19
|
+
* op: 'iam.attach_policy', params: { user: 'svc-bot', policy_arn: 'arn:aws:iam::aws:policy/AdministratorAccess' }, receipt,
|
|
20
|
+
* });
|
|
21
|
+
*/
|
|
22
|
+
import { createAdapter, manifestFromPack } from './_kit.js';
|
|
23
|
+
|
|
24
|
+
export const AWS_ACTION_PACK = Object.freeze([
|
|
25
|
+
Object.freeze({
|
|
26
|
+
id: 'aws.iam.attach_policy', label: 'IAM attach policy', action_type: 'aws.iam.attach_policy',
|
|
27
|
+
risk: 'critical', receipt_required: true, assurance_class: 'quorum',
|
|
28
|
+
match: { protocol: 'aws', tool: 'attach_user_policy' },
|
|
29
|
+
why: 'Grants permissions. Privilege escalation deserves the two-person rule.',
|
|
30
|
+
execution_binding: { required_fields: ['action_type', 'user', 'policy_arn'] },
|
|
31
|
+
}),
|
|
32
|
+
Object.freeze({
|
|
33
|
+
id: 'aws.iam.create_access_key', label: 'IAM create access key', action_type: 'aws.iam.create_access_key',
|
|
34
|
+
risk: 'critical', receipt_required: true, assurance_class: 'class_a',
|
|
35
|
+
match: { protocol: 'aws', tool: 'create_access_key' },
|
|
36
|
+
why: 'Mints long-lived credentials. Bind the user to a named approval.',
|
|
37
|
+
execution_binding: { required_fields: ['action_type', 'user'] },
|
|
38
|
+
}),
|
|
39
|
+
Object.freeze({
|
|
40
|
+
id: 'aws.iam.delete_user', label: 'IAM delete user', action_type: 'aws.iam.delete_user',
|
|
41
|
+
risk: 'high', receipt_required: true, assurance_class: 'class_a',
|
|
42
|
+
match: { protocol: 'aws', tool: 'delete_user' },
|
|
43
|
+
why: 'Destroys an identity. Bind the target user.',
|
|
44
|
+
execution_binding: { required_fields: ['action_type', 'user'] },
|
|
45
|
+
}),
|
|
46
|
+
Object.freeze({
|
|
47
|
+
id: 'aws.ec2.authorize_ingress', label: 'Open security-group ingress', action_type: 'aws.ec2.authorize_ingress',
|
|
48
|
+
risk: 'critical', receipt_required: true, assurance_class: 'quorum',
|
|
49
|
+
match: { protocol: 'aws', tool: 'authorize_security_group_ingress' },
|
|
50
|
+
why: 'Opens the network. Bind group/CIDR/port so 0.0.0.0/0:22 cannot slip through.',
|
|
51
|
+
execution_binding: { required_fields: ['action_type', 'group_id', 'cidr', 'from_port'] },
|
|
52
|
+
}),
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const OPS = {
|
|
56
|
+
'iam.attach_policy': {
|
|
57
|
+
selector: { protocol: 'aws', tool: 'attach_user_policy' },
|
|
58
|
+
observed: (p) => ({ action_type: 'aws.iam.attach_policy', user: p.user, policy_arn: p.policy_arn }),
|
|
59
|
+
perform: (client, p) => client.iam.attachUserPolicy({ UserName: p.user, PolicyArn: p.policy_arn }),
|
|
60
|
+
},
|
|
61
|
+
'iam.create_access_key': {
|
|
62
|
+
selector: { protocol: 'aws', tool: 'create_access_key' },
|
|
63
|
+
observed: (p) => ({ action_type: 'aws.iam.create_access_key', user: p.user }),
|
|
64
|
+
perform: (client, p) => client.iam.createAccessKey({ UserName: p.user }),
|
|
65
|
+
},
|
|
66
|
+
'iam.delete_user': {
|
|
67
|
+
selector: { protocol: 'aws', tool: 'delete_user' },
|
|
68
|
+
observed: (p) => ({ action_type: 'aws.iam.delete_user', user: p.user }),
|
|
69
|
+
perform: (client, p) => client.iam.deleteUser({ UserName: p.user }),
|
|
70
|
+
},
|
|
71
|
+
'ec2.authorize_ingress': {
|
|
72
|
+
selector: { protocol: 'aws', tool: 'authorize_security_group_ingress' },
|
|
73
|
+
observed: (p) => ({ action_type: 'aws.ec2.authorize_ingress', group_id: p.group_id, cidr: p.cidr, from_port: p.from_port }),
|
|
74
|
+
perform: (client, p) => client.ec2.authorizeSecurityGroupIngress({
|
|
75
|
+
GroupId: p.group_id, CidrIp: p.cidr, FromPort: p.from_port, ToPort: p.to_port ?? p.from_port, IpProtocol: p.protocol ?? 'tcp',
|
|
76
|
+
}),
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const adapter = createAdapter({ system: 'aws', ops: OPS });
|
|
81
|
+
export const AWS_OPS = adapter.OPS;
|
|
82
|
+
|
|
83
|
+
export function createAwsManifest(extraActions = []) {
|
|
84
|
+
return manifestFromPack(AWS_ACTION_PACK, extraActions);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Guard a high-blast-radius AWS mutation behind the gate.
|
|
89
|
+
* @param {object} gate a gate built with createAwsManifest()
|
|
90
|
+
* @param {object} client { iam: {attachUserPolicy, createAccessKey, deleteUser}, ec2: {authorizeSecurityGroupIngress} }
|
|
91
|
+
* @param {object} o { op, params, receipt }
|
|
92
|
+
* @throws Error{code:'EMILIA_RECEIPT_REQUIRED'} if refused — the call never reaches AWS
|
|
93
|
+
*/
|
|
94
|
+
export function guardAwsMutation(gate, client, args) {
|
|
95
|
+
return adapter.guard(gate, client, args);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export default { AWS_ACTION_PACK, AWS_OPS, createAwsManifest, guardAwsMutation };
|
|
@@ -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 };
|