argusqa-os 9.8.1 → 10.0.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/glama.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "$schema": "https://glama.ai/mcp/schemas/server.json",
3
3
  "name": "argus",
4
- "description": "AI-powered QA harness that audits web apps via Chrome DevTools Protocol. Catches JS errors, network failures, a11y violations, SEO issues, security headers, CSS regressions, and more — directly from Claude conversations. 9 MCP tools: argus_audit (fast 8-analyzer pass), argus_audit_full (Lighthouse + memory + responsive), argus_compare (dev vs staging diff), argus_last_report (retrieve last JSON report), argus_watch_snapshot (live tab snapshot without navigating), argus_get_context (LLM-optimized context + fix loop with snapshot_id diff), argus_design_audit (Figma design fidelity — 13 finding types), argus_visual_diff (screenshot baseline comparison, updateBaseline flag), argus_pr_validate (PR diff → affected routes → baseline-aware targeted audit + PR comment/Check Run → blocked flag). Every finding is post-processed with intelligent baseline filtering (cross-run noise classifier) and root cause linking (recent git commits mapped to new findings). 166 test blocks, 961 hard assertions, 67 detection categories.",
4
+ "description": "AI-powered QA harness that audits web apps via Chrome DevTools Protocol. Catches JS errors, network failures, a11y violations, SEO issues, security headers, CSS regressions, and more — directly from Claude conversations. 9 MCP tools: argus_audit (fast 8-analyzer pass), argus_audit_full (Lighthouse + memory + responsive), argus_compare (dev vs staging diff), argus_last_report (retrieve last JSON report), argus_watch_snapshot (live tab snapshot without navigating), argus_get_context (LLM-optimized context + fix loop with snapshot_id diff), argus_design_audit (Figma design fidelity — 13 finding types), argus_visual_diff (screenshot baseline comparison, updateBaseline flag), argus_pr_validate (PR diff → affected routes → baseline-aware targeted audit + PR comment/Check Run → blocked flag). Every finding is post-processed with intelligent baseline filtering (cross-run noise classifier) and root cause linking (recent git commits mapped to new findings). Every tool response passes through the Aegis confidentiality egress boundary (default-on, fail-closed): secrets, PII, and exploit detail are redacted before a finding can cross into the calling agent's context window — the local on-disk report keeps 100% fidelity, opt out with ARGUS_REDACT_SENSITIVE=0. 171 test blocks, 998 hard assertions, 67 detection categories.",
5
5
  "maintainers": ["ironclawdevs27"],
6
6
  "tools": [
7
7
  {
8
8
  "name": "argus_audit",
9
- "description": "Fast QA audit — JS errors, network failures (4xx/5xx), CORS, API frequency loops, slow/blocking third-party requests, API contract violations, SEO violations, security headers, content quality, DevTools Issues panel, and HTTPS enforcement. Returns { findings, summary }. Supports cache: true to skip re-crawl on repeat calls."
9
+ "description": "Fast QA audit — JS errors, network failures (4xx/5xx), CORS, API frequency loops, slow/blocking third-party requests, API contract violations, SEO violations, security headers, content quality, DevTools Issues panel, and HTTPS enforcement. Returns { findings, summary } plus an optional redaction rider when the Aegis egress boundary withholds sensitive detail. Supports cache: true to skip re-crawl on repeat calls."
10
10
  },
11
11
  {
12
12
  "name": "argus_audit_full",
package/package.json CHANGED
@@ -1,18 +1,24 @@
1
1
  {
2
2
  "name": "argusqa-os",
3
- "version": "9.8.1",
3
+ "version": "10.0.0",
4
4
  "mcpName": "io.github.ironclawdevs27/argus",
5
- "description": "Argus — AI-powered automated dev-testing platform using Chrome DevTools MCP and Claude Code",
5
+ "description": "Argus — the QA layer for AI-assisted development. Claude-native (MCP) Chrome audits across 67 categories — errors, visual regressions, a11y, security, performance — no test files, secrets redacted by default (Aegis).",
6
6
  "keywords": [
7
7
  "mcp",
8
8
  "mcp-server",
9
9
  "claude",
10
+ "claude-code",
11
+ "ai-agents",
12
+ "agentic-coding",
10
13
  "qa",
11
14
  "testing",
12
15
  "accessibility",
13
16
  "automation",
14
17
  "chrome-devtools",
15
- "web-testing"
18
+ "web-testing",
19
+ "visual-regression",
20
+ "web-vitals",
21
+ "security"
16
22
  ],
17
23
  "author": "ironclawdevs",
18
24
  "license": "MIT",
@@ -23,6 +29,7 @@
23
29
  },
24
30
  "files": [
25
31
  "src/",
32
+ "scripts/rehydrate-report.mjs",
26
33
  ".mcp.json",
27
34
  "glama.json"
28
35
  ],
@@ -43,6 +50,7 @@
43
50
  "init": "node src/cli/init.js",
44
51
  "chrome": "node src/cli/chrome-launcher.js",
45
52
  "doctor": "node src/cli/doctor.js",
53
+ "metrics": "node scripts/metrics-snapshot.mjs",
46
54
  "crawl": "node src/orchestration/crawl-and-report.js",
47
55
  "compare": "node src/orchestration/env-comparison.js",
48
56
  "watch": "node src/orchestration/watch-mode.js",
@@ -59,6 +67,7 @@
59
67
  "test:coverage": "npm run coverage:harness && npm run coverage:unit && npm run coverage:gate",
60
68
  "report:html": "node src/utils/html-reporter.js",
61
69
  "report:pdf": "node src/utils/pdf-exporter.js",
70
+ "report:rehydrate": "node scripts/rehydrate-report.mjs",
62
71
  "mcp-server": "node src/mcp-server.js"
63
72
  },
64
73
  "dependencies": {
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Aegis rehydrate CLI (Step 8 of REDACTION_BOUNDARY_MAX_PLAN.md).
4
+ *
5
+ * Re-inflates a token-redacted report LOCALLY by swapping every `AEGIS_` vault token
6
+ * back to its original from the on-disk vault. This is a LOCAL-ONLY operator tool —
7
+ * it requires the per-machine vault key + the mapping files under
8
+ * reports/.aegis-vault/ (both 0600, gitignored). The token that crossed the trust
9
+ * boundary is information-free; only this machine can re-hydrate.
10
+ *
11
+ * npm run report:rehydrate -- <report.json> [--stdout]
12
+ *
13
+ * Without --stdout, writes <report>.rehydrated.json next to the input and prints the
14
+ * path. The original (redacted) file is never modified.
15
+ */
16
+ import fs from 'fs';
17
+ import { rehydrate, loadVault } from '../src/utils/aegis-vault.js';
18
+
19
+ const args = process.argv.slice(2);
20
+ const toStdout = args.includes('--stdout');
21
+ const file = args.find((a) => !a.startsWith('--'));
22
+
23
+ if (!file) {
24
+ console.error('Usage: npm run report:rehydrate -- <report.json> [--stdout]');
25
+ process.exit(1);
26
+ }
27
+
28
+ let raw;
29
+ try {
30
+ raw = fs.readFileSync(file, 'utf8');
31
+ } catch (err) {
32
+ console.error(`[Aegis] cannot read ${file}: ${err.message}`);
33
+ process.exit(1);
34
+ }
35
+
36
+ let obj;
37
+ try {
38
+ obj = JSON.parse(raw);
39
+ } catch (err) {
40
+ console.error(`[Aegis] ${file} is not valid JSON: ${err.message}`);
41
+ process.exit(1);
42
+ }
43
+
44
+ const vault = loadVault();
45
+ if (vault.size === 0) {
46
+ console.error('[Aegis] vault is empty — nothing to rehydrate. Was ARGUS_REDACT_VAULT=1 (and ARGUS_REDACT_MODE=token) set during the run?');
47
+ }
48
+
49
+ const out = rehydrate(obj, vault);
50
+
51
+ if (toStdout) {
52
+ process.stdout.write(JSON.stringify(out, null, 2) + '\n');
53
+ } else {
54
+ const outPath = file.replace(/\.json$/i, '') + '.rehydrated.json';
55
+ try {
56
+ fs.writeFileSync(outPath, JSON.stringify(out, null, 2), 'utf8');
57
+ console.error(`[Aegis] rehydrated using ${vault.size} mapping(s) → ${outPath}`);
58
+ } catch (err) {
59
+ console.error(`[Aegis] could not write ${outPath}: ${err.message}`);
60
+ process.exit(1);
61
+ }
62
+ }
@@ -31,6 +31,8 @@ import { auditRoutesConcurrently, auditRouteWithRetry, routeResilienceFromEnv }
31
31
  import { fetchPrFiles, mapFilesToRoutesDeep, resolveAnnotationTarget } from '../utils/pr-diff-analyzer.js';
32
32
  import { resolveTargetUrl } from '../utils/deploy-preview.js';
33
33
  import { reportPrValidation } from '../utils/github-reporter.js';
34
+ import { redactForEgress } from '../utils/sensitivity-classifier.js';
35
+ import { scrubText } from '../utils/secret-patterns.js';
34
36
  import { getCurrentBranch } from '../utils/baseline-manager.js';
35
37
  import {
36
38
  decidePrBlock, resolvePrBaselineFile, loadPrBaseline, savePrBaseline, tagFindingNovelty, severityTally,
@@ -38,6 +40,24 @@ import {
38
40
 
39
41
  // ── Exported helpers (testable without Chrome) ────────────────────────────────
40
42
 
43
+ /**
44
+ * Aegis egress guard (Step 7): the $GITHUB_STEP_SUMMARY is a CI artifact readable by anyone
45
+ * with repo access (A4), so a finding's free-text must never cross raw. Project the finding
46
+ * through redactForEgress and return its egress-safe `message` + `url`: a sensitive finding's
47
+ * message collapses to the 🔒 marker, a benign one keeps its scrubbed message; the url is
48
+ * stripped of query/userinfo. ARGUS_REDACT_SENSITIVE=0 ⇒ raw (byte-identical to pre-Aegis).
49
+ *
50
+ * @param {object} f
51
+ * @returns {{ message: string, url: string }}
52
+ */
53
+ export function safeFindingLine(f) {
54
+ if (process.env.ARGUS_REDACT_SENSITIVE === '0') {
55
+ return { message: String(f?.message ?? ''), url: String(f?.url ?? '') };
56
+ }
57
+ const [p] = redactForEgress([f]);
58
+ return { message: String(p?.message ?? ''), url: String(p?.url ?? '') };
59
+ }
60
+
41
61
  /**
42
62
  * Build a GitHub-flavoured markdown step summary.
43
63
  *
@@ -105,8 +125,9 @@ export function buildStepSummary({ blocked, summary, affectedRoutes, perRoute, f
105
125
  const sev = f.severity === 'critical' ? '🔴 critical'
106
126
  : f.severity === 'warning' ? '⚠️ warning'
107
127
  : 'ℹ️ info';
108
- const msg = String(f.message ?? '').replace(/\|/g, '\\|').slice(0, 100);
109
- const url = String(f.url ?? '').replace(/\|/g, '\\|').slice(0, 80);
128
+ const safe = safeFindingLine(f); // Aegis: scrub message + strip url query before egress
129
+ const msg = safe.message.replace(/\|/g, '\\|').slice(0, 100);
130
+ const url = safe.url.replace(/\|/g, '\\|').slice(0, 80);
110
131
  md += `| ${sev} | \`${f.type ?? ''}\` | ${msg} | ${url} |\n`;
111
132
  }
112
133
  if (findings.length > 50) {
@@ -455,11 +476,18 @@ async function main() {
455
476
  // genuine diff line (see pr-diff-analyzer.js).
456
477
  const annTarget = resolveAnnotationTarget(route.path, prFiles);
457
478
  const loc = annTarget ? ` file=${annTarget.path},line=${annTarget.line}` : '';
479
+ // Aegis (Step 7): a GitHub Actions ::error/::warning annotation is rendered on the PR and
480
+ // captured in the CI log (A4). scrubText the message so a secret/PII captured from the page
481
+ // never lands in an annotation; the type + audit-target url stay (structural, non-secret).
482
+ const annMsg = (m) => {
483
+ const s = String(m ?? '');
484
+ return (process.env.ARGUS_REDACT_SENSITIVE === '0' ? s : scrubText(s)).replace(/\n/g, ' ');
485
+ };
458
486
  for (const f of findings.filter(g => g.severity === 'critical')) {
459
- console.log(`::error${loc}::${String(f.message ?? '').replace(/\n/g, ' ')} [${f.type}] on ${url}`);
487
+ console.log(`::error${loc}::${annMsg(f.message)} [${f.type}] on ${url}`);
460
488
  }
461
489
  for (const f of findings.filter(g => g.severity === 'warning')) {
462
- console.log(`::warning${loc}::${String(f.message ?? '').replace(/\n/g, ' ')} [${f.type}] on ${url}`);
490
+ console.log(`::warning${loc}::${annMsg(f.message)} [${f.type}] on ${url}`);
463
491
  }
464
492
  }
465
493
 
@@ -546,7 +574,14 @@ async function main() {
546
574
  blockOn,
547
575
  baseline: baselineInfo,
548
576
  };
549
- console.log(JSON.stringify(result, null, 2));
577
+ // Aegis (Step 7): the CLI prints this result to stdout, which lands in the GitHub Actions log
578
+ // (world-readable for public repos, A4). Project the findings through redactForEgress for the
579
+ // printed view (type/severity/route/scrubbed-message survive — the downstream contract holds);
580
+ // reportPrValidation below receives the RAW `result` and redacts independently. Opt-out ⇒ raw.
581
+ const printResult = process.env.ARGUS_REDACT_SENSITIVE === '0'
582
+ ? result
583
+ : { ...result, findings: redactForEgress(allFindings) };
584
+ console.log(JSON.stringify(printResult, null, 2));
550
585
 
551
586
  // Step 9: Post/update the Argus PR comment + Check Run (Phase A). Idempotent — updates
552
587
  // the single marker-tagged comment in place; the Check Run conclusion maps to the block
package/src/mcp-server.js CHANGED
@@ -45,6 +45,10 @@ import { getCurrentBranch } from './utils/baseline-manager.js
45
45
  import {
46
46
  decidePrBlock, resolvePrBaselineFile, loadPrBaseline, savePrBaseline, tagFindingNovelty, severityTally,
47
47
  } from './utils/pr-baseline.js';
48
+ import {
49
+ redactForEgress, buildRedactionRider, redactReport, deepScrub,
50
+ } from './utils/sensitivity-classifier.js';
51
+ import { flushTeamVault } from './utils/team-vault.js';
48
52
 
49
53
  const logger = childLogger('mcp-server');
50
54
 
@@ -78,6 +82,85 @@ function cacheAudit(url, result) {
78
82
  }
79
83
  }
80
84
 
85
+ // ── Aegis egress projection (REDACTION_BOUNDARY_MAX_PLAN.md Step 4) ─────────────
86
+ // The MCP boundary is the PRIMARY agent trust boundary: every tool response transits to
87
+ // the calling agent's provider (OWASP LLM02). These helpers project findings to the
88
+ // deny-by-default allowlist + attach a `redaction` rider BEFORE the response crosses, so
89
+ // no secret / PII / exploit-detail substring ever leaves in raw form. The LOCAL on-disk
90
+ // report keeps full fidelity (the JSON write in report-processor is untouched). FAIL
91
+ // CLOSED: any projection error yields an empty-findings, redacted response — never the raw
92
+ // payload. ARGUS_REDACT_SENSITIVE=0 = byte-identical passthrough (the documented opt-out).
93
+ const redactOff = () => process.env.ARGUS_REDACT_SENSITIVE === '0';
94
+
95
+ /**
96
+ * Project a findings-bearing result object ({ findings, ...rest }) for egress: `findings`
97
+ * is run through redactForEgress, every other field (summary computed PRE-redaction, urls,
98
+ * etc.) is preserved, and a `redaction` rider is appended. Used by the findings-array tools
99
+ * (argus_audit / argus_visual_diff / argus_design_audit / argus_pr_validate).
100
+ */
101
+ function egressResult(obj, opts = {}) {
102
+ if (redactOff() || !obj || typeof obj !== 'object') return obj;
103
+ try {
104
+ const findings = Array.isArray(obj.findings) ? obj.findings : [];
105
+ return { ...obj, findings: redactForEgress(findings), redaction: buildRedactionRider(findings, opts) };
106
+ } catch (err) {
107
+ logger.error('[ARGUS] Aegis MCP egress failed — failing closed:', err.message);
108
+ const { findings: _drop, ...rest } = obj;
109
+ return { ...rest, findings: [],
110
+ redaction: { redacted: 0, total: 0, localReportPath: opts.localReportPath ?? null,
111
+ failClosed: true, note: 'Redaction error — findings withheld (fail closed).' } };
112
+ }
113
+ }
114
+
115
+ /** Project an argus_watch_snapshot result ({ findings, newConsole, newNetwork }). */
116
+ function egressWatch(r) {
117
+ if (redactOff() || !r || typeof r !== 'object') return r;
118
+ try {
119
+ const findings = Array.isArray(r.findings) ? r.findings : [];
120
+ return {
121
+ findings: redactForEgress(findings),
122
+ newConsole: (Array.isArray(r.newConsole) ? r.newConsole : []).map((m) => deepScrub(m)),
123
+ newNetwork: (Array.isArray(r.newNetwork) ? r.newNetwork : []).map((n) => deepScrub(n)),
124
+ redaction: buildRedactionRider(findings),
125
+ };
126
+ } catch (err) {
127
+ logger.error('[ARGUS] Aegis watch egress failed — failing closed:', err.message);
128
+ return { findings: [], newConsole: [], newNetwork: [], redaction: { redacted: 0, total: 0, failClosed: true } };
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Project an argus_get_context result: each finding array is redacted, the raw
134
+ * console/network arrays are deepScrub'd, open_tabs urls are sanitised, and a rider is
135
+ * appended. `allFindings` (the full pre-split findings list) feeds the rider count.
136
+ */
137
+ function egressContext(ctx, allFindings) {
138
+ if (redactOff() || !ctx || typeof ctx !== 'object') return ctx;
139
+ try {
140
+ const out = { ...ctx };
141
+ for (const k of ['critical_issues', 'warnings', 'js_errors', 'network_failures', 'resolved', 'new_issues', 'persisting']) {
142
+ if (Array.isArray(ctx[k])) out[k] = redactForEgress(ctx[k]);
143
+ }
144
+ if (Array.isArray(ctx.console_errors)) out.console_errors = ctx.console_errors.map((m) => deepScrub(m));
145
+ if (Array.isArray(ctx.recent_requests)) out.recent_requests = ctx.recent_requests.map((n) => deepScrub(n));
146
+ if (Array.isArray(ctx.open_tabs)) {
147
+ // open_tabs is the user's own diagnostic tab list (not a finding). deepScrub the url —
148
+ // masks a DETECTED secret (JWT / basic-auth / high-entropy token) while preserving benign
149
+ // query structure (a sanitizeUrl query-strip would destroy diagnostic context + tab identity).
150
+ out.open_tabs = ctx.open_tabs.map((t) =>
151
+ (t && typeof t === 'object' && typeof t.url === 'string') ? { ...t, url: deepScrub(t.url) } : t);
152
+ }
153
+ out.redaction = buildRedactionRider(Array.isArray(allFindings) ? allFindings : []);
154
+ return out;
155
+ } catch (err) {
156
+ logger.error('[ARGUS] Aegis get_context egress failed — failing closed:', err.message);
157
+ return { snapshot_id: ctx.snapshot_id, summary: 'redacted', url: ctx.url, timestamp: ctx.timestamp,
158
+ critical_issues: [], warnings: [], js_errors: [], network_failures: [],
159
+ console_errors: [], recent_requests: [], open_tabs: ctx.open_tabs ?? [],
160
+ redaction: { redacted: 0, total: 0, failClosed: true } };
161
+ }
162
+ }
163
+
81
164
  // ── Tool definitions ─────────────────────────────────────────────────────────
82
165
 
83
166
  const TOOLS = [
@@ -200,13 +283,21 @@ async function withMcp(fn) {
200
283
  // PR-validate path (handlePrValidate) passes the depth-policy-selected expensive analyzer
201
284
  // names (D2) so the same handler runs them on the affected route. The public tool is always
202
285
  // called with no `analyzers` → [] → crawlRouteWithDepth returns the cheap pass unchanged.
203
- async function handleAudit({ url, critical = false, cache = false, analyzers = [] }) {
286
+ // `ctx.internal` is set ONLY by handlePrValidate (an in-process call, NOT through the MCP
287
+ // dispatcher) so the PR-validate block/baseline/novelty/reporting logic operates on RAW
288
+ // findings. It is a SECOND positional argument — the dispatcher calls handleAudit(args) with
289
+ // no ctx, so a client cannot inject internal:true via req.params.arguments to bypass redaction.
290
+ async function handleAudit({ url, critical = false, cache = false, analyzers = [] }, ctx = {}) {
291
+ const internal = ctx.internal === true;
292
+ const finalize = (payload) =>
293
+ ({ content: [{ type: 'text', text: JSON.stringify(internal ? payload : egressResult(payload), null, 2) }] });
294
+
204
295
  if (cache && auditCache.has(url)) {
205
296
  const { result, ts } = auditCache.get(url);
206
297
  // Refresh recency on read so eviction is true LRU, not insertion-order FIFO.
207
298
  auditCache.delete(url);
208
299
  auditCache.set(url, { result, ts });
209
- return { content: [{ type: 'text', text: JSON.stringify({ ...result, _cached: true, _cachedAt: new Date(ts).toISOString() }, null, 2) }] };
300
+ return finalize({ ...result, _cached: true, _cachedAt: new Date(ts).toISOString() });
210
301
  }
211
302
  return withMcp(async (mcp) => {
212
303
  const parsed = new URL(url);
@@ -224,8 +315,8 @@ async function handleAudit({ url, critical = false, cache = false, analyzers = [
224
315
  pageTitle: raw.pageTitle,
225
316
  screenshot: raw.screenshot,
226
317
  };
227
- if (cache) cacheAudit(url, result);
228
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
318
+ if (cache) cacheAudit(url, result); // cache stores RAW; redaction happens in finalize
319
+ return finalize(result);
229
320
  });
230
321
  }
231
322
 
@@ -237,14 +328,18 @@ async function handleAuditFull({ url, critical = false }) {
237
328
  [{ path: parsed.pathname + parsed.search + parsed.hash, name: 'audit', critical }],
238
329
  parsed.origin,
239
330
  );
240
- return { content: [{ type: 'text', text: JSON.stringify(report, null, 2) }] };
331
+ // Report-shaped egress: redact every finding array (routes[].errors / flows[].findings /
332
+ // codebase[]) + attach the rider; report metadata stays so the golden schema holds.
333
+ return { content: [{ type: 'text', text: JSON.stringify(redactReport(report), null, 2) }] };
241
334
  });
242
335
  }
243
336
 
244
337
  async function handleCompare() {
245
338
  return withMcp(async (mcp) => {
246
339
  const report = await runComparison(mcp);
247
- return { content: [{ type: 'text', text: JSON.stringify(report, null, 2) }] };
340
+ // Handles BOTH compare modes: css-analysis routes[].findings via redactForEgress and
341
+ // env-comparison routes[].diffs via deepScrub (descriptions/urls scrubbed, paths stripped).
342
+ return { content: [{ type: 'text', text: JSON.stringify(redactReport(report), null, 2) }] };
248
343
  });
249
344
  }
250
345
 
@@ -255,7 +350,9 @@ async function handleWatchSnapshot({ url, tabId } = {}) {
255
350
  const baseUrl = url ?? process.env.TARGET_DEV_URL ?? 'http://localhost:3000';
256
351
  const session = new WatchSession(browser, baseUrl);
257
352
  const result = await session.poll();
258
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
353
+ // Redact findings + scrub the RAW newConsole/newNetwork arrays (a Bearer token in a
354
+ // console.error or a ?token= query in a request URL would otherwise cross raw).
355
+ return { content: [{ type: 'text', text: JSON.stringify(egressWatch(result), null, 2) }] };
259
356
  });
260
357
  }
261
358
 
@@ -333,7 +430,10 @@ async function handleGetContext({ url, snapshot_id: prevId, tabId } = {}) {
333
430
  ...(isDiff ? { resolved, new_issues, persisting } : {}),
334
431
  };
335
432
 
336
- return { content: [{ type: 'text', text: JSON.stringify(context, null, 2) }] };
433
+ // Egress projection at the boundary AFTER all internal logic (diff keys, summary) ran on
434
+ // raw findings: redact every finding array + scrub console_errors/recent_requests + sanitise
435
+ // open_tabs urls + append the rider (counted over the full `findings` set).
436
+ return { content: [{ type: 'text', text: JSON.stringify(egressContext(context, findings), null, 2) }] };
337
437
  });
338
438
  }
339
439
 
@@ -360,7 +460,10 @@ async function handleVisualDiff({ url, updateBaseline = false, baselineDir }) {
360
460
  const baseline = findings.find(f => f.type === 'visual_baseline_created');
361
461
  const summary = findings.find(f => f.type === 'visual_diff_summary');
362
462
 
363
- return { content: [{ type: 'text', text: JSON.stringify({
463
+ // The structured summary is computed from the RAW findings ABOVE, then egressResult
464
+ // redacts the findings array (cosmetic visual_* findings keep a scrubbed message; any
465
+ // accidental embedded secret is caught) and appends the rider.
466
+ return { content: [{ type: 'text', text: JSON.stringify(egressResult({
364
467
  findings,
365
468
  summary: {
366
469
  status: regression ? 'regression' : baseline ? 'baseline_created' : 'no_change',
@@ -369,7 +472,7 @@ async function handleVisualDiff({ url, updateBaseline = false, baselineDir }) {
369
472
  totalPixels: summary?.totalPixels ?? 0,
370
473
  severity: regression?.severity ?? 'info',
371
474
  },
372
- }, null, 2) }] };
475
+ }), null, 2) }] };
373
476
  });
374
477
  }
375
478
 
@@ -379,11 +482,11 @@ async function handleDesignAudit({ url, figmaFrameUrl }) {
379
482
 
380
483
  const figmaData = await getFigmaFrame(figmaFrameUrl);
381
484
  if (!figmaData) {
382
- return { content: [{ type: 'text', text: JSON.stringify({
485
+ return { content: [{ type: 'text', text: JSON.stringify(egressResult({
383
486
  error: 'Could not fetch Figma data. Ensure FIGMA_API_TOKEN is set and the figmaFrameUrl is valid.',
384
487
  findings: [],
385
488
  summary: { tokenMismatches: 0, missingComponents: 0, colorMismatches: 0, typographyMismatches: 0, spacingMismatches: 0, radiusMismatches: 0, boundsOverflows: 0, positionDrifts: 0, strokeMismatches: 0, shadowMismatches: 0, opacityMismatches: 0, gapMismatches: 0, textMismatches: 0 },
386
- }) }] };
489
+ })) }] };
387
490
  }
388
491
 
389
492
  return withMcp(async (mcp) => {
@@ -405,7 +508,9 @@ async function handleDesignAudit({ url, figmaFrameUrl }) {
405
508
  gapMismatches: count('design_gap_mismatch'),
406
509
  textMismatches: count('design_text_mismatch'),
407
510
  };
408
- return { content: [{ type: 'text', text: JSON.stringify({ findings, summary }, null, 2) }] };
511
+ // Summary counts are computed from the RAW findings above; egressResult then redacts the
512
+ // findings array (cosmetic design_* findings keep a scrubbed message) + appends the rider.
513
+ return { content: [{ type: 'text', text: JSON.stringify(egressResult({ findings, summary }), null, 2) }] };
409
514
  });
410
515
  }
411
516
 
@@ -481,7 +586,9 @@ async function handlePrValidate({ prUrl, targetUrl, githubToken, blockOn } = {})
481
586
  const routePath = String(route.path ?? '/').startsWith('/') ? route.path : `/${route.path}`;
482
587
  const url = `${baseUrl}${routePath}`;
483
588
  const res = await auditRouteWithRetry(
484
- () => handleAudit({ url, critical: route.critical ?? false, analyzers: depthAnalyzers }),
589
+ // internal:true handleAudit returns RAW findings so the block/baseline/novelty/
590
+ // reporting logic below sees full detail; the MCP RESPONSE is redacted at the return.
591
+ () => handleAudit({ url, critical: route.critical ?? false, analyzers: depthAnalyzers }, { internal: true }),
485
592
  { timeoutMs: routeTimeoutMs, retries: routeRetries, label: `Route audit ${routePath}` },
486
593
  );
487
594
  const data = JSON.parse(res.content[0].text);
@@ -560,7 +667,10 @@ async function handlePrValidate({ prUrl, targetUrl, githubToken, blockOn } = {})
560
667
  reporting = { posted: false, checked: false, skipped: true, reason: `reporting failed: ${err.message}` };
561
668
  }
562
669
 
563
- return { content: [{ type: 'text', text: JSON.stringify({ ...result, reporting }, null, 2) }] };
670
+ // Egress projection of the MCP response redacts `result.findings` + appends the rider.
671
+ // reportPrValidation above already ran on the RAW `result` (the GitHub sink is guarded
672
+ // separately in Step 6); the block decision / baseline used raw findings too.
673
+ return { content: [{ type: 'text', text: JSON.stringify(egressResult({ ...result, reporting }), null, 2) }] };
564
674
  }
565
675
 
566
676
  async function handleLastReport() {
@@ -575,7 +685,12 @@ async function handleLastReport() {
575
685
  .map(f => ({ f, mt: fs.statSync(path.join(REPORTS_DIR, f)).mtimeMs }))
576
686
  .sort((a, b) => b.mt - a.mt)[0].f;
577
687
  const json = fs.readFileSync(path.join(REPORTS_DIR, latest), 'utf8');
578
- return { content: [{ type: 'text', text: json }] };
688
+ // The on-disk report keeps full fidelity (raw secrets); when it crosses the MCP boundary
689
+ // it MUST be redacted (this tool is a direct local-report → agent-context egress path).
690
+ if (redactOff()) return { content: [{ type: 'text', text: json }] };
691
+ let parsed;
692
+ try { parsed = JSON.parse(json); } catch { return { content: [{ type: 'text', text: json }] }; }
693
+ return { content: [{ type: 'text', text: JSON.stringify(redactReport(parsed), null, 2) }] };
579
694
  }
580
695
 
581
696
  // ── Server bootstrap ──────────────────────────────────────────────────────────
@@ -587,20 +702,32 @@ const server = new Server(
587
702
 
588
703
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
589
704
 
705
+ async function callTool(req) {
706
+ switch (req.params.name) {
707
+ case 'argus_audit': return await handleAudit(req.params.arguments ?? {});
708
+ case 'argus_audit_full': return await handleAuditFull(req.params.arguments ?? {});
709
+ case 'argus_compare': return await handleCompare();
710
+ case 'argus_last_report': return await handleLastReport();
711
+ case 'argus_watch_snapshot': return await handleWatchSnapshot(req.params.arguments ?? {});
712
+ case 'argus_get_context': return await handleGetContext(req.params.arguments ?? {});
713
+ case 'argus_visual_diff': return await handleVisualDiff(req.params.arguments ?? {});
714
+ case 'argus_design_audit': return await handleDesignAudit(req.params.arguments ?? {});
715
+ case 'argus_pr_validate': return await handlePrValidate(req.params.arguments ?? {});
716
+ default: throw new Error(`Unknown tool: ${req.params.name}`);
717
+ }
718
+ }
719
+
590
720
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
591
721
  try {
592
- switch (req.params.name) {
593
- case 'argus_audit': return await handleAudit(req.params.arguments ?? {});
594
- case 'argus_audit_full': return await handleAuditFull(req.params.arguments ?? {});
595
- case 'argus_compare': return await handleCompare();
596
- case 'argus_last_report': return await handleLastReport();
597
- case 'argus_watch_snapshot': return await handleWatchSnapshot(req.params.arguments ?? {});
598
- case 'argus_get_context': return await handleGetContext(req.params.arguments ?? {});
599
- case 'argus_visual_diff': return await handleVisualDiff(req.params.arguments ?? {});
600
- case 'argus_design_audit': return await handleDesignAudit(req.params.arguments ?? {});
601
- case 'argus_pr_validate': return await handlePrValidate(req.params.arguments ?? {});
602
- default: throw new Error(`Unknown tool: ${req.params.name}`);
603
- }
722
+ const result = await callTool(req);
723
+ // Aegis team-vault flush (AEGIS_FOR_TEAMS §9): if this response was projected in
724
+ // `token` mode under an active team vault, ship the buffered token→secret mappings
725
+ // to the org's central vault so a token that just crossed to the agent can later be
726
+ // re-hydrated through the authorized RBAC flow. Best-effort + inert without a gov
727
+ // token + team-vault URL (byte-identical default); never throws, never leaks (the
728
+ // emitted tokens are information-free).
729
+ await flushTeamVault();
730
+ return result;
604
731
  } catch (err) {
605
732
  return {
606
733
  content: [{ type: 'text', text: JSON.stringify({ error: err.message }) }],
@@ -13,6 +13,7 @@ import { postBugReport } from './slack-notifier.js';
13
13
  import { isSlackConfigured } from '../utils/slack-guard.js';
14
14
  import { isGitHubConfigured, reportToGitHub } from '../utils/github-reporter.js';
15
15
  import { generateHtmlReport } from '../utils/html-reporter.js';
16
+ import { redactForEgress, summarizeRedaction } from '../utils/sensitivity-classifier.js';
16
17
 
17
18
  const logger = childLogger('dispatcher');
18
19
 
@@ -57,6 +58,30 @@ function errorText(e) {
57
58
  * Info → single digest message summarising all routes
58
59
  */
59
60
  async function dispatchToSlack(report, diff) {
61
+ // ── Aegis egress guard (Step 5) ───────────────────────────────────────────
62
+ // Slack is a third-party sink outside the trust boundary (a shared channel, A2).
63
+ // Project EVERY finding through redactForEgress before any description / details
64
+ // line is built, so no secret / PII / exploit detail crosses: a sensitive finding's
65
+ // line collapses to the 🔒 marker (its message becomes REDACT_MARKER), a benign
66
+ // finding keeps its mandatory-scrubbed message. The LOCAL on-disk report is untouched
67
+ // (this rebinds the local `report` param to a redacted CLONE). ARGUS_REDACT_SENSITIVE=0
68
+ // ⇒ byte-identical passthrough (redactForEgress returns the same objects).
69
+ let redactedCount = 0;
70
+ if (process.env.ARGUS_REDACT_SENSITIVE !== '0') {
71
+ const rawFindings = [
72
+ ...report.routes.flatMap(r => r.errors ?? []),
73
+ ...(report.flows ?? []).flatMap(f => f.findings ?? []),
74
+ ...(report.codebase ?? []),
75
+ ];
76
+ redactedCount = summarizeRedaction(rawFindings).redacted;
77
+ report = {
78
+ ...report,
79
+ routes: report.routes.map(r => ({ ...r, errors: redactForEgress(r.errors ?? []) })),
80
+ flows: (report.flows ?? []).map(f => ({ ...f, findings: redactForEgress(f.findings ?? []) })),
81
+ codebase: redactForEgress(report.codebase ?? []),
82
+ };
83
+ }
84
+
60
85
  const { summary } = report;
61
86
 
62
87
  // ── Criticals: one message per affected route ─────────────────────────────
@@ -218,6 +243,9 @@ async function dispatchToSlack(report, diff) {
218
243
  description:
219
244
  `Summary: ${summary.total} findings across ${report.routes.length} routes\n` +
220
245
  `:red_circle: ${summary.critical} critical :large_yellow_circle: ${summary.warning} warnings :large_blue_circle: ${summary.info} info\n` +
246
+ (redactedCount > 0
247
+ ? `:lock: ${redactedCount} sensitive finding(s) redacted at the boundary — full detail in the local report\n`
248
+ : '') +
221
249
  (trendLine ? trendLine + '\n' : '') + '\n' +
222
250
  (digestLines.length > 0 ? digestLines.join('\n') : '_No info-level findings._'),
223
251
  url: report.baseUrl,