@mossbear/eval-core 0.1.0-alpha.26

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mossbear
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,9 @@
1
+ # @mossbear/eval-core
2
+
3
+ Portable pieces of Mossbear's evaluation path — command redaction, the `EvalAdapter` LLM seam, and on-demand guide normalization. Grading itself runs on the [Mossbear](https://app.mossbear.io) dashboard; this package holds the parts that must stay importable outside it.
4
+
5
+ `sanitizeCommand` is used by the [`mossbear` CLI](https://www.npmjs.com/package/mossbear) to redact shell command text before an action is ever recorded; the adapter and normalization prompt are used server-side.
6
+
7
+ You normally don't install this directly; it ships as a dependency of `mossbear`.
8
+
9
+ Source: https://github.com/auden-to/auden (`packages/eval-core`)
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The LLM completion seam.
3
+ *
4
+ * eval-core never imports fetch or any HTTP library directly — a caller
5
+ * supplies the transport and this package stays dependency-free and testable.
6
+ * The dashboard implements it in `src/lib/eval/serverEvalAdapter.ts`; tests
7
+ * pass mocks.
8
+ */
9
+ /** Token counts for one completion call, when an adapter can report them. */
10
+ export type EvalUsage = {
11
+ inputTokens: number;
12
+ outputTokens: number;
13
+ cacheReadTokens?: number;
14
+ cacheWriteTokens?: number;
15
+ };
16
+ /** Adapter interface for LLM completion. */
17
+ export interface EvalAdapter {
18
+ complete(system: string, user: string): Promise<string>;
19
+ /**
20
+ * Optional: adapters that can report token usage alongside a completion's
21
+ * text implement this instead of (or in addition to) `complete`. Callers
22
+ * that care about usage/cost attribution (docs/plans/token-cost-tracking-plan.md)
23
+ * call this when present; an adapter without it is still a valid
24
+ * `EvalAdapter`.
25
+ */
26
+ completeWithUsage?(system: string, user: string): Promise<{
27
+ text: string;
28
+ usage: EvalUsage | null;
29
+ }>;
30
+ }
31
+ //# sourceMappingURL=adapter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,6EAA6E;AAC7E,MAAM,MAAM,SAAS,GAAG;IACtB,WAAW,EAAE,MAAM,CAAA;IACnB,YAAY,EAAE,MAAM,CAAA;IACpB,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAC1B,CAAA;AAED,4CAA4C;AAC5C,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IACvD;;;;;;OAMG;IACH,iBAAiB,CAAC,CAChB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,GACX,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,SAAS,GAAG,IAAI,CAAA;KAAE,CAAC,CAAA;CACtD"}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The LLM completion seam.
3
+ *
4
+ * eval-core never imports fetch or any HTTP library directly — a caller
5
+ * supplies the transport and this package stays dependency-free and testable.
6
+ * The dashboard implements it in `src/lib/eval/serverEvalAdapter.ts`; tests
7
+ * pass mocks.
8
+ */
9
+ export {};
10
+ //# sourceMappingURL=adapter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter.js","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG"}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @mossbear/eval-core
3
+ *
4
+ * Pure evaluation logic with no filesystem, network, or secret-loading side effects.
5
+ * This package must remain importable outside the CLI package.
6
+ */
7
+ export { type EvalAdapter, type EvalUsage } from './adapter.js';
8
+ export { sanitizeCommand } from './sanitize-command.js';
9
+ export { stripCodeBlocks, type CodeBlockStripResult } from './strip-code-blocks.js';
10
+ export { MAX_PROSE_CHARS, PROSE_REDACTION_CLASSES, redactProse, type ProseRedactionClass, type ProseRedactionReport, } from './redact-prose.js';
11
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,KAAK,WAAW,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAA;AAE/D,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAA;AAEvD,OAAO,EAAE,eAAe,EAAE,KAAK,oBAAoB,EAAE,MAAM,wBAAwB,CAAA;AAEnF,OAAO,EACL,eAAe,EACf,uBAAuB,EACvB,WAAW,EACX,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,GAC1B,MAAM,mBAAmB,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @mossbear/eval-core
3
+ *
4
+ * Pure evaluation logic with no filesystem, network, or secret-loading side effects.
5
+ * This package must remain importable outside the CLI package.
6
+ */
7
+ export { sanitizeCommand } from './sanitize-command.js';
8
+ export { stripCodeBlocks } from './strip-code-blocks.js';
9
+ export { MAX_PROSE_CHARS, PROSE_REDACTION_CLASSES, redactProse, } from './redact-prose.js';
10
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAA;AAEvD,OAAO,EAAE,eAAe,EAA6B,MAAM,wBAAwB,CAAA;AAEnF,OAAO,EACL,eAAe,EACf,uBAAuB,EACvB,WAAW,GAGZ,MAAM,mBAAmB,CAAA"}
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Secret redaction and length-capping for **prose** — transcript message text,
3
+ * as opposed to the shell command text `sanitizeCommand` handles.
4
+ *
5
+ * Why a second redactor rather than reusing the first: `sanitizeCommand` is
6
+ * pattern-matched against *shell syntax* — heredocs, `KEY=value` anchored at a
7
+ * shell token boundary, `Authorization:` headers
8
+ * (`./sanitize-command.ts:31-49`). `transcript-content-classes-plan.md:20`
9
+ * records that it "catches none of it in prose" and names a prose redactor the
10
+ * load-bearing deliverable rather than a checkbox on one. This is that gate.
11
+ *
12
+ * It lives here beside `sanitizeCommand`, and not in the CLI, because the plan
13
+ * asks for exactly that (`transcript-content-classes-plan.md:43`): shared, so
14
+ * the CLI and any future server-side path cannot drift into different
15
+ * strengths of the same protection.
16
+ *
17
+ * **What this is not.** Slice 1 is the redactor and nothing else. It does not
18
+ * extract transcript messages, does not strip code blocks, does not touch the
19
+ * sync payload, and no caller uploads its output — nothing about what leaves
20
+ * the machine changes in this slice. Two consequences of that are worth saying
21
+ * out loud rather than assuming:
22
+ *
23
+ * - **Code-block stripping is structural and happens before this runs.**
24
+ * Fenced and indented blocks come out by the Markdown structure of the
25
+ * message (Slice 2). A regex over prose must never be what stands between
26
+ * a code block and the wire, so nothing here attempts it.
27
+ * - **Redaction is best-effort; the length cap is the backstop.** The same
28
+ * admission `sanitizeCommand` makes (`./sanitize-command.ts:26-29`). A
29
+ * pattern list cannot enumerate every
30
+ * secret shape, which is exactly why the class table strips whole classes
31
+ * rather than trusting a scrubber to find secrets inside them.
32
+ *
33
+ * Reports **what it redacted, by class and count**, so a disclosure surface can
34
+ * show the user what was removed rather than asserting safety at them.
35
+ */
36
+ /**
37
+ * Maximum characters of a single prose message that survive redaction.
38
+ *
39
+ * Deliberately far larger than `sanitizeCommand`'s 200
40
+ * (`./sanitize-command.ts:20`): a command is a tool
41
+ * invocation, where anything long is bulk payload, whereas prose is the
42
+ * content — "I'm going to X because Y" is the whole reason this class syncs at
43
+ * all, and truncating it to a couple of sentences would leave the grader with
44
+ * the same intent-free evidence it has today. Long enough for a real
45
+ * explanation, short enough that a pasted file body cannot ride along whole.
46
+ */
47
+ export declare const MAX_PROSE_CHARS = 2000;
48
+ /**
49
+ * The classes this reports on. Named rather than free-form strings so a
50
+ * disclosure surface can label each count without re-deriving the taxonomy —
51
+ * and so adding a pattern forces a decision about which class it belongs to.
52
+ */
53
+ export declare const PROSE_REDACTION_CLASSES: readonly ["private_key", "url_credentials", "assignment", "token", "email", "long_random_string"];
54
+ export type ProseRedactionClass = (typeof PROSE_REDACTION_CLASSES)[number];
55
+ export type ProseRedactionReport = {
56
+ /** The redacted text, length-capped. */
57
+ text: string;
58
+ /** How many spans were replaced, per class. Absent classes redacted nothing. */
59
+ counts: Partial<Record<ProseRedactionClass, number>>;
60
+ /** Total spans replaced across all classes. */
61
+ redactedCount: number;
62
+ /** The text hit `MAX_PROSE_CHARS` and lost its tail. */
63
+ wasTruncated: boolean;
64
+ };
65
+ /**
66
+ * Redact secrets from prose and cap its length, reporting what was removed.
67
+ *
68
+ * Runs on-device, before upload, always — the server never receives the
69
+ * unredacted form and therefore cannot leak it.
70
+ */
71
+ export declare function redactProse(input: string | null | undefined): ProseRedactionReport;
72
+ //# sourceMappingURL=redact-prose.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redact-prose.d.ts","sourceRoot":"","sources":["../src/redact-prose.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEH;;;;;;;;;;GAUG;AACH,eAAO,MAAM,eAAe,OAAO,CAAA;AAqBnC;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,mGAO1B,CAAA;AAEV,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,uBAAuB,CAAC,CAAC,MAAM,CAAC,CAAA;AAE1E,MAAM,MAAM,oBAAoB,GAAG;IACjC,wCAAwC;IACxC,IAAI,EAAE,MAAM,CAAA;IACZ,gFAAgF;IAChF,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,mBAAmB,EAAE,MAAM,CAAC,CAAC,CAAA;IACpD,+CAA+C;IAC/C,aAAa,EAAE,MAAM,CAAA;IACrB,wDAAwD;IACxD,YAAY,EAAE,OAAO,CAAA;CACtB,CAAA;AA2FD;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,oBAAoB,CAiClF"}
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Secret redaction and length-capping for **prose** — transcript message text,
3
+ * as opposed to the shell command text `sanitizeCommand` handles.
4
+ *
5
+ * Why a second redactor rather than reusing the first: `sanitizeCommand` is
6
+ * pattern-matched against *shell syntax* — heredocs, `KEY=value` anchored at a
7
+ * shell token boundary, `Authorization:` headers
8
+ * (`./sanitize-command.ts:31-49`). `transcript-content-classes-plan.md:20`
9
+ * records that it "catches none of it in prose" and names a prose redactor the
10
+ * load-bearing deliverable rather than a checkbox on one. This is that gate.
11
+ *
12
+ * It lives here beside `sanitizeCommand`, and not in the CLI, because the plan
13
+ * asks for exactly that (`transcript-content-classes-plan.md:43`): shared, so
14
+ * the CLI and any future server-side path cannot drift into different
15
+ * strengths of the same protection.
16
+ *
17
+ * **What this is not.** Slice 1 is the redactor and nothing else. It does not
18
+ * extract transcript messages, does not strip code blocks, does not touch the
19
+ * sync payload, and no caller uploads its output — nothing about what leaves
20
+ * the machine changes in this slice. Two consequences of that are worth saying
21
+ * out loud rather than assuming:
22
+ *
23
+ * - **Code-block stripping is structural and happens before this runs.**
24
+ * Fenced and indented blocks come out by the Markdown structure of the
25
+ * message (Slice 2). A regex over prose must never be what stands between
26
+ * a code block and the wire, so nothing here attempts it.
27
+ * - **Redaction is best-effort; the length cap is the backstop.** The same
28
+ * admission `sanitizeCommand` makes (`./sanitize-command.ts:26-29`). A
29
+ * pattern list cannot enumerate every
30
+ * secret shape, which is exactly why the class table strips whole classes
31
+ * rather than trusting a scrubber to find secrets inside them.
32
+ *
33
+ * Reports **what it redacted, by class and count**, so a disclosure surface can
34
+ * show the user what was removed rather than asserting safety at them.
35
+ */
36
+ /**
37
+ * Maximum characters of a single prose message that survive redaction.
38
+ *
39
+ * Deliberately far larger than `sanitizeCommand`'s 200
40
+ * (`./sanitize-command.ts:20`): a command is a tool
41
+ * invocation, where anything long is bulk payload, whereas prose is the
42
+ * content — "I'm going to X because Y" is the whole reason this class syncs at
43
+ * all, and truncating it to a couple of sentences would leave the grader with
44
+ * the same intent-free evidence it has today. Long enough for a real
45
+ * explanation, short enough that a pasted file body cannot ride along whole.
46
+ */
47
+ export const MAX_PROSE_CHARS = 2000;
48
+ /**
49
+ * Characters actually scanned by the patterns below.
50
+ *
51
+ * The cap on the *output* is not a bound on the *work*: without this, every
52
+ * pattern scans an arbitrarily long untrusted message before a single
53
+ * character is discarded, and the assignment matcher backtracks badly on
54
+ * adversarial input (a long run of `SECRET` with no `=` after it). A pasted
55
+ * megabyte could then stall CLI sync to produce 2,000 characters.
56
+ *
57
+ * Generous rather than equal to `MAX_PROSE_CHARS` because redaction *shrinks*
58
+ * text — a secret replaced by `[redacted]` pulls later content into the
59
+ * surviving window — so the scan has to see more than it will keep. Anything
60
+ * past this is dropped before scanning and counts as truncation.
61
+ */
62
+ const MAX_SCAN_CHARS = MAX_PROSE_CHARS * 8;
63
+ /** Marker substituted for redacted spans. Matches `sanitizeCommand`'s. */
64
+ const REDACTED = '[redacted]';
65
+ /**
66
+ * The classes this reports on. Named rather than free-form strings so a
67
+ * disclosure surface can label each count without re-deriving the taxonomy —
68
+ * and so adding a pattern forces a decision about which class it belongs to.
69
+ */
70
+ export const PROSE_REDACTION_CLASSES = [
71
+ 'private_key',
72
+ 'url_credentials',
73
+ 'assignment',
74
+ 'token',
75
+ 'email',
76
+ 'long_random_string',
77
+ ];
78
+ /**
79
+ * Ordered redactions. Order matters the same way it does in `sanitizeCommand`:
80
+ * the widest structural shapes run first, so a token *inside* a PEM block or a
81
+ * URL is already gone before the narrower patterns scan for it.
82
+ */
83
+ const PROSE_REDACTIONS = [
84
+ // Whole private-key blocks, including an unterminated one — a truncated key
85
+ // is still a key. First, because everything else would otherwise match the
86
+ // base64 inside it.
87
+ [
88
+ 'private_key',
89
+ /-----BEGIN[A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END[A-Z ]*PRIVATE KEY-----|$)/g,
90
+ REDACTED,
91
+ ],
92
+ // `Authorization: Bearer <token>` / `Basic <blob>`, as pasted from a request
93
+ // or a curl transcript. These values are routinely short-lived and short —
94
+ // too short for the prefix and long-run rules below — and `Authorization` is
95
+ // a header name rather than a sensitive assignment key, so without this rule
96
+ // a pasted header uploads a live credential verbatim. `sanitizeCommand`
97
+ // carries the same pattern for command text (`./sanitize-command.ts:45`).
98
+ [
99
+ 'token',
100
+ /\b(authorization\s*:\s*)(?:bearer\s+|basic\s+|token\s+)?\S+/gi,
101
+ `$1${REDACTED}`,
102
+ ],
103
+ // URL userinfo. Both shapes: `scheme://user:secret@host` and the
104
+ // token-as-sole-userinfo form `scheme://token@host`, which a colon-requiring
105
+ // pattern misses entirely and which no other rule here catches when the token
106
+ // is short. The host always stays — "it failed against api.example.com" is
107
+ // the part worth grading.
108
+ [
109
+ 'url_credentials',
110
+ /\b([a-z][a-z0-9+.-]*:\/\/)[^/\s:@]+(?::[^@\s]*)?@/gi,
111
+ `$1${REDACTED}@`,
112
+ ],
113
+ // A sensitive key followed by `=` or `:` and a value. Unlike the command
114
+ // version this is *not* anchored at a shell-token boundary — in prose the
115
+ // pair is embedded in a sentence ("I set OPENAI_API_KEY=sk-… and reran"), so
116
+ // anchoring on shell syntax is precisely what misses it.
117
+ //
118
+ // The sensitive word must be a **whole `_`/`-` separated segment** of the
119
+ // key, not a substring of it. As an arbitrary substring, `SECRET` matches
120
+ // `secretary` and `TOKEN` matches `tokenization`, so ordinary prose like
121
+ // "secretary: Alice" lost its value — and prose scrubbed into nonsense is
122
+ // useless to a grader and alarming to a reader, which makes over-redaction a
123
+ // defect here rather than a safe default.
124
+ //
125
+ // The quoted-value arms consume escape sequences (`\\.`) rather than stopping
126
+ // at the first inner quote: a value like `"correct horse \\"battery staple"`
127
+ // otherwise terminates early and leaves the tail of the password in place.
128
+ [
129
+ 'assignment',
130
+ /\b((?:[A-Za-z0-9]+[_-])*(?:SECRET|PASSWORD|PASSWD|TOKEN|API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIAL)(?:[_-][A-Za-z0-9]+)*)(\s*[=:]\s*)(?:"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|\S+)/gi,
131
+ `$1$2${REDACTED}`,
132
+ ],
133
+ // Well-known bare token shapes, by their published prefixes. Prefix-anchored
134
+ // rather than entropy-guessed, so an ordinary long word is never mistaken for
135
+ // a credential.
136
+ [
137
+ 'token',
138
+ /\b(?:sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{20,}|gho_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|AIza[A-Za-z0-9_-]{20,})/g,
139
+ REDACTED,
140
+ ],
141
+ // Email addresses, listed among the plan's targets
142
+ // (`transcript-content-classes-plan.md:43`).
143
+ ['email', /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, REDACTED],
144
+ // Long unbroken base64/hex runs: a credential shape this list has no prefix
145
+ // for. Runs last, so anything a named pattern would have caught is gone.
146
+ //
147
+ // Two constraints keep it from eating ordinary text, and both are load-bearing:
148
+ //
149
+ // - **Delimiter lookarounds, not `\b`.** `+` and `/` are in the base64
150
+ // alphabet but are not word characters, so a blob beginning with one has
151
+ // no word boundary to match at — `\b` would start a character late and
152
+ // leave a 40-character secret one short of the floor, passing it through
153
+ // whole.
154
+ // - **It must contain a digit.** Length alone is not a random-string
155
+ // signal: `createAuthenticatedDashboardRequestContext` is 42 characters
156
+ // of ordinary identifier, and redacting it removes the exact detail a
157
+ // grader needs. Digests, ids and encoded blobs carry digits; long
158
+ // camel-case names do not. An all-alphabetic secret is the accepted
159
+ // miss, and it is what the named patterns above and the cap exist for.
160
+ [
161
+ 'long_random_string',
162
+ /(?<![A-Za-z0-9+/=_-])(?=[A-Za-z0-9+/_-]*\d)[A-Za-z0-9+/_-]{40,}={0,2}(?![A-Za-z0-9+/=_-])/g,
163
+ REDACTED,
164
+ ],
165
+ ];
166
+ /**
167
+ * Redact secrets from prose and cap its length, reporting what was removed.
168
+ *
169
+ * Runs on-device, before upload, always — the server never receives the
170
+ * unredacted form and therefore cannot leak it.
171
+ */
172
+ export function redactProse(input) {
173
+ if (typeof input !== 'string' || input === '') {
174
+ return { text: '', counts: {}, redactedCount: 0, wasTruncated: false };
175
+ }
176
+ // Bounded before any pattern runs — see `MAX_SCAN_CHARS`.
177
+ const wasScanTruncated = input.length > MAX_SCAN_CHARS;
178
+ let text = wasScanTruncated ? input.slice(0, MAX_SCAN_CHARS) : input;
179
+ const counts = {};
180
+ let redactedCount = 0;
181
+ for (const [redactionClass, pattern, replacement] of PROSE_REDACTIONS) {
182
+ // Counted before replacing, and against a fresh regex, so the shared
183
+ // literal's `lastIndex` cannot carry between calls — a stateful `g` regex
184
+ // silently skipping the first match of the next message is exactly the
185
+ // failure a redactor must not have.
186
+ const counter = new RegExp(pattern.source, pattern.flags);
187
+ const matches = text.match(counter);
188
+ if (!matches || matches.length === 0)
189
+ continue;
190
+ counts[redactionClass] = (counts[redactionClass] ?? 0) + matches.length;
191
+ redactedCount += matches.length;
192
+ text = text.replace(new RegExp(pattern.source, pattern.flags), replacement);
193
+ }
194
+ const wasTruncated = wasScanTruncated || text.length > MAX_PROSE_CHARS;
195
+ if (text.length > MAX_PROSE_CHARS) {
196
+ // Marked rather than silently cut, so a reader can tell a short message
197
+ // from a long one that lost its tail.
198
+ text = `${text.slice(0, MAX_PROSE_CHARS)}… [truncated]`;
199
+ }
200
+ return { text, counts, redactedCount, wasTruncated };
201
+ }
202
+ //# sourceMappingURL=redact-prose.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redact-prose.js","sourceRoot":"","sources":["../src/redact-prose.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=redact-prose.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redact-prose.test.d.ts","sourceRoot":"","sources":["../src/redact-prose.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,234 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { MAX_PROSE_CHARS, redactProse } from './redact-prose.js';
3
+ /**
4
+ * The corpus this redactor is judged against. Each entry is a plausible line of
5
+ * assistant or user prose from a real session: what must be removed, and — just
6
+ * as load-bearing — what must survive intact.
7
+ */
8
+ describe('redactProse', () => {
9
+ describe('secrets that must not survive', () => {
10
+ it('removes a PEM private key block', () => {
11
+ const result = redactProse('Here is the key I generated:\n-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAx7Vn9kQm\nQm5kZXJ0aGVzdHJpbmc=\n-----END RSA PRIVATE KEY-----\nUse it for the deploy.');
12
+ expect(result.text).not.toContain('MIIEowIBAAKCAQEAx7Vn9kQm');
13
+ expect(result.text).not.toContain('BEGIN RSA PRIVATE KEY');
14
+ expect(result.text).toContain('Here is the key I generated:');
15
+ expect(result.text).toContain('Use it for the deploy.');
16
+ expect(result.counts.private_key).toBe(1);
17
+ });
18
+ // A truncated key is still a key.
19
+ it('removes an unterminated private key block', () => {
20
+ const result = redactProse('-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA');
21
+ expect(result.text).not.toContain('b3BlbnNzaC1rZXktdjEAAAAA');
22
+ expect(result.counts.private_key).toBe(1);
23
+ });
24
+ it('removes credentials from a URL but keeps the host', () => {
25
+ const result = redactProse('I connected with postgres://admin:hunter2@db.example.com:5432/app and it worked.');
26
+ expect(result.text).not.toContain('hunter2');
27
+ expect(result.text).toContain('db.example.com');
28
+ expect(result.counts.url_credentials).toBe(1);
29
+ });
30
+ // `sanitizeCommand` anchors this at a shell-token boundary, which is
31
+ // exactly what misses it mid-sentence. This is the gap that made a
32
+ // separate prose redactor the load-bearing deliverable.
33
+ it('removes KEY=value embedded in a sentence', () => {
34
+ const result = redactProse('I set OPENAI_API_KEY=sk-proj-abcdefghijklmnop1234 and reran the script.');
35
+ expect(result.text).not.toContain('sk-proj-abcdefghijklmnop1234');
36
+ expect(result.text).toContain('I set OPENAI_API_KEY');
37
+ expect(result.text).toContain('and reran the script.');
38
+ });
39
+ it('removes a quoted value with spaces', () => {
40
+ const result = redactProse('The config had DB_PASSWORD = "correct horse battery"');
41
+ expect(result.text).not.toContain('correct horse battery');
42
+ expect(result.counts.assignment).toBe(1);
43
+ });
44
+ it('removes a colon-separated secret', () => {
45
+ const result = redactProse('auth token: aVeryLongOpaqueValue99');
46
+ expect(result.text).not.toContain('aVeryLongOpaqueValue99');
47
+ });
48
+ it.each([
49
+ ['OpenAI-style', 'sk-proj-QQQQwwwweeee1234RRRR'],
50
+ ['GitHub PAT', 'ghp_abcdefghijklmnopqrstuvwxyz0123'],
51
+ ['GitHub fine-grained', 'github_pat_11ABCDEFG0abcdefghij_KLMNOP'],
52
+ ['Slack', 'xoxb-1234567890-abcdefghijkl'],
53
+ ['AWS access key', 'AKIAIOSFODNN7EXAMPLE'],
54
+ ['Google API key', 'AIzaSyA1234567890abcdefghijklmnopqrs'],
55
+ ])('removes a bare %s token', (_name, token) => {
56
+ const result = redactProse(`The failing call used ${token} as its credential.`);
57
+ expect(result.text).not.toContain(token);
58
+ expect(result.text).toContain('The failing call used');
59
+ expect(result.redactedCount).toBeGreaterThan(0);
60
+ });
61
+ // A pasted request header: `Authorization` is not a sensitive assignment
62
+ // key, and these values are routinely too short for the prefix and
63
+ // long-run rules, so without a rule of their own they upload verbatim.
64
+ it.each([
65
+ ['Bearer', 'Authorization: Bearer short-live-token-123', 'short-live-token-123'],
66
+ ['Basic', 'Authorization: Basic dXNlcjpwYXNz', 'dXNlcjpwYXNz'],
67
+ ])('removes an %s Authorization header credential', (_name, prose, secret) => {
68
+ const result = redactProse(prose);
69
+ expect(result.text).not.toContain(secret);
70
+ expect(result.text).toContain('Authorization');
71
+ });
72
+ // A colon-requiring pattern misses the token-as-sole-userinfo form, and
73
+ // nothing else here catches it when the token is short.
74
+ it('removes token-only URL userinfo', () => {
75
+ const result = redactProse('Connected with postgres://short-live-token@127.0.0.1/db');
76
+ expect(result.text).not.toContain('short-live-token');
77
+ expect(result.text).toContain('127.0.0.1');
78
+ });
79
+ // Stopping at the first inner quote leaves the tail of the password in
80
+ // place, which is worse than not matching at all — it looks redacted.
81
+ it('consumes escaped quotes inside a quoted value', () => {
82
+ const result = redactProse('DB_PASSWORD="correct horse \\"battery staple"');
83
+ expect(result.text).not.toContain('battery staple');
84
+ expect(result.text).toBe('DB_PASSWORD=[redacted]');
85
+ });
86
+ // `+` and `/` are base64 characters but not word characters, so a leading
87
+ // one leaves `\b` nothing to match and the run one short of the floor.
88
+ it('removes a base64 run beginning with a non-word character', () => {
89
+ const secret = '/123456789012345678901234567890123456789';
90
+ const result = redactProse(`The blob was ${secret} in the header.`);
91
+ expect(result.text).not.toContain(secret);
92
+ expect(result.counts.long_random_string).toBe(1);
93
+ });
94
+ it('removes email addresses', () => {
95
+ const result = redactProse('Ping alex.doe+work@example.co.uk about the migration.');
96
+ expect(result.text).not.toContain('alex.doe+work@example.co.uk');
97
+ expect(result.text).toContain('about the migration.');
98
+ expect(result.counts.email).toBe(1);
99
+ });
100
+ it('removes a long opaque base64/hex run', () => {
101
+ const digest = 'a3f5c9e17b2d4869fa0c5e3d7b91240affe6c8d251b7093e';
102
+ const result = redactProse(`The session id was ${digest} in the logs.`);
103
+ expect(result.text).not.toContain(digest);
104
+ expect(result.counts.long_random_string).toBe(1);
105
+ });
106
+ it('removes every secret when a message carries several', () => {
107
+ const result = redactProse('Mailed ops@example.com the key ghp_abcdefghijklmnopqrstuvwxyz0123 and set API_KEY=topsecret123');
108
+ expect(result.text).not.toContain('ops@example.com');
109
+ expect(result.text).not.toContain('ghp_abcdefghijklmnopqrstuvwxyz0123');
110
+ expect(result.text).not.toContain('topsecret123');
111
+ expect(result.redactedCount).toBeGreaterThanOrEqual(3);
112
+ });
113
+ });
114
+ /**
115
+ * The other half of the contract. Prose scrubbed into nonsense is useless to
116
+ * a grader and alarming to a reader, so over-redaction is a defect too.
117
+ */
118
+ describe('ordinary text that must not be mangled', () => {
119
+ it.each([
120
+ "I'm going to refactor the sync queue because the retry logic double-counts.",
121
+ 'The test failed at apps/dashboard/src/lib/runs/format.ts:42 with a TypeError.',
122
+ 'Renaming getUserById to fetchUserById across 14 call sites; the answer: 42.',
123
+ 'Run `bun run test` first — it takes about 18 seconds on this machine.',
124
+ 'The verdict vocabulary is aligned | unguided | misaligned, never anything else.',
125
+ 'See https://example.com/docs/getting-started for the setup steps.',
126
+ ])('leaves %s untouched', (prose) => {
127
+ const result = redactProse(prose);
128
+ expect(result.text).toBe(prose);
129
+ expect(result.redactedCount).toBe(0);
130
+ });
131
+ // The floor on the loosest pattern is what protects ordinary language:
132
+ // English words, paths and identifiers do not reach 40 unbroken characters.
133
+ it('does not redact a long ordinary sentence as a random string', () => {
134
+ const prose = 'The implementation of the deterministic detector counts distinct runs rather than rows.';
135
+ const result = redactProse(prose);
136
+ expect(result.text).toBe(prose);
137
+ });
138
+ // As an arbitrary substring, SECRET matches `secretary` and TOKEN matches
139
+ // `tokenization`. These are ordinary prose, not credential assignments.
140
+ it.each([
141
+ ['secretary: Alice', 'Alice'],
142
+ ['tokenization: complete', 'complete'],
143
+ ['The password reset flow: works now', 'works now'],
144
+ ])('keeps %s, whose key only contains a sensitive word', (prose, value) => {
145
+ expect(redactProse(prose).text).toContain(value);
146
+ });
147
+ // Length alone is not a random-string signal. Redacting a long identifier
148
+ // removes the exact implementation detail a grader needs.
149
+ it.each([
150
+ 'createAuthenticatedDashboardRequestContext',
151
+ 'useRunGuideVerdictLabelsWithFallbackResolution',
152
+ ])('keeps the long ordinary identifier %s', (identifier) => {
153
+ const prose = `The call to ${identifier} failed.`;
154
+ expect(redactProse(prose).text).toBe(prose);
155
+ });
156
+ it('keeps a non-sensitive assignment', () => {
157
+ const prose = 'I set NODE_ENV=production and the build shrank by 40%.';
158
+ const result = redactProse(prose);
159
+ expect(result.text).toBe(prose);
160
+ });
161
+ });
162
+ describe('length capping', () => {
163
+ it('caps and marks a message past the limit', () => {
164
+ const long = 'word '.repeat(MAX_PROSE_CHARS);
165
+ const result = redactProse(long);
166
+ expect(result.wasTruncated).toBe(true);
167
+ expect(result.text).toContain('[truncated]');
168
+ expect(result.text.length).toBeLessThan(MAX_PROSE_CHARS + 30);
169
+ });
170
+ it('leaves a message inside the limit alone', () => {
171
+ const result = redactProse('A short explanation of the change.');
172
+ expect(result.wasTruncated).toBe(false);
173
+ expect(result.text).not.toContain('[truncated]');
174
+ });
175
+ // The output cap is not a bound on the work: without a scan bound every
176
+ // pattern scans the whole untrusted message first, and the assignment
177
+ // matcher backtracks badly on adversarial input.
178
+ it('bounds the work on a hostile input rather than only the output', () => {
179
+ const hostile = `${'SECRET'.repeat(200_000)} tail`;
180
+ const started = performance.now();
181
+ const result = redactProse(hostile);
182
+ const elapsedMs = performance.now() - started;
183
+ expect(elapsedMs).toBeLessThan(2000);
184
+ expect(result.wasTruncated).toBe(true);
185
+ expect(result.text.length).toBeLessThan(MAX_PROSE_CHARS + 30);
186
+ });
187
+ // The cap is the backstop for whatever the patterns miss, so it has to
188
+ // apply after redaction rather than instead of it.
189
+ it('still redacts before truncating', () => {
190
+ const result = redactProse(`ghp_abcdefghijklmnopqrstuvwxyz0123 ${'padding '.repeat(MAX_PROSE_CHARS)}`);
191
+ expect(result.text).not.toContain('ghp_abcdefghijklmnopqrstuvwxyz0123');
192
+ expect(result.wasTruncated).toBe(true);
193
+ });
194
+ });
195
+ describe('the report', () => {
196
+ it('counts each class separately', () => {
197
+ const result = redactProse('Contact a@example.com and b@example.com; the token is ghp_abcdefghijklmnopqrstuvwxyz0123');
198
+ expect(result.counts.email).toBe(2);
199
+ expect(result.counts.token).toBe(1);
200
+ expect(result.redactedCount).toBe(3);
201
+ });
202
+ it('omits classes that redacted nothing', () => {
203
+ const result = redactProse('Ping ops@example.com when the deploy lands.');
204
+ expect(result.counts.email).toBe(1);
205
+ expect(result.counts.private_key).toBeUndefined();
206
+ expect(result.counts.token).toBeUndefined();
207
+ });
208
+ it('reports nothing redacted for clean prose', () => {
209
+ const result = redactProse('Nothing sensitive here at all.');
210
+ expect(result.counts).toEqual({});
211
+ expect(result.redactedCount).toBe(0);
212
+ });
213
+ it('treats empty, null, and undefined as empty prose', () => {
214
+ for (const input of ['', null, undefined]) {
215
+ const result = redactProse(input);
216
+ expect(result.text).toBe('');
217
+ expect(result.redactedCount).toBe(0);
218
+ expect(result.wasTruncated).toBe(false);
219
+ }
220
+ });
221
+ });
222
+ // A stateful `g` regex silently skipping the first match of the next message
223
+ // is exactly the failure a redactor must not have.
224
+ it('redacts identically on repeated calls', () => {
225
+ const prose = 'The key ghp_abcdefghijklmnopqrstuvwxyz0123 leaked into the log.';
226
+ const first = redactProse(prose);
227
+ const second = redactProse(prose);
228
+ const third = redactProse(prose);
229
+ expect(second.text).toBe(first.text);
230
+ expect(third.text).toBe(first.text);
231
+ expect(second.counts).toEqual(first.counts);
232
+ });
233
+ });
234
+ //# sourceMappingURL=redact-prose.test.js.map