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/README.md +524 -395
- package/glama.json +2 -2
- package/package.json +12 -3
- package/scripts/rehydrate-report.mjs +62 -0
- package/src/cli/pr-validate.js +40 -5
- package/src/mcp-server.js +155 -28
- package/src/orchestration/dispatcher.js +28 -0
- package/src/orchestration/report-processor.js +65 -1
- package/src/utils/aegis-vault.js +251 -0
- package/src/utils/github-reporter.js +33 -4
- package/src/utils/governance-seam.js +237 -0
- package/src/utils/html-reporter.js +26 -1
- package/src/utils/redaction-policy.js +278 -0
- package/src/utils/secret-patterns.js +444 -0
- package/src/utils/sensitivity-classifier.js +563 -0
- package/src/utils/team-vault.js +166 -0
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).
|
|
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": "
|
|
3
|
+
"version": "10.0.0",
|
|
4
4
|
"mcpName": "io.github.ironclawdevs27/argus",
|
|
5
|
-
"description": "Argus — AI-
|
|
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
|
+
}
|
package/src/cli/pr-validate.js
CHANGED
|
@@ -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
|
|
109
|
-
const
|
|
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}::${
|
|
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}::${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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,
|