@emilia-protocol/fire-drill 0.1.1 → 0.3.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/cli.mjs +7 -1
- package/corpus.js +53 -0
- package/corpus.mjs +61 -0
- package/index.js +100 -0
- package/package.json +3 -3
package/cli.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* @license Apache-2.0
|
|
13
13
|
*/
|
|
14
14
|
import fs from 'node:fs';
|
|
15
|
-
import { scan, TAGLINE } from './index.js';
|
|
15
|
+
import { scan, TAGLINE, generatePullRequest } from './index.js';
|
|
16
16
|
|
|
17
17
|
const argv = process.argv.slice(2);
|
|
18
18
|
const flags = new Set(argv.filter((a) => a.startsWith('--')));
|
|
@@ -43,6 +43,12 @@ if (flags.has('--json')) {
|
|
|
43
43
|
process.exit(report.eg1 === 'pass' ? 0 : 1);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
if (flags.has('--pr')) {
|
|
47
|
+
const pr = generatePullRequest(report);
|
|
48
|
+
console.log(`# ${pr.title}\n\n${pr.body}`);
|
|
49
|
+
process.exit(report.eg1 === 'pass' ? 0 : 1);
|
|
50
|
+
}
|
|
51
|
+
|
|
46
52
|
const G = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
47
53
|
const R = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
48
54
|
const Y = (s) => `\x1b[33m${s}\x1b[0m`;
|
package/corpus.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* A representative sample of common MCP server tool surfaces, modeled on the
|
|
4
|
+
* publicly-documented tools of widely-used servers. This is a SAMPLE for the
|
|
5
|
+
* Report's computed figure — not the whole ecosystem. Point `corpus.mjs` at a
|
|
6
|
+
* directory of real manifests to compute the index over a larger corpus.
|
|
7
|
+
*
|
|
8
|
+
* Each entry is { name, manifest } where manifest is an MCP-style { tools }.
|
|
9
|
+
*/
|
|
10
|
+
export const REPRESENTATIVE_CORPUS = [
|
|
11
|
+
{
|
|
12
|
+
name: 'filesystem',
|
|
13
|
+
manifest: { tools: [{ name: 'read_file' }, { name: 'write_file' }, { name: 'list_directory' }, { name: 'delete_file', description: 'delete a file from disk' }, { name: 'move_file' }] },
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: 'github',
|
|
17
|
+
manifest: { tools: [{ name: 'get_repo' }, { name: 'create_issue' }, { name: 'delete_repository', description: 'delete a repo' }, { name: 'add_collaborator', description: 'grant access' }] },
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'postgres',
|
|
21
|
+
manifest: { tools: [{ name: 'query', description: 'run a read query' }, { name: 'execute_sql', description: 'run arbitrary SQL including DELETE and DROP' }] },
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'stripe',
|
|
25
|
+
manifest: { tools: [{ name: 'list_charges' }, { name: 'create_payout', description: 'pay out funds' }, { name: 'refund_charge', description: 'refund a charge' }] },
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'shopify',
|
|
29
|
+
manifest: { tools: [{ name: 'get_product' }, { name: 'delete_product', description: 'remove a product' }, { name: 'cancel_order', description: 'cancel a customer order' }] },
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: 'slack',
|
|
33
|
+
manifest: { tools: [{ name: 'post_message' }, { name: 'list_channels' }, { name: 'delete_message', description: 'delete a message' }] },
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'google-drive',
|
|
37
|
+
manifest: { tools: [{ name: 'search_files' }, { name: 'export_file', description: 'export and download a document' }, { name: 'delete_file' }] },
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'aws',
|
|
41
|
+
manifest: { tools: [{ name: 'describe_instances' }, { name: 'attach_user_policy', description: 'grant IAM permissions' }, { name: 'delete_user' }] },
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'notion',
|
|
45
|
+
manifest: { tools: [{ name: 'search' }, { name: 'create_page' }, { name: 'delete_block', description: 'delete content' }] },
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'weather (read-only)',
|
|
49
|
+
manifest: { tools: [{ name: 'get_forecast' }, { name: 'get_alerts' }, { name: 'list_stations' }] },
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
export default { REPRESENTATIVE_CORPUS };
|
package/corpus.mjs
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* Agent Action Firewall Index — scan a corpus of MCP manifests / OpenAPI specs
|
|
5
|
+
* and aggregate. The number behind the Report.
|
|
6
|
+
*
|
|
7
|
+
* node corpus.mjs <dir> scan every *.json under <dir>
|
|
8
|
+
* node corpus.mjs scan the bundled representative sample
|
|
9
|
+
* node corpus.mjs <dir> --json
|
|
10
|
+
*
|
|
11
|
+
* @license Apache-2.0
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { scan, aggregate } from './index.js';
|
|
16
|
+
import { REPRESENTATIVE_CORPUS } from './corpus.js';
|
|
17
|
+
|
|
18
|
+
const argv = process.argv.slice(2);
|
|
19
|
+
const json = argv.includes('--json');
|
|
20
|
+
const dir = argv.find((a) => !a.startsWith('--'));
|
|
21
|
+
|
|
22
|
+
const reports = [];
|
|
23
|
+
const labels = [];
|
|
24
|
+
if (dir) {
|
|
25
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
|
|
26
|
+
for (const f of files) {
|
|
27
|
+
try {
|
|
28
|
+
reports.push(scan(JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'))));
|
|
29
|
+
labels.push(f);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
console.error(`skip ${f}: ${e.message}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} else {
|
|
35
|
+
for (const { name, manifest } of REPRESENTATIVE_CORPUS) {
|
|
36
|
+
reports.push(scan(manifest));
|
|
37
|
+
labels.push(name);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const agg = aggregate(reports);
|
|
42
|
+
|
|
43
|
+
if (json) {
|
|
44
|
+
console.log(JSON.stringify(agg, null, 2));
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const R = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
49
|
+
const G = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
50
|
+
console.log('='.repeat(66));
|
|
51
|
+
console.log(' Agent Action Firewall Index');
|
|
52
|
+
console.log('='.repeat(66));
|
|
53
|
+
reports.forEach((r, i) => {
|
|
54
|
+
const tag = r.eg1 === 'pass' ? G('EG-1') : R(`${r.summary.ungated} unguarded`);
|
|
55
|
+
console.log(` ${String(r.score).padStart(3)}/100 ${labels[i].padEnd(22)} ${tag}`);
|
|
56
|
+
});
|
|
57
|
+
console.log(' ' + '-'.repeat(62));
|
|
58
|
+
console.log(` ${agg.servers} servers · ${R(`${agg.pct_servers_with_unguarded_action}%`)} expose a dangerous action with NO receipt requirement`);
|
|
59
|
+
console.log(` ${agg.unguarded_operations} unguarded dangerous operations · mean score ${agg.mean_score}/100`);
|
|
60
|
+
console.log(` by family: ${Object.entries(agg.by_family).map(([k, v]) => `${k}=${v}`).join(' · ') || 'none'}`);
|
|
61
|
+
console.log('='.repeat(66));
|
package/index.js
CHANGED
|
@@ -188,8 +188,108 @@ export function buildReport(operations, targetType = 'unknown') {
|
|
|
188
188
|
|
|
189
189
|
export const TAGLINE = 'If your agent can take an irreversible action without a receipt, you do not have control. You have hope.';
|
|
190
190
|
|
|
191
|
+
// ── Shareable badge ──────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
/** Approximate pixel width of a label segment (flat-badge sizing, no font metrics). */
|
|
194
|
+
function segWidth(text) {
|
|
195
|
+
return Math.max(40, Math.round(String(text).length * 6.6) + 20);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* A shields-style flat SVG badge: "agent action firewall | EG-1 Enforced".
|
|
200
|
+
* Green when EG-1 passes / score 100, amber for partial, red for fail.
|
|
201
|
+
* @param {object} o
|
|
202
|
+
* @param {'pass'|'fail'} [o.eg1]
|
|
203
|
+
* @param {number} [o.score] 0..100 — overrides the message with "N/100" when given
|
|
204
|
+
* @param {string} [o.label='agent action firewall']
|
|
205
|
+
*/
|
|
206
|
+
export function badgeSvg({ eg1, score, label = 'agent action firewall' } = {}) {
|
|
207
|
+
const hasScore = typeof score === 'number' && Number.isFinite(score);
|
|
208
|
+
const pass = eg1 === 'pass' || score === 100;
|
|
209
|
+
const message = hasScore ? `${score}/100` : (pass ? 'EG-1 Enforced' : 'EG-1 not earned');
|
|
210
|
+
const color = pass ? '#16A34A' : (hasScore && score >= 50 ? '#D97706' : '#DC2626');
|
|
211
|
+
const lw = segWidth(label);
|
|
212
|
+
const mw = segWidth(message);
|
|
213
|
+
const w = lw + mw;
|
|
214
|
+
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
215
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="20" role="img" aria-label="${esc(label)}: ${esc(message)}">`
|
|
216
|
+
+ `<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>`
|
|
217
|
+
+ `<rect rx="3" width="${w}" height="20" fill="#555"/>`
|
|
218
|
+
+ `<rect rx="3" x="${lw}" width="${mw}" height="20" fill="${color}"/>`
|
|
219
|
+
+ `<rect rx="3" width="${w}" height="20" fill="url(#s)"/>`
|
|
220
|
+
+ `<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">`
|
|
221
|
+
+ `<text x="${lw / 2}" y="14">${esc(label)}</text>`
|
|
222
|
+
+ `<text x="${lw + mw / 2}" y="14">${esc(message)}</text>`
|
|
223
|
+
+ `</g></svg>`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── Corpus aggregation (the Report) ──────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Aggregate many scan reports into one Agent Action Firewall Index — the figure
|
|
230
|
+
* behind the Report. Honest by construction: it only summarizes the reports it
|
|
231
|
+
* was given; the caller decides the corpus.
|
|
232
|
+
* @param {object[]} reports buildReport() results
|
|
233
|
+
*/
|
|
234
|
+
export function aggregate(reports = []) {
|
|
235
|
+
const servers = reports.length;
|
|
236
|
+
let dangerous = 0;
|
|
237
|
+
let ungated = 0;
|
|
238
|
+
let withUnguarded = 0;
|
|
239
|
+
let scoreSum = 0;
|
|
240
|
+
const byFamily = {};
|
|
241
|
+
for (const r of reports) {
|
|
242
|
+
dangerous += r.summary.dangerous;
|
|
243
|
+
ungated += r.summary.ungated;
|
|
244
|
+
if (r.summary.ungated > 0) withUnguarded += 1;
|
|
245
|
+
scoreSum += r.score;
|
|
246
|
+
for (const f of (r.findings || [])) byFamily[f.family] = (byFamily[f.family] || 0) + 1;
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
'@version': FIRE_DRILL_VERSION,
|
|
250
|
+
servers,
|
|
251
|
+
servers_with_unguarded_action: withUnguarded,
|
|
252
|
+
pct_servers_with_unguarded_action: servers ? Math.round((withUnguarded / servers) * 100) : 0,
|
|
253
|
+
dangerous_operations: dangerous,
|
|
254
|
+
unguarded_operations: ungated,
|
|
255
|
+
mean_score: servers ? Math.round(scoreSum / servers) : 100,
|
|
256
|
+
by_family: byFamily,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── Generate-PR ──────────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Turn a report into a ready-to-open pull request (title + Markdown body) that
|
|
264
|
+
* tells a maintainer exactly which dangerous tools to gate and how to earn EG-1.
|
|
265
|
+
* @param {object} report a buildReport() result
|
|
266
|
+
* @param {object} [o]
|
|
267
|
+
* @param {string} [o.project] project/repo name for the title
|
|
268
|
+
*/
|
|
269
|
+
export function generatePullRequest(report, { project } = {}) {
|
|
270
|
+
const name = project ? ` for ${project}` : '';
|
|
271
|
+
const failing = report.findings || [];
|
|
272
|
+
const title = failing.length
|
|
273
|
+
? `Require an EMILIA receipt for ${failing.length} high-risk action${failing.length > 1 ? 's' : ''}${name} (earn EG-1)`
|
|
274
|
+
: `Confirm EG-1 Enforced${name}`;
|
|
275
|
+
const lines = [];
|
|
276
|
+
lines.push(`## Agent Action Firewall: ${report.score}/100 (EG-1 ${report.eg1.toUpperCase()})`, '');
|
|
277
|
+
if (!failing.length) {
|
|
278
|
+
lines.push('No dangerous action can run without a receipt. This integration is **EG-1 Enforced**.', '');
|
|
279
|
+
} else {
|
|
280
|
+
lines.push('These tool calls can mutate money, data, permissions, or production **without an accountable human receipt**:', '');
|
|
281
|
+
for (const f of failing) lines.push(`- \`${f.operation}\` — ${f.family} — ${f.fix}`);
|
|
282
|
+
lines.push('', '### Fix', '', '```js', `import { createGate } from '@emilia-protocol/gate';`, `import { gateMcpTool } from '@emilia-protocol/gate/mcp';`, '', `const gate = createGate({ manifest, trustedKeys: [process.env.EMILIA_ISSUER] });`);
|
|
283
|
+
for (const f of failing) lines.push(`server.tool('${f.operation}', gateMcpTool(gate, { tool: '${f.operation}' }, handler));`);
|
|
284
|
+
lines.push('```', '', `Then \`npx @emilia-protocol/fire-drill <manifest>\` should report **EG-1 Enforced**.`);
|
|
285
|
+
}
|
|
286
|
+
lines.push('', '---', `_Generated by [@emilia-protocol/fire-drill](https://www.npmjs.com/package/@emilia-protocol/fire-drill) — the Agent Action Firewall Test._`);
|
|
287
|
+
return { title, body: lines.join('\n') };
|
|
288
|
+
}
|
|
289
|
+
|
|
191
290
|
export default {
|
|
192
291
|
FIRE_DRILL_VERSION, TAGLINE,
|
|
193
292
|
classifyOperation, detectReceiptGate, buildReport,
|
|
194
293
|
scan, scanMcpManifest, scanOpenApi, scanToolList,
|
|
294
|
+
badgeSvg, generatePullRequest, aggregate,
|
|
195
295
|
};
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@emilia-protocol/fire-drill",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
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
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "index.js",
|
|
8
8
|
"bin": { "fire-drill": "cli.mjs" },
|
|
9
|
-
"exports": { ".": "./index.js", "./package.json": "./package.json" },
|
|
10
|
-
"files": ["index.js", "cli.mjs", "README.md"],
|
|
9
|
+
"exports": { ".": "./index.js", "./corpus.js": "./corpus.js", "./package.json": "./package.json" },
|
|
10
|
+
"files": ["index.js", "cli.mjs", "corpus.js", "corpus.mjs", "README.md"],
|
|
11
11
|
"scripts": { "test": "node --test" },
|
|
12
12
|
"keywords": ["emilia", "mcp", "mcp-security", "agent", "ai-safety", "ai-agent-security", "firewall", "openapi", "scanner", "fire-drill", "authorization", "receipt", "eg-1"]
|
|
13
13
|
}
|