@sibylline-labs/eris 0.0.0-stage → 0.3.1

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.
Files changed (69) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +2 -0
  3. package/README.md +146 -2
  4. package/bin/eris.ts +42 -0
  5. package/docs/ARCHITECTURE.md +188 -0
  6. package/docs/COMPLETION.md +106 -0
  7. package/docs/ERRORS.md +632 -0
  8. package/docs/OUTPUT-CONTRACT.md +219 -0
  9. package/docs/README.md +30 -0
  10. package/docs/UPDATE.md +159 -0
  11. package/docs/YAK.md +395 -0
  12. package/docs/dev/ADDING-A-COMMAND.md +79 -0
  13. package/docs/dev/DEVELOPMENT.md +88 -0
  14. package/docs/dev/LAYOUT.md +74 -0
  15. package/docs/dev/PLUGINS.md +382 -0
  16. package/docs/dev/RELEASING.md +157 -0
  17. package/docs/dev/TESTING.md +84 -0
  18. package/docs/dev/surfaces/secrets.md +95 -0
  19. package/package.json +55 -4
  20. package/packages/yak-contract/package.json +28 -0
  21. package/packages/yak-contract/schema/yak-v2.schema.json +416 -0
  22. package/packages/yak-contract/src/index.ts +1 -0
  23. package/packages/yak-contract/src/v2.ts +375 -0
  24. package/plugins/index.ts +12 -0
  25. package/plugins/yak/handlers.ts +128 -0
  26. package/plugins/yak/index.ts +51 -0
  27. package/plugins/yak/login.ts +300 -0
  28. package/plugins/yak/yak-context.ts +280 -0
  29. package/plugins/yak/yak-identity.ts +399 -0
  30. package/plugins/yak/yak-redact.ts +686 -0
  31. package/plugins/yak/yak-setup.ts +133 -0
  32. package/plugins/yak/yak-submit.ts +1166 -0
  33. package/plugins/yak/yak.ts +485 -0
  34. package/src/build.ts +248 -0
  35. package/src/cli/args.ts +434 -0
  36. package/src/cli/commands/completion/bash.ts +24 -0
  37. package/src/cli/commands/completion/domain.ts +203 -0
  38. package/src/cli/commands/completion/emit.ts +246 -0
  39. package/src/cli/commands/completion/shell.ts +24 -0
  40. package/src/cli/commands/completion/zsh.ts +24 -0
  41. package/src/cli/commands/completion.ts +48 -0
  42. package/src/cli/commands/update/github.ts +328 -0
  43. package/src/cli/commands/update/opt-in.ts +32 -0
  44. package/src/cli/commands/update/refusal.ts +21 -0
  45. package/src/cli/commands/update/release.ts +351 -0
  46. package/src/cli/commands/update/replace.ts +143 -0
  47. package/src/cli/commands/update/semver.ts +48 -0
  48. package/src/cli/commands/update.ts +47 -0
  49. package/src/cli/commands/usage.ts +68 -0
  50. package/src/cli/commands/version.ts +29 -0
  51. package/src/cli/compose.ts +273 -0
  52. package/src/cli/output.ts +81 -0
  53. package/src/cli/plugin.ts +85 -0
  54. package/src/cli/registry.ts +180 -0
  55. package/src/cli/router.ts +31 -0
  56. package/src/cli/ts-verb.ts +126 -0
  57. package/src/main.ts +223 -0
  58. package/src/runtime/env.ts +16 -0
  59. package/src/runtime/eris-assets.d.ts +5 -0
  60. package/src/runtime/home.ts +306 -0
  61. package/src/runtime/logging.ts +43 -0
  62. package/src/runtime/process.ts +388 -0
  63. package/src/runtime/settings.ts +191 -0
  64. package/src/sdk.ts +62 -0
  65. package/testing/build-child.ts +55 -0
  66. package/testing/fake-runner.ts +23 -0
  67. package/testing/index.ts +16 -0
  68. package/ts/lib/eris-fail.ts +180 -0
  69. package/ts/lib/pool.ts +63 -0
@@ -0,0 +1,686 @@
1
+ /**
2
+ * yak-redact.ts — the Yak submission redaction pass.
3
+ *
4
+ * Agent-authored Yak bodies are free text persisted server-side and posted
5
+ * to an issue tracker. Redaction belongs in this CLI, which sees the material
6
+ * first: nothing on the server can un-see it. An agent reporting a broken
7
+ * harness may paste an env dump.
8
+ *
9
+ * PURE: no I/O, no filesystem, no network, no config load, no added latency.
10
+ * `redact()` runs on `summary` / `body` / `evidence` BEFORE key derivation
11
+ * so the Actor's re-derived key agrees with the
12
+ * client's — deriving from unredacted text would guarantee a mismatch on
13
+ * every report.
14
+ *
15
+ * THIRTEEN CLASSES, applied in a fixed pipeline order, most-specific first.
16
+ * Order is load-bearing for three reasons:
17
+ *
18
+ * 1. A JSON-embedded GCP private key ("private_key":"-----BEGIN...") is
19
+ * also a syntactic match for the generic PEM-block pattern (`[\s\S]*?`
20
+ * matches escaped `\n` sequences same as it matches real newlines). The
21
+ * GCP-specific pass runs first so that case is classified and redacted
22
+ * as `gcp-private-key`, not `pem`, and is never double-counted by the
23
+ * generic pass that follows.
24
+ * 2. `auth-header` runs before `assignment`, because "Authorization: Bearer
25
+ * …" is ALSO a syntactic match for the generic NAME:value scrub (`AUTH`
26
+ * is in the assignment name list and `Authorization` starts with it).
27
+ * Running auth-header first means the marker is in place before
28
+ * `assignment`'s pass ever sees that text, so it is classified once, as
29
+ * the more specific class.
30
+ * 3. `sib-token` and `sibdc-code` run right after `jwt`, before `aws-key`,
31
+ * `github-token` and `sk-key`. A token's base64url secret can itself contain `-sk-`
32
+ * (where `\bsk-` holds after the `-`) or `ghp_…`, so any of those classes
33
+ * running first would replace only the embedded run and leave
34
+ * `sib_<orgId>_<head>` in plaintext. It also runs before `assignment`, so
35
+ * `ERIS_TOKEN=sib_…` is classified once, as the more specific class.
36
+ *
37
+ * MARKER GUARD. Each pass runs sequentially over the STRING RESULT of the
38
+ * previous pass, and the `«redacted:<class>»` marker matches none of the
39
+ * patterns below by its own shape (verified in tests/yak-redact.test.ts —
40
+ * idempotence and "marker is never re-matched"). Three passes are the
41
+ * exception: each takes an arbitrary non-whitespace run as the value at a
42
+ * position — `assignment` (after the operator), `auth-header` (the credential
43
+ * after the scheme) and `conn-string` (the password in the userinfo) — and a
44
+ * marker an earlier pass left at that position is exactly such a run. A pass
45
+ * may decline such a value, so that the more specific class stays the only
46
+ * one classified and counted, only where declining leaves in place nothing
47
+ * but the marker and text that cannot be secret:
48
+ *
49
+ * - `assignment` declines a value that is EXACTLY one marker (`MARKER`):
50
+ * bare and ending where the value would end, or alone in matching quotes.
51
+ * The name stays, as it does for any redacted value.
52
+ * - `auth-header` declines a credential that is exactly one marker, ending
53
+ * at whitespace, after a known scheme word (`AUTH_DECLINED`). `assignment`
54
+ * must honour that too, or it would take `Authorization` as a governed
55
+ * name and `Bearer` as its value (`AUTH_DECLINED_RE`).
56
+ * - `conn-string` never declines: its scheme and host runs have no fixed
57
+ * edge, so with no separator around the span they can carry secret text.
58
+ *
59
+ * A value that runs on past a marker (`«redacted:sk-key»` followed by the tail
60
+ * of a key longer than that class's cap, or by `.`, `%21`, `:x-oauth-…`) is
61
+ * NOT declined: the run is redacted and both classes are counted. That
62
+ * over-counts, but declining on the marker alone would ship the tail.
63
+ * `assignment` also redacts every assignment that begins inside such a value
64
+ * (`runAssignmentPass`), so a tail cannot swallow an adjacent `"name": value`.
65
+ * No bespoke "already redacted" bookkeeping is needed.
66
+ *
67
+ * NO CATASTROPHIC BACKTRACKING. This runs on agent-authored text of
68
+ * arbitrary shape mid-task, so every pattern here is either a fixed-literal
69
+ * scan, a single unambiguous character class with a quantifier, or the
70
+ * standard `(?:[^"\\]|\\.)*` safe-JSON-string idiom (mutually exclusive
71
+ * alternatives, no ambiguity for the engine to search). None of them nest a
72
+ * quantified group inside another quantified group over an unbounded class —
73
+ * the shape that produces exponential blowup. `ASSIGNMENT_RE`'s NAME group is
74
+ * `[A-Za-z0-9_]{0,60}WORD[A-Za-z0-9_]{0,60}` — two BOUNDED, non-nested
75
+ * quantifiers either side of a fixed-literal alternation, not a lookahead —
76
+ * so a worst-case backtrack is O(60) per candidate `\b` position, never
77
+ * exponential in input length. See the "pathological input" tests for the
78
+ * exercised worst cases. No pattern carries a lookbehind either: the bun this
79
+ * repo pins cannot compile one to native code, so the whole pattern would run
80
+ * in the regex interpreter, about ten times slower on every input
81
+ * (`rules/regex-no-lookbehind.yml`).
82
+ *
83
+ * NAME MATCHING (`ASSIGNMENT_RE`): the governed word must appear ANYWHERE inside the identifier token —
84
+ * prefix, suffix, or infix, `_`-delimited or not — not just as the token's
85
+ * own prefix. `_` is a `\w` character, so `\bTOKEN` never matches the
86
+ * `TOKEN` in `GITHUB_TOKEN` (no boundary between `_` and `T`); the original
87
+ * prefix-only pattern therefore missed the dominant real-world shape,
88
+ * `*_TOKEN` / `*_SECRET` / `*_API_KEY` (`DATABASE_PASSWORD`, `CLERK_API_KEY`,
89
+ * `ERIS_YAKS_TOKEN` — this CLI's own bearer — among others). The fix wraps
90
+ * the alternation in a bounded `[A-Za-z0-9_]{0,60}` on each side instead of
91
+ * anchoring it to the start, and the surrounding `\b`s still require the
92
+ * WHOLE token to be an identifier (no partial match inside a longer
93
+ * non-identifier run) — outer boundaries are unaffected by *where* in the
94
+ * token the word sits. Case-insensitive (`gi`), which is what catches the
95
+ * lowercase `aws_session_token` shape.
96
+ *
97
+ * NAME MATCHING — HYPHENATED NAMES (fixed, the same class of miss one
98
+ * separator over): `[A-Za-z0-9_]` excludes `-`, so `x-api-key`,
99
+ * `api-key`, and `private-key` — the standard HTTP-header and YAML-config
100
+ * spelling — broke into non-governed halves at the hyphen (`api`/`key`,
101
+ * `private`/`key`; neither half alone is a governed word) and shipped in
102
+ * plaintext, even though the underscore-joined spelling (`API_KEY`,
103
+ * `PRIVATE_KEY`) already matched. The prefix/suffix class is now
104
+ * `[A-Za-z0-9_-]`, and the two-word alternatives (`PRIVATE_KEY`, `API_KEY`,
105
+ * `ACCESS_KEY`, `CLIENT_SECRET`) use `[_-]` in place of a literal `_` for
106
+ * their internal separator, so both spellings match. The single-word
107
+ * governed terms (SECRET, TOKEN, AUTH, …) already matched a hyphenated
108
+ * neighbor by accident before this fix — they stand alone as their own
109
+ * hyphen-bounded identifier regardless of what the surrounding class
110
+ * allows — which is why `client-secret`/`access-token` were never in the
111
+ * leak list, only the two-word phrases were.
112
+ *
113
+ * `.` is deliberately NOT added to this class, despite being another
114
+ * realistic dotted-config-key separator (`service.api.key`). Unlike `-`,
115
+ * `.` is also the sentence-ending and file-extension character, and this
116
+ * codebase's own convention — used throughout this file's comments and
117
+ * `docs/YAK.md` — is `word.ext: description` (`yak-redact.ts: the
118
+ * redaction pass`). Merging across `.` would turn every such reference
119
+ * whose filename happens to start with a governed word (`auth.md: …`,
120
+ * `token.ts: …`) into a false-positive `NAME: value` match, which is a
121
+ * common and realistic yak-report shape, not a contrived one. `-` doesn't
122
+ * carry that collision (English prose runs `word-word`, not
123
+ * `sentence.-next`), so it clears the bar `.` doesn't. If a dotted-config
124
+ * shape becomes a real, observed miss, it needs its own scoped pattern
125
+ * (e.g. anchored to a known key vocabulary) rather than widening this
126
+ * general-purpose class further.
127
+ *
128
+ * VALUE BOUND (fixed): the unquoted/quoted value alternatives are
129
+ * bounded to 20,000 chars — `YAK_EVIDENCE_MAX_LENGTH` in
130
+ * `@sibylline-labs/yak-contract`, the largest of the three redacted fields — not
131
+ * 4,096.
132
+ * A value longer than the capture bound is only PARTIALLY replaced: the
133
+ * captured prefix becomes a marker and everything past the bound survives as
134
+ * plaintext directly after it, which is worse than no redaction (it *looks*
135
+ * handled). This module deliberately does not import the schema constant —
136
+ * it stays dependency-free by design (file header, "PURE") — so the bound is
137
+ * a literal number here, with this comment as the cross-reference. If
138
+ * `YAK_EVIDENCE_MAX_LENGTH` changes, this bound is the sibling to update.
139
+ *
140
+ * MINIMUM UNQUOTED VALUE LENGTH (fixed, found while rebuilding the negative
141
+ * corpus): the unquoted value alternative requires >= 4 non-whitespace
142
+ * chars, not >= 1. No real credential this pass targets is 1-3 characters;
143
+ * requiring 4 is what keeps `Session: 3 of 4 workers failed to start` from
144
+ * matching as `Session: «redacted:assignment»` (captured value `"3"`) while
145
+ * costing nothing against any real secret shape — every positive fixture in
146
+ * the test file is one to three orders of magnitude longer than 4 chars.
147
+ * Quoted values keep no minimum: an explicit `"…"` or `'…'` is already a
148
+ * stronger structural signal of an intentional value than bare unquoted text
149
+ * is, so a short quoted value (`"token": "x"` in a JSON fixture) is left
150
+ * alone by this rule (it may still be caught by GCP's own JSON-string
151
+ * pattern where relevant).
152
+ *
153
+ * THE NAME: VALUE TRADE-OFF (deliberate, NOT "fixed" — a structural ambiguity,
154
+ * not a bug): `seed: 4815162342 (deterministic replay)` and
155
+ * `- SECRET_SCANNING: enabled` are syntactically indistinguishable from a
156
+ * real `SEED=<wallet-seed>` or `CLIENT_SECRET: <credential>` assignment —
157
+ * both are `NAME` `[:=]` `value`, and this pass has no semantic model of
158
+ * "this NAME is a log field, that NAME is a credential." The minimum-length
159
+ * fix above removes the cheapest false positives (single-digit/short
160
+ * values); it does NOT and cannot resolve this class, because a real seed
161
+ * or session value can legitimately be exactly as long as a fake one. The
162
+ * deliberate choice: keep matching. A false positive here costs one line's
163
+ * readability (the marker plus the surrounding sentence are still legible,
164
+ * and `--no-redact` exists for exactly this complaint); a miss costs a
165
+ * leaked credential. Those costs are not symmetric, so this pass is tuned
166
+ * toward over-redaction on ambiguous `NAME: value` shapes rather than
167
+ * under-redaction. See `tests/yak-redact.test.ts`'s "documented trade-off"
168
+ * block, which pins this as intended behavior so a future reader sees it was
169
+ * decided, not missed.
170
+ *
171
+ * AUTH SCHEME (fixed): `AUTH_HEADER_RE` matched only `Bearer|Token`, so
172
+ * `Authorization: Basic <base64>` fell through to the generic `assignment`
173
+ * pass, which eats only the scheme word (`AUTH` is a governed name) and
174
+ * ships the base64 credential in plaintext. The pattern now matches ANY
175
+ * RFC-7235-shaped scheme token (`Basic`, `Bearer`, `Digest`, `Token`,
176
+ * `AWS4-HMAC-SHA256`, …) — always anchored to a literal `Authorization:`
177
+ * prefix, so this cannot fire on ordinary prose that happens to contain the
178
+ * word "authorization".
179
+ *
180
+ * SK- WORD BOUNDARY (fixed): `SK_KEY_RE` had no boundary before
181
+ * `sk-`, so it matched mid-word inside `risk-assessment-methodology`,
182
+ * `disk-cache-manager-impl`, and any other hyphenated word with `sk-`
183
+ * inside it — a real path or a real sentence, mangled. A leading `\b` fixes
184
+ * this: `\b` between two `\w` characters (the `i` and `s` in `risk`) never
185
+ * holds, so `\bsk-` cannot start mid-identifier; it still matches happily
186
+ * after whitespace, `=`, a quote, or the start of the string, which is
187
+ * every real `sk-…` key's actual context.
188
+ */
189
+
190
+ export type RedactionClass =
191
+ | "pem"
192
+ | "jwt"
193
+ | "sib-token"
194
+ | "sibdc-code"
195
+ | "aws-key"
196
+ | "github-token"
197
+ | "slack-token"
198
+ | "google-api-key"
199
+ | "sk-key"
200
+ | "auth-header"
201
+ | "gcp-private-key"
202
+ | "conn-string"
203
+ | "assignment";
204
+
205
+ export interface RedactionResult {
206
+ text: string;
207
+ counts: Record<RedactionClass, number>;
208
+ total: number;
209
+ }
210
+
211
+ /** Every class, in a fixed order — the single source of truth for
212
+ * what a fresh `counts` record looks like. */
213
+ const REDACTION_CLASSES: RedactionClass[] = [
214
+ "pem",
215
+ "jwt",
216
+ "sib-token",
217
+ "sibdc-code",
218
+ "aws-key",
219
+ "github-token",
220
+ "slack-token",
221
+ "google-api-key",
222
+ "sk-key",
223
+ "auth-header",
224
+ "gcp-private-key",
225
+ "conn-string",
226
+ "assignment",
227
+ ];
228
+
229
+ function emptyCounts(): Record<RedactionClass, number> {
230
+ const counts = {} as Record<RedactionClass, number>;
231
+ for (const c of REDACTION_CLASSES) counts[c] = 0;
232
+ return counts;
233
+ }
234
+
235
+ function marker(cls: RedactionClass): string {
236
+ return `«redacted:${cls}»`;
237
+ }
238
+
239
+ // Regex source for exactly one `marker()` output: every RedactionClass name is
240
+ // lowercase words joined by `-`, at most 40 chars. The three guards in the file
241
+ // header's "MARKER GUARD" decline a value only when it is this and nothing
242
+ // more. tests/yak-redact.test.ts holds every class's marker to it.
243
+ const MARKER = "«redacted:[a-z-]{1,40}»";
244
+
245
+ // --- pass definitions ------------------------------------------------------
246
+ //
247
+ // Each pass is a (RedactionClass, RegExp) pair. `re` must carry the `g` flag.
248
+ // `replace(text, counts)` runs the pass once over `text`, incrementing
249
+ // `counts[cls]` per match, and returns the transformed text. Passes that need
250
+ // to preserve surrounding structure (a capture group they must keep) supply
251
+ // a `build` function instead of relying on whole-match replacement.
252
+
253
+ interface Pass {
254
+ cls: RedactionClass;
255
+ re: RegExp;
256
+ /** Given the match, return the literal replacement string. Defaults to the
257
+ * bare class marker (whole match replaced). */
258
+ build?: (m: RegExpExecArray) => string;
259
+ }
260
+
261
+ // PEM private-key blocks: BEGIN...END, non-greedy across the body so a file
262
+ // with multiple keys yields one match — and one count — per key.
263
+ const PEM_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g;
264
+
265
+ // JWTs: three dot-separated base64url segments, header always starts "eyJ"
266
+ // (base64 of `{"`). Bounded segment lengths — no unbounded nested repetition.
267
+ const JWT_RE = /eyJ[A-Za-z0-9_-]{10,500}\.[A-Za-z0-9_-]{10,500}\.[A-Za-z0-9_-]{10,500}/g;
268
+
269
+ // `sib_` service tokens: sib_<26-char Crockford ULID>_<43 base64url>.
270
+ // Contract-shaped on purpose, not a generic `sib_` + run: the issuer pins this
271
+ // shape, and a generic run would eat prose identifiers such as
272
+ // `sib_service_token_rotation_policy`. The ULID part accepts either case so a
273
+ // lower-cased paste still redacts (this pass is tuned toward over-redaction).
274
+ // The secret is exactly `{43}`: the issuer formats and parses only 32 random
275
+ // bytes as 43 base64url characters, so a longer run is a whole token with other
276
+ // text glued straight onto it (`<token>Authorization: …`, `<token>PASSWORD=…`).
277
+ // That text belongs to the later passes, which need its leading letters as
278
+ // their anchor; a wider run would swallow them and ship what follows. No
279
+ // leading `\b`: `x_sib_…` still redacts, and the fixed 26+1+43 structure is
280
+ // what blocks false positives. Ordered ahead of the classes whose shapes its
281
+ // secret can contain (file header, reason 3).
282
+ const SIB_TOKEN_RE = /sib_[0-9A-Za-z]{26}_[A-Za-z0-9_-]{43}/g;
283
+
284
+ // `sibdc_` device codes, what `eris login` polls with: sibdc_<8-char user
285
+ // code>_<43 base64url>. An approved code redeems a fresh `sib_` bearer for
286
+ // whoever polls first, so it is a credential for its ten-minute life. Shaped
287
+ // like SIB_TOKEN_RE and for the same reasons: contract-shaped, either case in
288
+ // the user code (the issuer's alphabet is 20 upper-case consonants), exactly 43
289
+ // in the secret, no leading `\b`, ordered ahead of the classes its secret can
290
+ // embed (file header, reason 3). Neither shape can occur inside the other.
291
+ const SIBDC_CODE_RE = /sibdc_[A-Za-z]{8}_[A-Za-z0-9_-]{43}/g;
292
+
293
+ // AWS: access-key ids (AKIA/ASIA + 16 alnum) and the aws_secret_access_key
294
+ // assignment, as two independent matches under one class.
295
+ const AWS_KEY_ID_RE = /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g;
296
+ const AWS_SECRET_RE =
297
+ /aws_secret_access_key\s*[:=]\s*['"]?[A-Za-z0-9/+=]{20,80}['"]?/gi;
298
+
299
+ // GitHub fine-grained/classic tokens: gh[pousr]_<alnum>{36,}.
300
+ const GITHUB_TOKEN_RE = /gh[pousr]_[A-Za-z0-9]{36,255}/g;
301
+
302
+ // Slack tokens: xox[baprs]-<alnum/hyphen>{10,}.
303
+ const SLACK_TOKEN_RE = /xox[baprs]-[A-Za-z0-9-]{10,128}/g;
304
+
305
+ // Google API keys: AIza + 35 fixed chars (39 total, per Google's own format).
306
+ const GOOGLE_API_KEY_RE = /AIza[0-9A-Za-z_-]{35}/g;
307
+
308
+ // OpenAI/Anthropic-style secret keys: sk-, sk-ant-, sk-proj-, etc. `\b`
309
+ // before `sk-` is load-bearing (see file header, "SK- WORD BOUNDARY") — it
310
+ // is what stops this from matching mid-word inside `risk-…`/`disk-…`/`task-…`.
311
+ const SK_KEY_RE = /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,120}/g;
312
+
313
+ // Authorization headers: any scheme token + bounded credential, applied
314
+ // before the generic `assignment` pass so "Authorization: <scheme> …" is
315
+ // classified once here (see file header, "AUTH SCHEME") — not just Bearer
316
+ // and Token, so `Authorization: Basic …` no longer falls through to the
317
+ // generic pass and ships its base64 credential unredacted.
318
+ //
319
+ // The guard declines a header whose scheme is a known scheme word and whose
320
+ // credential is exactly one EARLIER class's marker, ending at whitespace or the
321
+ // end of input (file header, "MARKER GUARD"): `Authorization: Bearer
322
+ // «redacted:github-token»` stays as it is, scheme visible, instead of
323
+ // collapsing to `«redacted:auth-header»` with both classes counted. Anything
324
+ // after the marker in the same run (`«redacted:sk-key»<tail of an over-long
325
+ // key>`) is not declined: the whole header is redacted. Nor is an unknown
326
+ // scheme word, because `AUTH_SCHEME` also matches a raw key sent with no
327
+ // scheme (`Authorization: sk_live_… «redacted:jwt»`), and declining would ship
328
+ // it. The lookahead is fixed literals plus bounded classes, so it adds no
329
+ // backtracking. `assignment` must leave that same declined shape alone too
330
+ // (`AUTH_DECLINED_RE`), so both build it from `AUTH_DECLINED` and cannot drift.
331
+ const AUTH_SCHEME = "[A-Za-z][A-Za-z0-9._-]{0,40}";
332
+ const AUTH_DECLINED = `(?:Bearer|Basic|Token|Digest|Negotiate|NTLM|OAuth|DPoP)\\s+${MARKER}(?!\\S)`;
333
+ const AUTH_HEADER_RE = new RegExp(
334
+ `Authorization:\\s*(?!${AUTH_DECLINED})${AUTH_SCHEME}\\s+[^\\s\\r\\n]{1,2048}`,
335
+ "gi",
336
+ );
337
+
338
+ // GCP service-account "private_key" JSON field: the standard safe JSON-string
339
+ // idiom for the value, so the surrounding quotes and key name survive and
340
+ // only the material is replaced.
341
+ const GCP_PRIVATE_KEY_RE = /("private_key"\s*:\s*")((?:[^"\\]|\\.)*)(")/g;
342
+
343
+ // Connection strings with an inline password: proto://user:pass@host. Every
344
+ // character class excludes the next delimiter, so there is no ambiguity for
345
+ // the engine to backtrack through.
346
+ //
347
+ // No marker guard (file header, "MARKER GUARD"): a password that is an earlier
348
+ // class's marker is redacted again, with the scheme, user and host, and both
349
+ // classes are counted. Declining it would leave the span's own runs as they
350
+ // are, and with no separator around it those runs can carry secret text: the
351
+ // scheme run takes letters before `postgres`, and the host run takes a key
352
+ // pasted straight after `@db`.
353
+ const CONN_STRING_RE =
354
+ /[a-zA-Z][a-zA-Z0-9+.-]{0,30}:\/\/[^\s:@/]{1,255}:[^\s@/]{1,255}@[^\s/]{1,255}/g;
355
+
356
+ // Key-name assignments: NAME=value / NAME: value, case-insensitive, where
357
+ // NAME contains one of the governed words ANYWHERE in the identifier — see
358
+ // file header, "NAME MATCHING". The operator must follow only optional
359
+ // whitespace — prose like "the auth token was missing" never reaches an
360
+ // `=`/`:` and so never matches. The value is captured separately (quoted or
361
+ // a bounded non-whitespace run) so only the VALUE is replaced, keeping the
362
+ // variable name visible for triage.
363
+ //
364
+ // Group 1 is `[A-Za-z0-9_-]{0,60}` + the governed-word alternation +
365
+ // `[A-Za-z0-9_-]{0,60}` — bounded prefix AND suffix, so `SECRET_KEY`,
366
+ // `DATABASE_PASSWORD`, `ERIS_YAKS_TOKEN`, and `x-api-key` all capture their
367
+ // FULL identifier, not just the governed word itself; losing either bounded
368
+ // run from the capture would silently truncate the name in the rebuilt
369
+ // replacement. `-` is in the class (file header, "NAME MATCHING —
370
+ // HYPHENATED NAMES") for the standard HTTP-header/YAML-config spelling; the
371
+ // two-word alternatives (`PRIVATE[_-]KEY`, `API[_-]KEY`, `ACCESS[_-]KEY`,
372
+ // `CLIENT[_-]SECRET`) allow `_` OR `-` as their internal separator so
373
+ // `private-key` and `x-api-key` match the same as `PRIVATE_KEY` and
374
+ // `API_KEY` do — a literal underscore in the alternation would never match
375
+ // a hyphenated spelling no matter how permissive the surrounding class is.
376
+ //
377
+ // The value alternatives are bounded to 20,000 chars (file header, "VALUE
378
+ // BOUND") and the unquoted alternative requires >= 4 chars (file header,
379
+ // "MINIMUM UNQUOTED VALUE LENGTH").
380
+ //
381
+ // The guard directly after the operator stops this pass from treating an
382
+ // EARLIER pass's marker as its "value": without it, "credentials:
383
+ // «redacted:aws-key»" (the aws-key pass having already run) would match here
384
+ // too — CREDENTIAL is a governed name, the marker has no internal whitespace
385
+ // so it satisfies the unquoted-value alternative, and the text would be
386
+ // reclassified/reprocessed as `assignment`, breaking both idempotence and the
387
+ // count for the class that actually caught it. It declines only a value that
388
+ // is EXACTLY one marker (file header, "MARKER GUARD"): unquoted and ending
389
+ // where the unquoted value would end, or alone inside a matching pair of
390
+ // quotes. A value that runs on past the marker (`«redacted:sk-key»<tail>`) is
391
+ // redacted whole.
392
+ //
393
+ // `AUTH_DECLINED_RE` is the same guard for the one header `auth-header` owns.
394
+ // When `auth-header` declines `Authorization: Bearer «redacted:github-token»`
395
+ // (credential exactly a marker), this pass would otherwise read the `AUTH`
396
+ // infix in `Authorization` as a governed name and `Bearer` as its value,
397
+ // leaving `Authorization: «redacted:assignment» «redacted:github-token»`. It
398
+ // applies only to an unquoted name ending in `Authorization` (so
399
+ // `Proxy-Authorization` is covered, and an ordinary `password: hunter2
400
+ // «redacted:…»` is not), and it is `AUTH_HEADER_RE`'s declined shape exactly:
401
+ // colon directly after the name, then `AUTH_DECLINED`.
402
+ // The replace callback checks it, sticky, at the matched name: a lookbehind
403
+ // in `ASSIGNMENT_RE` would keep the pinned bun from compiling the pattern to
404
+ // native code, about ten times slower on every input (`regex-no-lookbehind`).
405
+ const AUTH_DECLINED_RE = new RegExp(`authorization:\\s*${AUTH_DECLINED}`, "iy");
406
+ const AUTHORIZATION = "authorization";
407
+ const GOVERNED_NAME =
408
+ "[A-Za-z0-9_-]{0,60}(?:SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE[_-]KEY|API[_-]KEY|CREDENTIAL|ACCESS[_-]KEY|CLIENT[_-]SECRET|MNEMONIC|SEED|SESSION|COOKIE|AUTH)[A-Za-z0-9_-]{0,60}";
409
+
410
+ // The NAME side is TWO alternatives, not one pattern with an optional
411
+ // quote — see file header, "JSON/QUOTED NAME MATCHING" for why a single
412
+ // `["']?` can't do this safely. `(?<q>["'])…\k<q>` requires the SAME quote
413
+ // character to close (so `"token'` never matches), and the unquoted
414
+ // alternative keeps its own `\b` pair — a quoted match doesn't need `\b`
415
+ // at all, because the quote character is itself an unambiguous delimiter
416
+ // no ordinary prose produces around a bare identifier.
417
+ const QUOTED_VALUE = `"(?:[^"\\\\\\r\\n]|\\\\.){0,20000}"|'(?:[^'\\\\\\r\\n]|\\\\.){0,20000}'`;
418
+ const assignmentRe = (valueGuard: string, ledValue = ""): RegExp => new RegExp(
419
+ `(?:(?<q>["'])(?<qname>${GOVERNED_NAME})\\k<q>|\\b(?<uname>${GOVERNED_NAME})\\b)` +
420
+ // The quoted exact-marker alternatives (not just a bare marker) are
421
+ // what make this guard work with the quoted-value alternative
422
+ // below: an EARLIER pass (sk-key, jwt, gcp-private-key, …) that fires
423
+ // on a value embedded in `"api_key": "sk-ant-…"` leaves that marker
424
+ // wrapped in its ORIGINAL quotes — `"api_key": "«redacted:sk-key»"` —
425
+ // untouched by that pass (it only ever replaces the material INSIDE
426
+ // the quotes, never the quotes themselves). Without the quoted
427
+ // alternatives, the guard only sees a `"` immediately after the operator
428
+ // (not the marker glyph itself), lets the quoted-value alternative consume
429
+ // the whole `"«redacted:sk-key»"` span, and reclassifies it as the
430
+ // generic `assignment` — corrupting a MORE specific classification
431
+ // into a less specific one, silently, for any class that can appear
432
+ // as a governed key's JSON value.
433
+ `(?<sep>\\s*[:=]\\s*)${valueGuard}` +
434
+ `(?<value>${ledValue}${QUOTED_VALUE}|[^\\s,}\\]]{4,20000})`,
435
+ "gi",
436
+ );
437
+
438
+ // Declines only a value that is exactly a marker; redacts one that runs on past it.
439
+ const EXACT_MARKER_GUARD = `(?!${MARKER}(?![^\\s,}\\]])|"${MARKER}"|'${MARKER}')`;
440
+ const ASSIGNMENT_RE = assignmentRe(EXACT_MARKER_GUARD);
441
+ // The same, except a value that starts with a marker matches only its opening
442
+ // quote and `«redacted:` (group `led`), so `runAssignmentPass` can work out its
443
+ // extent in code and keep scanning inside it.
444
+ const ASSIGNMENT_LED_RE = assignmentRe(EXACT_MARKER_GUARD, `(?<led>['"]?«redacted:)|`);
445
+ const QUOTED_VALUE_RE = new RegExp(QUOTED_VALUE, "y");
446
+ const MARKER_LED_RE = /['"]?«redacted:/y;
447
+
448
+ function runWholeMatchPass(
449
+ text: string,
450
+ cls: RedactionClass,
451
+ re: RegExp,
452
+ counts: Record<RedactionClass, number>,
453
+ ): string {
454
+ re.lastIndex = 0;
455
+ return text.replace(re, () => {
456
+ counts[cls]++;
457
+ return marker(cls);
458
+ });
459
+ }
460
+
461
+ interface AssignmentGroups {
462
+ q?: string;
463
+ qname?: string;
464
+ uname?: string;
465
+ sep: string;
466
+ value: string;
467
+ led?: string;
468
+ }
469
+
470
+ /** True when the match is `Authorization: <scheme> <marker>`, a header
471
+ * auth-header declined: it is left whole and uncounted (AUTH_DECLINED_RE). */
472
+ function isDeclinedAuthHeader(text: string, m: RegExpExecArray, groups: AssignmentGroups): boolean {
473
+ if (groups.uname === undefined || !groups.uname.toLowerCase().endsWith(AUTHORIZATION)) return false;
474
+ AUTH_DECLINED_RE.lastIndex = m.index + groups.uname.length - AUTHORIZATION.length;
475
+ return AUTH_DECLINED_RE.test(text);
476
+ }
477
+
478
+ /** For each index, the first index at or after it that ends an unquoted value
479
+ * (whitespace, `,`, `}`, `]`), or `text.length`. */
480
+ function unquotedValueEnds(text: string): Int32Array {
481
+ const ends = new Int32Array(text.length + 1);
482
+ ends[text.length] = text.length;
483
+ for (let i = text.length - 1; i >= 0; i--) ends[i] = /[\s,}\]]/.test(text[i]) ? i : ends[i + 1];
484
+ return ends;
485
+ }
486
+
487
+ /**
488
+ * The `assignment` pass. Only the VALUE is replaced; the name stays for triage.
489
+ *
490
+ * A value that starts with a marker and runs on past it
491
+ * (`«redacted:sk-key»<tail of an over-long key>`) is ambiguous: its tail may
492
+ * be the rest of a secret, or, with no whitespace in between, the start of the
493
+ * next assignment (`"authorization": "Basic …"`, `auth: '…'`). Redacting the
494
+ * run as one value covers the first reading but swallows the second one's
495
+ * name, so its value would ship. Declining it covers the second and ships the
496
+ * tail. So when the plain scan (`ASSIGNMENT_RE`) meets such a value, a second
497
+ * scan (`ASSIGNMENT_LED_RE`) records each marker-led value's full extent AND
498
+ * keeps scanning inside it, and the pass redacts the union of both scans'
499
+ * values: each overlapping or touching run becomes one marker and one count.
500
+ *
501
+ * Linear: a text with no marker-led value is scanned once. In the second scan
502
+ * a marker-led value's regex match stops after `«redacted:`; its unquoted
503
+ * extent is a table lookup, and its quoted extent ends at the next matching
504
+ * quote, so the extents cost O(n) in total.
505
+ */
506
+ function runAssignmentPass(text: string, counts: Record<RedactionClass, number>): string {
507
+ const ranges: Array<[number, number]> = [];
508
+ let markerLed = false;
509
+ ASSIGNMENT_RE.lastIndex = 0;
510
+ for (let m = ASSIGNMENT_RE.exec(text); m !== null; m = ASSIGNMENT_RE.exec(text)) {
511
+ const groups = m.groups as unknown as AssignmentGroups;
512
+ if (isDeclinedAuthHeader(text, m, groups)) continue;
513
+ const end = m.index + m[0].length;
514
+ const valueStart = end - groups.value.length;
515
+ MARKER_LED_RE.lastIndex = valueStart;
516
+ if (MARKER_LED_RE.test(text)) markerLed = true;
517
+ ranges.push([valueStart, end]);
518
+ }
519
+ if (markerLed) {
520
+ const unquotedEnds = unquotedValueEnds(text);
521
+ ASSIGNMENT_LED_RE.lastIndex = 0;
522
+ for (let m = ASSIGNMENT_LED_RE.exec(text); m !== null; m = ASSIGNMENT_LED_RE.exec(text)) {
523
+ const groups = m.groups as unknown as AssignmentGroups;
524
+ if (isDeclinedAuthHeader(text, m, groups)) continue;
525
+ const end = m.index + m[0].length;
526
+ const valueStart = end - groups.value.length;
527
+ if (groups.led === undefined) {
528
+ ranges.push([valueStart, end]);
529
+ continue;
530
+ }
531
+ // The extent ASSIGNMENT_RE's value alternatives would give it.
532
+ QUOTED_VALUE_RE.lastIndex = valueStart;
533
+ const extent = QUOTED_VALUE_RE.test(text)
534
+ ? QUOTED_VALUE_RE.lastIndex
535
+ : Math.min(unquotedEnds[valueStart], valueStart + 20000);
536
+ ranges.push([valueStart, extent]);
537
+ }
538
+ ranges.sort((a, b) => a[0] - b[0]);
539
+ }
540
+ let out = "";
541
+ let last = 0;
542
+ for (let i = 0; i < ranges.length; ) {
543
+ const start = ranges[i][0];
544
+ let end = ranges[i][1];
545
+ for (i++; i < ranges.length && ranges[i][0] <= end; i++) end = Math.max(end, ranges[i][1]);
546
+ counts.assignment++;
547
+ out += `${text.slice(last, start)}${marker("assignment")}`;
548
+ last = end;
549
+ }
550
+ return out + text.slice(last);
551
+ }
552
+
553
+ // --- the pipeline, most-specific class first (see file header) -----------------
554
+
555
+ function runPipeline(input: string): { text: string; counts: Record<RedactionClass, number> } {
556
+ const counts = emptyCounts();
557
+ let text = input;
558
+
559
+ // gcp-private-key BEFORE the generic pem pass (reason 1, file header).
560
+ GCP_PRIVATE_KEY_RE.lastIndex = 0;
561
+ text = text.replace(GCP_PRIVATE_KEY_RE, (_whole, prefix: string, _value: string, suffix: string) => {
562
+ counts["gcp-private-key"]++;
563
+ return `${prefix}${marker("gcp-private-key")}${suffix}`;
564
+ });
565
+
566
+ text = runWholeMatchPass(text, "pem", PEM_RE, counts);
567
+ text = runWholeMatchPass(text, "jwt", JWT_RE, counts);
568
+
569
+ // sib-token BEFORE aws-key / github-token / sk-key (reason 3, file header).
570
+ text = runWholeMatchPass(text, "sib-token", SIB_TOKEN_RE, counts);
571
+ text = runWholeMatchPass(text, "sibdc-code", SIBDC_CODE_RE, counts);
572
+
573
+ // aws-key: two independent regexes feeding one class.
574
+ text = runWholeMatchPass(text, "aws-key", AWS_KEY_ID_RE, counts);
575
+ text = runWholeMatchPass(text, "aws-key", AWS_SECRET_RE, counts);
576
+
577
+ text = runWholeMatchPass(text, "github-token", GITHUB_TOKEN_RE, counts);
578
+ text = runWholeMatchPass(text, "slack-token", SLACK_TOKEN_RE, counts);
579
+ text = runWholeMatchPass(text, "google-api-key", GOOGLE_API_KEY_RE, counts);
580
+ text = runWholeMatchPass(text, "sk-key", SK_KEY_RE, counts);
581
+
582
+ // auth-header BEFORE assignment (reason 2, file header).
583
+ text = runWholeMatchPass(text, "auth-header", AUTH_HEADER_RE, counts);
584
+
585
+ text = runWholeMatchPass(text, "conn-string", CONN_STRING_RE, counts);
586
+
587
+ // assignment last: the catch-all, most likely to overlap an already-handled
588
+ // class's surface text if run earlier.
589
+ //
590
+ // Named groups, not positional args: the NAME side is an alternation
591
+ // (quoted vs. unquoted — see ASSIGNMENT_RE's definition), so exactly one
592
+ // of `qname`/`uname` is defined per match and the other is `undefined`.
593
+ // A quoted match reconstructs `q + qname + q` (the quotes are part of the
594
+ // preserved NAME text, same as the original design preserved an unquoted
595
+ // name verbatim); an unquoted match reconstructs `uname` alone.
596
+ text = runAssignmentPass(text, counts);
597
+
598
+ return { text, counts };
599
+ }
600
+
601
+ /**
602
+ * Redact secret material from a single string. Pure, synchronous, no I/O.
603
+ *
604
+ * Each match is replaced with `«redacted:<class>»`. `counts` names every
605
+ * `RedactionClass` (zero when unmatched — never an absent key) and `total` is
606
+ * their total. Idempotent: redacting the output of `redact()` again returns the
607
+ * same text with every count at zero (tests/yak-redact.test.ts).
608
+ */
609
+ export function redact(text: string): RedactionResult {
610
+ const { text: redacted, counts } = runPipeline(text);
611
+ const total = REDACTION_CLASSES.reduce((acc, c) => acc + counts[c], 0);
612
+ return { text: redacted, counts, total };
613
+ }
614
+
615
+ /**
616
+ * Render one concise stderr line for a redaction pass's count/class
617
+ * breakdown — `docs/YAK.md` promises this on stderr in text mode, not
618
+ * only in `--output json`'s `redactions` field. Pure formatting, no policy: the caller decides WHEN to emit this
619
+ * (`yak.ts` only calls it in text mode, and only when there is something to
620
+ * report) — this function does not gate on `count` itself, so it stays a
621
+ * formatter, not a decision.
622
+ *
623
+ * Takes the `{count, classes}` shape structurally (matches
624
+ * `yak-submit.ts`'s `RedactionSummary` without importing it — this module
625
+ * stays dependency-free by design, file header "PURE") rather than a full
626
+ * `RedactionResult`, since the caller already has the summarized shape and
627
+ * re-deriving classes from `counts` would be a second implementation of
628
+ * `summarizeRedactionCounts` living in the wrong module.
629
+ */
630
+ export function formatRedactionSummary(summary: { count: number; classes: RedactionClass[] }): string {
631
+ const noun = summary.count === 1 ? "redaction" : "redactions";
632
+ return `${summary.count} ${noun}: ${summary.classes.join(", ")}\n`;
633
+ }
634
+
635
+ /**
636
+ * Result shape for `redactDeep()` — same `counts`/`total` contract as
637
+ * `RedactionResult`, but `value` is the redacted structure rather than a
638
+ * single string.
639
+ */
640
+ export interface DeepRedactionResult<T> {
641
+ value: T;
642
+ counts: Record<RedactionClass, number>;
643
+ total: number;
644
+ }
645
+
646
+ /**
647
+ * Redact every string leaf in an arbitrary JSON-ish value (objects, arrays,
648
+ * strings, and pass-through primitives), accumulating one combined
649
+ * `counts`/`total` across the whole tree.
650
+ *
651
+ * For provenance: `provenance.git.origin_url` can carry a token
652
+ * embedded in the URL, and this is the one function that needs to reach it
653
+ * without the caller hand-walking the shape.
654
+ *
655
+ * `undefined` leaves pass through as `undefined` rather than being coerced to
656
+ * a string — this is what keeps an absent `evidence` field absent rather than
657
+ * becoming the four-character string `"undefined"` (the absent-vs-empty
658
+ * discipline the submission core one layer up depends on).
659
+ */
660
+ export function redactDeep<T>(value: T): DeepRedactionResult<T> {
661
+ const counts = emptyCounts();
662
+
663
+ function walk(v: unknown): unknown {
664
+ if (typeof v === "string") {
665
+ const r = redact(v);
666
+ for (const c of REDACTION_CLASSES) counts[c] += r.counts[c];
667
+ return r.text;
668
+ }
669
+ if (Array.isArray(v)) {
670
+ return v.map(walk);
671
+ }
672
+ if (v !== null && typeof v === "object") {
673
+ const out: Record<string, unknown> = {};
674
+ for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
675
+ out[k] = walk(val);
676
+ }
677
+ return out;
678
+ }
679
+ // numbers, booleans, null, undefined — pass through unchanged.
680
+ return v;
681
+ }
682
+
683
+ const redactedValue = walk(value) as T;
684
+ const total = REDACTION_CLASSES.reduce((acc, c) => acc + counts[c], 0);
685
+ return { value: redactedValue, counts, total };
686
+ }