@emilia-protocol/fire-drill 0.2.0 → 0.3.1

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/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
@@ -211,7 +211,13 @@ export function badgeSvg({ eg1, score, label = 'agent action firewall' } = {}) {
211
211
  const lw = segWidth(label);
212
212
  const mw = segWidth(message);
213
213
  const w = lw + mw;
214
- const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
214
+ // Escape ALL XML-significant chars including quotes — because `label`/`message`
215
+ // are interpolated into a double-quoted SVG attribute (aria-label). Missing the
216
+ // quote escape allowed attribute breakout / event-handler injection when the SVG
217
+ // is served as image/svg+xml from a public route.
218
+ const esc = (s) => String(s)
219
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
220
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
215
221
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="20" role="img" aria-label="${esc(label)}: ${esc(message)}">`
216
222
  + `<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>`
217
223
  + `<rect rx="3" width="${w}" height="20" fill="#555"/>`
@@ -223,6 +229,40 @@ export function badgeSvg({ eg1, score, label = 'agent action firewall' } = {}) {
223
229
  + `</g></svg>`;
224
230
  }
225
231
 
232
+ // ── Corpus aggregation (the Report) ──────────────────────────────────────────
233
+
234
+ /**
235
+ * Aggregate many scan reports into one Agent Action Firewall Index — the figure
236
+ * behind the Report. Honest by construction: it only summarizes the reports it
237
+ * was given; the caller decides the corpus.
238
+ * @param {object[]} reports buildReport() results
239
+ */
240
+ export function aggregate(reports = []) {
241
+ const servers = reports.length;
242
+ let dangerous = 0;
243
+ let ungated = 0;
244
+ let withUnguarded = 0;
245
+ let scoreSum = 0;
246
+ const byFamily = {};
247
+ for (const r of reports) {
248
+ dangerous += r.summary.dangerous;
249
+ ungated += r.summary.ungated;
250
+ if (r.summary.ungated > 0) withUnguarded += 1;
251
+ scoreSum += r.score;
252
+ for (const f of (r.findings || [])) byFamily[f.family] = (byFamily[f.family] || 0) + 1;
253
+ }
254
+ return {
255
+ '@version': FIRE_DRILL_VERSION,
256
+ servers,
257
+ servers_with_unguarded_action: withUnguarded,
258
+ pct_servers_with_unguarded_action: servers ? Math.round((withUnguarded / servers) * 100) : 0,
259
+ dangerous_operations: dangerous,
260
+ unguarded_operations: ungated,
261
+ mean_score: servers ? Math.round(scoreSum / servers) : 100,
262
+ by_family: byFamily,
263
+ };
264
+ }
265
+
226
266
  // ── Generate-PR ──────────────────────────────────────────────────────────────
227
267
 
228
268
  /**
@@ -257,5 +297,5 @@ export default {
257
297
  FIRE_DRILL_VERSION, TAGLINE,
258
298
  classifyOperation, detectReceiptGate, buildReport,
259
299
  scan, scanMcpManifest, scanOpenApi, scanToolList,
260
- badgeSvg, generatePullRequest,
300
+ badgeSvg, generatePullRequest, aggregate,
261
301
  };
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@emilia-protocol/fire-drill",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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
  }