@crewhaus/spec-patch 0.1.8 → 0.2.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.
- package/dist/index.d.ts +58 -0
- package/dist/index.js +219 -1
- package/dist/redact.d.ts +42 -0
- package/dist/redact.js +102 -0
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
*/
|
|
33
33
|
import { CrewhausError } from "@crewhaus/errors";
|
|
34
34
|
import { type Spec } from "@crewhaus/spec";
|
|
35
|
+
export { REDACTED_VALUE, isCredentialKey, maskCredentialTokens } from "./redact";
|
|
35
36
|
export declare class SpecPatchError extends CrewhausError {
|
|
36
37
|
readonly name = "SpecPatchError";
|
|
37
38
|
constructor(message: string, cause?: unknown);
|
|
@@ -104,3 +105,60 @@ export declare function formatWriteBackHeader(opts: {
|
|
|
104
105
|
readonly iterations: number;
|
|
105
106
|
readonly timestamp?: string;
|
|
106
107
|
}): string;
|
|
108
|
+
/**
|
|
109
|
+
* Item 46 — the fields `formatWriteBackHeader` stamped into a written-back
|
|
110
|
+
* spec's leading comment block, recovered by `parseWriteBackHeader`. Only
|
|
111
|
+
* `runId` is guaranteed (it lives on the stamp's first line); the rest are
|
|
112
|
+
* best-effort so a hand-trimmed header still yields its surviving fields.
|
|
113
|
+
*/
|
|
114
|
+
export type WriteBackHeaderInfo = {
|
|
115
|
+
readonly runId: string;
|
|
116
|
+
readonly mutator?: string;
|
|
117
|
+
readonly iterations?: number;
|
|
118
|
+
readonly scoreBefore?: number;
|
|
119
|
+
readonly scoreAfter?: number;
|
|
120
|
+
readonly generated?: string;
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* Inverse of `formatWriteBackHeader`: scan the LEADING comment block of a
|
|
124
|
+
* YAML text for the optimize write-back stamp and return its fields, or
|
|
125
|
+
* `undefined` when the text was never written back. Only the leading block
|
|
126
|
+
* is scanned — a `# crewhaus optimize:` line further down (e.g. quoted
|
|
127
|
+
* inside an instructions prompt) is document content, not a header.
|
|
128
|
+
*/
|
|
129
|
+
export declare function parseWriteBackHeader(yamlText: string): WriteBackHeaderInfo | undefined;
|
|
130
|
+
/**
|
|
131
|
+
* Item 46 — one field-level difference between two spec versions (see
|
|
132
|
+
* `diffSpecYaml`). `path` is dot-joined property keys with array indices
|
|
133
|
+
* rendered as `[i]` (e.g. `steps[2].prompt`). `before`/`after` carry the
|
|
134
|
+
* FORMATTED (truncated, credential-REDACTED — see `renderDiffValue`) values,
|
|
135
|
+
* ready for a changelog line — the differ is a reporting primitive, not a
|
|
136
|
+
* patch source.
|
|
137
|
+
*/
|
|
138
|
+
export type SpecDiffEntry = {
|
|
139
|
+
readonly kind: "added" | "removed" | "changed";
|
|
140
|
+
readonly path: string;
|
|
141
|
+
/** Formatted old value — absent for `"added"`. */
|
|
142
|
+
readonly before?: string;
|
|
143
|
+
/** Formatted new value — absent for `"removed"`. */
|
|
144
|
+
readonly after?: string;
|
|
145
|
+
};
|
|
146
|
+
/** Diff values longer than this render truncated with a trailing ellipsis. */
|
|
147
|
+
export declare const DIFF_VALUE_MAX_LENGTH = 72;
|
|
148
|
+
/**
|
|
149
|
+
* Render a value for a diff line: scalars as JSON (strings quoted), maps and
|
|
150
|
+
* sequences as compact JSON, truncated to `maxLength` with an ellipsis so a
|
|
151
|
+
* multi-paragraph instructions prompt doesn't flood the changelog. Pure
|
|
152
|
+
* formatting — credential redaction happens upstream in `renderDiffValue`,
|
|
153
|
+
* which is what the differ itself calls.
|
|
154
|
+
*/
|
|
155
|
+
export declare function formatDiffValue(value: unknown, maxLength?: number): string;
|
|
156
|
+
/**
|
|
157
|
+
* Item 46 — structural (field-level) diff between two YAML documents.
|
|
158
|
+
* Parses both texts and walks the value trees: maps diff by key, sequences
|
|
159
|
+
* by index, everything else is a leaf compared by value. Comments and
|
|
160
|
+
* formatting are invisible here BY DESIGN — two texts that parse to the
|
|
161
|
+
* same values produce an empty diff. Throws `SpecPatchError` when either
|
|
162
|
+
* text is unparseable.
|
|
163
|
+
*/
|
|
164
|
+
export declare function diffSpecYaml(beforeYaml: string, afterYaml: string): ReadonlyArray<SpecDiffEntry>;
|
package/dist/index.js
CHANGED
|
@@ -32,8 +32,13 @@
|
|
|
32
32
|
*/
|
|
33
33
|
import { CrewhausError } from "@crewhaus/errors";
|
|
34
34
|
import { parseSpec } from "@crewhaus/spec";
|
|
35
|
-
import { parseDocument } from "yaml";
|
|
35
|
+
import { parse, parseDocument } from "yaml";
|
|
36
36
|
import { z } from "zod";
|
|
37
|
+
import { REDACTED_VALUE, isCredentialKey, maskCredentialTokens } from "./redact";
|
|
38
|
+
// Re-exported so downstream renderers (the CLI changelog, future reporters)
|
|
39
|
+
// can apply the SAME redaction to strings the differ never sees (rationales,
|
|
40
|
+
// free-form provenance) without duplicating the pattern table a third time.
|
|
41
|
+
export { REDACTED_VALUE, isCredentialKey, maskCredentialTokens } from "./redact";
|
|
37
42
|
export class SpecPatchError extends CrewhausError {
|
|
38
43
|
name = "SpecPatchError";
|
|
39
44
|
constructor(message, cause) {
|
|
@@ -143,6 +148,10 @@ function formatPath(path) {
|
|
|
143
148
|
export const OPTIMIZABLE_PATHS = Object.freeze({
|
|
144
149
|
cli: Object.freeze([
|
|
145
150
|
Object.freeze(["agent", "instructions"]),
|
|
151
|
+
// Item 14 — `crewhaus advise`'s truncation-pressure rule patches the
|
|
152
|
+
// per-turn output-token cap when max_tokens truncations recur; safe to
|
|
153
|
+
// autotune (a bigger cap can only trade cost for completeness).
|
|
154
|
+
Object.freeze(["agent", "max_tokens"]),
|
|
146
155
|
Object.freeze(["failure_taxonomy"]),
|
|
147
156
|
Object.freeze(["compaction", "threshold"]),
|
|
148
157
|
// Pillar 2 active context curation — eval-optimizer can flip the
|
|
@@ -292,3 +301,212 @@ export function formatWriteBackHeader(opts) {
|
|
|
292
301
|
"",
|
|
293
302
|
].join("\n");
|
|
294
303
|
}
|
|
304
|
+
/**
|
|
305
|
+
* Inverse of `formatWriteBackHeader`: scan the LEADING comment block of a
|
|
306
|
+
* YAML text for the optimize write-back stamp and return its fields, or
|
|
307
|
+
* `undefined` when the text was never written back. Only the leading block
|
|
308
|
+
* is scanned — a `# crewhaus optimize:` line further down (e.g. quoted
|
|
309
|
+
* inside an instructions prompt) is document content, not a header.
|
|
310
|
+
*/
|
|
311
|
+
export function parseWriteBackHeader(yamlText) {
|
|
312
|
+
let runId;
|
|
313
|
+
let mutator;
|
|
314
|
+
let iterations;
|
|
315
|
+
let scoreBefore;
|
|
316
|
+
let scoreAfter;
|
|
317
|
+
let generated;
|
|
318
|
+
for (const rawLine of yamlText.split("\n")) {
|
|
319
|
+
const line = rawLine.trim();
|
|
320
|
+
if (line === "")
|
|
321
|
+
continue;
|
|
322
|
+
if (!line.startsWith("#"))
|
|
323
|
+
break; // end of the leading comment block
|
|
324
|
+
const stamp = /^# crewhaus optimize: runId (\S+)$/.exec(line);
|
|
325
|
+
if (stamp?.[1] !== undefined) {
|
|
326
|
+
runId = stamp[1];
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
// Field lines only count once the stamp line has been seen, so ordinary
|
|
330
|
+
// leading comments (`# - mutator: ...` in a user's own notes) don't
|
|
331
|
+
// masquerade as header fields.
|
|
332
|
+
if (runId === undefined)
|
|
333
|
+
continue;
|
|
334
|
+
const field = /^# - ([a-z]+): (.*)$/.exec(line);
|
|
335
|
+
if (field?.[1] === undefined || field[2] === undefined)
|
|
336
|
+
continue;
|
|
337
|
+
const value = field[2].trim();
|
|
338
|
+
if (field[1] === "mutator") {
|
|
339
|
+
mutator = value;
|
|
340
|
+
}
|
|
341
|
+
else if (field[1] === "iterations") {
|
|
342
|
+
const n = Number.parseInt(value, 10);
|
|
343
|
+
if (!Number.isNaN(n))
|
|
344
|
+
iterations = n;
|
|
345
|
+
}
|
|
346
|
+
else if (field[1] === "score") {
|
|
347
|
+
const scores = /^([0-9.eE+-]+) → ([0-9.eE+-]+)/.exec(value);
|
|
348
|
+
const before = scores?.[1] !== undefined ? Number.parseFloat(scores[1]) : Number.NaN;
|
|
349
|
+
const after = scores?.[2] !== undefined ? Number.parseFloat(scores[2]) : Number.NaN;
|
|
350
|
+
if (!Number.isNaN(before))
|
|
351
|
+
scoreBefore = before;
|
|
352
|
+
if (!Number.isNaN(after))
|
|
353
|
+
scoreAfter = after;
|
|
354
|
+
}
|
|
355
|
+
else if (field[1] === "generated") {
|
|
356
|
+
generated = value;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (runId === undefined)
|
|
360
|
+
return undefined;
|
|
361
|
+
return {
|
|
362
|
+
runId,
|
|
363
|
+
...(mutator !== undefined ? { mutator } : {}),
|
|
364
|
+
...(iterations !== undefined ? { iterations } : {}),
|
|
365
|
+
...(scoreBefore !== undefined ? { scoreBefore } : {}),
|
|
366
|
+
...(scoreAfter !== undefined ? { scoreAfter } : {}),
|
|
367
|
+
...(generated !== undefined ? { generated } : {}),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
/** Diff values longer than this render truncated with a trailing ellipsis. */
|
|
371
|
+
export const DIFF_VALUE_MAX_LENGTH = 72;
|
|
372
|
+
/**
|
|
373
|
+
* Render a value for a diff line: scalars as JSON (strings quoted), maps and
|
|
374
|
+
* sequences as compact JSON, truncated to `maxLength` with an ellipsis so a
|
|
375
|
+
* multi-paragraph instructions prompt doesn't flood the changelog. Pure
|
|
376
|
+
* formatting — credential redaction happens upstream in `renderDiffValue`,
|
|
377
|
+
* which is what the differ itself calls.
|
|
378
|
+
*/
|
|
379
|
+
export function formatDiffValue(value, maxLength = DIFF_VALUE_MAX_LENGTH) {
|
|
380
|
+
const rendered = value === undefined ? "undefined" : (JSON.stringify(value) ?? String(value));
|
|
381
|
+
if (rendered.length <= maxLength)
|
|
382
|
+
return rendered;
|
|
383
|
+
return `${rendered.slice(0, Math.max(1, maxLength - 1))}…`;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Item 46 — structural (field-level) diff between two YAML documents.
|
|
387
|
+
* Parses both texts and walks the value trees: maps diff by key, sequences
|
|
388
|
+
* by index, everything else is a leaf compared by value. Comments and
|
|
389
|
+
* formatting are invisible here BY DESIGN — two texts that parse to the
|
|
390
|
+
* same values produce an empty diff. Throws `SpecPatchError` when either
|
|
391
|
+
* text is unparseable.
|
|
392
|
+
*/
|
|
393
|
+
export function diffSpecYaml(beforeYaml, afterYaml) {
|
|
394
|
+
let before;
|
|
395
|
+
let after;
|
|
396
|
+
try {
|
|
397
|
+
before = parse(beforeYaml);
|
|
398
|
+
}
|
|
399
|
+
catch (err) {
|
|
400
|
+
throw new SpecPatchError("diff: previous YAML is not parseable", err);
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
after = parse(afterYaml);
|
|
404
|
+
}
|
|
405
|
+
catch (err) {
|
|
406
|
+
throw new SpecPatchError("diff: new YAML is not parseable", err);
|
|
407
|
+
}
|
|
408
|
+
const out = [];
|
|
409
|
+
walkDiff(before, after, "", out, []);
|
|
410
|
+
return out;
|
|
411
|
+
}
|
|
412
|
+
function isPlainObject(v) {
|
|
413
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
414
|
+
}
|
|
415
|
+
function leafEqual(a, b) {
|
|
416
|
+
if (a === b)
|
|
417
|
+
return true;
|
|
418
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Adversarial-review F1 — render a diff value with credentials removed.
|
|
422
|
+
* `diffSpecYaml`'s output lands verbatim in `.crewhaus/specs/<name>/
|
|
423
|
+
* CHANGELOG.md` on every compile and echoes through `crewhaus spec log`, so
|
|
424
|
+
* the redaction has to happen HERE in the differ, for every consumer:
|
|
425
|
+
*
|
|
426
|
+
* - a value under a credential-carrying key — final or parent segment
|
|
427
|
+
* (`agent.api_key`, `mcp_servers.*.env.TOKEN`, sse `headers.*`) —
|
|
428
|
+
* renders as `[redacted]`, never the value;
|
|
429
|
+
* - every other value has nested credential keys redacted and
|
|
430
|
+
* credential-shaped tokens masked out of its strings (an `sk-…` pasted
|
|
431
|
+
* into `instructions` prose) BEFORE `formatDiffValue` truncates.
|
|
432
|
+
*
|
|
433
|
+
* `keyTrail` is the chain of property keys down to the value; array indices
|
|
434
|
+
* are not keys and don't appear in it.
|
|
435
|
+
*/
|
|
436
|
+
function renderDiffValue(value, keyTrail) {
|
|
437
|
+
const finalKey = keyTrail[keyTrail.length - 1];
|
|
438
|
+
const parentKey = keyTrail[keyTrail.length - 2];
|
|
439
|
+
if ((finalKey !== undefined && isCredentialKey(finalKey)) ||
|
|
440
|
+
(parentKey !== undefined && isCredentialKey(parentKey))) {
|
|
441
|
+
return REDACTED_VALUE;
|
|
442
|
+
}
|
|
443
|
+
return formatDiffValue(redactTree(value));
|
|
444
|
+
}
|
|
445
|
+
/** Deep-copy `value` with credential-keyed leaves replaced by `[redacted]`
|
|
446
|
+
* and credential-shaped tokens masked inside the remaining strings, so an
|
|
447
|
+
* added/removed SUBTREE (a whole MCP server map with an `env` block) can't
|
|
448
|
+
* smuggle secrets through its compact-JSON rendering. */
|
|
449
|
+
function redactTree(value, underCredentialKey = false) {
|
|
450
|
+
if (Array.isArray(value)) {
|
|
451
|
+
return value.map((v) => redactTree(v, underCredentialKey));
|
|
452
|
+
}
|
|
453
|
+
if (isPlainObject(value)) {
|
|
454
|
+
const out = {};
|
|
455
|
+
for (const [k, v] of Object.entries(value)) {
|
|
456
|
+
out[k] = redactTree(v, underCredentialKey || isCredentialKey(k));
|
|
457
|
+
}
|
|
458
|
+
return out;
|
|
459
|
+
}
|
|
460
|
+
if (underCredentialKey)
|
|
461
|
+
return REDACTED_VALUE;
|
|
462
|
+
return typeof value === "string" ? maskCredentialTokens(value) : value;
|
|
463
|
+
}
|
|
464
|
+
function walkDiff(a, b, path, out, keyTrail) {
|
|
465
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
466
|
+
// Union of keys, a's ordering first — deterministic output.
|
|
467
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
468
|
+
for (const key of keys) {
|
|
469
|
+
const childPath = path === "" ? key : `${path}.${key}`;
|
|
470
|
+
const childTrail = [...keyTrail, key];
|
|
471
|
+
if (!(key in a)) {
|
|
472
|
+
out.push({ kind: "added", path: childPath, after: renderDiffValue(b[key], childTrail) });
|
|
473
|
+
}
|
|
474
|
+
else if (!(key in b)) {
|
|
475
|
+
out.push({
|
|
476
|
+
kind: "removed",
|
|
477
|
+
path: childPath,
|
|
478
|
+
before: renderDiffValue(a[key], childTrail),
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
walkDiff(a[key], b[key], childPath, out, childTrail);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
488
|
+
const len = Math.max(a.length, b.length);
|
|
489
|
+
for (let i = 0; i < len; i++) {
|
|
490
|
+
const childPath = `${path}[${i}]`;
|
|
491
|
+
if (i >= a.length) {
|
|
492
|
+
out.push({ kind: "added", path: childPath, after: renderDiffValue(b[i], keyTrail) });
|
|
493
|
+
}
|
|
494
|
+
else if (i >= b.length) {
|
|
495
|
+
out.push({ kind: "removed", path: childPath, before: renderDiffValue(a[i], keyTrail) });
|
|
496
|
+
}
|
|
497
|
+
else {
|
|
498
|
+
walkDiff(a[i], b[i], childPath, out, keyTrail);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
// Leaf (or type-mismatch, e.g. map → sequence): compare whole values.
|
|
504
|
+
if (!leafEqual(a, b)) {
|
|
505
|
+
out.push({
|
|
506
|
+
kind: "changed",
|
|
507
|
+
path: path === "" ? "(root)" : path,
|
|
508
|
+
before: renderDiffValue(a, keyTrail),
|
|
509
|
+
after: renderDiffValue(b, keyTrail),
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
}
|
package/dist/redact.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential redaction for HUMAN-READABLE renderings of spec/IR content —
|
|
3
|
+
* changelog diff lines (`spec-patch`) and generated bundle READMEs (`ir`).
|
|
4
|
+
* Two layers:
|
|
5
|
+
*
|
|
6
|
+
* 1. `isCredentialKey` — key-based: a value stored under a key that NAMES
|
|
7
|
+
* a credential (`api_key`, `botToken`, `headers`, `env`, …) is redacted
|
|
8
|
+
* wholesale, whatever the value looks like.
|
|
9
|
+
* 2. `maskCredentialTokens` — value-based: strings that are NOT under a
|
|
10
|
+
* credential key (instructions prose, command args, URLs) are scanned
|
|
11
|
+
* for well-known credential token shapes (`sk-…`, `ghp_…`, `xoxb-…`,
|
|
12
|
+
* `AKIA…`, `Bearer <token>`) plus high-length opaque tokens preceded by
|
|
13
|
+
* key-ish context words. Deliberately conservative: a bare 32-char
|
|
14
|
+
* identifier with no "key/token/secret/password" context is left alone
|
|
15
|
+
* so normal prose never gets chewed up.
|
|
16
|
+
*
|
|
17
|
+
* KEEP IN SYNC: this module is intentionally duplicated as
|
|
18
|
+
* `packages/ir/src/redact.ts` and `packages/spec-patch/src/redact.ts`.
|
|
19
|
+
* `@crewhaus/ir` keeps ZERO package dependencies (its `readme.ts` already
|
|
20
|
+
* mirrors `OUTWARD_TOOL_NAMES` from tool-builder for the same reason) and
|
|
21
|
+
* `spec-patch` is spec-layer infrastructure that must not grow an edge onto
|
|
22
|
+
* the IR layer — so neither package can host the single copy without a new
|
|
23
|
+
* dependency edge. Change one file, change both.
|
|
24
|
+
*/
|
|
25
|
+
/** Placeholder rendered in place of a value under a credential-carrying key. */
|
|
26
|
+
export declare const REDACTED_VALUE = "[redacted]";
|
|
27
|
+
/** Placeholder substituted for a credential-shaped token inside a string. */
|
|
28
|
+
export declare const MASKED_TOKEN = "***";
|
|
29
|
+
/**
|
|
30
|
+
* Whether a property key names a credential. Suffix matches require a word
|
|
31
|
+
* boundary (snake/kebab/camel) so `api_key` / `botToken` / `GITHUB_TOKEN` /
|
|
32
|
+
* `signingSecret` redact while `monkey` / `max_tokens` don't.
|
|
33
|
+
*/
|
|
34
|
+
export declare function isCredentialKey(key: string): boolean;
|
|
35
|
+
/** A path segment / standalone word shaped like an opaque credential
|
|
36
|
+
* (Alchemy/Infura-style `/v2/<key>`): 32+ chars of token alphabet. */
|
|
37
|
+
export declare const OPAQUE_TOKEN_RE: RegExp;
|
|
38
|
+
/**
|
|
39
|
+
* Mask credential-shaped tokens inside a string. Non-credential text is
|
|
40
|
+
* returned unchanged (hit/no-hit cases are unit-tested in both packages).
|
|
41
|
+
*/
|
|
42
|
+
export declare function maskCredentialTokens(text: string): string;
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential redaction for HUMAN-READABLE renderings of spec/IR content —
|
|
3
|
+
* changelog diff lines (`spec-patch`) and generated bundle READMEs (`ir`).
|
|
4
|
+
* Two layers:
|
|
5
|
+
*
|
|
6
|
+
* 1. `isCredentialKey` — key-based: a value stored under a key that NAMES
|
|
7
|
+
* a credential (`api_key`, `botToken`, `headers`, `env`, …) is redacted
|
|
8
|
+
* wholesale, whatever the value looks like.
|
|
9
|
+
* 2. `maskCredentialTokens` — value-based: strings that are NOT under a
|
|
10
|
+
* credential key (instructions prose, command args, URLs) are scanned
|
|
11
|
+
* for well-known credential token shapes (`sk-…`, `ghp_…`, `xoxb-…`,
|
|
12
|
+
* `AKIA…`, `Bearer <token>`) plus high-length opaque tokens preceded by
|
|
13
|
+
* key-ish context words. Deliberately conservative: a bare 32-char
|
|
14
|
+
* identifier with no "key/token/secret/password" context is left alone
|
|
15
|
+
* so normal prose never gets chewed up.
|
|
16
|
+
*
|
|
17
|
+
* KEEP IN SYNC: this module is intentionally duplicated as
|
|
18
|
+
* `packages/ir/src/redact.ts` and `packages/spec-patch/src/redact.ts`.
|
|
19
|
+
* `@crewhaus/ir` keeps ZERO package dependencies (its `readme.ts` already
|
|
20
|
+
* mirrors `OUTWARD_TOOL_NAMES` from tool-builder for the same reason) and
|
|
21
|
+
* `spec-patch` is spec-layer infrastructure that must not grow an edge onto
|
|
22
|
+
* the IR layer — so neither package can host the single copy without a new
|
|
23
|
+
* dependency edge. Change one file, change both.
|
|
24
|
+
*/
|
|
25
|
+
/** Placeholder rendered in place of a value under a credential-carrying key. */
|
|
26
|
+
export const REDACTED_VALUE = "[redacted]";
|
|
27
|
+
/** Placeholder substituted for a credential-shaped token inside a string. */
|
|
28
|
+
export const MASKED_TOKEN = "***";
|
|
29
|
+
/**
|
|
30
|
+
* Keys that carry credentials, matched case-insensitively after lowercasing.
|
|
31
|
+
* Aligned with the spec schema's credential carriers (`botToken`,
|
|
32
|
+
* `signingSecret`, `appToken`, `secretToken`, `accessToken`, `appSecret`,
|
|
33
|
+
* `retrieve.apiKey`, wallet `keyRef`) and the compiler's `lowerCredential` /
|
|
34
|
+
* `lowerWalletKeyRef` call sites — see `packages/compiler/src/index.ts` §12.
|
|
35
|
+
* `headers` and `env` are container keys: everything under them redacts.
|
|
36
|
+
*/
|
|
37
|
+
const CREDENTIAL_KEY_EXACT = new Set([
|
|
38
|
+
"key",
|
|
39
|
+
"apikey",
|
|
40
|
+
"api_key",
|
|
41
|
+
"api-key",
|
|
42
|
+
"token",
|
|
43
|
+
"secret",
|
|
44
|
+
"password",
|
|
45
|
+
"passwd",
|
|
46
|
+
"pwd",
|
|
47
|
+
"authorization",
|
|
48
|
+
"auth",
|
|
49
|
+
"credential",
|
|
50
|
+
"credentials",
|
|
51
|
+
"headers",
|
|
52
|
+
"env",
|
|
53
|
+
"keyref",
|
|
54
|
+
"key_ref",
|
|
55
|
+
"key-ref",
|
|
56
|
+
"privatekey",
|
|
57
|
+
"private_key",
|
|
58
|
+
"private-key",
|
|
59
|
+
]);
|
|
60
|
+
/**
|
|
61
|
+
* Whether a property key names a credential. Suffix matches require a word
|
|
62
|
+
* boundary (snake/kebab/camel) so `api_key` / `botToken` / `GITHUB_TOKEN` /
|
|
63
|
+
* `signingSecret` redact while `monkey` / `max_tokens` don't.
|
|
64
|
+
*/
|
|
65
|
+
export function isCredentialKey(key) {
|
|
66
|
+
const k = key.toLowerCase();
|
|
67
|
+
if (CREDENTIAL_KEY_EXACT.has(k))
|
|
68
|
+
return true;
|
|
69
|
+
if (/[_-](?:key|token|secret|password)$/.test(k))
|
|
70
|
+
return true;
|
|
71
|
+
return /[a-z0-9](?:Key|Token|Secret|Password)$/.test(key);
|
|
72
|
+
}
|
|
73
|
+
/** A path segment / standalone word shaped like an opaque credential
|
|
74
|
+
* (Alchemy/Infura-style `/v2/<key>`): 32+ chars of token alphabet. */
|
|
75
|
+
export const OPAQUE_TOKEN_RE = /^[A-Za-z0-9_-]{32,}$/;
|
|
76
|
+
/** Well-known credential token shapes, masked wherever they appear. */
|
|
77
|
+
const TOKEN_SHAPE_RES = [
|
|
78
|
+
/\bsk-[A-Za-z0-9_-]{8,}/g, // OpenAI/Anthropic/Stripe-style secret keys
|
|
79
|
+
/\bgh[oprsu]_[A-Za-z0-9]{16,}/g, // GitHub tokens (ghp_/gho_/ghu_/ghs_/ghr_)
|
|
80
|
+
/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, // Slack tokens
|
|
81
|
+
/\bAKIA[A-Z0-9]{12,}/g, // AWS access key ids
|
|
82
|
+
];
|
|
83
|
+
/** `Bearer <token>` — the scheme word is kept, the token is masked. */
|
|
84
|
+
const BEARER_RE = /\b(bearer)\s+[A-Za-z0-9._~+/-]{8,}=*/gi;
|
|
85
|
+
/**
|
|
86
|
+
* Generic 32+-char opaque token, masked ONLY when preceded by a key-ish
|
|
87
|
+
* context word — `key: XXXX…`, `token=XXXX…` — so hashes/ids in ordinary
|
|
88
|
+
* prose survive. Group 1 (the context) is kept; the token is masked.
|
|
89
|
+
*/
|
|
90
|
+
const CONTEXTUAL_OPAQUE_RE = /\b((?:api[-_ ]?)?(?:key|token|secret|password|credential)s?\b["'\s:=-]{0,5})([A-Za-z0-9+/_-]{32,})/gi;
|
|
91
|
+
/**
|
|
92
|
+
* Mask credential-shaped tokens inside a string. Non-credential text is
|
|
93
|
+
* returned unchanged (hit/no-hit cases are unit-tested in both packages).
|
|
94
|
+
*/
|
|
95
|
+
export function maskCredentialTokens(text) {
|
|
96
|
+
let out = text;
|
|
97
|
+
for (const re of TOKEN_SHAPE_RES)
|
|
98
|
+
out = out.replace(re, MASKED_TOKEN);
|
|
99
|
+
out = out.replace(BEARER_RE, `$1 ${MASKED_TOKEN}`);
|
|
100
|
+
out = out.replace(CONTEXTUAL_OPAQUE_RE, `$1${MASKED_TOKEN}`);
|
|
101
|
+
return out;
|
|
102
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/spec-patch",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Pillar-2 patch infrastructure — apply a SpecPatch to a YAML source preserving comments and key order via the yaml CST. Drives the active eval optimizer's spec-level mutation loop.",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"test": "bun test src"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@crewhaus/errors": "0.1
|
|
19
|
-
"@crewhaus/spec": "0.1
|
|
18
|
+
"@crewhaus/errors": "0.2.1",
|
|
19
|
+
"@crewhaus/spec": "0.2.1",
|
|
20
20
|
"yaml": "^2.6.0",
|
|
21
21
|
"zod": "^3.23.8"
|
|
22
22
|
},
|