humanish 0.114.0 → 0.115.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/CONTEXT.md +1 -1
  3. package/dist/actors/closing-report-limits.d.ts +11 -0
  4. package/dist/actors/closing-report-limits.js +13 -0
  5. package/dist/actors/closing-report.d.ts +68 -0
  6. package/dist/actors/closing-report.js +55 -0
  7. package/dist/actors/codex/restricted-participant-policy.d.ts +4 -47
  8. package/dist/actors/codex/restricted-participant-policy.js +12 -40
  9. package/dist/actors/computer-use/loop/debrief.d.ts +1 -10
  10. package/dist/actors/computer-use/loop/debrief.js +10 -46
  11. package/dist/actors/computer-use/loop/impressions.d.ts +2 -2
  12. package/dist/actors/computer-use/loop/impressions.js +6 -1
  13. package/dist/actors/computer-use/loop.d.ts +0 -1
  14. package/dist/actors/computer-use/loop.js +3 -3
  15. package/dist/actors/computer-use/openai-conversation.d.ts +1 -1
  16. package/dist/actors/computer-use/openai-conversation.js +3 -29
  17. package/dist/actors/computer-use/openai-provider.js +11 -5
  18. package/dist/actors/contract.d.ts +2 -20
  19. package/dist/actors/contract.js +0 -13
  20. package/dist/analysis/admission.d.ts +14 -4
  21. package/dist/analysis/admission.js +14 -9
  22. package/dist/analysis/execute.d.ts +5 -0
  23. package/dist/analysis/execute.js +3 -0
  24. package/dist/analysis/finding-lead.d.ts +27 -0
  25. package/dist/analysis/finding-lead.js +23 -0
  26. package/dist/analysis/service.js +5 -3
  27. package/dist/cli/commands/notes.js +4 -3
  28. package/dist/cli/findings.js +10 -32
  29. package/dist/cli/io.js +4 -7
  30. package/dist/cli/telemetry.js +10 -22
  31. package/dist/cli/update-check.js +6 -18
  32. package/dist/cli/user-config.d.ts +11 -0
  33. package/dist/cli/user-config.js +25 -0
  34. package/dist/comms/email-catch.js +7 -31
  35. package/dist/evidence/encoded-text.d.ts +12 -11
  36. package/dist/evidence/encoded-text.js +81 -70
  37. package/dist/evidence/redaction.d.ts +11 -0
  38. package/dist/evidence/redaction.js +21 -10
  39. package/dist/evidence/secret-scrub.d.ts +14 -1
  40. package/dist/evidence/secret-scrub.js +108 -32
  41. package/dist/feedback/draft.d.ts +1 -1
  42. package/dist/feedback/draft.js +13 -16
  43. package/dist/feedback/export-bundle.js +3 -3
  44. package/dist/feedback/export.js +32 -46
  45. package/dist/feedback/feedback.js +6 -19
  46. package/dist/keys/key-resolution.js +12 -17
  47. package/dist/observer/artifact.d.ts +1 -1
  48. package/dist/observer/http.d.ts +7 -0
  49. package/dist/observer/http.js +19 -1
  50. package/dist/observer/notes-route.d.ts +0 -1
  51. package/dist/observer/notes-route.js +24 -44
  52. package/dist/observer/pinned-files.js +4 -19
  53. package/dist/observer/render.js +3 -2
  54. package/dist/observer/run-routes.js +13 -28
  55. package/dist/observer/serve.js +2 -1
  56. package/dist/observer-app.html +3 -3
  57. package/dist/run/contained-output.d.ts +6 -5
  58. package/dist/run/contained-output.js +19 -26
  59. package/dist/run/evidence-files.d.ts +7 -0
  60. package/dist/run/evidence-files.js +24 -14
  61. package/dist/run/note-files.d.ts +26 -45
  62. package/dist/run/note-files.js +86 -58
  63. package/dist/run/note-shape.d.ts +35 -0
  64. package/dist/run/note-shape.js +20 -0
  65. package/dist/run/notes.d.ts +3 -6
  66. package/dist/run/notes.js +18 -24
  67. package/dist/run/participant-records.d.ts +2 -0
  68. package/dist/run/participant-records.js +17 -2
  69. package/dist/run/transient-comms-secrets.d.ts +6 -5
  70. package/dist/run/transient-comms-secrets.js +19 -24
  71. package/dist/study/summary.d.ts +4 -5
  72. package/dist/study/summary.js +4 -2
  73. package/dist/study/warnings.js +2 -1
  74. package/dist/tui-app.js +1 -1
  75. package/docs/architecture/actor-contract.md +3 -3
  76. package/docs/architecture/restricted-codex-analysis.md +1 -1
  77. package/docs/architecture/state-driven-executor.md +1 -1
  78. package/docs/contracts/run-bundle.md +7 -3
  79. package/docs/contracts/schemas.md +12 -12
  80. package/docs/contracts/study-analysis.md +12 -4
  81. package/package.json +7 -6
  82. package/skills/humanish/SKILL.md +1 -2
@@ -1,17 +1,19 @@
1
+ /** Each run of characters U+0080 to U+00FF read as the UTF-8 bytes they may be: é as é. */
2
+ export declare const latin1RunsAsUtf8: (text: string) => string;
1
3
  /**
2
4
  * Undoes JSON and JS escapes, percent-encoding and HTML character references, one pass each. Each
3
- * percent escape is one character, the byte's code, so `caf%C3%A9` reads as `café`;
4
- * decodeEscapesUtf8 reads it as `café`. A scan checks both: either reading can split a value that
5
- * the other keeps whole, as `%E2%80%80` (U+2000, a space, in UTF-8) does.
5
+ * percent escape is one character, the byte's code, so `caf%C3%A9` reads as `café`; readingsOf
6
+ * also gives the reading as UTF-8, `café`.
6
7
  */
7
8
  export declare function decodeEscapes(text: string): string;
8
- /** decodeEscapes with each run of percent escapes read as UTF-8. */
9
- export declare function decodeEscapesUtf8(text: string): string;
10
9
  /**
11
- * The UTF-8 reading of text's escapes when it differs from decodeEscapes, which it can only where
12
- * a percent escape holds a byte of 0x80 or more.
10
+ * Every reading of the text a scan matches, each once: as written, decodeEscapes, with percent
11
+ * escapes read as UTF-8, decodeEscapes with transfer escapes expanded, and the text as written and
12
+ * decoded with each run of U+0080 to U+00FF characters read as UTF-8 bytes, which undoes text that
13
+ * a reader already decoded one byte per character. Either percent reading can split a value that
14
+ * the other keeps whole, as `%E2%80%80` (U+2000, a space, in UTF-8) does.
13
15
  */
14
- export declare function utf8ReadingOf(text: string, decoded: string): string | undefined;
16
+ export declare function readingsOf(text: string): string[];
15
17
  /** How scanEncodedText reads text. */
16
18
  export interface EncodedTextScanOptions {
17
19
  /** Do not count an encoded binary run as opaque. */
@@ -26,9 +28,8 @@ export interface EncodedTextScan {
26
28
  readonly opaque: boolean;
27
29
  }
28
30
  /**
29
- * Scans text for secrets as written, after decodeEscapes (also with percent escapes read as UTF-8)
30
- * and transfer escapes, and inside each
31
- * base64 (standard, URL-safe or line-wrapped) and hex run. A run that decodes to text is scanned in
31
+ * Scans text for secrets in each of its readingsOf, and inside each base64 (standard, URL-safe or
32
+ * line-wrapped) and hex run. A run that decodes to text is scanned in
32
33
  * turn. A run that decodes to an archive, or to other binary past MIN_OPAQUE_BASE64_RUN characters,
33
34
  * is opaque unless the caller allows opaque runs.
34
35
  */
@@ -2,43 +2,44 @@
2
2
  // undoes the encodings a reader undoes for free (JSON and JS escapes, percent-encoding, HTML
3
3
  // entities) and looks inside base64 runs. verify and bundle export share decodeEscapes, so they
4
4
  // judge the same decoded text.
5
+ import { isUtf8 } from "node:buffer";
5
6
  import { createHash } from "node:crypto";
6
7
  import { readPlainText } from "./plain-text.js";
7
8
  import { containsSensitive } from "./redaction.js";
8
9
  // HTML5 named references that stand for printable ASCII. Letters and digits have only numeric
9
10
  // references, which decodeEscapes handles.
10
- const ASCII_ENTITIES = {
11
- amp: "&",
12
- apos: "'",
13
- ast: "*",
14
- bsol: "\\",
15
- colon: ":",
16
- comma: ",",
17
- commat: "@",
18
- dollar: "$",
19
- equals: "=",
20
- excl: "!",
21
- grave: "`",
22
- gt: ">",
23
- hat: "^",
24
- lcub: "{",
25
- lowbar: "_",
26
- lpar: "(",
27
- lsqb: "[",
28
- lt: "<",
29
- num: "#",
30
- percnt: "%",
31
- period: ".",
32
- plus: "+",
33
- quest: "?",
34
- quot: '"',
35
- rcub: "}",
36
- rpar: ")",
37
- rsqb: "]",
38
- semi: ";",
39
- sol: "/",
40
- verbar: "|",
41
- };
11
+ const ASCII_ENTITIES = new Map([
12
+ ["amp", "&"],
13
+ ["apos", "'"],
14
+ ["ast", "*"],
15
+ ["bsol", "\\"],
16
+ ["colon", ":"],
17
+ ["comma", ","],
18
+ ["commat", "@"],
19
+ ["dollar", "$"],
20
+ ["equals", "="],
21
+ ["excl", "!"],
22
+ ["grave", "`"],
23
+ ["gt", ">"],
24
+ ["hat", "^"],
25
+ ["lcub", "{"],
26
+ ["lowbar", "_"],
27
+ ["lpar", "("],
28
+ ["lsqb", "["],
29
+ ["lt", "<"],
30
+ ["num", "#"],
31
+ ["percnt", "%"],
32
+ ["period", "."],
33
+ ["plus", "+"],
34
+ ["quest", "?"],
35
+ ["quot", '"'],
36
+ ["rcub", "}"],
37
+ ["rpar", ")"],
38
+ ["rsqb", "]"],
39
+ ["semi", ";"],
40
+ ["sol", "/"],
41
+ ["verbar", "|"],
42
+ ]);
42
43
  function codePoint(value, original) {
43
44
  return Number.isInteger(value) && value >= 0 && value <= 0x10ffff
44
45
  ? String.fromCodePoint(value)
@@ -57,20 +58,19 @@ function sequenceLength(lead) {
57
58
  return 0;
58
59
  }
59
60
  /**
60
- * A run of percent escapes read as UTF-8, as a browser reads a URL. A byte that starts no valid
61
- * sequence stands for itself, the character with that code, as decodeEscapes reads every byte.
61
+ * Bytes read as UTF-8, as a browser reads a URL. A byte that starts no valid sequence stands for
62
+ * itself, the character with that code, as decodeEscapes reads every byte.
62
63
  */
63
- function decodePercentRun(run) {
64
- const bytes = Buffer.from(run.replace(/%/g, ""), "hex");
64
+ function utf8Characters(bytes) {
65
+ if (isUtf8(bytes))
66
+ return bytes.toString("utf8");
65
67
  let text = "";
66
68
  for (let at = 0; at < bytes.length;) {
67
69
  const length = sequenceLength(bytes[at]);
68
- const sequence = length > 0 ? bytes.subarray(at, at + length) : undefined;
69
- const decoded = sequence?.length === length ? sequence.toString("utf8") : undefined;
70
- // Node writes the replacement character for an overlong form, a surrogate half or a bad
71
- // continuation byte, so a sequence counts only when its character encodes back to its bytes.
72
- if (decoded !== undefined && Buffer.from(decoded, "utf8").equals(sequence)) {
73
- text += decoded;
70
+ const sequence = bytes.subarray(at, at + length);
71
+ // isUtf8 refuses an overlong form, a surrogate half and a bad continuation byte.
72
+ if (length > 0 && sequence.length === length && isUtf8(sequence)) {
73
+ text += sequence.toString("utf8");
74
74
  at += length;
75
75
  }
76
76
  else {
@@ -80,31 +80,47 @@ function decodePercentRun(run) {
80
80
  }
81
81
  return text;
82
82
  }
83
+ const decodePercentRun = (run) => utf8Characters(Buffer.from(run.replace(/%/g, ""), "hex"));
84
+ // A character from U+0080 to U+00FF, which a byte of 0x80 or more becomes when read one per
85
+ // character.
86
+ const HIGH_BYTE = /[\x80-\xff]/;
87
+ /** Each run of characters U+0080 to U+00FF read as the UTF-8 bytes they may be: é as é. */
88
+ export const latin1RunsAsUtf8 = (text) => text.replace(/[\x80-\xff]+/g, (run) => utf8Characters(Buffer.from(run, "latin1")));
83
89
  function decodePercentBytes(run) {
84
90
  return Buffer.from(run.replace(/%/g, ""), "hex").toString("latin1");
85
91
  }
86
92
  /**
87
93
  * Undoes JSON and JS escapes, percent-encoding and HTML character references, one pass each. Each
88
- * percent escape is one character, the byte's code, so `caf%C3%A9` reads as `café`;
89
- * decodeEscapesUtf8 reads it as `café`. A scan checks both: either reading can split a value that
90
- * the other keeps whole, as `%E2%80%80` (U+2000, a space, in UTF-8) does.
94
+ * percent escape is one character, the byte's code, so `caf%C3%A9` reads as `café`; readingsOf
95
+ * also gives the reading as UTF-8, `café`.
91
96
  */
92
97
  export function decodeEscapes(text) {
93
98
  return decodeWith(text, decodePercentBytes);
94
99
  }
95
- /** decodeEscapes with each run of percent escapes read as UTF-8. */
96
- export function decodeEscapesUtf8(text) {
97
- return decodeWith(text, decodePercentRun);
100
+ /** The readings a scan matches, with the two that scanEncodedText also searches for runs. */
101
+ function readings(text) {
102
+ const decoded = decodeEscapes(text);
103
+ // The UTF-8 reading differs only where a percent escape holds a byte of 0x80 or more, which
104
+ // decodeEscapes writes as a character from U+0080 to U+00FF. Testing the decoded text also
105
+ // catches a `%` that a `\u0025` wrote.
106
+ const utf8 = HIGH_BYTE.test(decoded) ? decodeWith(text, decodePercentRun) : decoded;
107
+ const expanded = decodeTransferEscapes(decoded);
108
+ const recovered = [text, decoded].filter((reading) => HIGH_BYTE.test(reading));
109
+ return {
110
+ decoded,
111
+ expanded,
112
+ all: [...new Set([text, decoded, utf8, expanded, ...recovered.map(latin1RunsAsUtf8)])],
113
+ };
98
114
  }
99
115
  /**
100
- * The UTF-8 reading of text's escapes when it differs from decodeEscapes, which it can only where
101
- * a percent escape holds a byte of 0x80 or more.
116
+ * Every reading of the text a scan matches, each once: as written, decodeEscapes, with percent
117
+ * escapes read as UTF-8, decodeEscapes with transfer escapes expanded, and the text as written and
118
+ * decoded with each run of U+0080 to U+00FF characters read as UTF-8 bytes, which undoes text that
119
+ * a reader already decoded one byte per character. Either percent reading can split a value that
120
+ * the other keeps whole, as `%E2%80%80` (U+2000, a space, in UTF-8) does.
102
121
  */
103
- export function utf8ReadingOf(text, decoded) {
104
- if (!/%[89a-f][0-9a-f]/i.test(text))
105
- return undefined;
106
- const utf8 = decodeEscapesUtf8(text);
107
- return utf8 === decoded ? undefined : utf8;
122
+ export function readingsOf(text) {
123
+ return readings(text).all;
108
124
  }
109
125
  function decodeWith(text, percent) {
110
126
  return text
@@ -114,7 +130,10 @@ function decodeWith(text, percent) {
114
130
  .replace(/(?:%[0-9a-f]{2})+/gi, percent)
115
131
  .replace(/&#x([0-9a-f]{1,6});?/gi, (match, hex) => codePoint(Number.parseInt(hex, 16), match))
116
132
  .replace(/&#(\d{1,7});?/g, (match, digits) => codePoint(Number.parseInt(digits, 10), match))
117
- .replace(/&([a-z]+);/gi, (match, name) => ASCII_ENTITIES[name.toLowerCase()] ?? match);
133
+ .replace(/&([a-z]+);/gi, namedReference);
134
+ }
135
+ function namedReference(match, name) {
136
+ return ASCII_ENTITIES.get(name.toLowerCase()) ?? match;
118
137
  }
119
138
  // Sixteen characters hold twelve bytes, enough for the start of a key. Shorter runs are mostly
120
139
  // words and identifiers.
@@ -262,8 +281,7 @@ function inspectDecoded(bytes, run, options, depth) {
262
281
  if (inner !== undefined) {
263
282
  if (depth < MAX_DEPTH)
264
283
  return scanEncodedText(inner, options, depth + 1);
265
- const innerDecoded = decodeEscapes(inner);
266
- return matches(inner) || (innerDecoded !== inner && matches(innerDecoded)) ? SENSITIVE : CLEAN;
284
+ return readingsOf(inner).some(matches) ? SENSITIVE : CLEAN;
267
285
  }
268
286
  // A key next to a few binary bytes is still a printable stretch.
269
287
  if (matches(printableStretches(bytes)))
@@ -276,22 +294,15 @@ function inspectDecoded(bytes, run, options, depth) {
276
294
  };
277
295
  }
278
296
  /**
279
- * Scans text for secrets as written, after decodeEscapes (also with percent escapes read as UTF-8)
280
- * and transfer escapes, and inside each
281
- * base64 (standard, URL-safe or line-wrapped) and hex run. A run that decodes to text is scanned in
297
+ * Scans text for secrets in each of its readingsOf, and inside each base64 (standard, URL-safe or
298
+ * line-wrapped) and hex run. A run that decodes to text is scanned in
282
299
  * turn. A run that decodes to an archive, or to other binary past MIN_OPAQUE_BASE64_RUN characters,
283
300
  * is opaque unless the caller allows opaque runs.
284
301
  */
285
302
  export function scanEncodedText(text, options = {}, depth = 0) {
286
303
  const matches = matcherOf(options);
287
- const decoded = decodeEscapes(text);
288
- const utf8 = utf8ReadingOf(text, decoded);
289
- const expanded = decodeTransferEscapes(decoded);
290
- // A decoding that returns the same string would match the same way, so it is not matched again.
291
- if (matches(text) ||
292
- (decoded !== text && matches(decoded)) ||
293
- (utf8 !== undefined && matches(utf8)) ||
294
- (expanded !== decoded && matches(expanded)))
304
+ const { decoded, expanded, all } = readings(text);
305
+ if (all.some(matches))
295
306
  return SENSITIVE;
296
307
  const runs = [];
297
308
  // A piece or a run decoded from a slash only adds findings; its bytes are not judged opaque.
@@ -341,7 +352,7 @@ export function scanEncodedText(text, options = {}, depth = 0) {
341
352
  // Bump when scanEncodedText, decodeEscapes or the sensitive patterns change what they return. The
342
353
  // cache lives in one process, so the version guards results across a hot reload or a test that
343
354
  // swaps the scanner.
344
- const ENCODED_SCAN_VERSION = 4;
355
+ const ENCODED_SCAN_VERSION = 5;
345
356
  // Distinct files one process verifies in a burst (a run's files, a serve library's runs).
346
357
  const SCAN_CACHE_LIMIT = 256;
347
358
  const scanCache = new Map();
@@ -1,3 +1,14 @@
1
+ /**
2
+ * Every marker humanish writes in place of what it withholds. The known-value scrub leaves text
3
+ * wholly inside one of these as it is, so a writer of a new marker adds it here.
4
+ */
5
+ export declare const REDACTION_MARKERS: {
6
+ readonly secret: "[REDACTED_SECRET]";
7
+ readonly localPath: "[REDACTED_LOCAL_PATH]";
8
+ readonly runtimePath: "[REDACTED_RUNTIME_PATH]";
9
+ readonly promptText: "[REDACTED_PROMPT_TEXT]";
10
+ readonly lobbyCode: "[REDACTED_LOBBY_CODE]";
11
+ };
1
12
  /** Every pattern containsSensitive tests. A test pins each to ASCII, which scanEncodedText relies on. */
2
13
  export declare function sensitivePatterns(): readonly RegExp[];
3
14
  /** True if the text contains a credential. Local paths and a URL on an E2B host alone do not count. */
@@ -126,18 +126,29 @@ const SECRET_PATTERNS = [
126
126
  SANDBOX_URL,
127
127
  /BEGIN (RSA|OPENSSH|PRIVATE) KEY/gi,
128
128
  ];
129
+ /**
130
+ * Every marker humanish writes in place of what it withholds. The known-value scrub leaves text
131
+ * wholly inside one of these as it is, so a writer of a new marker adds it here.
132
+ */
133
+ export const REDACTION_MARKERS = {
134
+ secret: "[REDACTED_SECRET]",
135
+ localPath: "[REDACTED_LOCAL_PATH]",
136
+ runtimePath: "[REDACTED_RUNTIME_PATH]",
137
+ promptText: "[REDACTED_PROMPT_TEXT]",
138
+ lobbyCode: "[REDACTED_LOBBY_CODE]",
139
+ };
129
140
  const LOCAL_PATH_PATTERNS = [
130
- [/\/private\/var\/folders\/[^\s"'`<>)]*/g, "[REDACTED_LOCAL_PATH]"],
131
- [/\/var\/folders\/[^\s"'`<>)]*/g, "[REDACTED_LOCAL_PATH]"],
132
- [/\/private\/tmp\/[^\s"'`<>)]*/g, "[REDACTED_LOCAL_PATH]"],
133
- [/\/tmp\/[^\s"'`<>)]*/g, "[REDACTED_LOCAL_PATH]"],
134
- [/\/Users\/[A-Za-z0-9._-]+(?:\/[^\s"'`<>)]*)?/g, "[REDACTED_LOCAL_PATH]"],
135
- [/\/home\/[A-Za-z0-9._-]+(?:\/[^\s"'`<>)]*)?/g, "[REDACTED_RUNTIME_PATH]"],
141
+ [/\/private\/var\/folders\/[^\s"'`<>)]*/g, REDACTION_MARKERS.localPath],
142
+ [/\/var\/folders\/[^\s"'`<>)]*/g, REDACTION_MARKERS.localPath],
143
+ [/\/private\/tmp\/[^\s"'`<>)]*/g, REDACTION_MARKERS.localPath],
144
+ [/\/tmp\/[^\s"'`<>)]*/g, REDACTION_MARKERS.localPath],
145
+ [/\/Users\/[A-Za-z0-9._-]+(?:\/[^\s"'`<>)]*)?/g, REDACTION_MARKERS.localPath],
146
+ [/\/home\/[A-Za-z0-9._-]+(?:\/[^\s"'`<>)]*)?/g, REDACTION_MARKERS.runtimePath],
136
147
  // A Windows profile path, with `\`, `\\` (JSON-escaped) or `/` between segments. A match never
137
148
  // ends in a backslash, so it cannot swallow the escape of a closing quote.
138
149
  [
139
150
  /\b[A-Za-z]:(?:\\\\|\\|\/)Users(?:\\\\|\\|\/)[^\\/\s"'`<>)]+[^\s"'`<>)]*/g,
140
- "[REDACTED_LOCAL_PATH]",
151
+ REDACTION_MARKERS.localPath,
141
152
  ],
142
153
  ];
143
154
  /** Every pattern containsSensitive tests. A test pins each to ASCII, which scanEncodedText relies on. */
@@ -265,7 +276,7 @@ function redactSecrets(text) {
265
276
  return SECRET_PATTERNS.reduce((current, pattern) => current.replace(pattern, (...args) => {
266
277
  const groups = args.at(-1);
267
278
  const keep = isRecord(groups) && typeof groups.keep === "string" ? groups.keep : "";
268
- return `${keep}[REDACTED_SECRET]`;
279
+ return `${keep}${REDACTION_MARKERS.secret}`;
269
280
  }), text);
270
281
  }
271
282
  /** Redact secrets to [REDACTED_SECRET] and local paths to their path labels. */
@@ -274,7 +285,7 @@ export function redactText(text) {
274
285
  }
275
286
  /** Redact every sensitive match (secrets and paths) to a single [REDACTED_SECRET] label. */
276
287
  export function redactToSecretLabel(text) {
277
- return redactLocalPaths(redactSecrets(text), "[REDACTED_SECRET]");
288
+ return redactLocalPaths(redactSecrets(text), REDACTION_MARKERS.secret);
278
289
  }
279
290
  function canonicalizePath(value) {
280
291
  const resolved = path.resolve(value);
@@ -526,7 +537,7 @@ function blurPass(src, width, height, radius, horizontal) {
526
537
  * pattern-match, such as provisioned keys and subject env values. `values` is read on each call,
527
538
  * so a value registered later is scrubbed too.
528
539
  */
529
- export function scrubLiterals(values, marker = "[REDACTED_SECRET]") {
540
+ export function scrubLiterals(values, marker = REDACTION_MARKERS.secret) {
530
541
  return (text) => values.reduce((current, value) => current.split(value).join(marker), text);
531
542
  }
532
543
  /**
@@ -1,10 +1,17 @@
1
1
  /**
2
2
  * A value as written, and percent-encoded, JSON-escaped once (also with non-ASCII characters as
3
- * `\u` escapes) and twice, its UTF-8 bytes read one per character, base64 at each byte offset, base64url and hex. Twice, because a JSON event can carry a command's JSON output as a string. An
3
+ * `\u` escapes) and twice, its UTF-8 bytes read one per character and the reverse, base64 at each
4
+ * byte offset, base64url and hex. Twice, because a JSON event can carry a command's JSON output as
5
+ * a string. An
4
6
  * escaped form is searched for at any length, as the value is: it holds a backslash or a `%`, so it
5
7
  * is not ordinary text.
6
8
  */
7
9
  export declare function encodedForms(value: string): string[];
10
+ /**
11
+ * A check that the text holds one of the values' forms outside the markers in any of its
12
+ * readingsOf. A scrub's output must pass it.
13
+ */
14
+ export declare function holdsSecretValue(values: readonly string[]): (text: string) => boolean;
8
15
  /**
9
16
  * A scrub that replaces each non-empty value and its encoded forms with `[REDACTED_SECRET]`. Every
10
17
  * occurrence of every form is found on its own, and overlapping finds are merged into one span, so
@@ -12,5 +19,11 @@ export declare function encodedForms(value: string): string[];
12
19
  * through decodeEscapes, the decoder verify uses, and returns the decoded text: a warning with
13
20
  * percent-encoding, JSON escapes or HTML references shows them decoded, with the values removed.
14
21
  * That finds a value written partly encoded, which no single encoded form matches.
22
+ *
23
+ * The result's other readingsOf only detect. If one still holds a form, the scrub returns
24
+ * `[REDACTED_SECRET]` in place of the whole text. That happens for a value encoded twice, which
25
+ * the decoded text shows encoded once; for a value the UTF-8 or transfer reading holds, such as
26
+ * one with one of two non-ASCII characters percent-encoded; and for a value that holds part of a
27
+ * marker, such as `ET]x`, which the marker that replaced it can spell again.
15
28
  */
16
29
  export declare function scrubSecretValues(values: readonly string[]): (text: string) => string;
@@ -2,10 +2,14 @@
2
2
  // run's warnings. A value is found as written and in the encoded forms an HTTP client, a log line or
3
3
  // a JSON body gives it, so a token that reaches the text percent-encoded or base64-encoded is still
4
4
  // removed. Pattern redaction (redactText) runs after this and finds secrets by shape.
5
- import { decodeEscapes } from "./encoded-text.js";
6
- const REDACTED = "[REDACTED_SECRET]";
7
- // A marker already in the text, from this scrub or from redactText. No value is matched inside one.
8
- const MARKER = /\[REDACTED_[A-Z0-9_]+\]/g;
5
+ import { escapeRegExp } from "../run/text.js";
6
+ import { decodeEscapes, latin1RunsAsUtf8, readingsOf } from "./encoded-text.js";
7
+ import { REDACTION_MARKERS } from "./redaction.js";
8
+ const REDACTED = REDACTION_MARKERS.secret;
9
+ // A form wholly inside a marker humanish writes is the marker's own text, as when a value is part
10
+ // of the marker's name. Other bracketed text, such as `[REDACTED_373433393231]`, is searched like
11
+ // any text.
12
+ const MARKER = new RegExp(Object.values(REDACTION_MARKERS).map(escapeRegExp).join("|"), "g");
9
13
  // The shortest encoded form searched for. Shorter base64 or hex runs are ordinary text often enough
10
14
  // that matching them would redact words. A value itself is searched for at any length.
11
15
  const MIN_ENCODED_FORM = 8;
@@ -27,7 +31,9 @@ function base64Middles(bytes, encoding) {
27
31
  }
28
32
  /**
29
33
  * A value as written, and percent-encoded, JSON-escaped once (also with non-ASCII characters as
30
- * `\u` escapes) and twice, its UTF-8 bytes read one per character, base64 at each byte offset, base64url and hex. Twice, because a JSON event can carry a command's JSON output as a string. An
34
+ * `\u` escapes) and twice, its UTF-8 bytes read one per character and the reverse, base64 at each
35
+ * byte offset, base64url and hex. Twice, because a JSON event can carry a command's JSON output as
36
+ * a string. An
31
37
  * escaped form is searched for at any length, as the value is: it holds a backslash or a `%`, so it
32
38
  * is not ordinary text.
33
39
  */
@@ -57,13 +63,18 @@ export function encodedForms(value) {
57
63
  const bytewise = bytes.toString("latin1");
58
64
  if (bytewise !== value)
59
65
  escaped.push(bytewise);
66
+ // The mirror: a value such as xéz€ whose characters include bytes that read as UTF-8, written
67
+ // with them read (xéz€). No reading of the text turns xéz€ back into the value.
68
+ const mirrored = latin1RunsAsUtf8(value);
69
+ if (mirrored !== value)
70
+ escaped.push(mirrored);
60
71
  return [value, ...escaped, ...binary.filter((form) => form.length >= MIN_ENCODED_FORM)];
61
72
  }
62
73
  /**
63
- * Whether [at, end) overlaps a marker. The markers are one regex's matches, so they are sorted and
64
- * disjoint: only the first marker that ends after `at` can also start before `end`.
74
+ * Whether [at, end) lies wholly inside one marker. The markers are one regex's matches, so they are
75
+ * sorted and disjoint: only the first marker that ends after `at` can hold it.
65
76
  */
66
- function overlapsMarker(markers, at, end) {
77
+ function insideMarker(markers, at, end) {
67
78
  let low = 0;
68
79
  let high = markers.length;
69
80
  while (low < high) {
@@ -73,33 +84,96 @@ function overlapsMarker(markers, at, end) {
73
84
  else
74
85
  low = middle + 1;
75
86
  }
76
- return low < markers.length && markers[low][0] < end;
87
+ return low < markers.length && markers[low][0] <= at && end <= markers[low][1];
77
88
  }
78
- /** Every occurrence of every form, outside the markers, merged where they overlap or touch. */
79
- function secretSpans(text, forms) {
89
+ /**
90
+ * The Knuth-Morris-Pratt failure table: entry `k` is the length of the longest proper prefix of
91
+ * the form's first `k` characters that is also their suffix.
92
+ */
93
+ function failureTable(form) {
94
+ const table = new Int32Array(form.length + 1);
95
+ for (let at = 1, length = 0; at < form.length; at += 1) {
96
+ while (length > 0 && form[at] !== form[length])
97
+ length = table[length];
98
+ if (form[at] === form[length])
99
+ length += 1;
100
+ table[at + 1] = length;
101
+ }
102
+ return table;
103
+ }
104
+ /**
105
+ * Calls `found` with the start of every occurrence of the form, overlapping ones included, in time
106
+ * linear in the text. indexOf skips to the next occurrence. After one, the failure table carries
107
+ * the match on through the occurrences that overlap it, so no character is compared again, until
108
+ * no prefix of the form is left matched and indexOf takes over.
109
+ */
110
+ function eachOccurrence(text, form, found) {
111
+ let table;
112
+ for (let at = text.indexOf(form); at !== -1;) {
113
+ found(at);
114
+ table ??= failureTable(form);
115
+ let matched = table[form.length];
116
+ let next = at + form.length;
117
+ for (; matched > 0 && next < text.length; next += 1) {
118
+ while (matched > 0 && text[next] !== form[matched])
119
+ matched = table[matched];
120
+ if (text[next] === form[matched])
121
+ matched += 1;
122
+ if (matched === form.length) {
123
+ found(next + 1 - form.length);
124
+ matched = table[form.length];
125
+ }
126
+ }
127
+ at = matched > 0 ? -1 : text.indexOf(form, next);
128
+ }
129
+ }
130
+ /** Every occurrence of every form not wholly inside a marker, in no order. */
131
+ function occurrences(text, forms) {
80
132
  const markers = [];
81
133
  for (const match of text.matchAll(MARKER)) {
82
134
  const start = match.index ?? 0;
83
135
  markers.push([start, start + match[0].length]);
84
136
  }
137
+ const found = [];
138
+ for (const form of forms)
139
+ eachOccurrence(text, form, (at) => {
140
+ if (!insideMarker(markers, at, at + form.length))
141
+ found.push([at, at + form.length]);
142
+ });
143
+ return found;
144
+ }
145
+ /** The occurrences sorted and merged where they overlap or touch. */
146
+ function merged(found) {
147
+ found.sort((left, right) => left[0] - right[0]);
85
148
  const spans = [];
86
- for (const form of forms) {
87
- for (let at = text.indexOf(form); at !== -1; at = text.indexOf(form, at + 1)) {
88
- const end = at + form.length;
89
- if (!overlapsMarker(markers, at, end))
90
- spans.push([at, end]);
91
- }
92
- }
93
- spans.sort((left, right) => left[0] - right[0]);
94
- const merged = [];
95
- for (const span of spans) {
96
- const last = merged.at(-1);
149
+ for (const span of found) {
150
+ const last = spans.at(-1);
97
151
  if (last !== undefined && span[0] <= last[1])
98
152
  last[1] = Math.max(last[1], span[1]);
99
153
  else
100
- merged.push([span[0], span[1]]);
154
+ spans.push([span[0], span[1]]);
155
+ }
156
+ return spans;
157
+ }
158
+ function replaceSpans(text, spans) {
159
+ let result = "";
160
+ let cursor = 0;
161
+ for (const [start, end] of spans) {
162
+ result += text.slice(cursor, start) + REDACTED;
163
+ cursor = end;
101
164
  }
102
- return merged;
165
+ return result + text.slice(cursor);
166
+ }
167
+ const formsOf = (values) => [
168
+ ...new Set(values.filter((value) => value.length > 0).flatMap(encodedForms)),
169
+ ];
170
+ /**
171
+ * A check that the text holds one of the values' forms outside the markers in any of its
172
+ * readingsOf. A scrub's output must pass it.
173
+ */
174
+ export function holdsSecretValue(values) {
175
+ const forms = formsOf(values);
176
+ return (text) => readingsOf(text).some((reading) => occurrences(reading, forms).length > 0);
103
177
  }
104
178
  /**
105
179
  * A scrub that replaces each non-empty value and its encoded forms with `[REDACTED_SECRET]`. Every
@@ -108,19 +182,21 @@ function secretSpans(text, forms) {
108
182
  * through decodeEscapes, the decoder verify uses, and returns the decoded text: a warning with
109
183
  * percent-encoding, JSON escapes or HTML references shows them decoded, with the values removed.
110
184
  * That finds a value written partly encoded, which no single encoded form matches.
185
+ *
186
+ * The result's other readingsOf only detect. If one still holds a form, the scrub returns
187
+ * `[REDACTED_SECRET]` in place of the whole text. That happens for a value encoded twice, which
188
+ * the decoded text shows encoded once; for a value the UTF-8 or transfer reading holds, such as
189
+ * one with one of two non-ASCII characters percent-encoded; and for a value that holds part of a
190
+ * marker, such as `ET]x`, which the marker that replaced it can spell again.
111
191
  */
112
192
  export function scrubSecretValues(values) {
113
- const forms = [...new Set(values.filter((value) => value.length > 0).flatMap(encodedForms))];
193
+ const forms = formsOf(values);
114
194
  if (forms.length === 0)
115
195
  return (text) => text;
196
+ const holds = holdsSecretValue(values);
116
197
  return (text) => {
117
198
  const decoded = decodeEscapes(text);
118
- let result = "";
119
- let cursor = 0;
120
- for (const [start, end] of secretSpans(decoded, forms)) {
121
- result += decoded.slice(cursor, start) + REDACTED;
122
- cursor = end;
123
- }
124
- return result + decoded.slice(cursor);
199
+ const result = replaceSpans(decoded, merged(occurrences(decoded, forms)));
200
+ return holds(result) ? REDACTED : result;
125
201
  };
126
202
  }
@@ -1,7 +1,7 @@
1
1
  import type { RunBundle, RunFeedbackCandidate } from "../run/bundle.js";
2
2
  import type { PreparedRunArtifactPaths } from "../run/paths.js";
3
3
  import { loadRunBundlePrepared } from "../run/locate.js";
4
- import { type RunNotes } from "../run/notes.js";
4
+ import { type RunNotes } from "../run/note-files.js";
5
5
  export declare const FEEDBACK_SCHEMA = "humanish.feedback.v1";
6
6
  export interface FeedbackDraft {
7
7
  schema: typeof FEEDBACK_SCHEMA;