argusqa-os 9.9.0 → 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 -419
- package/glama.json +1 -1
- package/package.json +10 -3
- package/src/mcp-server.js +25 -12
- package/src/orchestration/report-processor.js +45 -2
- package/src/utils/governance-seam.js +237 -0
- package/src/utils/redaction-policy.js +278 -0
- package/src/utils/secret-patterns.js +444 -428
- package/src/utils/sensitivity-classifier.js +563 -498
- package/src/utils/team-vault.js +166 -0
package/glama.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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). 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.
|
|
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
|
{
|
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",
|
|
@@ -44,6 +50,7 @@
|
|
|
44
50
|
"init": "node src/cli/init.js",
|
|
45
51
|
"chrome": "node src/cli/chrome-launcher.js",
|
|
46
52
|
"doctor": "node src/cli/doctor.js",
|
|
53
|
+
"metrics": "node scripts/metrics-snapshot.mjs",
|
|
47
54
|
"crawl": "node src/orchestration/crawl-and-report.js",
|
|
48
55
|
"compare": "node src/orchestration/env-comparison.js",
|
|
49
56
|
"watch": "node src/orchestration/watch-mode.js",
|
package/src/mcp-server.js
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
import {
|
|
49
49
|
redactForEgress, buildRedactionRider, redactReport, deepScrub,
|
|
50
50
|
} from './utils/sensitivity-classifier.js';
|
|
51
|
+
import { flushTeamVault } from './utils/team-vault.js';
|
|
51
52
|
|
|
52
53
|
const logger = childLogger('mcp-server');
|
|
53
54
|
|
|
@@ -701,20 +702,32 @@ const server = new Server(
|
|
|
701
702
|
|
|
702
703
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
703
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
|
+
|
|
704
720
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
705
721
|
try {
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
case 'argus_pr_validate': return await handlePrValidate(req.params.arguments ?? {});
|
|
716
|
-
default: throw new Error(`Unknown tool: ${req.params.name}`);
|
|
717
|
-
}
|
|
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;
|
|
718
731
|
} catch (err) {
|
|
719
732
|
return {
|
|
720
733
|
content: [{ type: 'text', text: JSON.stringify({ error: err.message }) }],
|
|
@@ -15,7 +15,9 @@ import { applyOverrides } from
|
|
|
15
15
|
import { loadBaseline, saveBaseline, applyBaseline, appendTrend, getCurrentBranch } from '../utils/baseline-manager.js';
|
|
16
16
|
import { loadRunHistory, recordRunHistory, applyNoiseFilter } from '../utils/noise-filter.js';
|
|
17
17
|
import { getRecentChanges, linkRootCauses } from '../utils/root-cause-linker.js';
|
|
18
|
-
import { classifySensitivity }
|
|
18
|
+
import { classifySensitivity, summarizeRedaction } from '../utils/sensitivity-classifier.js';
|
|
19
|
+
import { effectivePolicyOpts, governanceOptOutClamped } from '../utils/redaction-policy.js';
|
|
20
|
+
import { ensureGovernancePolicy, postRedactionAggregate, governanceActive } from '../utils/governance-seam.js';
|
|
19
21
|
|
|
20
22
|
const logger = childLogger('report-processor');
|
|
21
23
|
|
|
@@ -67,6 +69,24 @@ export function rebuildSummary(report) {
|
|
|
67
69
|
}
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Flatten every finding in a report (route errors, flow findings, codebase) into
|
|
74
|
+
* one array — used to compute the secret-free governance aggregate. Read-only.
|
|
75
|
+
* @param {object} report
|
|
76
|
+
* @returns {object[]}
|
|
77
|
+
*/
|
|
78
|
+
export function collectAllFindings(report) {
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const routeResult of (report.routes ?? [])) {
|
|
81
|
+
for (const err of (routeResult.errors ?? [])) out.push(err);
|
|
82
|
+
}
|
|
83
|
+
for (const flowResult of (report.flows ?? [])) {
|
|
84
|
+
for (const finding of (flowResult.findings ?? [])) out.push(finding);
|
|
85
|
+
}
|
|
86
|
+
for (const finding of (report.codebase ?? [])) out.push(finding);
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
70
90
|
// ── Main Post-Crawl Processor ─────────────────────────────────────────────────
|
|
71
91
|
|
|
72
92
|
/**
|
|
@@ -79,6 +99,13 @@ export function rebuildSummary(report) {
|
|
|
79
99
|
* @returns {{ reportPath: string, diff: object }}
|
|
80
100
|
*/
|
|
81
101
|
export async function processReport(report, { outputDir, severityOverrides }) {
|
|
102
|
+
// 0. Aegis governance seam (opt-in, AEGIS_FOR_TEAMS §9): if a gov token is set,
|
|
103
|
+
// fetch + Ed25519-verify the org's central policy BEFORE any classification so
|
|
104
|
+
// every downstream redaction enforces it. Best-effort + fail-closed internally
|
|
105
|
+
// (a fetch/verify error leaves the engine on its strict floor). No token ⇒ no
|
|
106
|
+
// network, byte-identical.
|
|
107
|
+
await ensureGovernancePolicy();
|
|
108
|
+
|
|
82
109
|
// 1. Apply severity overrides (suppress or reclassify findings)
|
|
83
110
|
const { overriddenCount, suppressedCount } = applyOverrides(report, severityOverrides);
|
|
84
111
|
if (overriddenCount > 0 || suppressedCount > 0) {
|
|
@@ -142,7 +169,9 @@ export async function processReport(report, { outputDir, severityOverrides }) {
|
|
|
142
169
|
// SECURITY-CRITICAL: this is the ONE post-processor that fails CLOSED. On
|
|
143
170
|
// any error the egress guards must redact everything, so we set the
|
|
144
171
|
// _aegisFailClosed flag rather than swallowing-and-continuing.
|
|
145
|
-
|
|
172
|
+
// A gov token clamps the ARGUS_REDACT_SENSITIVE=0 opt-out (§4.3) so tagging
|
|
173
|
+
// still runs under an org policy — a dev can't disable it locally.
|
|
174
|
+
if (process.env.ARGUS_REDACT_SENSITIVE !== '0' || governanceOptOutClamped()) {
|
|
146
175
|
try {
|
|
147
176
|
const { sensitiveCount } = classifySensitivity(report, { reportRef: path.basename(reportPath) });
|
|
148
177
|
if (sensitiveCount > 0) logger.info(`[ARGUS] Aegis: ${sensitiveCount} finding(s) tagged sensitive`);
|
|
@@ -150,6 +179,20 @@ export async function processReport(report, { outputDir, severityOverrides }) {
|
|
|
150
179
|
logger.error(`[ARGUS] Aegis classify failed — egress guards will fail closed: ${err.message}`);
|
|
151
180
|
report._aegisFailClosed = true; // sinks read this and redact everything
|
|
152
181
|
}
|
|
182
|
+
|
|
183
|
+
// 3d. Governance audit trail (opt-in): post a SECRET-FREE redaction aggregate
|
|
184
|
+
// (byReason label counts only) to the org's audit sink. Best-effort — never
|
|
185
|
+
// blocks or fails the run, never sends a secret. No-op unless a gov token +
|
|
186
|
+
// ARGUS_REDACT_AUDIT_URL are set.
|
|
187
|
+
if (governanceActive() && process.env.ARGUS_REDACT_AUDIT_URL) {
|
|
188
|
+
try {
|
|
189
|
+
const all = collectAllFindings(report);
|
|
190
|
+
const summary = summarizeRedaction(all, effectivePolicyOpts({}));
|
|
191
|
+
await postRedactionAggregate(summary, { meta: { runRef: path.basename(reportPath) } });
|
|
192
|
+
} catch (err) {
|
|
193
|
+
logger.debug(`[ARGUS] Aegis governance aggregate skipped: ${err.message}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
153
196
|
}
|
|
154
197
|
|
|
155
198
|
// 4. Write JSON report
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aegis — governance seam (the opt-in, self-hosted central-policy bridge).
|
|
3
|
+
*
|
|
4
|
+
* The shipped engine applies a structured redaction policy locally via the
|
|
5
|
+
* `policy` param / ARGUS_REDACT_POLICY env (redaction-policy.js). This module is
|
|
6
|
+
* the THIN, OPT-IN network wrapper that lets a self-hosted laptop / CI run
|
|
7
|
+
* *participate in central governance*: it fetches the org's signed policy from the
|
|
8
|
+
* closed control plane, VERIFIES it (Ed25519, MITM-resistant), applies it through
|
|
9
|
+
* the existing `policy` param, caches it with a TTL, and posts SECRET-FREE
|
|
10
|
+
* redaction aggregates back for the compliance trail.
|
|
11
|
+
*
|
|
12
|
+
* OPEN-CORE DISCIPLINE (AEGIS_FOR_TEAMS_PLAN.md §9 — re-read before editing):
|
|
13
|
+
* • Inert without a governance token. `ARGUS_GOV_TOKEN` unset ⇒ this module wires
|
|
14
|
+
* a source that returns null ⇒ the engine is BYTE-IDENTICAL to v9.9.0. No
|
|
15
|
+
* phone-home, no fetch, no new required dependency, no perf cost by default.
|
|
16
|
+
* • Fail-CLOSED everywhere. Any fetch / parse / signature error ⇒ the engine
|
|
17
|
+
* falls back to the STRICT floor (redact MORE, never less). A verify failure
|
|
18
|
+
* NEVER loosens redaction — it is treated exactly as "no policy fetched".
|
|
19
|
+
* • Ed25519-signed policy, verified before use. The control plane signs; the
|
|
20
|
+
* engine verifies with the org's PUBLIC key (ARGUS_REDACT_POLICY_PUBKEY, pinned
|
|
21
|
+
* out-of-band). A bad signature / wrong key / tampered body is rejected → floor.
|
|
22
|
+
* • Secret-free aggregates only. What posts to ARGUS_REDACT_AUDIT_URL is the
|
|
23
|
+
* `byReason` LABEL stream (secret:* / pii:* / category:* counts) — never a
|
|
24
|
+
* secret, token, or raw value. Best-effort: posting never blocks or fails a run.
|
|
25
|
+
* • Strict enforcement clamps the local opt-out. When a gov token is present a
|
|
26
|
+
* dev cannot set ARGUS_REDACT_SENSITIVE=0 to escape the org policy — that clamp
|
|
27
|
+
* is exposed to the classifier via redaction-policy.js governanceOptOutClamped().
|
|
28
|
+
*
|
|
29
|
+
* Uses only node:crypto + the global fetch (both built-in on the supported Node) —
|
|
30
|
+
* NO new dependency. The pure policy resolver stays in redaction-policy.js (I/O-
|
|
31
|
+
* free); this module does the I/O and injects a sync source into it, mirroring the
|
|
32
|
+
* aegis-vault.js ⇄ secret-patterns.js setVaultTokenizer seam.
|
|
33
|
+
*
|
|
34
|
+
* DEFERRED (needs the central control plane's team-vault endpoint): routing
|
|
35
|
+
* `mode=token` vault writes/reads to the team endpoint when a gov token is present.
|
|
36
|
+
* Today `mode=token` without a wired vault degrades to mask in-engine (unchanged).
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { verify as edVerify, createPublicKey } from 'node:crypto';
|
|
40
|
+
import { childLogger } from './logger.js';
|
|
41
|
+
import { scrubText } from './secret-patterns.js';
|
|
42
|
+
import { resolvePolicy, STRICT_DEFAULT, setGovernanceSource } from './redaction-policy.js';
|
|
43
|
+
|
|
44
|
+
const logger = childLogger('aegis');
|
|
45
|
+
|
|
46
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 min — a policy is re-fetched at most this often
|
|
47
|
+
|
|
48
|
+
// Module-level policy cache. `ok` distinguishes a verified policy (apply the
|
|
49
|
+
// resolved config) from a fetch/verify failure (fail-closed to the strict floor).
|
|
50
|
+
let _cache = { resolved: STRICT_DEFAULT, fetchedAt: null, ok: false, version: null, error: null };
|
|
51
|
+
|
|
52
|
+
/** True iff a governance token is provisioned — the master switch for the seam. */
|
|
53
|
+
export function governanceActive() {
|
|
54
|
+
return !!process.env.ARGUS_GOV_TOKEN;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function ttlMs() {
|
|
58
|
+
const raw = Number(process.env.ARGUS_REDACT_POLICY_TTL_MS);
|
|
59
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TTL_MS;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function pubKeyPem() {
|
|
63
|
+
return process.env.ARGUS_REDACT_POLICY_PUBKEY || '';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The sync source injected into redaction-policy.js. Returns:
|
|
68
|
+
* • null — governance inactive (no token) ⇒ engine keeps env/default
|
|
69
|
+
* • resolved config — active + a fresh verified policy ⇒ apply it
|
|
70
|
+
* • STRICT_DEFAULT — active but cold / expired / failed ⇒ fail-CLOSED strict floor
|
|
71
|
+
* Never throws.
|
|
72
|
+
*/
|
|
73
|
+
function governanceSource() {
|
|
74
|
+
if (!governanceActive()) return null;
|
|
75
|
+
if (_cache.ok && _cache.fetchedAt !== null && (Date.now() - _cache.fetchedAt) < ttlMs()) {
|
|
76
|
+
return _cache.resolved;
|
|
77
|
+
}
|
|
78
|
+
return STRICT_DEFAULT; // active but no valid policy in force ⇒ strict floor
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Register the source at module load: merely importing this module makes the
|
|
82
|
+
// engine governance-aware (inert until a token is set). Mirrors setVaultTokenizer.
|
|
83
|
+
setGovernanceSource(governanceSource);
|
|
84
|
+
|
|
85
|
+
/** Key-sorted JSON — the exact bytes the control plane signs over. Must match the
|
|
86
|
+
* cloud signer's canonicalize() so a policy signed there verifies here. */
|
|
87
|
+
export function canonicalPolicyBytes(policy) {
|
|
88
|
+
return JSON.stringify(sortKeys(policy));
|
|
89
|
+
}
|
|
90
|
+
function sortKeys(value) {
|
|
91
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
92
|
+
if (value && typeof value === 'object') {
|
|
93
|
+
const out = {};
|
|
94
|
+
for (const k of Object.keys(value).sort()) out[k] = sortKeys(value[k]);
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Decode an Ed25519 signature that may arrive hex or base64/base64url. */
|
|
101
|
+
function decodeSignature(sig) {
|
|
102
|
+
if (/^[0-9a-fA-F]+$/.test(sig) && sig.length % 2 === 0) return Buffer.from(sig, 'hex');
|
|
103
|
+
return Buffer.from(sig.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Verify a signed-policy payload against the pinned org public key. Returns the
|
|
108
|
+
* inner policy doc on success; THROWS on any problem (missing fields, no pubkey,
|
|
109
|
+
* bad signature, wrong key) so the caller fails closed. Exported for unit proof.
|
|
110
|
+
* @param {{policy?:object, signature?:string}} payload
|
|
111
|
+
* @param {string} [publicKeyPem]
|
|
112
|
+
* @returns {object} the verified policy doc
|
|
113
|
+
*/
|
|
114
|
+
export function verifySignedPolicy(payload, publicKeyPem = pubKeyPem()) {
|
|
115
|
+
if (!payload || typeof payload !== 'object') throw new Error('governance policy payload is not an object');
|
|
116
|
+
const { policy, signature } = payload;
|
|
117
|
+
if (!policy || typeof policy !== 'object') throw new Error('governance policy payload missing "policy"');
|
|
118
|
+
if (typeof signature !== 'string' || signature.length === 0) throw new Error('governance policy payload missing "signature"');
|
|
119
|
+
if (!publicKeyPem) throw new Error('ARGUS_REDACT_POLICY_PUBKEY is not set — cannot verify a signed policy');
|
|
120
|
+
|
|
121
|
+
const key = createPublicKey({ key: publicKeyPem, format: 'pem' });
|
|
122
|
+
const ok = edVerify(null, Buffer.from(canonicalPolicyBytes(policy), 'utf8'), key, decodeSignature(signature));
|
|
123
|
+
if (!ok) throw new Error('governance policy signature verification failed');
|
|
124
|
+
return policy;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function fetchSignedPolicy(fetchImpl) {
|
|
128
|
+
const url = process.env.ARGUS_REDACT_POLICY_URL;
|
|
129
|
+
const token = process.env.ARGUS_GOV_TOKEN;
|
|
130
|
+
if (!url) throw new Error('ARGUS_REDACT_POLICY_URL is not set');
|
|
131
|
+
if (!token) throw new Error('ARGUS_GOV_TOKEN is not set');
|
|
132
|
+
const res = await fetchImpl(url, {
|
|
133
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
134
|
+
});
|
|
135
|
+
if (!res || res.ok !== true) throw new Error(`governance policy fetch HTTP ${res && res.status}`);
|
|
136
|
+
return res.json();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Fetch + Ed25519-verify + cache the org policy. Idempotent + TTL-gated: at most
|
|
141
|
+
* one fetch per TTL window. Best-effort and NEVER throws — on any error it caches
|
|
142
|
+
* the STRICT floor (fail-closed) and returns it. Call once at run start (before the
|
|
143
|
+
* first redaction); the verified config then feeds every redaction via the source.
|
|
144
|
+
*
|
|
145
|
+
* @param {{ fetchImpl?: Function, now?: () => number }} [deps] test seams
|
|
146
|
+
* @returns {Promise<typeof STRICT_DEFAULT | null>} null when inactive
|
|
147
|
+
*/
|
|
148
|
+
export async function ensureGovernancePolicy(deps = {}) {
|
|
149
|
+
if (!governanceActive()) return null; // opt-in: no token ⇒ nothing fetched, byte-identical
|
|
150
|
+
const now = (deps.now || Date.now)();
|
|
151
|
+
// Fresh cache (success OR recent failure) ⇒ don't re-hit the endpoint every run.
|
|
152
|
+
if (_cache.fetchedAt !== null && (now - _cache.fetchedAt) < ttlMs()) {
|
|
153
|
+
return _cache.ok ? _cache.resolved : STRICT_DEFAULT;
|
|
154
|
+
}
|
|
155
|
+
const fetchImpl = deps.fetchImpl || globalThis.fetch;
|
|
156
|
+
try {
|
|
157
|
+
if (typeof fetchImpl !== 'function') throw new Error('no fetch implementation available');
|
|
158
|
+
const payload = await fetchSignedPolicy(fetchImpl);
|
|
159
|
+
const doc = verifySignedPolicy(payload); // throws on bad sig / wrong key / missing pubkey
|
|
160
|
+
_cache = { resolved: resolvePolicy(doc), fetchedAt: now, ok: true, version: typeof doc.version === 'number' ? doc.version : null, error: null };
|
|
161
|
+
logger.info(`[ARGUS] Aegis governance: applied org policy v${_cache.version ?? '?'} (verified)`);
|
|
162
|
+
return _cache.resolved;
|
|
163
|
+
} catch (err) {
|
|
164
|
+
_cache = { resolved: STRICT_DEFAULT, fetchedAt: now, ok: false, version: null, error: String((err && err.message) || err) };
|
|
165
|
+
logger.warn(`[ARGUS] Aegis governance: policy fetch/verify failed — failing closed to strict floor: ${_cache.error}`);
|
|
166
|
+
return STRICT_DEFAULT;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Coerce a byReason map into a SECRET-FREE {label:int}. Each label is scrubbed
|
|
171
|
+
* (defence in depth — reason labels are structural, but a tainted one dies here)
|
|
172
|
+
* and length-capped; each value is a non-negative integer. */
|
|
173
|
+
function safeByReason(byReason) {
|
|
174
|
+
const out = {};
|
|
175
|
+
if (!byReason || typeof byReason !== 'object') return out;
|
|
176
|
+
for (const [rawKey, rawVal] of Object.entries(byReason)) {
|
|
177
|
+
const label = scrubText(String(rawKey)).slice(0, 64);
|
|
178
|
+
const n = Number(rawVal);
|
|
179
|
+
if (label && Number.isFinite(n) && n >= 0) out[label] = Math.floor(n);
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function safeInt(v) {
|
|
185
|
+
const n = Number(v);
|
|
186
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* POST a SECRET-FREE redaction aggregate to ARGUS_REDACT_AUDIT_URL (the compliance
|
|
191
|
+
* trail). Best-effort: never blocks, never throws, never sends a secret. The body
|
|
192
|
+
* carries only label counts + coarse non-secret meta + the enforced policy version.
|
|
193
|
+
* No-op (not-configured) unless a gov token AND an audit URL are set.
|
|
194
|
+
*
|
|
195
|
+
* @param {{ total?:number, redacted?:number, byReason?:object }} summary from summarizeRedaction
|
|
196
|
+
* @param {{ fetchImpl?: Function, meta?: object }} [deps]
|
|
197
|
+
* @returns {Promise<{posted:boolean, reason?:string, body?:object}>}
|
|
198
|
+
*/
|
|
199
|
+
export async function postRedactionAggregate(summary, deps = {}) {
|
|
200
|
+
const url = process.env.ARGUS_REDACT_AUDIT_URL;
|
|
201
|
+
const token = process.env.ARGUS_GOV_TOKEN;
|
|
202
|
+
if (!url || !token) return { posted: false, reason: 'not-configured' };
|
|
203
|
+
|
|
204
|
+
const meta = deps.meta && typeof deps.meta === 'object' ? deps.meta : {};
|
|
205
|
+
const body = {
|
|
206
|
+
total: safeInt(summary && summary.total),
|
|
207
|
+
redacted: safeInt(summary && summary.redacted),
|
|
208
|
+
byReason: safeByReason(summary && summary.byReason),
|
|
209
|
+
policyVersion: _cache.version,
|
|
210
|
+
// Coarse, non-secret provenance only — every string is scrubbed.
|
|
211
|
+
runRef: typeof meta.runRef === 'string' ? scrubText(meta.runRef).slice(0, 128) : null,
|
|
212
|
+
at: new Date().toISOString(),
|
|
213
|
+
};
|
|
214
|
+
try {
|
|
215
|
+
const fetchImpl = deps.fetchImpl || globalThis.fetch;
|
|
216
|
+
if (typeof fetchImpl !== 'function') throw new Error('no fetch implementation available');
|
|
217
|
+
await fetchImpl(url, {
|
|
218
|
+
method: 'POST',
|
|
219
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
220
|
+
body: JSON.stringify(body),
|
|
221
|
+
});
|
|
222
|
+
return { posted: true, body };
|
|
223
|
+
} catch (err) {
|
|
224
|
+
logger.debug(`[ARGUS] Aegis governance: aggregate POST failed (best-effort, ignored): ${String((err && err.message) || err)}`);
|
|
225
|
+
return { posted: false, reason: String((err && err.message) || err), body };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The policy version currently in force (for callers/telemetry). null when none. */
|
|
230
|
+
export function governancePolicyVersion() {
|
|
231
|
+
return governanceActive() && _cache.ok ? _cache.version : null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Test seam: reset the module cache to the cold fail-closed state. */
|
|
235
|
+
export function _resetGovernanceForTests() {
|
|
236
|
+
_cache = { resolved: STRICT_DEFAULT, fetchedAt: null, ok: false, version: null, error: null };
|
|
237
|
+
}
|