decision-gate 0.1.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,189 @@
1
+ // Adapted from herdr-find 4736dd5 (Apache-2.0)
2
+ import { APIError, RateLimitError, TypeSafeClient } from "@typesafe-ai/sdk";
3
+ import { usdAt } from "../budget.mjs";
4
+ import { RequestSizeError, ServiceError, SpendCapError } from "../errors.mjs";
5
+
6
+ export const JEV_ENDPOINT = "https://api.typesafe.ai/v1/systemone";
7
+ export const PINNED_MODEL = "jev-1.13.0";
8
+ // Output tokens are free for this model
9
+ export const JEV_PRICE = {
10
+ model: PINNED_MODEL,
11
+ usd_per_million_input_tokens: 0.042,
12
+ source: "https://docs.typesafe.ai/models.md",
13
+ checked: "2026-09-25"
14
+ };
15
+
16
+ // The documented context length has two budgets: the whole request, and the state plus its
17
+ // longest question, since the state is read once and each question is judged against it. Spend
18
+ // reserves the whole-request budget, not an empirical bytes/token ratio
19
+ export const MAX_INPUT_TOKENS = 65536;
20
+ export const MAX_STATE_QUESTION_TOKENS = 32768;
21
+ // For estimates shown before a run, the measured rate rather than the reservation
22
+ export const ESTIMATE_TOKENS_PER_BYTE = 0.25;
23
+
24
+ export const usdFor = (tokens) => usdAt(JEV_PRICE, tokens);
25
+
26
+ export const estimateUsd = (bytes) => usdFor(bytes * ESTIMATE_TOKENS_PER_BYTE);
27
+
28
+ const estimatedTokens = (value) => Buffer.byteLength(JSON.stringify(value) ?? "") * ESTIMATE_TOKENS_PER_BYTE;
29
+
30
+ // Refuses a request the model would reject for its length, by the same measured estimate shown
31
+ // before a run, so it fails here without being sent. The estimate is not exact, so a request close
32
+ // to either budget can still be rejected by the service
33
+ function checkSize(request) {
34
+ const questions = Object.values(request?.questions ?? {});
35
+ if (estimatedTokens(request) > MAX_INPUT_TOKENS) {
36
+ throw new RequestSizeError(`request is estimated above the model's ${MAX_INPUT_TOKENS}-token limit; nothing was sent`);
37
+ }
38
+ const longest = Math.max(0, ...questions.map(estimatedTokens));
39
+ if (estimatedTokens(request?.state) + longest > MAX_STATE_QUESTION_TOKENS) {
40
+ throw new RequestSizeError(`state plus its longest question is estimated above the model's ${MAX_STATE_QUESTION_TOKENS}-token limit; nothing was sent`);
41
+ }
42
+ }
43
+
44
+ // Only a loopback address may stand in for a provider's fixed endpoint, so a test's stand-in can
45
+ // never be a real host the key would be sent to
46
+ const destination = (fixed) => (env = process.env) => {
47
+ const wanted = env.DECISION_GATE_ENDPOINT;
48
+ if (!wanted) return fixed;
49
+ try {
50
+ const url = new URL(wanted);
51
+ if (url.protocol === "http:" && !url.username && !url.password && ["127.0.0.1", "[::1]"].includes(url.hostname)) return url.href;
52
+ } catch { /* Report a generic error without echoing the supplied URL */ }
53
+ throw new ServiceError("DECISION_GATE_ENDPOINT must be a numeric HTTP loopback URL");
54
+ };
55
+
56
+ // The request and response shapes are TypeSafe's own, so any provider serving that API at a fixed
57
+ // endpoint shares this responder; the gate, not the caller, names the model on the wire
58
+ const createResponder = ({ label, wireModel, maxRetries: providerRetries, pauseFallbackMs }) => function respond({ key, budget, assertSafe, limiter, book = async () => {}, fetchImpl = globalThis.fetch, endpoint, timeoutMs = 30000, maxRetries = providerRetries }) {
59
+ if (!key?.authorization || !budget || !assertSafe || !endpoint) throw new TypeError("key, spend budget, never-send check and endpoint are required");
60
+ return async (request, { signal } = {}) => {
61
+ if (![undefined, PINNED_MODEL].includes(request.model)) throw new ServiceError(`request model must be omitted or ${PINNED_MODEL}`);
62
+ request = { ...request, model: wireModel };
63
+ const abort = new AbortController();
64
+ let localError;
65
+ const local = async (fn) => {
66
+ try { return await fn(); }
67
+ catch (error) { localError = error; abort.abort(); throw error; }
68
+ };
69
+ // Usage measures about a quarter token per request byte, so counting bytes overcounts
70
+ // without throttling like the full-context spend reservation would
71
+ const counted = (bytes) => Math.min(bytes, MAX_INPUT_TOKENS);
72
+ let first, slot, sent = false;
73
+ const client = new TypeSafeClient({
74
+ apiKey: key.authorization.slice("Bearer ".length),
75
+ baseURL: new URL(endpoint).origin,
76
+ defaultModel: wireModel,
77
+ // Explicit settings prevent SDK environment defaults from leaking data or keys
78
+ logLevel: "off",
79
+ timeout: timeoutMs,
80
+ retry: { maxRetries, respectRetryAfter: true },
81
+ fetch: async (_url, init) => {
82
+ await local(() => assertSafe(init.body));
83
+ if (sent) {
84
+ // A retry's wait for the account's window runs inside its timer, so an abort there is
85
+ // left to the SDK as a retryable timeout, not a local failure that ends the ask
86
+ try { await slot?.again(counted(Buffer.byteLength(init.body)), { signal: init.signal }); }
87
+ catch (error) { if (init.signal.aborted) throw error; await local(() => { throw error; }); }
88
+ }
89
+ sent = true;
90
+ let ticket = first;
91
+ first = undefined;
92
+ // A retry after an attempt that may have been billed cannot wait for spend inside its
93
+ // timer, so one that does not fit now fails this request rather than the ceiling
94
+ if (!ticket) {
95
+ ticket = await local(async () => {
96
+ let held;
97
+ try { held = budget.reserve(MAX_INPUT_TOKENS); }
98
+ catch (error) { throw error instanceof SpendCapError ? new ServiceError(`${label} request failed and its retry does not fit under the spend ceiling now`) : error; }
99
+ try { await book(); } catch (error) { held.release(); throw error; }
100
+ return held;
101
+ });
102
+ }
103
+ let response;
104
+ try {
105
+ // Surface redirects as non-retryable HTTP errors without following them
106
+ response = await fetchImpl(endpoint, { ...init, redirect: "manual" });
107
+ } catch (error) {
108
+ await local(async () => { ticket.settle(null); await book(); });
109
+ throw error;
110
+ }
111
+ await local(async () => {
112
+ if (!response.ok) {
113
+ if (response.status >= 500 || response.status === 408) ticket.settle(null);
114
+ // An unbilled refusal keeps its reservation for the retry, so the retry needs no new room
115
+ else first = ticket;
116
+ } else {
117
+ let json;
118
+ try { json = await response.clone().json(); }
119
+ catch { /* An unreadable response may still have been billed */ }
120
+ const tokens = json?.usage?.input_tokens;
121
+ ticket.settle(Number.isInteger(tokens) && tokens <= MAX_INPUT_TOKENS ? tokens : null);
122
+ }
123
+ await book();
124
+ // The slot is held until the whole call settles, so no waiter starts ahead of this pause
125
+ if (response.status === 429 || response.status === 529) {
126
+ const delay = new RateLimitError(response.status, undefined, response.headers).retryAfterMs;
127
+ await limiter?.pause(Number.isFinite(delay) ? delay : pauseFallbackMs);
128
+ }
129
+ });
130
+ return response;
131
+ }
132
+ });
133
+ const bytes = Buffer.byteLength(JSON.stringify(request));
134
+ // The first attempt's reservation and the account slot are taken before the SDK starts its
135
+ // attempt timer, so waiting for either never uses up or aborts the attempt
136
+ first = await budget.acquire(MAX_INPUT_TOKENS, signal);
137
+ try {
138
+ slot = await limiter?.take(counted(bytes), { estimated: bytes * ESTIMATE_TOKENS_PER_BYTE, signal });
139
+ await book();
140
+ } catch (error) { first.release(); await slot?.release(); throw error; }
141
+ let json;
142
+ try {
143
+ json = await client.systemOne(request, { signal: signal ? AbortSignal.any([signal, abort.signal]) : abort.signal });
144
+ } catch (error) {
145
+ if (signal?.aborted) throw signal.reason;
146
+ if (localError) throw localError;
147
+ if (error instanceof APIError) {
148
+ if (error.status === 429 || error.status === 529) {
149
+ // The SDK exposes its Retry-After parser through RateLimitError, including HTTP dates
150
+ const delay = new RateLimitError(error.status, undefined, error.headers).retryAfterMs;
151
+ const after = Number.isFinite(delay) ? `${Math.ceil(delay / 1000)} seconds` : "not supplied";
152
+ throw new ServiceError(`${label} returned HTTP ${error.status}; stopped; retry-after: ${after}`, { status: error.status });
153
+ }
154
+ throw new ServiceError(`${label} returned HTTP ${error.status}`, { status: error.status });
155
+ }
156
+ throw new ServiceError(`${label} request failed; no input or key logged`);
157
+ } finally {
158
+ await slot?.release();
159
+ // A reservation no attempt used holds no spend
160
+ if (first) { first.release(); await book(); }
161
+ }
162
+ if (json?.model !== wireModel) throw new ServiceError(`${label} answered with an unexpected model`);
163
+ return json;
164
+ };
165
+ };
166
+
167
+ // A provider serving TypeSafe's API: its key variable, one hard-coded destination, the model id it
168
+ // sends and expects back, its price, and limiter defaults for an account there
169
+ export function systemOneProvider({ name, label, keyEnv, endpoint, wireModel, pinned, price, limits, maxRetries = 2, pauseFallbackMs = 1000 }) {
170
+ return Object.freeze({
171
+ name, label, keyEnv, model: wireModel, pinned, price,
172
+ limits: Object.freeze(limits),
173
+ endpoint: destination(endpoint),
174
+ checkSize,
175
+ respond: createResponder({ label, wireModel, maxRetries, pauseFallbackMs })
176
+ });
177
+ }
178
+
179
+ export const typesafe = systemOneProvider({
180
+ name: "typesafe",
181
+ label: "TypeSafe",
182
+ keyEnv: "TYPESAFE_API_KEY",
183
+ endpoint: JEV_ENDPOINT,
184
+ wireModel: PINNED_MODEL,
185
+ pinned: true,
186
+ price: JEV_PRICE,
187
+ // TypeSafe's published limits for the pinned model
188
+ limits: { requestsPerMinute: 1200, tokensPerSecond: 250000, inFlight: 4 }
189
+ });
@@ -0,0 +1,20 @@
1
+ import { JEV_PRICE, systemOneProvider } from "./typesafe.mjs";
2
+
3
+ // Experimental and unpinned: the gate sends this floating id, so the Jev version behind it can change
4
+ export const GATEWAY_MODEL = "typesafe-ai/jev";
5
+
6
+ export const vercelAiGateway = systemOneProvider({
7
+ name: "vercel-ai-gateway",
8
+ label: "Vercel AI Gateway",
9
+ keyEnv: "AI_GATEWAY_API_KEY",
10
+ // The one endpoint the gate posts to, with TypeSafe's request and response shapes
11
+ endpoint: "https://ai-gateway.vercel.sh/typesafe/v1/systemone",
12
+ wireModel: GATEWAY_MODEL,
13
+ pinned: false,
14
+ // Booked at TypeSafe's list price
15
+ price: { ...JEV_PRICE, model: GATEWAY_MODEL, source: "https://vercel.com/ai-gateway/models/jev", checked: "2026-09-26" },
16
+ // Conservative guesses rather than measurements
17
+ limits: { requestsPerMinute: 60, tokensPerSecond: 250000, inFlight: 2 },
18
+ maxRetries: 1,
19
+ pauseFallbackMs: 5000
20
+ });
@@ -0,0 +1,355 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { RedactionError } from "./errors.mjs";
4
+
5
+ // Adapted from herdr-find 4736dd5 (Apache-2.0)
6
+
7
+ // Redaction for everything a caller sends, in two layers: secret shapes built in here,
8
+ // then the user's own value list (names, customer ids, domains no pattern can know). A fail-closed
9
+ // sweep follows: a request in which any forbidden pattern survives is never sent. No rule, pattern
10
+ // or match is ever printed; a report names rule classes and counts, nothing else.
11
+ //
12
+ // The optional value list format:
13
+ //
14
+ // { "rules": [[class, pattern, replacement], ...], "forbidden": [pattern, ...] }
15
+ //
16
+ // Patterns are Python-flavoured regular expressions; a leading inline flag group such as (?i)
17
+ // becomes a JavaScript flag. Replacements use Python's \1 and \g<name> group syntax.
18
+
19
+ // A secret word as a whole segment of a key, split by _ or - or a case change, so SECRET_KEY_BASE,
20
+ // apiKey, DBPassword and password_confirmation qualify and tokens, monkey and keyboard do not
21
+ const SECRET_SEGMENT = String.raw`(?:(?<![A-Za-z0-9])(?:password|passwd|pwd|secret|token|apikey|key)(?![a-z])|(?:Password|Passwd|Pwd|Secret|Token|Apikey|Key)(?![a-z])|(?<![A-Za-z0-9])(?:PASSWORD|PASSWD|PWD|SECRET|TOKEN|APIKEY|KEY)(?![A-Za-z]))`;
22
+
23
+ const SECRET_PAIR = new RegExp(String.raw`(?<![A-Za-z0-9_-])[A-Za-z0-9_-]+(?![A-Za-z0-9_-])(?<=${SECRET_SEGMENT}[A-Za-z0-9_-]*)["']?\s*(?:===|!==|==|!=|=>|:=|=|:(?!:))\s*`, "g");
24
+ const QUOTES = "\"'`";
25
+ const CLOSES = { ")": "(", "]": "[", "}": "{" };
26
+
27
+ // Plainly code: a bare identifier with no digits, a $VAR or ${VAR} reference, member access or
28
+ // indexing, or a call. Keyword-led expressions such as await x or new X() stop at the space and
29
+ // arrive here as a bare identifier
30
+ const PLAINLY_CODE = /^(?:[A-Za-z_]+|\$\{*\s*[A-Za-z_]*\s*\}*|[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\[[^\]]*\])+|[^(]*\(.*)$/;
31
+
32
+ // Brackets and quotes left open earlier on the current line, which a value may close; read once
33
+ // per line as the scan moves forward
34
+ function lineContext(text) {
35
+ let at = 0;
36
+ let open = [];
37
+ let quotes = new Set();
38
+ return (to) => {
39
+ for (; at < to; at += 1) {
40
+ const c = text[at];
41
+ if (c === "\n") { open = []; quotes = new Set(); }
42
+ else if (QUOTES.includes(c)) quotes.has(c) ? quotes.delete(c) : quotes.add(c);
43
+ else if ("([{".includes(c)) open.push(c);
44
+ else if (c in CLOSES && open.at(-1) === CLOSES[c]) open.pop();
45
+ }
46
+ return { open, quotes };
47
+ };
48
+ }
49
+
50
+ // The value of a key that names a secret is redacted unless it is plainly code. A quoted value
51
+ // runs to its closing quote, honouring escapes, or to the end of the line. A bare value ends at
52
+ // whitespace, a comma, or a quote or bracket that closes one opened earlier on the line; a stray
53
+ // quote or bracket stays inside it
54
+ function redactPairs(text, counted) {
55
+ let out = "";
56
+ let last = 0;
57
+ const context = lineContext(text);
58
+ SECRET_PAIR.lastIndex = 0;
59
+ for (let match; (match = SECRET_PAIR.exec(text));) {
60
+ const start = match.index + match[0].length;
61
+ const lineEnd = text.indexOf("\n", start) === -1 ? text.length : text.indexOf("\n", start);
62
+ let end = start;
63
+ let value;
64
+ if (QUOTES.includes(text[start])) {
65
+ const quote = text[start];
66
+ end += 1;
67
+ while (end < lineEnd && text[end] !== quote) end += text[end] === "\\" ? 2 : 1;
68
+ end = Math.min(end + 1, lineEnd);
69
+ value = `${quote}[redacted]${quote}`;
70
+ } else {
71
+ const before = context(start);
72
+ const inner = [];
73
+ for (; end < lineEnd; end += 1) {
74
+ const c = text[end];
75
+ if (/\s/.test(c) || c === ",") break;
76
+ if (QUOTES.includes(c) && before.quotes.has(c)) break;
77
+ if ("([{".includes(c)) inner.push(c);
78
+ else if (c in CLOSES) {
79
+ if (inner.at(-1) === CLOSES[c]) inner.pop();
80
+ else if (before.open.includes(CLOSES[c])) break;
81
+ }
82
+ }
83
+ const bare = text.slice(start, end).replace(/;+$/, "");
84
+ value = !bare || PLAINLY_CODE.test(bare) ? text.slice(start, end) : `[redacted]${text.slice(start + bare.length, end)}`;
85
+ }
86
+ if (value !== text.slice(start, end)) counted();
87
+ out += text.slice(last, start) + value;
88
+ last = end;
89
+ SECRET_PAIR.lastIndex = Math.max(end, start);
90
+ }
91
+ return out + text.slice(last);
92
+ }
93
+
94
+ // An authorization or auth key, as a header, a JSON or YAML key, or a query or shell parameter.
95
+ // Prose such as "work authorization: F-1 OPT" also has one, so its value decides
96
+ const AUTH_KEY = /(?<![A-Za-z0-9])(?:proxy-)?auth(?:orization)?["'`]?\s*[:=]+\s*/gi;
97
+ // HTTP authentication schemes, registered and common vendor ones
98
+ const AUTH_SCHEME = /^(?:basic|bearer|token|digest|negotiate|ntlm|hoba|mutual|vapid|scram-sha-(?:1|256)|aws4-hmac-sha256|dpop|gnap|oauth|hawk|signature|apikey|api-key|sso-key|key)[ \t]+/i;
99
+ // Several schemes are also English words, so what follows decides: auth-params such as Digest's,
100
+ // a placeholder an earlier rule wrote, or a token68 run that is not a plain word such as "required".
101
+ // A single letter counts, as in "Bearer x"
102
+ function schemeCredential(value) {
103
+ const scheme = AUTH_SCHEME.exec(value);
104
+ if (!scheme) return false;
105
+ const rest = value.slice(scheme[0].length);
106
+ if (/^[A-Za-z][\w-]*\s*=/.test(rest) || /^\[[a-z ]+\]/.test(rest)) return true;
107
+ const run = /^[A-Za-z0-9._~+/-]+=*/.exec(rest)?.[0];
108
+ return Boolean(run) && !/^[A-Za-z][a-z]+$/.test(run);
109
+ }
110
+ // A long run with no spaces holding a letter and a digit, as keys and hex digests do, or a base64
111
+ // marker; hyphen- or slash-joined words such as "OPT-STEM-Extension" are not tokens
112
+ const TOKEN_CHARS = /^[A-Za-z0-9._~+/=-]{16,}$/;
113
+ const tokenShaped = (value) => TOKEN_CHARS.test(value) && /[A-Za-z]/.test(value) && /[0-9+]|=$/.test(value);
114
+
115
+ // The value after an authorization or auth key is redacted when it is a scheme followed by a
116
+ // credential, which may carry parameters, so it runs to the end of its quotes or line, or when it
117
+ // is token-shaped on its own. Anything else, such as a visa status or a sentence, is kept
118
+ function redactAuthorization(text, counted) {
119
+ let out = "";
120
+ let last = 0;
121
+ const context = lineContext(text);
122
+ AUTH_KEY.lastIndex = 0;
123
+ for (let match; (match = AUTH_KEY.exec(text));) {
124
+ const start = match.index + match[0].length;
125
+ const lineEnd = text.slice(start).search(/[\r\n]/);
126
+ const stop = lineEnd === -1 ? text.length : start + lineEnd;
127
+ let end = start;
128
+ let value;
129
+ if (QUOTES.includes(text[start])) {
130
+ const quote = text[start];
131
+ let close = start + 1;
132
+ while (close < stop && text[close] !== quote) close += text[close] === "\\" ? 2 : 1;
133
+ const inner = text.slice(start + 1, Math.min(close, stop));
134
+ if (schemeCredential(inner) || tokenShaped(inner)) {
135
+ end = Math.min(close + 1, stop);
136
+ value = `${quote}[redacted]${quote}`;
137
+ }
138
+ } else {
139
+ // A quote opened earlier on the line, as around a curl -H header, ends the value
140
+ const { quotes } = context(start);
141
+ const closing = (c) => QUOTES.includes(c) && quotes.has(c);
142
+ if (schemeCredential(text.slice(start, stop))) {
143
+ end = start;
144
+ while (end < stop && !closing(text[end])) end += text[end] === "\\" ? 2 : 1;
145
+ end = Math.min(end, stop);
146
+ value = "[redacted]";
147
+ } else {
148
+ end = start;
149
+ while (end < stop && !/[\s,;&)\]}]/.test(text[end]) && !QUOTES.includes(text[end])) end += 1;
150
+ value = tokenShaped(text.slice(start, end)) ? "[redacted]" : undefined;
151
+ }
152
+ }
153
+ if (value === undefined) continue;
154
+ counted();
155
+ out += text.slice(last, start) + value;
156
+ last = end;
157
+ AUTH_KEY.lastIndex = end;
158
+ }
159
+ return out + text.slice(last);
160
+ }
161
+
162
+ // Known secret formats only, applied before the user's rules to whole texts. A secret in no listed
163
+ // format and not on the user's list is sent; guessing at random-looking strings erased file paths.
164
+ export const BUILT_IN_RULES = [
165
+ ["private-key", /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----|$)/g, "[private key]"],
166
+ // Runs before the prefixed formats so a variable holding one loses its whole value
167
+ ["secret-pair", redactPairs],
168
+ ["jwt", /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, "[token]"],
169
+ ["bearer", /\b(bearer)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "$1 [token]"],
170
+ ["authorization", redactAuthorization],
171
+ // OpenAI and Anthropic: a known label with any base64url body, or any body with a capital and a
172
+ // digit; kebab-case names such as sk-learn-model-v2.py are lowercase and pass
173
+ ["api-key", /\bsk-(?:(?:proj|svcacct|admin|None|ant-[a-z]+[0-9]*)-[A-Za-z0-9_-]{20,}|(?=[A-Za-z0-9_-]*[A-Z])(?=[A-Za-z0-9_-]*[0-9])[A-Za-z0-9_-]{20,})/g, "[api key]"],
174
+ ["api-key", /\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/g, "[api key]"],
175
+ ["github-token", /\b(?:gh[oprsu]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/g, "[github token]"],
176
+ ["gitlab-token", /\bglpat-[A-Za-z0-9_-]{20,}/g, "[gitlab token]"],
177
+ ["npm-token", /\bnpm_[A-Za-z0-9]{36}/g, "[npm token]"],
178
+ ["slack-token", /\bxox[abposr]-[A-Za-z0-9-]{10,}/g, "[slack token]"],
179
+ ["aws-key", /\b(?:AKIA|ASIA)[0-9A-Z]{16}/g, "[aws key]"],
180
+ ["google-key", /\bAIza[0-9A-Za-z_-]{35}/g, "[google key]"],
181
+ ["url-credentials", /\b([a-z][a-z0-9+.-]*:\/\/)[^\s:/@]+:[^\s@/]+@/gi, "$1[redacted]@"],
182
+ // Pixel-density image names such as logo@2x.png are not addresses
183
+ ["email", /\b[A-Za-z0-9._%+-]+@(?![0-9]+(?:\.[0-9]+)?[xX]\.(?:[pP][nN][gG]|[jJ][pP][eE]?[gG]|[gG][iI][fF]|[wW][eE][bB][pP]|[sS][vV][gG]|[aA][vV][iI][fF])\b)[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}\b/g, "[email]"]
184
+ ];
185
+
186
+ // A private key piped in as several lines, judged by the key text at the end of each line so any
187
+ // prefix (rg paths, diff markers, line numbers, or a quote opening a multi-line value) is ignored
188
+ // and kept. A block opens on a line whose last text is the BEGIN marker, so a one-line string
189
+ // constant holding the marker never opens one. It runs while lines end with key body, to a line
190
+ // holding the END marker, whatever code closes the value after it, or, when the key was cut off,
191
+ // to the first line that is not body
192
+ const BEGIN_AT_END = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----\s*$/;
193
+ const END_AT_END = /-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----.*$/;
194
+ const HEADER_AT_END = /(?<![A-Za-z-])(?:Proc-Type|DEK-Info|Version|Comment|Hash|Charset): [^\n]*$/;
195
+ const BODY_AT_END = /(?<![A-Za-z0-9+/=])[A-Za-z0-9+/=]{40,}\s*$/;
196
+ // A blank line, perhaps under a prefix such as rg's path:3: or cat -n's number, as PGP armour has
197
+ const BLANK_AT_END = /(?:^|[:-]\d+[:-]|^\s*\d+|^[+-])\s*$/;
198
+ // The last body line and a PGP checksum line are short; they count only in a short run ending at END
199
+ const TAIL_AT_END = /(?<![A-Za-z0-9+/=])[A-Za-z0-9+/=]+\s*$/;
200
+
201
+ // A PGP block ends with the last short body line, then its checksum line, then END
202
+ function tailBeforeEnd(lines, i) {
203
+ for (let j = i + 1; j < lines.length && j <= i + 3; j += 1) {
204
+ if (END_AT_END.test(lines[j])) return true;
205
+ if (!TAIL_AT_END.test(lines[j])) return false;
206
+ }
207
+ return false;
208
+ }
209
+
210
+ function keyText(line, match) {
211
+ if (!match) return undefined;
212
+ // A diff's leading + is a prefix, although + is also a base64 character
213
+ const at = match.index === 0 && line[0] === "+" ? 1 : match.index;
214
+ return `${line.slice(0, at)}[private key]`;
215
+ }
216
+
217
+ // Maps each line of a piped key block to its redacted form
218
+ export function privateKeyLines(lines) {
219
+ const redacted = new Map();
220
+ let open = false;
221
+ lines.forEach((line, i) => {
222
+ if (open) {
223
+ const end = line.match(END_AT_END);
224
+ const body = line.match(HEADER_AT_END) ?? line.match(BODY_AT_END)
225
+ ?? (tailBeforeEnd(lines, i) ? line.match(TAIL_AT_END) : null);
226
+ if (end || body) {
227
+ redacted.set(line, keyText(line, end ?? body));
228
+ open = !end;
229
+ return;
230
+ }
231
+ if (BLANK_AT_END.test(line)) return;
232
+ }
233
+ const begin = line.match(BEGIN_AT_END);
234
+ open = Boolean(begin);
235
+ if (begin) redacted.set(line, keyText(line, begin));
236
+ });
237
+ return redacted;
238
+ }
239
+
240
+ // Shapes that must never survive redaction, whatever the user's list says
241
+ export const BUILT_IN_FORBIDDEN = [
242
+ /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/,
243
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/,
244
+ /\bsk-(?:(?:proj|svcacct|admin|None|ant-[a-z]+[0-9]*)-[A-Za-z0-9_-]{20,}|(?=[A-Za-z0-9_-]*[A-Z])(?=[A-Za-z0-9_-]*[0-9])[A-Za-z0-9_-]{20,})/,
245
+ /\b(?:gh[oprsu]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/,
246
+ /\bglpat-[A-Za-z0-9_-]{20,}/,
247
+ /\bnpm_[A-Za-z0-9]{36}/,
248
+ /\bxox[abposr]-[A-Za-z0-9-]{10,}/,
249
+ /\b(?:AKIA|ASIA)[0-9A-Z]{16}/,
250
+ /\bAIza[0-9A-Za-z_-]{35}/
251
+ ];
252
+
253
+ function compile(pattern, extra = "") {
254
+ if (typeof pattern !== "string") throw new RedactionError("redaction patterns must be strings");
255
+ const match = pattern.match(/^\(\?([aimsux]+)\)/);
256
+ const flags = new Set(extra);
257
+ let source = pattern;
258
+ if (match) {
259
+ source = pattern.slice(match[0].length);
260
+ for (const flag of match[1]) {
261
+ if (flag === "i" || flag === "m" || flag === "s") flags.add(flag);
262
+ else if (flag !== "u" && flag !== "a") throw new RedactionError("a redaction pattern uses an unsupported inline flag");
263
+ }
264
+ }
265
+ // Unicode mode where the pattern allows it, since Python matches by code point; a pattern with
266
+ // an escape unicode mode rejects (such as \-) compiles without it
267
+ for (const mode of ["u", ""]) {
268
+ try {
269
+ return new RegExp(source, [...flags].join("") + mode);
270
+ } catch { /* try the next mode */ }
271
+ }
272
+ // The pattern itself is private, so the error does not quote it
273
+ throw new RedactionError("a redaction pattern does not compile as a JavaScript regular expression");
274
+ }
275
+
276
+ // Python's replacement syntax: \1, \g<1> and \g<name> insert a group, \\ a backslash; nothing
277
+ // else is special, and a JavaScript $ stays literal
278
+ function expand(replacement, match) {
279
+ const named = typeof match[match.length - 1] === "object" ? match[match.length - 1] : {};
280
+ return replacement.replace(/\\(?:g<(\w+)>|(\d{1,2})|(\\))/g, (_all, name, number, slash) => {
281
+ if (slash) return "\\";
282
+ const key = name ?? number;
283
+ const value = /^\d+$/.test(key) ? match[Number(key)] : named?.[key];
284
+ return value ?? "";
285
+ });
286
+ }
287
+
288
+ export function createRedactor(spec = { rules: [], forbidden: [] }) {
289
+ if (!spec || !Array.isArray(spec.rules) || !Array.isArray(spec.forbidden)) throw new RedactionError("the redaction file must hold rules and forbidden arrays");
290
+ const rules = spec.rules.map((rule, index) => {
291
+ if (!Array.isArray(rule) || rule.length !== 3 || rule.some((part) => typeof part !== "string")) throw new RedactionError(`redaction rule ${index + 1} must be [class, pattern, replacement]`);
292
+ return { cls: rule[0], pattern: compile(rule[1], "g"), replacement: rule[2] };
293
+ });
294
+ const forbidden = [...BUILT_IN_FORBIDDEN, ...spec.forbidden.map((pattern) => compile(pattern))];
295
+ const counts = Object.create(null);
296
+ const count = (cls) => { counts[cls] = (counts[cls] ?? 0) + 1; };
297
+ const redact = (text) => {
298
+ let out = text;
299
+ for (const [cls, pattern, replacement] of BUILT_IN_RULES) {
300
+ if (typeof pattern === "function") { out = pattern(out, () => count(cls)); continue; }
301
+ out = out.replace(pattern, (...match) => { count(cls); return replacement.replace(/\$(\d)/g, (_all, n) => match[Number(n)] ?? ""); });
302
+ }
303
+ for (const rule of rules) {
304
+ out = out.replace(rule.pattern, (...match) => { count(rule.cls); return expand(rule.replacement, match); });
305
+ }
306
+ return out;
307
+ };
308
+ const allowed = (text) => forbidden.every((pattern) => !pattern.test(text));
309
+ // True when nothing forbidden survives and every built-in rule is already applied. Rule
310
+ // fixpoints only hold for decoded text: on serialized JSON a placeholder such as
311
+ // "authorization: [redacted]" runs into the next field and would never look redacted
312
+ const clean = (text) => allowed(text) && BUILT_IN_RULES.every(([, pattern, replacement]) => {
313
+ if (typeof pattern === "function") return pattern(text, () => {}) === text;
314
+ pattern.lastIndex = 0;
315
+ return text.replace(pattern, replacement) === text;
316
+ });
317
+ return {
318
+ redact,
319
+ allowed,
320
+ clean,
321
+ // The fail-closed check on a serialized request: every decoded key and string must be clean,
322
+ // and the exact bytes must hold nothing forbidden
323
+ check(body) {
324
+ const decoded = (value) => {
325
+ if (typeof value === "string") return clean(value);
326
+ if (value === null || typeof value !== "object") return true;
327
+ return Object.entries(value).every(([key, child]) => clean(key) && decoded(child));
328
+ };
329
+ let request;
330
+ try { request = JSON.parse(body); }
331
+ catch { throw new RedactionError("request must serialize as JSON; nothing was sent"); }
332
+ if (!decoded(request) || !allowed(body)) throw new RedactionError("never-send check refused a request; nothing was sent");
333
+ },
334
+ fingerprint: createHash("sha256").update(JSON.stringify(spec)).digest("hex"),
335
+ counts: () => ({ ...counts }),
336
+ size: { rules: BUILT_IN_RULES.length + rules.length, forbidden: forbidden.length }
337
+ };
338
+ }
339
+
340
+ export function loadRedactor(path) {
341
+ let stat;
342
+ try {
343
+ stat = statSync(path);
344
+ } catch {
345
+ throw new RedactionError("cannot read the configured redaction file");
346
+ }
347
+ if (!stat.isFile() || (stat.mode & 0o077) !== 0) throw new RedactionError("the redaction file must be a private regular file (chmod 600)");
348
+ let spec;
349
+ try {
350
+ spec = JSON.parse(readFileSync(path, "utf8"));
351
+ } catch {
352
+ throw new RedactionError("the redaction file is not valid JSON");
353
+ }
354
+ return createRedactor(spec);
355
+ }