argusqa-os 9.8.0 → 9.9.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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/argusqa-os?color=7C3AED)](https://www.npmjs.com/package/argusqa-os)
6
6
  [![MCP Server](https://glama.ai/mcp/servers/ironclawdevs27/Argus/badges/card.svg)](https://glama.ai/mcp/servers/ironclawdevs27/Argus)
7
- [![Harness](https://img.shields.io/badge/harness-961%2F961-4ADE80)](test-harness/)
7
+ [![Harness](https://img.shields.io/badge/harness-978%2F978-4ADE80)](test-harness/)
8
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
9
9
 
10
10
  **Argus catches the bugs your test suite misses — visual regressions, API loops, CSS drift, console noise, accessibility failures, and more — and delivers rich reports to Slack (or a local HTML dashboard).**
@@ -62,7 +62,7 @@ Argus scans your app and either posts findings to Slack or opens a local `report
62
62
 
63
63
  ## What Argus Catches
64
64
 
65
- 32 analysis engines, 140 distinct issue types, zero test-file maintenance:
65
+ 32 analysis engines, 149 distinct issue types, zero test-file maintenance:
66
66
 
67
67
  | Category | What it detects |
68
68
  |---|---|
@@ -95,6 +95,24 @@ And every finding is post-processed with:
95
95
 
96
96
  ---
97
97
 
98
+ ## Confidentiality — Aegis Egress Boundary
99
+
100
+ > **Default ON.** Argus audits your app for *secrets and vulnerabilities* — so its findings are exactly the data you least want leaving your machine. **Aegis** redacts them at every external boundary before they cross.
101
+
102
+ A finding sent to an external sink — an **MCP tool response** (which lands in the calling agent's context window and transits to that agent's model provider), a **Slack** message, a **GitHub** PR comment + its `::error` annotations, the **hosted/CI HTML report**, or **CI logs** — is reduced to a need-to-know projection: a **sensitive** finding crosses as its `type` + `route` + `severity` + a `🔒` marker, and **never** its raw payload (`message`, `evidence`, request/response bodies, headers, cookies, stack). URLs are projected with the query string stripped (tokens hide there). A **benign** finding keeps its message — but that message is still scrubbed for any accidentally-embedded secret or PII.
103
+
104
+ | Principle | Behavior |
105
+ |---|---|
106
+ | **Local fidelity preserved** | The on-disk JSON report and the locally-opened HTML keep **100%** detail — redaction only removes detail on the way *out* |
107
+ | **Fail-closed** | On any classifier error or unknown finding shape, Aegis redacts *more*, never less |
108
+ | **Deny-by-default** | Only an explicit allowlist of safe fields ever crosses; a new field leaks nothing until deliberately allowlisted |
109
+ | **5-layer detection** | Category rules ∪ 13 secret regexes ∪ statistical rarity (entropy / token-efficiency) ∪ 7 Luhn-validated PII rules ∪ context boosting |
110
+ | **Opt-out** | `ARGUS_REDACT_SENSITIVE=0` → output is byte-identical to pre-Aegis |
111
+
112
+ This implements the **OWASP LLM02:2025 — Sensitive Information Disclosure** mitigations (data minimization, redaction, deny-by-default egress filtering) at Argus's own boundaries. An optional, local-only re-hydration vault (`ARGUS_REDACT_VAULT=1`) can mint reversible, information-free tokens for diff-stable artifacts — re-inflate locally with `npm run report:rehydrate`. Full behavior change is documented in [CHANGELOG.md](CHANGELOG.md).
113
+
114
+ ---
115
+
98
116
  ## MCP Tools
99
117
 
100
118
  Ask Claude (or any MCP client) — no terminal required:
@@ -111,6 +129,8 @@ Ask Claude (or any MCP client) — no terminal required:
111
129
  | `argus_visual_diff` | Screenshot baseline comparison. Pass `updateBaseline: true` to reset. |
112
130
  | `argus_pr_validate` | Fetch GitHub PR diff → map changed files to affected routes → targeted audit → baseline-aware block decision (blocks on findings the PR *introduces*) + idempotent PR comment + Check Run → `{ blocked, findings, baseline, reporting }` |
113
131
 
132
+ > Every tool response is projected through the [Aegis egress boundary](#confidentiality--aegis-egress-boundary) before it reaches the agent, and carries an optional `redaction` rider (`{ redacted, total }`) when sensitive detail was withheld.
133
+
114
134
  **Example prompts:**
115
135
 
116
136
  ```
@@ -217,8 +237,8 @@ npm run report:html # Generate reports/report.html from last JSON audit
217
237
  npm run report:pdf # Export HTML report to A4 PDF (requires: npm install puppeteer)
218
238
  npm run server # Start Slack slash-command server (port 3001)
219
239
  npm run init # Interactive setup wizard
220
- npm run test:unit # 366 unit tests — no Chrome required
221
- npm run test:harness # 166-block correctness harness — requires Chrome
240
+ npm run test:unit # 495 unit tests — no Chrome required
241
+ npm run test:harness # 168-block correctness harness — requires Chrome
222
242
  npm run test:harness:log # same, but tees full output to harness-results.txt
223
243
  npm run test:coverage # merged unit + harness coverage gate (requires Chrome)
224
244
  ```
@@ -295,6 +315,10 @@ The included [workflow](.github/workflows/argus.yml) runs on push to `main`, dai
295
315
  | `FIGMA_API_TOKEN` | — | Required for `argus_design_audit` |
296
316
  | `FONT_SLOW_MS` | `1000` | Slow web font load threshold (ms) |
297
317
  | `A11Y_CONTRAST_AA` | `4.5` | WCAG AA min contrast ratio for CVD simulation |
318
+ | `ARGUS_REDACT_SENSITIVE` | `ON` | **Aegis** egress redaction. `0` disables (byte-identical pre-Aegis output) |
319
+ | `ARGUS_REDACT_MODE` | `mask` | Matched-span style: `mask` / `label` / `hash` / `token` / `drop` |
320
+ | `ARGUS_REDACT_HTML` | off local / ON in CI | `1` redacts the hosted HTML report too |
321
+ | `ARGUS_REDACT_VAULT` | `OFF` | `1` (with `ARGUS_REDACT_MODE=token`) mints reversible `AEGIS_<hmac16>` tokens into a local `0600` vault; re-inflate with `npm run report:rehydrate` |
298
322
 
299
323
  </details>
300
324
 
@@ -343,7 +367,7 @@ Argus is a **complementary layer**, not a replacement for unit or E2E tests:
343
367
 
344
368
  ## Known Limitations
345
369
 
346
- All 961 harness assertions pass (`961/961`) — there are currently no known MCP- or Chrome-layer restrictions. Lighthouse now runs in headless (after the `lighthouse_audit` argument fix); the remaining soft assertions (perf traces, GC-dependent heap-growth) are promoted to counted hard assertions only in the weekly strict-soft lane (`harness-strict.yml`) via `ARGUS_HARNESS_STRICT_SOFT`.
370
+ All 978 harness assertions pass (`978/978`) — there are currently no known MCP- or Chrome-layer restrictions. Lighthouse now runs in headless (after the `lighthouse_audit` argument fix); the remaining soft assertions (perf traces, GC-dependent heap-growth) are promoted to counted hard assertions only in the weekly strict-soft lane (`harness-strict.yml`) via `ARGUS_HARNESS_STRICT_SOFT`.
347
371
 
348
372
  ---
349
373
 
@@ -362,8 +386,8 @@ src/
362
386
  chrome-launcher.js — npm run chrome / argus-chrome — launches Chrome with correct flags
363
387
  doctor.js — npm run doctor / argus-doctor — pre-flight checks
364
388
  pr-validate.js — headless CI entry point for GitHub Actions
365
- test-harness/ — 166-block correctness harness, 961 hard assertions, 63 fixture pages
366
- test/unit/ — 366 Vitest unit tests (no Chrome required)
389
+ test-harness/ — 168-block correctness harness, 978 hard assertions, 64 fixture pages
390
+ test/unit/ — 495 Vitest unit tests (no Chrome required)
367
391
  landing/ — Product landing page (React 19 + Vite + Tailwind)
368
392
  ```
369
393
 
@@ -374,7 +398,7 @@ Full source map → [CLAUDE.md](CLAUDE.md) · MCP/DSL reference → [SKILL.md](S
374
398
  ## Contributing
375
399
 
376
400
  1. Fork the repo and create a branch
377
- 2. `npm run test:unit` — verify without Chrome (366 tests)
401
+ 2. `npm run test:unit` — verify without Chrome (495 tests)
378
402
  3. `npm run test:harness` — full integration coverage (requires Chrome on port 9222)
379
403
  4. Open a PR — Argus audits itself via the CI workflow
380
404
 
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. 168 test blocks, 978 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,6 +1,6 @@
1
1
  {
2
2
  "name": "argusqa-os",
3
- "version": "9.8.0",
3
+ "version": "9.9.0",
4
4
  "mcpName": "io.github.ironclawdevs27/argus",
5
5
  "description": "Argus — AI-powered automated dev-testing platform using Chrome DevTools MCP and Claude Code",
6
6
  "keywords": [
@@ -23,6 +23,7 @@
23
23
  },
24
24
  "files": [
25
25
  "src/",
26
+ "scripts/rehydrate-report.mjs",
26
27
  ".mcp.json",
27
28
  "glama.json"
28
29
  ],
@@ -59,6 +60,7 @@
59
60
  "test:coverage": "npm run coverage:harness && npm run coverage:unit && npm run coverage:gate",
60
61
  "report:html": "node src/utils/html-reporter.js",
61
62
  "report:pdf": "node src/utils/pdf-exporter.js",
63
+ "report:rehydrate": "node scripts/rehydrate-report.mjs",
62
64
  "mcp-server": "node src/mcp-server.js"
63
65
  },
64
66
  "dependencies": {
@@ -76,10 +78,10 @@
76
78
  "zod": "^4.4.3"
77
79
  },
78
80
  "devDependencies": {
79
- "@vitest/coverage-v8": "^4.1.8",
81
+ "@vitest/coverage-v8": "^4.1.9",
80
82
  "c8": "^11.0.0",
81
83
  "fast-check": "^4.8.0",
82
84
  "istanbul-lib-coverage": "^3.2.2",
83
- "vitest": "^4.1.8"
85
+ "vitest": "^4.1.9"
84
86
  }
85
87
  }
@@ -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
+ }
package/src/cli/init.js CHANGED
@@ -288,7 +288,9 @@ async function main() {
288
288
  const targetsContent = generateTargetsJs(finalRoutes, { framework, sourceDir, envFile: envFilePath });
289
289
 
290
290
  try {
291
- fs.writeFileSync('.env', envContent, { flag: 'wx', encoding: 'utf8' });
291
+ // .env holds Slack / GitHub / Figma tokens + the auth password — create it owner-only
292
+ // (0600) so credentials are never world-readable on shared / POSIX / CI hosts.
293
+ fs.writeFileSync('.env', envContent, { flag: 'wx', encoding: 'utf8', mode: 0o600 });
292
294
  tick('Wrote .env');
293
295
  } catch (err) {
294
296
  if (err.code === 'EEXIST') {
@@ -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,9 @@ 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';
48
51
 
49
52
  const logger = childLogger('mcp-server');
50
53
 
@@ -78,6 +81,85 @@ function cacheAudit(url, result) {
78
81
  }
79
82
  }
80
83
 
84
+ // ── Aegis egress projection (REDACTION_BOUNDARY_MAX_PLAN.md Step 4) ─────────────
85
+ // The MCP boundary is the PRIMARY agent trust boundary: every tool response transits to
86
+ // the calling agent's provider (OWASP LLM02). These helpers project findings to the
87
+ // deny-by-default allowlist + attach a `redaction` rider BEFORE the response crosses, so
88
+ // no secret / PII / exploit-detail substring ever leaves in raw form. The LOCAL on-disk
89
+ // report keeps full fidelity (the JSON write in report-processor is untouched). FAIL
90
+ // CLOSED: any projection error yields an empty-findings, redacted response — never the raw
91
+ // payload. ARGUS_REDACT_SENSITIVE=0 = byte-identical passthrough (the documented opt-out).
92
+ const redactOff = () => process.env.ARGUS_REDACT_SENSITIVE === '0';
93
+
94
+ /**
95
+ * Project a findings-bearing result object ({ findings, ...rest }) for egress: `findings`
96
+ * is run through redactForEgress, every other field (summary computed PRE-redaction, urls,
97
+ * etc.) is preserved, and a `redaction` rider is appended. Used by the findings-array tools
98
+ * (argus_audit / argus_visual_diff / argus_design_audit / argus_pr_validate).
99
+ */
100
+ function egressResult(obj, opts = {}) {
101
+ if (redactOff() || !obj || typeof obj !== 'object') return obj;
102
+ try {
103
+ const findings = Array.isArray(obj.findings) ? obj.findings : [];
104
+ return { ...obj, findings: redactForEgress(findings), redaction: buildRedactionRider(findings, opts) };
105
+ } catch (err) {
106
+ logger.error('[ARGUS] Aegis MCP egress failed — failing closed:', err.message);
107
+ const { findings: _drop, ...rest } = obj;
108
+ return { ...rest, findings: [],
109
+ redaction: { redacted: 0, total: 0, localReportPath: opts.localReportPath ?? null,
110
+ failClosed: true, note: 'Redaction error — findings withheld (fail closed).' } };
111
+ }
112
+ }
113
+
114
+ /** Project an argus_watch_snapshot result ({ findings, newConsole, newNetwork }). */
115
+ function egressWatch(r) {
116
+ if (redactOff() || !r || typeof r !== 'object') return r;
117
+ try {
118
+ const findings = Array.isArray(r.findings) ? r.findings : [];
119
+ return {
120
+ findings: redactForEgress(findings),
121
+ newConsole: (Array.isArray(r.newConsole) ? r.newConsole : []).map((m) => deepScrub(m)),
122
+ newNetwork: (Array.isArray(r.newNetwork) ? r.newNetwork : []).map((n) => deepScrub(n)),
123
+ redaction: buildRedactionRider(findings),
124
+ };
125
+ } catch (err) {
126
+ logger.error('[ARGUS] Aegis watch egress failed — failing closed:', err.message);
127
+ return { findings: [], newConsole: [], newNetwork: [], redaction: { redacted: 0, total: 0, failClosed: true } };
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Project an argus_get_context result: each finding array is redacted, the raw
133
+ * console/network arrays are deepScrub'd, open_tabs urls are sanitised, and a rider is
134
+ * appended. `allFindings` (the full pre-split findings list) feeds the rider count.
135
+ */
136
+ function egressContext(ctx, allFindings) {
137
+ if (redactOff() || !ctx || typeof ctx !== 'object') return ctx;
138
+ try {
139
+ const out = { ...ctx };
140
+ for (const k of ['critical_issues', 'warnings', 'js_errors', 'network_failures', 'resolved', 'new_issues', 'persisting']) {
141
+ if (Array.isArray(ctx[k])) out[k] = redactForEgress(ctx[k]);
142
+ }
143
+ if (Array.isArray(ctx.console_errors)) out.console_errors = ctx.console_errors.map((m) => deepScrub(m));
144
+ if (Array.isArray(ctx.recent_requests)) out.recent_requests = ctx.recent_requests.map((n) => deepScrub(n));
145
+ if (Array.isArray(ctx.open_tabs)) {
146
+ // open_tabs is the user's own diagnostic tab list (not a finding). deepScrub the url —
147
+ // masks a DETECTED secret (JWT / basic-auth / high-entropy token) while preserving benign
148
+ // query structure (a sanitizeUrl query-strip would destroy diagnostic context + tab identity).
149
+ out.open_tabs = ctx.open_tabs.map((t) =>
150
+ (t && typeof t === 'object' && typeof t.url === 'string') ? { ...t, url: deepScrub(t.url) } : t);
151
+ }
152
+ out.redaction = buildRedactionRider(Array.isArray(allFindings) ? allFindings : []);
153
+ return out;
154
+ } catch (err) {
155
+ logger.error('[ARGUS] Aegis get_context egress failed — failing closed:', err.message);
156
+ return { snapshot_id: ctx.snapshot_id, summary: 'redacted', url: ctx.url, timestamp: ctx.timestamp,
157
+ critical_issues: [], warnings: [], js_errors: [], network_failures: [],
158
+ console_errors: [], recent_requests: [], open_tabs: ctx.open_tabs ?? [],
159
+ redaction: { redacted: 0, total: 0, failClosed: true } };
160
+ }
161
+ }
162
+
81
163
  // ── Tool definitions ─────────────────────────────────────────────────────────
82
164
 
83
165
  const TOOLS = [
@@ -200,13 +282,21 @@ async function withMcp(fn) {
200
282
  // PR-validate path (handlePrValidate) passes the depth-policy-selected expensive analyzer
201
283
  // names (D2) so the same handler runs them on the affected route. The public tool is always
202
284
  // called with no `analyzers` → [] → crawlRouteWithDepth returns the cheap pass unchanged.
203
- async function handleAudit({ url, critical = false, cache = false, analyzers = [] }) {
285
+ // `ctx.internal` is set ONLY by handlePrValidate (an in-process call, NOT through the MCP
286
+ // dispatcher) so the PR-validate block/baseline/novelty/reporting logic operates on RAW
287
+ // findings. It is a SECOND positional argument — the dispatcher calls handleAudit(args) with
288
+ // no ctx, so a client cannot inject internal:true via req.params.arguments to bypass redaction.
289
+ async function handleAudit({ url, critical = false, cache = false, analyzers = [] }, ctx = {}) {
290
+ const internal = ctx.internal === true;
291
+ const finalize = (payload) =>
292
+ ({ content: [{ type: 'text', text: JSON.stringify(internal ? payload : egressResult(payload), null, 2) }] });
293
+
204
294
  if (cache && auditCache.has(url)) {
205
295
  const { result, ts } = auditCache.get(url);
206
296
  // Refresh recency on read so eviction is true LRU, not insertion-order FIFO.
207
297
  auditCache.delete(url);
208
298
  auditCache.set(url, { result, ts });
209
- return { content: [{ type: 'text', text: JSON.stringify({ ...result, _cached: true, _cachedAt: new Date(ts).toISOString() }, null, 2) }] };
299
+ return finalize({ ...result, _cached: true, _cachedAt: new Date(ts).toISOString() });
210
300
  }
211
301
  return withMcp(async (mcp) => {
212
302
  const parsed = new URL(url);
@@ -224,8 +314,8 @@ async function handleAudit({ url, critical = false, cache = false, analyzers = [
224
314
  pageTitle: raw.pageTitle,
225
315
  screenshot: raw.screenshot,
226
316
  };
227
- if (cache) cacheAudit(url, result);
228
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
317
+ if (cache) cacheAudit(url, result); // cache stores RAW; redaction happens in finalize
318
+ return finalize(result);
229
319
  });
230
320
  }
231
321
 
@@ -237,14 +327,18 @@ async function handleAuditFull({ url, critical = false }) {
237
327
  [{ path: parsed.pathname + parsed.search + parsed.hash, name: 'audit', critical }],
238
328
  parsed.origin,
239
329
  );
240
- return { content: [{ type: 'text', text: JSON.stringify(report, null, 2) }] };
330
+ // Report-shaped egress: redact every finding array (routes[].errors / flows[].findings /
331
+ // codebase[]) + attach the rider; report metadata stays so the golden schema holds.
332
+ return { content: [{ type: 'text', text: JSON.stringify(redactReport(report), null, 2) }] };
241
333
  });
242
334
  }
243
335
 
244
336
  async function handleCompare() {
245
337
  return withMcp(async (mcp) => {
246
338
  const report = await runComparison(mcp);
247
- return { content: [{ type: 'text', text: JSON.stringify(report, null, 2) }] };
339
+ // Handles BOTH compare modes: css-analysis routes[].findings via redactForEgress and
340
+ // env-comparison routes[].diffs via deepScrub (descriptions/urls scrubbed, paths stripped).
341
+ return { content: [{ type: 'text', text: JSON.stringify(redactReport(report), null, 2) }] };
248
342
  });
249
343
  }
250
344
 
@@ -255,7 +349,9 @@ async function handleWatchSnapshot({ url, tabId } = {}) {
255
349
  const baseUrl = url ?? process.env.TARGET_DEV_URL ?? 'http://localhost:3000';
256
350
  const session = new WatchSession(browser, baseUrl);
257
351
  const result = await session.poll();
258
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
352
+ // Redact findings + scrub the RAW newConsole/newNetwork arrays (a Bearer token in a
353
+ // console.error or a ?token= query in a request URL would otherwise cross raw).
354
+ return { content: [{ type: 'text', text: JSON.stringify(egressWatch(result), null, 2) }] };
259
355
  });
260
356
  }
261
357
 
@@ -333,7 +429,10 @@ async function handleGetContext({ url, snapshot_id: prevId, tabId } = {}) {
333
429
  ...(isDiff ? { resolved, new_issues, persisting } : {}),
334
430
  };
335
431
 
336
- return { content: [{ type: 'text', text: JSON.stringify(context, null, 2) }] };
432
+ // Egress projection at the boundary AFTER all internal logic (diff keys, summary) ran on
433
+ // raw findings: redact every finding array + scrub console_errors/recent_requests + sanitise
434
+ // open_tabs urls + append the rider (counted over the full `findings` set).
435
+ return { content: [{ type: 'text', text: JSON.stringify(egressContext(context, findings), null, 2) }] };
337
436
  });
338
437
  }
339
438
 
@@ -360,7 +459,10 @@ async function handleVisualDiff({ url, updateBaseline = false, baselineDir }) {
360
459
  const baseline = findings.find(f => f.type === 'visual_baseline_created');
361
460
  const summary = findings.find(f => f.type === 'visual_diff_summary');
362
461
 
363
- return { content: [{ type: 'text', text: JSON.stringify({
462
+ // The structured summary is computed from the RAW findings ABOVE, then egressResult
463
+ // redacts the findings array (cosmetic visual_* findings keep a scrubbed message; any
464
+ // accidental embedded secret is caught) and appends the rider.
465
+ return { content: [{ type: 'text', text: JSON.stringify(egressResult({
364
466
  findings,
365
467
  summary: {
366
468
  status: regression ? 'regression' : baseline ? 'baseline_created' : 'no_change',
@@ -369,7 +471,7 @@ async function handleVisualDiff({ url, updateBaseline = false, baselineDir }) {
369
471
  totalPixels: summary?.totalPixels ?? 0,
370
472
  severity: regression?.severity ?? 'info',
371
473
  },
372
- }, null, 2) }] };
474
+ }), null, 2) }] };
373
475
  });
374
476
  }
375
477
 
@@ -379,11 +481,11 @@ async function handleDesignAudit({ url, figmaFrameUrl }) {
379
481
 
380
482
  const figmaData = await getFigmaFrame(figmaFrameUrl);
381
483
  if (!figmaData) {
382
- return { content: [{ type: 'text', text: JSON.stringify({
484
+ return { content: [{ type: 'text', text: JSON.stringify(egressResult({
383
485
  error: 'Could not fetch Figma data. Ensure FIGMA_API_TOKEN is set and the figmaFrameUrl is valid.',
384
486
  findings: [],
385
487
  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
- }) }] };
488
+ })) }] };
387
489
  }
388
490
 
389
491
  return withMcp(async (mcp) => {
@@ -405,7 +507,9 @@ async function handleDesignAudit({ url, figmaFrameUrl }) {
405
507
  gapMismatches: count('design_gap_mismatch'),
406
508
  textMismatches: count('design_text_mismatch'),
407
509
  };
408
- return { content: [{ type: 'text', text: JSON.stringify({ findings, summary }, null, 2) }] };
510
+ // Summary counts are computed from the RAW findings above; egressResult then redacts the
511
+ // findings array (cosmetic design_* findings keep a scrubbed message) + appends the rider.
512
+ return { content: [{ type: 'text', text: JSON.stringify(egressResult({ findings, summary }), null, 2) }] };
409
513
  });
410
514
  }
411
515
 
@@ -481,7 +585,9 @@ async function handlePrValidate({ prUrl, targetUrl, githubToken, blockOn } = {})
481
585
  const routePath = String(route.path ?? '/').startsWith('/') ? route.path : `/${route.path}`;
482
586
  const url = `${baseUrl}${routePath}`;
483
587
  const res = await auditRouteWithRetry(
484
- () => handleAudit({ url, critical: route.critical ?? false, analyzers: depthAnalyzers }),
588
+ // internal:true handleAudit returns RAW findings so the block/baseline/novelty/
589
+ // reporting logic below sees full detail; the MCP RESPONSE is redacted at the return.
590
+ () => handleAudit({ url, critical: route.critical ?? false, analyzers: depthAnalyzers }, { internal: true }),
485
591
  { timeoutMs: routeTimeoutMs, retries: routeRetries, label: `Route audit ${routePath}` },
486
592
  );
487
593
  const data = JSON.parse(res.content[0].text);
@@ -560,7 +666,10 @@ async function handlePrValidate({ prUrl, targetUrl, githubToken, blockOn } = {})
560
666
  reporting = { posted: false, checked: false, skipped: true, reason: `reporting failed: ${err.message}` };
561
667
  }
562
668
 
563
- return { content: [{ type: 'text', text: JSON.stringify({ ...result, reporting }, null, 2) }] };
669
+ // Egress projection of the MCP response redacts `result.findings` + appends the rider.
670
+ // reportPrValidation above already ran on the RAW `result` (the GitHub sink is guarded
671
+ // separately in Step 6); the block decision / baseline used raw findings too.
672
+ return { content: [{ type: 'text', text: JSON.stringify(egressResult({ ...result, reporting }), null, 2) }] };
564
673
  }
565
674
 
566
675
  async function handleLastReport() {
@@ -575,7 +684,12 @@ async function handleLastReport() {
575
684
  .map(f => ({ f, mt: fs.statSync(path.join(REPORTS_DIR, f)).mtimeMs }))
576
685
  .sort((a, b) => b.mt - a.mt)[0].f;
577
686
  const json = fs.readFileSync(path.join(REPORTS_DIR, latest), 'utf8');
578
- return { content: [{ type: 'text', text: json }] };
687
+ // The on-disk report keeps full fidelity (raw secrets); when it crosses the MCP boundary
688
+ // it MUST be redacted (this tool is a direct local-report → agent-context egress path).
689
+ if (redactOff()) return { content: [{ type: 'text', text: json }] };
690
+ let parsed;
691
+ try { parsed = JSON.parse(json); } catch { return { content: [{ type: 'text', text: json }] }; }
692
+ return { content: [{ type: 'text', text: JSON.stringify(redactReport(parsed), null, 2) }] };
579
693
  }
580
694
 
581
695
  // ── Server bootstrap ──────────────────────────────────────────────────────────
@@ -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,