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.
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Aegis — team-vault routing (AEGIS_FOR_TEAMS_PLAN.md §9, the last T3 sub-item).
3
+ *
4
+ * When an org's central policy runs in `token` mode AND a governance token is set AND
5
+ * a team-vault endpoint URL is configured, route the reversible token→secret mapping
6
+ * to the org's CENTRAL team vault (the closed control plane) INSTEAD of the LOCAL
7
+ * 0600 file (aegis-vault.js). That lets a token minted on a dev's laptop / in CI be
8
+ * re-hydrated through the org's authorized, RBAC-gated `Reveal ▸` flow — the whole
9
+ * point of a *team* vault (plan §5.2). Without the URL, `token` mode keeps using the
10
+ * local vault (or masks) — the secret never leaves the machine.
11
+ *
12
+ * OPEN-CORE + fail-closed discipline (identical to governance-seam.js §9):
13
+ * • Inert unless ARGUS_GOV_TOKEN (the same master switch governanceActive() reads)
14
+ * AND ARGUS_REDACT_VAULT_URL are BOTH set. Off ⇒ the engine is byte-identical to
15
+ * v9.9.0. No phone-home, no fetch, no new required dependency, no perf cost.
16
+ * • The token is an HMAC tag (information-free — aegis-vault computeToken). The
17
+ * SECRET only ever travels to the authenticated team-vault endpoint (TLS + the
18
+ * gov-token bearer) and is encrypted at rest there; it NEVER reaches Slack /
19
+ * GitHub / the LLM / the on-disk report. The redacted artifact carries only the
20
+ * token. Centralized secret storage is an EXPLICIT opt-in (§14 Q1): you must set
21
+ * ARGUS_REDACT_VAULT_URL — presence of the URL IS the opt-in.
22
+ * • The tokenizer is SYNCHRONOUS (scrubText calls _vaultTokenFn inline): it computes
23
+ * the token + buffers the mapping in memory, and a best-effort async
24
+ * flushTeamVault() ships the buffer after the egress pass (mirrors
25
+ * governance-seam.js postRedactionAggregate). A flush failure loses only
26
+ * re-hydratability (the emitted token is already safe) — never a leak, never a
27
+ * throw. On a failed flush the buffer is retained for the next flush.
28
+ * • Precedence (§4.3 — a gov policy clamps the local opt-out): when the team vault
29
+ * is active it WINS over the local ARGUS_REDACT_VAULT vault; ensureTokenVaultWired
30
+ * falls back to the local vault only when the team vault is inactive.
31
+ *
32
+ * Uses only the global fetch (built-in on the supported Node) — NO new dependency.
33
+ * secret-patterns.js stays I/O-free; this module injects teamTokenFor through the
34
+ * SAME setVaultTokenizer seam aegis-vault.js uses.
35
+ */
36
+
37
+ import { childLogger } from './logger.js';
38
+ import { setVaultTokenizer, scrubText } from './secret-patterns.js';
39
+ import { computeToken, ensureVaultWired } from './aegis-vault.js';
40
+
41
+ const logger = childLogger('aegis');
42
+
43
+ // Cap the in-memory mapping buffer so a persistent flush failure can never grow it
44
+ // unbounded. Once full we stop buffering NEW mappings (the tokens still emit + are
45
+ // safe — they just can't be re-hydrated). Matches the spirit of aegis-vault dedup.
46
+ const MAX_BUFFER = 5000;
47
+
48
+ const _buffer = new Map(); // token → secret, pending flush (dedup: first-seen wins)
49
+ const _flushed = new Set(); // tokens already shipped this process (avoid re-upload)
50
+ let _wired = false; // is teamTokenFor currently registered on the seam?
51
+
52
+ /**
53
+ * True iff the team vault is active: a governance token AND a team-vault endpoint URL
54
+ * are both provisioned. ARGUS_GOV_TOKEN is the SAME master switch governanceActive()
55
+ * reads (checked inline to keep this module free of the governance-seam import graph).
56
+ * @returns {boolean}
57
+ */
58
+ export function teamVaultActive() {
59
+ return !!process.env.ARGUS_GOV_TOKEN && !!process.env.ARGUS_REDACT_VAULT_URL;
60
+ }
61
+
62
+ /**
63
+ * The synchronous tokenizer registered on scrubText's `token` mode when the team
64
+ * vault is active. Computes the deterministic, information-free token (aegis-vault
65
+ * HMAC) and buffers the token→secret mapping for the async flush. Returns the token
66
+ * to embed in the egress artifact. Never throws (scrubText's maskValue falls back to
67
+ * mask on any throw, so even a defect here fails closed).
68
+ * @param {*} value
69
+ * @returns {string}
70
+ */
71
+ export function teamTokenFor(value) {
72
+ const token = computeToken(value);
73
+ if (!_flushed.has(token) && !_buffer.has(token) && _buffer.size < MAX_BUFFER) {
74
+ _buffer.set(token, String(value ?? ''));
75
+ }
76
+ return token;
77
+ }
78
+
79
+ /**
80
+ * Wire the team tokenizer onto the scrubText `token` mode seam when the team vault is
81
+ * active. Does NOT touch the seam when inactive (so the local vault path can manage
82
+ * it). Idempotent + cheap — the egress path calls this before redacting in token mode.
83
+ * @returns {boolean} true when the team vault is active (and now wired), else false
84
+ */
85
+ export function ensureTeamVaultWired() {
86
+ if (teamVaultActive()) {
87
+ if (!_wired) { setVaultTokenizer(teamTokenFor); _wired = true; }
88
+ return true;
89
+ }
90
+ if (_wired) { setVaultTokenizer(null); _wired = false; }
91
+ return false;
92
+ }
93
+
94
+ /**
95
+ * The unified `token` mode wiring entry the classifier calls: the TEAM vault wins
96
+ * when active (§4.3), else fall back to the LOCAL vault (aegis-vault ensureVaultWired,
97
+ * which itself masks when the local vault is disabled). One call site, correct
98
+ * precedence, no double-wiring.
99
+ * @returns {boolean} whether ANY reversible vault is now wired (team or local)
100
+ */
101
+ export function ensureTokenVaultWired() {
102
+ if (ensureTeamVaultWired()) return true;
103
+ return ensureVaultWired();
104
+ }
105
+
106
+ /**
107
+ * Ship the buffered token→secret mappings to the org's team-vault endpoint. Best-
108
+ * effort, mirrors postRedactionAggregate: never blocks, never throws, and — because
109
+ * the emitted tokens are already information-free — a failure never leaks. On success
110
+ * the shipped tokens are marked flushed and the buffer is cleared; on failure the
111
+ * buffer is retained for the next flush. No-op (inactive/empty) unless the team vault
112
+ * is active with pending mappings.
113
+ *
114
+ * The body is `{ mappings: [{ token, secret }], artifactRef }` — the shape the cloud
115
+ * team-vault ingest (flushVaultMappings) consumes. The SECRET is sent DELIBERATELY
116
+ * (that is the centralized-vault contract, §5.2): the endpoint encrypts it at rest so
117
+ * a later authorized, logged re-hydration can return it. The token stays on every
118
+ * OTHER sink; the secret goes ONLY here.
119
+ *
120
+ * @param {{ fetchImpl?: Function, artifactRef?: string }} [deps] test/caller seams
121
+ * @returns {Promise<{posted:boolean, count:number, reason?:string}>}
122
+ */
123
+ export async function flushTeamVault(deps = {}) {
124
+ if (!teamVaultActive()) return { posted: false, count: 0, reason: 'inactive' };
125
+ const url = process.env.ARGUS_REDACT_VAULT_URL;
126
+ const token = process.env.ARGUS_GOV_TOKEN;
127
+ if (!url || !token) return { posted: false, count: 0, reason: 'not-configured' };
128
+ if (_buffer.size === 0) return { posted: true, count: 0 };
129
+
130
+ const entries = [..._buffer.entries()];
131
+ const mappings = entries.map(([tok, secret]) => ({ token: tok, secret }));
132
+ const artifactRef = typeof deps.artifactRef === 'string' ? scrubText(deps.artifactRef).slice(0, 256) : null;
133
+ try {
134
+ const fetchImpl = deps.fetchImpl || globalThis.fetch;
135
+ if (typeof fetchImpl !== 'function') throw new Error('no fetch implementation available');
136
+ const res = await fetchImpl(url, {
137
+ method: 'POST',
138
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
139
+ body: JSON.stringify({ mappings, artifactRef }),
140
+ });
141
+ if (!res || res.ok !== true) throw new Error(`team-vault flush HTTP ${res && res.status}`);
142
+ for (const [tok] of entries) { _flushed.add(tok); }
143
+ _buffer.clear();
144
+ logger.debug(`[ARGUS] Aegis team vault: flushed ${mappings.length} mapping(s)`);
145
+ return { posted: true, count: mappings.length };
146
+ } catch (err) {
147
+ // Fail-closed: the emitted tokens are information-free, so a failed flush leaks
148
+ // nothing — it only forgoes central re-hydration for these tokens. Keep the buffer
149
+ // for a later flush; never throw into the egress path.
150
+ logger.debug(`[ARGUS] Aegis team vault: flush failed (best-effort, ignored): ${String((err && err.message) || err)}`);
151
+ return { posted: false, count: mappings.length, reason: String((err && err.message) || err) };
152
+ }
153
+ }
154
+
155
+ /** Count of mappings buffered and not yet flushed (telemetry/tests). */
156
+ export function pendingTeamVaultCount() {
157
+ return _buffer.size;
158
+ }
159
+
160
+ /** Test seam — clears the buffer/flushed/wiring state. NOT for production use. */
161
+ export function _resetTeamVaultForTests() {
162
+ _buffer.clear();
163
+ _flushed.clear();
164
+ _wired = false;
165
+ setVaultTokenizer(null);
166
+ }