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 +32 -8
- package/glama.json +2 -2
- package/package.json +5 -3
- package/scripts/rehydrate-report.mjs +62 -0
- package/src/cli/init.js +3 -1
- package/src/cli/pr-validate.js +40 -5
- package/src/mcp-server.js +130 -16
- package/src/orchestration/dispatcher.js +28 -0
- package/src/orchestration/report-processor.js +22 -1
- package/src/utils/aegis-vault.js +251 -0
- package/src/utils/github-reporter.js +33 -4
- package/src/utils/html-reporter.js +26 -1
- package/src/utils/import-graph.js +9 -3
- package/src/utils/pr-diff-analyzer.js +0 -2
- package/src/utils/secret-patterns.js +428 -0
- package/src/utils/sensitivity-classifier.js +498 -0
- package/src/utils/session-persistence.js +6 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/argusqa-os)
|
|
6
6
|
[](https://glama.ai/mcp/servers/ironclawdevs27/Argus)
|
|
7
|
-
[](test-harness/)
|
|
8
8
|
[](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,
|
|
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 #
|
|
221
|
-
npm run test:harness #
|
|
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
|
|
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/ —
|
|
366
|
-
test/unit/ —
|
|
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 (
|
|
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).
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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') {
|
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,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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|