@emilia-protocol/fire-drill 0.1.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.
Files changed (4) hide show
  1. package/README.md +57 -0
  2. package/cli.mjs +89 -0
  3. package/index.js +195 -0
  4. package/package.json +13 -0
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # @emilia-protocol/fire-drill — the Agent Action Firewall Test
2
+
3
+ > If your agent can take an irreversible action without a receipt, you do not have control.
4
+ > You have hope.
5
+
6
+ Scan any **MCP server manifest**, **OpenAPI spec**, or **tool list** for dangerous actions an AI
7
+ agent can take **without an accountable human receipt** — and find out before you're the screenshot:
8
+ *"our agent deleted prod and nobody can prove who approved it."*
9
+
10
+ ```bash
11
+ npx @emilia-protocol/fire-drill ./mcp-manifest.json
12
+ # or pipe it:
13
+ cat openapi.json | npx @emilia-protocol/fire-drill
14
+ ```
15
+
16
+ ```
17
+ ====================================================================
18
+ Agent Action Firewall Test — @emilia-protocol/fire-drill
19
+ ====================================================================
20
+ Target: mcp Operations: 3 Dangerous: 2 Gated: 0
21
+ Agent Action Firewall score: 0/100
22
+
23
+ ✗ FAIL: `delete_customer_data` can execute without an accountable human receipt (Data destruction).
24
+ Fix: Add EMILIA Gate — @emilia-protocol/gate/adapters/supabase (or gateMcpTool) requiring a class_a receipt.
25
+ Earn: EG-1 Enforced
26
+ ✗ FAIL: `release_payment` can execute without an accountable human receipt (Money movement).
27
+ Fix: Add EMILIA Gate — @emilia-protocol/gate/adapters/stripe (or gateMcpTool) requiring a class_a receipt.
28
+ Earn: EG-1 Enforced
29
+
30
+ EG-1: FAIL — 2 dangerous operation(s) can run without a receipt.
31
+ ```
32
+
33
+ ## What it checks
34
+
35
+ It classifies every operation into the high-risk families and flags any dangerous one that lacks a
36
+ receipt requirement:
37
+
38
+ - **Money movement** — pay, payout, refund, transfer, wire, payroll
39
+ - **Data destruction** — delete, drop, truncate, purge (and any HTTP `DELETE`)
40
+ - **Production deploy** — deploy, release, terraform/apply, migrate
41
+ - **Permission / admin change** — IAM, role, grant, policy, RBAC
42
+ - **Bulk data export** — export, dump, download, backup
43
+ - **Regulated decision override** — override/approve a claim, benefit, credit, or decision
44
+
45
+ ## Output
46
+
47
+ - **Agent Action Firewall score** — % of dangerous operations that require a receipt.
48
+ - **EG-1: pass/fail** — `pass` only if *no* dangerous operation can run unreceipted.
49
+ - **`--json`** — machine-readable report (exit non-zero on fail → drop into CI).
50
+ - **`--fix`** — print the EMILIA Gate patch snippet for each failure.
51
+
52
+ ## Honest scope
53
+
54
+ This is a **static** assessment from the manifest/spec — like SSL Labs or `npm audit`. It reveals the
55
+ gap. To *verify the fix at runtime*, run **EG-1 conformance** from
56
+ [`@emilia-protocol/gate`](../gate) (`node eg1.mjs`) against your gated integration and earn the
57
+ **EG-1 Enforced** badge. Zero dependencies; Apache-2.0.
package/cli.mjs ADDED
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * npx @emilia-protocol/fire-drill <manifest.json | openapi.json | tools.json>
5
+ *
6
+ * The Agent Action Firewall Test. Reads an MCP manifest, OpenAPI spec, or tool
7
+ * list (JSON, or stdin) and reports whether a dangerous action can run without
8
+ * an accountable human receipt. Exits non-zero if any does (CI-friendly).
9
+ *
10
+ * --json machine-readable report
11
+ * --fix print the EMILIA Gate patch snippets for the failures
12
+ * @license Apache-2.0
13
+ */
14
+ import fs from 'node:fs';
15
+ import { scan, TAGLINE } from './index.js';
16
+
17
+ const argv = process.argv.slice(2);
18
+ const flags = new Set(argv.filter((a) => a.startsWith('--')));
19
+ const file = argv.find((a) => !a.startsWith('--'));
20
+
21
+ function readInput() {
22
+ if (file) return fs.readFileSync(file, 'utf8');
23
+ if (!process.stdin.isTTY) return fs.readFileSync(0, 'utf8');
24
+ return null;
25
+ }
26
+
27
+ const raw = readInput();
28
+ if (!raw) {
29
+ console.error('usage: fire-drill <manifest.json|openapi.json|tools.json> [--json] [--fix]\n (or pipe JSON via stdin)');
30
+ process.exit(2);
31
+ }
32
+
33
+ let report;
34
+ try {
35
+ report = scan(JSON.parse(raw));
36
+ } catch (e) {
37
+ console.error(`fire-drill: ${e.message}`);
38
+ process.exit(2);
39
+ }
40
+
41
+ if (flags.has('--json')) {
42
+ console.log(JSON.stringify(report, null, 2));
43
+ process.exit(report.eg1 === 'pass' ? 0 : 1);
44
+ }
45
+
46
+ const G = (s) => `\x1b[32m${s}\x1b[0m`;
47
+ const R = (s) => `\x1b[31m${s}\x1b[0m`;
48
+ const Y = (s) => `\x1b[33m${s}\x1b[0m`;
49
+ const B = (s) => `\x1b[1m${s}\x1b[0m`;
50
+ const line = (s = '') => console.log(s);
51
+
52
+ line('='.repeat(68));
53
+ line(B(' Agent Action Firewall Test — @emilia-protocol/fire-drill'));
54
+ line('='.repeat(68));
55
+ line(` Target: ${report.target_type} Operations: ${report.summary.operations} `
56
+ + `Dangerous: ${report.summary.dangerous} Gated: ${report.summary.gated}`);
57
+ const scoreStr = ` Agent Action Firewall score: ${report.score}/100`;
58
+ line(report.score === 100 ? G(scoreStr) : report.score >= 50 ? Y(scoreStr) : R(scoreStr));
59
+ line('');
60
+
61
+ if (report.findings.length === 0) {
62
+ line(G(' ✓ No dangerous action can run without a receipt.'));
63
+ line(G(` ✓ EG-1: ${report.eg1.toUpperCase()} — eligible for the "EG-1 Enforced" badge.`));
64
+ } else {
65
+ for (const f of report.findings) {
66
+ line(` ${R('✗')} ${f.message}`);
67
+ line(` ${Y('Fix:')} ${f.fix}`);
68
+ line(` ${Y('Earn:')} ${f.earn}`);
69
+ if (flags.has('--fix')) line(fixSnippet(f));
70
+ }
71
+ line('');
72
+ line(R(` EG-1: FAIL — ${report.summary.ungated} dangerous operation(s) can run without a receipt.`));
73
+ }
74
+ line(' ' + '-'.repeat(64));
75
+ line(' ' + B(TAGLINE));
76
+ line('='.repeat(68));
77
+ process.exit(report.eg1 === 'pass' ? 0 : 1);
78
+
79
+ function fixSnippet(f) {
80
+ if (f.family && f.fix.includes('adapters/')) {
81
+ return `\n ──────────────────────────────────────────────\n`
82
+ + ` import { createGate } from '@emilia-protocol/gate';\n`
83
+ + ` import { gateMcpTool } from '@emilia-protocol/gate/mcp';\n`
84
+ + ` const gate = createGate({ manifest, trustedKeys: [ISSUER] });\n`
85
+ + ` server.tool('${f.operation}', gateMcpTool(gate, { tool: '${f.operation}' }, handler));\n`
86
+ + ` ──────────────────────────────────────────────`;
87
+ }
88
+ return '';
89
+ }
package/index.js ADDED
@@ -0,0 +1,195 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * @emilia-protocol/fire-drill — the Agent Action Firewall Test.
4
+ *
5
+ * The vortex. People don't wake up wanting authorization receipts; they wake up
6
+ * afraid of being the screenshot: "our agent deleted prod and nobody can prove
7
+ * who approved it." This is the test they're scared to fail.
8
+ *
9
+ * Point it at an MCP manifest, an OpenAPI spec, or a tool list. It classifies
10
+ * each operation into the high-risk families (money / data destruction /
11
+ * production deploy / permission change / data export / regulated override) and
12
+ * checks whether a dangerous one can execute WITHOUT an accountable human
13
+ * receipt. Output: an Agent Action Firewall score, the failing operations, the
14
+ * fix (EMILIA Gate), and EG-1 pass/fail.
15
+ *
16
+ * This is a STATIC assessment from the manifest/spec — like SSL Labs or
17
+ * `npm audit`. It reveals the gap; EG-1 conformance verifies the fix at runtime.
18
+ * Zero dependencies so `npx` is instant.
19
+ */
20
+
21
+ export const FIRE_DRILL_VERSION = 'EP-FIRE-DRILL-v1';
22
+
23
+ // High-risk families, mirrored from the EMILIA Gate default action packs. Each
24
+ // matcher is a hardcoded literal (no dynamic RegExp) — a stem alternation plus a
25
+ // bounded, non-backtracking suffix group `(?:s|es|d|ed|ing|ment|ments|ion|ions|
26
+ // er|ers|al)?` at word boundaries. Text has separators normalized to spaces.
27
+ // Tolerant of plurals/verb forms ("permissions", "deleted", "payment") without
28
+ // over-matching ("payload" does not match "pay").
29
+ const FAMILIES = [
30
+ {
31
+ family: 'money_movement', label: 'Money movement', tier: 'class_a', adapter: 'stripe',
32
+ name: /\b(?:pay|payout|payment|refund|transfer|wire|charge|disburse|invoice|withdraw|remit|payroll)(?:s|es|d|ed|ing|ment|ments|ion|ions|er|ers|al)?\b/i,
33
+ why: 'Moves funds or releases value.',
34
+ },
35
+ {
36
+ family: 'permission_change', label: 'Permission / admin change', tier: 'quorum', adapter: 'aws',
37
+ name: /\b(?:permission|role|grant|revoke|iam|admin|privileg|polic|scope|rbac|collaborator)(?:s|es|d|ed|ing|ment|ments|ion|ions|er|ers|al)?\b/i,
38
+ why: 'Changes who can act next.',
39
+ },
40
+ {
41
+ family: 'production_deploy', label: 'Production deploy', tier: 'quorum', adapter: 'github',
42
+ name: /\b(?:deploy|release|rollout|promote|provision|terraform|migrate|apply)(?:s|es|d|ed|ing|ment|ments|ion|ions|er|ers|al)?\b/i,
43
+ why: 'Changes live production behavior or infrastructure.',
44
+ },
45
+ {
46
+ family: 'data_export', label: 'Bulk data export', tier: 'class_a', adapter: 'supabase',
47
+ name: /\b(?:export|dump|download|extract|backup|exfil)(?:s|es|d|ed|ing|ment|ments|ion|ions|er|ers|al)?\b/i,
48
+ why: 'Moves sensitive data out of its system of record.',
49
+ },
50
+ {
51
+ family: 'regulated_override', label: 'Regulated decision override', tier: 'quorum', adapter: null,
52
+ name: /\b(?:override|adjudicat|reverse|approv)\w{0,8}\b.{0,24}\b(?:claim|benefit|credit|decision|case|loan|patient)/i,
53
+ why: 'Changes a decision with legal, benefit, credit, clinical, or safety impact.',
54
+ },
55
+ {
56
+ family: 'data_destruction', label: 'Data destruction', tier: 'class_a', adapter: 'supabase',
57
+ name: /\b(?:delete|destroy|drop|truncate|purge|wipe|erase|remove|teardown)(?:s|es|d|ed|ing|ment|ments|ion|ions|er|ers|al)?\b/i,
58
+ why: 'Destroys or hides system-of-record state.',
59
+ },
60
+ ];
61
+
62
+ const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
63
+
64
+ /** Classify one operation. Returns the strongest matching family (or {dangerous:false}). */
65
+ export function classifyOperation({ name = '', description = '', method = '', path = '' } = {}) {
66
+ // Normalize separators to spaces: tool names like `release_payment` or
67
+ // `delete-customer` must produce word boundaries, since \b treats `_` as a
68
+ // word char (so \bpayment\b would NOT match `release_payment`).
69
+ const text = `${name} ${description} ${path}`.replace(/[_/.-]+/g, ' ');
70
+ const m = String(method || '').toUpperCase();
71
+ // An HTTP DELETE is destructive regardless of naming.
72
+ if (m === 'DELETE') {
73
+ const fam = FAMILIES.find((f) => f.family === 'data_destruction');
74
+ return { dangerous: true, ...famOut(fam) };
75
+ }
76
+ for (const f of FAMILIES) {
77
+ if (f.name.test(text)) {
78
+ // A read-only GET that merely mentions a word is not a mutation, EXCEPT
79
+ // export/download which is dangerous even via GET.
80
+ if (m === 'GET' && f.family !== 'data_export') continue;
81
+ return { dangerous: true, ...famOut(f) };
82
+ }
83
+ }
84
+ return { dangerous: false };
85
+ }
86
+ const famOut = (f) => ({ family: f.family, label: f.label, tier: f.tier, adapter: f.adapter, why: f.why });
87
+
88
+ /** Does this operation require a receipt (any receipt-shaped parameter / marker)? */
89
+ export function detectReceiptGate(op = {}, raw = null) {
90
+ const blob = JSON.stringify(raw ?? op ?? {});
91
+ // A JSON KEY shaped like a receipt (MCP inputSchema property, request-body field).
92
+ if (/"[a-z0-9_-]{0,20}(?:receipt|emilia|signoff)[a-z0-9_-]{0,20}"\s*:/i.test(blob)) return true;
93
+ // A parameter/header whose NAME value is receipt-shaped (OpenAPI parameters).
94
+ if (/"name"\s*:\s*"[a-z0-9_\- ]{0,20}(?:receipt|emilia)[a-z0-9_\- ]{0,20}"/i.test(blob)) return true;
95
+ // An explicit marker some manifests set.
96
+ if (raw && (raw['x-emilia'] || raw.emilia_gate === true || raw.x_emilia_receipt_required === true)) return true;
97
+ return false;
98
+ }
99
+
100
+ function operationFinding(op, raw) {
101
+ const cls = classifyOperation(op);
102
+ const gated = cls.dangerous ? detectReceiptGate(op, raw) : true;
103
+ return {
104
+ name: op.name,
105
+ method: op.method || null,
106
+ path: op.path || null,
107
+ dangerous: cls.dangerous,
108
+ family: cls.family || null,
109
+ label: cls.label || null,
110
+ tier: cls.tier || null,
111
+ adapter: cls.adapter || null,
112
+ why: cls.why || null,
113
+ gated,
114
+ };
115
+ }
116
+
117
+ // ── Input adapters ───────────────────────────────────────────────────────────
118
+
119
+ export function scanMcpManifest(manifest = {}) {
120
+ const tools = manifest.tools || manifest.capabilities?.tools || [];
121
+ const ops = tools.map((t) => operationFinding(
122
+ { name: t.name, description: t.description || '', method: '', path: '' }, t,
123
+ ));
124
+ return buildReport(ops, 'mcp');
125
+ }
126
+
127
+ export function scanOpenApi(spec = {}) {
128
+ const ops = [];
129
+ const paths = spec.paths || {};
130
+ for (const [path, methods] of Object.entries(paths)) {
131
+ for (const [method, op] of Object.entries(methods || {})) {
132
+ if (!MUTATING_METHODS.has(method.toUpperCase()) && method.toUpperCase() !== 'GET') continue;
133
+ const finding = operationFinding({
134
+ name: op.operationId || `${method.toUpperCase()} ${path}`,
135
+ description: op.summary || op.description || '',
136
+ method, path,
137
+ }, op);
138
+ ops.push(finding);
139
+ }
140
+ }
141
+ return buildReport(ops, 'openapi');
142
+ }
143
+
144
+ export function scanToolList(list = []) {
145
+ return buildReport(list.map((t) => operationFinding({ name: t.name, description: t.description || '' }, t)), 'tools');
146
+ }
147
+
148
+ /** Auto-detect the input shape and scan. */
149
+ export function scan(input) {
150
+ if (Array.isArray(input)) return scanToolList(input);
151
+ if (input && (input.openapi || input.swagger || input.paths)) return scanOpenApi(input);
152
+ if (input && (input.tools || input.capabilities?.tools)) return scanMcpManifest(input);
153
+ throw new Error('fire-drill: unrecognized input. Expected an MCP manifest ({tools}), an OpenAPI spec ({paths}), or a tool array.');
154
+ }
155
+
156
+ // ── Report ───────────────────────────────────────────────────────────────────
157
+
158
+ export function buildReport(operations, targetType = 'unknown') {
159
+ const dangerous = operations.filter((o) => o.dangerous);
160
+ const ungated = dangerous.filter((o) => !o.gated);
161
+ const score = dangerous.length === 0 ? 100 : Math.round(((dangerous.length - ungated.length) / dangerous.length) * 100);
162
+ const findings = ungated.map((o) => ({
163
+ severity: o.tier === 'quorum' ? 'critical' : 'high',
164
+ operation: o.name,
165
+ family: o.family,
166
+ message: `FAIL: \`${o.name}\` can execute without an accountable human receipt (${o.label}).`,
167
+ fix: o.adapter
168
+ ? `Add EMILIA Gate — @emilia-protocol/gate/adapters/${o.adapter} (or gateMcpTool) requiring a ${o.tier} receipt.`
169
+ : `Add EMILIA Gate — require a ${o.tier} receipt for this action.`,
170
+ earn: 'EG-1 Enforced',
171
+ }));
172
+ return {
173
+ '@version': FIRE_DRILL_VERSION,
174
+ target_type: targetType,
175
+ score,
176
+ eg1: ungated.length === 0 ? 'pass' : 'fail',
177
+ summary: {
178
+ operations: operations.length,
179
+ dangerous: dangerous.length,
180
+ gated: dangerous.length - ungated.length,
181
+ ungated: ungated.length,
182
+ },
183
+ findings,
184
+ operations,
185
+ note: 'Static assessment from the manifest/spec. Verify the fix at runtime with EG-1 conformance (@emilia-protocol/gate).',
186
+ };
187
+ }
188
+
189
+ export const TAGLINE = 'If your agent can take an irreversible action without a receipt, you do not have control. You have hope.';
190
+
191
+ export default {
192
+ FIRE_DRILL_VERSION, TAGLINE,
193
+ classifyOperation, detectReceiptGate, buildReport,
194
+ scan, scanMcpManifest, scanOpenApi, scanToolList,
195
+ };
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "@emilia-protocol/fire-drill",
3
+ "version": "0.1.0",
4
+ "description": "The Agent Action Firewall Test. Scan any MCP server manifest, OpenAPI spec, or tool list for dangerous actions an AI agent can take without an accountable human receipt — money movement, data destruction, production deploy, permission change, bulk export, regulated override. Reports an Agent Action Firewall score, the failing operations, the fix (EMILIA Gate), and EG-1 pass/fail. Static, zero-dependency, CI-friendly.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "index.js",
8
+ "bin": { "fire-drill": "cli.mjs" },
9
+ "exports": { ".": "./index.js", "./package.json": "./package.json" },
10
+ "files": ["index.js", "cli.mjs", "README.md"],
11
+ "scripts": { "test": "node --test" },
12
+ "keywords": ["emilia", "mcp", "mcp-security", "agent", "ai-safety", "ai-agent-security", "firewall", "openapi", "scanner", "fire-drill", "authorization", "receipt", "eg-1"]
13
+ }