humanish 0.114.0 → 0.116.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 (204) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/CONTEXT.md +1 -1
  3. package/README.md +7 -6
  4. package/TELEMETRY.md +6 -0
  5. package/dist/actors/closing-report-limits.d.ts +11 -0
  6. package/dist/actors/closing-report-limits.js +13 -0
  7. package/dist/actors/closing-report.d.ts +68 -0
  8. package/dist/actors/closing-report.js +55 -0
  9. package/dist/actors/codex/app-server-ui.js +11 -4
  10. package/dist/actors/codex/restricted-participant-policy.d.ts +4 -47
  11. package/dist/actors/codex/restricted-participant-policy.js +12 -40
  12. package/dist/actors/computer-use/loop/debrief.d.ts +1 -10
  13. package/dist/actors/computer-use/loop/debrief.js +68 -70
  14. package/dist/actors/computer-use/loop/impressions.d.ts +2 -2
  15. package/dist/actors/computer-use/loop/impressions.js +6 -1
  16. package/dist/actors/computer-use/loop.d.ts +0 -1
  17. package/dist/actors/computer-use/loop.js +3 -3
  18. package/dist/actors/computer-use/openai-conversation.d.ts +1 -1
  19. package/dist/actors/computer-use/openai-conversation.js +3 -29
  20. package/dist/actors/computer-use/openai-provider.js +11 -5
  21. package/dist/actors/contract.d.ts +2 -20
  22. package/dist/actors/contract.js +0 -13
  23. package/dist/actors/scripted-browser/steps.js +6 -5
  24. package/dist/analysis/admission.d.ts +14 -4
  25. package/dist/analysis/admission.js +14 -9
  26. package/dist/analysis/analysis-limits.d.ts +35 -0
  27. package/dist/analysis/analysis-limits.js +41 -0
  28. package/dist/analysis/execute.d.ts +5 -0
  29. package/dist/analysis/execute.js +3 -0
  30. package/dist/analysis/finding-lead.d.ts +27 -0
  31. package/dist/analysis/finding-lead.js +23 -0
  32. package/dist/analysis/service.js +5 -3
  33. package/dist/analysis/validation.js +14 -21
  34. package/dist/cli/commands/analyze.js +2 -1
  35. package/dist/cli/commands/notes.js +5 -3
  36. package/dist/cli/commands/run-signals.js +5 -1
  37. package/dist/cli/commands/runs.js +2 -1
  38. package/dist/cli/commands/setup.js +3 -3
  39. package/dist/cli/commands/study-format.d.ts +5 -2
  40. package/dist/cli/commands/study-format.js +29 -9
  41. package/dist/cli/commands/study-route-computer-use.js +2 -2
  42. package/dist/cli/commands/study-route-scripted.js +1 -1
  43. package/dist/cli/commands/tui.js +59 -17
  44. package/dist/cli/doctor.js +9 -2
  45. package/dist/cli/findings-text.d.ts +5 -0
  46. package/dist/cli/findings-text.js +169 -0
  47. package/dist/cli/findings.d.ts +0 -4
  48. package/dist/cli/findings.js +11 -195
  49. package/dist/cli/io.js +10 -8
  50. package/dist/cli/telemetry.js +10 -22
  51. package/dist/cli/update-check.js +10 -19
  52. package/dist/cli/user-config.d.ts +11 -0
  53. package/dist/cli/user-config.js +25 -0
  54. package/dist/comms/connections.js +4 -3
  55. package/dist/comms/email-catch.js +7 -31
  56. package/dist/comms/setup.js +18 -12
  57. package/dist/evidence/encoded-text.d.ts +12 -11
  58. package/dist/evidence/encoded-text.js +81 -70
  59. package/dist/evidence/redaction.d.ts +11 -0
  60. package/dist/evidence/redaction.js +30 -11
  61. package/dist/evidence/secret-scrub.d.ts +26 -3
  62. package/dist/evidence/secret-scrub.js +172 -38
  63. package/dist/feedback/draft.d.ts +1 -1
  64. package/dist/feedback/draft.js +13 -16
  65. package/dist/feedback/export-bundle.js +21 -17
  66. package/dist/feedback/export.js +32 -46
  67. package/dist/feedback/feedback.js +31 -26
  68. package/dist/guest/desktop-keys.js +59 -5
  69. package/dist/keys/key-resolution.d.ts +9 -0
  70. package/dist/keys/key-resolution.js +62 -55
  71. package/dist/keys/key-status.d.ts +2 -0
  72. package/dist/keys/key-status.js +9 -16
  73. package/dist/observer/artifact.d.ts +1 -1
  74. package/dist/observer/http.d.ts +7 -0
  75. package/dist/observer/http.js +19 -1
  76. package/dist/observer/notes-route.d.ts +0 -1
  77. package/dist/observer/notes-route.js +24 -44
  78. package/dist/observer/pinned-files.js +4 -19
  79. package/dist/observer/render.js +6 -4
  80. package/dist/observer/run-routes.js +13 -28
  81. package/dist/observer/serve.js +2 -1
  82. package/dist/observer-app.html +7 -7
  83. package/dist/routes/computer-use/bundle-parts.d.ts +4 -4
  84. package/dist/routes/computer-use/bundle-parts.js +2 -2
  85. package/dist/routes/computer-use/bundle.d.ts +4 -5
  86. package/dist/routes/computer-use/bundle.js +1 -3
  87. package/dist/routes/computer-use/fanout-records.js +10 -11
  88. package/dist/routes/computer-use/live-phase.d.ts +1 -1
  89. package/dist/routes/computer-use/live-phase.js +0 -1
  90. package/dist/routes/computer-use/participant-execution.d.ts +3 -2
  91. package/dist/routes/computer-use/participant-runs.d.ts +6 -2
  92. package/dist/routes/computer-use/participant-runs.js +9 -5
  93. package/dist/routes/computer-use/plan.d.ts +3 -0
  94. package/dist/routes/computer-use/plan.js +14 -11
  95. package/dist/routes/computer-use/rerun-selection.js +2 -2
  96. package/dist/routes/computer-use/result.js +3 -6
  97. package/dist/routes/computer-use/setup.d.ts +4 -6
  98. package/dist/routes/computer-use/setup.js +2 -6
  99. package/dist/routes/computer-use/single-bundle.d.ts +4 -5
  100. package/dist/routes/computer-use/single-bundle.js +22 -25
  101. package/dist/routes/computer-use/subject-projection.d.ts +4 -8
  102. package/dist/routes/computer-use/subject-projection.js +9 -31
  103. package/dist/routes/computer-use/types.d.ts +4 -41
  104. package/dist/routes/scripted/scenario.js +28 -12
  105. package/dist/routes/scripted/session-result.js +6 -3
  106. package/dist/routes/shared-world/bundle-records.js +2 -8
  107. package/dist/routes/shared-world/participant-specs.d.ts +0 -2
  108. package/dist/routes/shared-world/participant-specs.js +1 -20
  109. package/dist/routes/shared-world/plan.d.ts +3 -0
  110. package/dist/routes/shared-world/plan.js +23 -3
  111. package/dist/routes/shared-world/setup.js +2 -2
  112. package/dist/routes/terminal/ledger.d.ts +9 -9
  113. package/dist/routes/terminal/ledger.js +63 -40
  114. package/dist/routes/terminal/live-finish.js +7 -10
  115. package/dist/routes/terminal/plan.d.ts +3 -0
  116. package/dist/routes/terminal/plan.js +8 -5
  117. package/dist/routes/terminal/token-usage.d.ts +1 -2
  118. package/dist/routes/terminal/token-usage.js +4 -6
  119. package/dist/routes/terminal/types.d.ts +9 -1
  120. package/dist/run/bundle-shape.js +6 -1
  121. package/dist/run/bundle.d.ts +5 -3
  122. package/dist/run/contained-output.d.ts +59 -10
  123. package/dist/run/contained-output.js +160 -68
  124. package/dist/run/detail.d.ts +2 -1
  125. package/dist/run/detail.js +12 -7
  126. package/dist/run/escape-sequences.d.ts +11 -0
  127. package/dist/run/escape-sequences.js +93 -0
  128. package/dist/run/evidence-files.d.ts +8 -1
  129. package/dist/run/evidence-files.js +24 -18
  130. package/dist/run/locate.d.ts +32 -3
  131. package/dist/run/locate.js +45 -40
  132. package/dist/run/note-files.d.ts +26 -45
  133. package/dist/run/note-files.js +86 -58
  134. package/dist/run/note-shape.d.ts +35 -0
  135. package/dist/run/note-shape.js +20 -0
  136. package/dist/run/notes.d.ts +3 -14
  137. package/dist/run/notes.js +23 -75
  138. package/dist/run/outcomes.d.ts +24 -0
  139. package/dist/run/outcomes.js +19 -0
  140. package/dist/run/participant-records.d.ts +2 -0
  141. package/dist/run/participant-records.js +17 -2
  142. package/dist/run/paths.d.ts +2 -0
  143. package/dist/run/paths.js +5 -1
  144. package/dist/run/preflight-receipts.js +13 -8
  145. package/dist/run/project.js +7 -4
  146. package/dist/run/reclaim-outcomes.d.ts +8 -6
  147. package/dist/run/reclaim-outcomes.js +10 -6
  148. package/dist/run/reclaim.d.ts +5 -0
  149. package/dist/run/reclaim.js +43 -16
  150. package/dist/run/run-clock.d.ts +50 -0
  151. package/dist/run/run-clock.js +66 -0
  152. package/dist/run/run.js +5 -2
  153. package/dist/run/sandbox-ids.d.ts +7 -2
  154. package/dist/run/sandbox-ids.js +27 -11
  155. package/dist/run/secrets.d.ts +2 -1
  156. package/dist/run/secrets.js +18 -22
  157. package/dist/run/stored-runs.js +14 -7
  158. package/dist/run/transient-comms-secrets.d.ts +6 -5
  159. package/dist/run/transient-comms-secrets.js +23 -39
  160. package/dist/run-study.js +22 -13
  161. package/dist/study/adapter-scorer-loader.js +6 -4
  162. package/dist/study/discover.js +9 -6
  163. package/dist/study/init-paths.js +7 -5
  164. package/dist/study/persona-resolve.d.ts +2 -2
  165. package/dist/study/persona-resolve.js +9 -5
  166. package/dist/study/plan-types.d.ts +2 -2
  167. package/dist/study/plan.d.ts +5 -3
  168. package/dist/study/plan.js +18 -9
  169. package/dist/study/preflight-probes.js +11 -3
  170. package/dist/study/preflight.js +9 -5
  171. package/dist/study/summary.d.ts +12 -7
  172. package/dist/study/summary.js +24 -24
  173. package/dist/study/warnings.d.ts +19 -12
  174. package/dist/study/warnings.js +39 -17
  175. package/dist/substrates/e2b/connection.d.ts +12 -0
  176. package/dist/substrates/e2b/connection.js +35 -0
  177. package/dist/substrates/e2b/desktop-executor.d.ts +1 -3
  178. package/dist/substrates/e2b/desktop-executor.js +42 -9
  179. package/dist/substrates/e2b/lifetime.d.ts +24 -4
  180. package/dist/substrates/e2b/lifetime.js +41 -4
  181. package/dist/substrates/e2b/sandbox.d.ts +4 -3
  182. package/dist/substrates/e2b/sandbox.js +13 -18
  183. package/dist/substrates/e2b/sdk.d.ts +20 -9
  184. package/dist/substrates/local/capacity.d.ts +18 -2
  185. package/dist/substrates/local/capacity.js +21 -2
  186. package/dist/substrates/local/runtime-host.js +21 -14
  187. package/dist/substrates/local/runtime.js +3 -11
  188. package/dist/tui/contract.d.ts +30 -5
  189. package/dist/tui/contract.js +4 -1
  190. package/dist/tui-app.js +149 -147
  191. package/dist/verify/artifacts.d.ts +9 -2
  192. package/dist/verify/artifacts.js +37 -13
  193. package/dist/verify/liveness.js +19 -5
  194. package/dist/verify/verify.js +54 -27
  195. package/docs/architecture/actor-contract.md +3 -3
  196. package/docs/architecture/guest-desktop.md +7 -2
  197. package/docs/architecture/restricted-codex-analysis.md +1 -1
  198. package/docs/architecture/state-driven-executor.md +5 -3
  199. package/docs/contracts/run-bundle.md +25 -13
  200. package/docs/contracts/schemas.md +38 -29
  201. package/docs/contracts/study-analysis.md +22 -5
  202. package/docs/product/automatic-analysis.md +7 -1
  203. package/package.json +8 -7
  204. package/skills/humanish/SKILL.md +1 -2
@@ -2,13 +2,20 @@
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;
9
- // The shortest encoded form searched for. Shorter base64 or hex runs are ordinary text often enough
10
- // that matching them would redact words. A value itself is searched for at any length.
11
- const MIN_ENCODED_FORM = 8;
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");
13
+ // The shortest encoded form searched for: the unpadded base64 of a four-byte value, such as a
14
+ // four-digit code. A given form of n characters turns up by chance in random base64 about once in
15
+ // 64^n characters, once in 69 billion at 6 and once in 17 million at 4. In the text of 9 real runs
16
+ // (2.5 million characters) no 6- or 7-character form of any 4-, 5- or 6-digit code occurred. A
17
+ // value itself is searched for at any length.
18
+ const MIN_ENCODED_FORM = 6;
12
19
  /**
13
20
  * The characters of a value's base64 encoding that do not depend on its neighbours, with 0, 1 or
14
21
  * 2 bytes before it. A prefix byte shares the next character with the value, and so does the byte
@@ -27,9 +34,12 @@ function base64Middles(bytes, encoding) {
27
34
  }
28
35
  /**
29
36
  * 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
31
- * escaped form is searched for at any length, as the value is: it holds a backslash or a `%`, so it
32
- * is not ordinary text.
37
+ * `\u` escapes) and twice, its UTF-8 bytes read one per character and the reverse, base64 padded,
38
+ * unpadded and at each byte offset, base64url and hex. Twice, because a JSON event can carry a
39
+ * command's JSON output as a string. An escaped form is searched for at any length, as the value
40
+ * is: it holds a backslash or a `%`, so it is not ordinary text. A base64 or hex form is searched
41
+ * for from MIN_ENCODED_FORM characters, so a four-byte value is found in base64 written whole and
42
+ * not inside a longer run, where four or five of its characters do not depend on its neighbours.
33
43
  */
34
44
  export function encodedForms(value) {
35
45
  const bytes = Buffer.from(value, "utf8");
@@ -45,8 +55,10 @@ export function encodedForms(value) {
45
55
  catch {
46
56
  // no percent-encoded form
47
57
  }
58
+ const base64 = bytes.toString("base64");
48
59
  const binary = [
49
- bytes.toString("base64"),
60
+ base64,
61
+ base64.replace(/=+$/, ""),
50
62
  bytes.toString("base64url"),
51
63
  bytes.toString("hex"),
52
64
  ...base64Middles(bytes, "base64"),
@@ -57,13 +69,18 @@ export function encodedForms(value) {
57
69
  const bytewise = bytes.toString("latin1");
58
70
  if (bytewise !== value)
59
71
  escaped.push(bytewise);
72
+ // The mirror: a value such as xéz€ whose characters include bytes that read as UTF-8, written
73
+ // with them read (xéz€). No reading of the text turns xéz€ back into the value.
74
+ const mirrored = latin1RunsAsUtf8(value);
75
+ if (mirrored !== value)
76
+ escaped.push(mirrored);
60
77
  return [value, ...escaped, ...binary.filter((form) => form.length >= MIN_ENCODED_FORM)];
61
78
  }
62
79
  /**
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`.
80
+ * Whether [at, end) lies wholly inside one marker. The markers are one regex's matches, so they are
81
+ * sorted and disjoint: only the first marker that ends after `at` can hold it.
65
82
  */
66
- function overlapsMarker(markers, at, end) {
83
+ function insideMarker(markers, at, end) {
67
84
  let low = 0;
68
85
  let high = markers.length;
69
86
  while (low < high) {
@@ -73,33 +90,148 @@ function overlapsMarker(markers, at, end) {
73
90
  else
74
91
  low = middle + 1;
75
92
  }
76
- return low < markers.length && markers[low][0] < end;
93
+ return low < markers.length && markers[low][0] <= at && end <= markers[low][1];
77
94
  }
78
- /** Every occurrence of every form, outside the markers, merged where they overlap or touch. */
79
- function secretSpans(text, forms) {
95
+ /**
96
+ * The Knuth-Morris-Pratt failure table: entry `k` is the length of the longest proper prefix of
97
+ * the form's first `k` characters that is also their suffix.
98
+ */
99
+ function failureTable(form) {
100
+ const table = new Int32Array(form.length + 1);
101
+ for (let at = 1, length = 0; at < form.length; at += 1) {
102
+ while (length > 0 && form[at] !== form[length])
103
+ length = table[length];
104
+ if (form[at] === form[length])
105
+ length += 1;
106
+ table[at + 1] = length;
107
+ }
108
+ return table;
109
+ }
110
+ /**
111
+ * Calls `found` with the start of every occurrence of the form, overlapping ones included, in time
112
+ * linear in the text. indexOf skips to the next occurrence. After one, the failure table carries
113
+ * the match on through the occurrences that overlap it, so no character is compared again, until
114
+ * no prefix of the form is left matched and indexOf takes over.
115
+ */
116
+ function eachOccurrence(text, form, found) {
117
+ let table;
118
+ for (let at = text.indexOf(form); at !== -1;) {
119
+ found(at);
120
+ table ??= failureTable(form);
121
+ let matched = table[form.length];
122
+ let next = at + form.length;
123
+ for (; matched > 0 && next < text.length; next += 1) {
124
+ while (matched > 0 && text[next] !== form[matched])
125
+ matched = table[matched];
126
+ if (text[next] === form[matched])
127
+ matched += 1;
128
+ if (matched === form.length) {
129
+ found(next + 1 - form.length);
130
+ matched = table[form.length];
131
+ }
132
+ }
133
+ at = matched > 0 ? -1 : text.indexOf(form, next);
134
+ }
135
+ }
136
+ /**
137
+ * Each value's occurrences, the longest value that starts at a position replaced from the start of
138
+ * the text on, in time linear in the text for each value.
139
+ */
140
+ function replaceLongestFirst(text, values) {
141
+ // The length of the longest value that starts at each position, allocated at the first find.
142
+ let longest;
143
+ for (const value of values)
144
+ eachOccurrence(text, value, (at) => {
145
+ longest ??= new Uint32Array(text.length);
146
+ longest[at] = Math.max(longest[at], value.length);
147
+ });
148
+ if (longest === undefined)
149
+ return text;
150
+ let result = "";
151
+ let cursor = 0;
152
+ for (let at = 0; at < text.length; at += 1) {
153
+ if (longest[at] === 0)
154
+ continue;
155
+ result += text.slice(cursor, at) + REDACTED;
156
+ cursor = at + longest[at];
157
+ at = cursor - 1;
158
+ }
159
+ return result + text.slice(cursor);
160
+ }
161
+ /**
162
+ * A scrub that replaces each non-empty value as written with `[REDACTED_SECRET]`, markers
163
+ * included. From the start of the text, the longest value that starts at a position is replaced
164
+ * and the search goes on after it, so the marker it writes is never searched. One global regex of
165
+ * the values, longest first, does that fastest. V8 compiles it at its first use and refuses it
166
+ * once a value has 32,768 characters; the scrub then finds each value itself, with the same
167
+ * output, in time linear in the text for each value.
168
+ */
169
+ export function scrubValuesAsWritten(values) {
170
+ const written = [...new Set(values)].filter((value) => value.length > 0);
171
+ if (written.length === 0)
172
+ return (text) => text;
173
+ let pattern = new RegExp([...written]
174
+ .sort((left, right) => right.length - left.length)
175
+ .map(escapeRegExp)
176
+ .join("|"), "g");
177
+ return (text) => {
178
+ if (pattern !== undefined)
179
+ try {
180
+ return text.replace(pattern, REDACTED);
181
+ }
182
+ catch {
183
+ pattern = undefined;
184
+ }
185
+ return replaceLongestFirst(text, written);
186
+ };
187
+ }
188
+ /** Every occurrence of every form not wholly inside a marker, in no order. */
189
+ function occurrences(text, forms) {
80
190
  const markers = [];
81
191
  for (const match of text.matchAll(MARKER)) {
82
192
  const start = match.index ?? 0;
83
193
  markers.push([start, start + match[0].length]);
84
194
  }
195
+ const found = [];
196
+ for (const form of forms)
197
+ eachOccurrence(text, form, (at) => {
198
+ if (!insideMarker(markers, at, at + form.length))
199
+ found.push([at, at + form.length]);
200
+ });
201
+ return found;
202
+ }
203
+ /** The occurrences sorted and merged where they overlap or touch. */
204
+ function merged(found) {
205
+ found.sort((left, right) => left[0] - right[0]);
85
206
  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);
207
+ for (const span of found) {
208
+ const last = spans.at(-1);
97
209
  if (last !== undefined && span[0] <= last[1])
98
210
  last[1] = Math.max(last[1], span[1]);
99
211
  else
100
- merged.push([span[0], span[1]]);
212
+ spans.push([span[0], span[1]]);
213
+ }
214
+ return spans;
215
+ }
216
+ function replaceSpans(text, spans) {
217
+ let result = "";
218
+ let cursor = 0;
219
+ for (const [start, end] of spans) {
220
+ result += text.slice(cursor, start) + REDACTED;
221
+ cursor = end;
101
222
  }
102
- return merged;
223
+ return result + text.slice(cursor);
224
+ }
225
+ const formsOf = (values) => [
226
+ ...new Set(values.filter((value) => value.length > 0).flatMap(encodedForms)),
227
+ ];
228
+ /**
229
+ * A check that the text holds one of the values' forms outside the markers in any of its
230
+ * readingsOf. A scrub's output must pass it.
231
+ */
232
+ export function holdsSecretValue(values) {
233
+ const forms = formsOf(values);
234
+ return (text) => readingsOf(text).some((reading) => occurrences(reading, forms).length > 0);
103
235
  }
104
236
  /**
105
237
  * A scrub that replaces each non-empty value and its encoded forms with `[REDACTED_SECRET]`. Every
@@ -108,19 +240,21 @@ function secretSpans(text, forms) {
108
240
  * through decodeEscapes, the decoder verify uses, and returns the decoded text: a warning with
109
241
  * percent-encoding, JSON escapes or HTML references shows them decoded, with the values removed.
110
242
  * That finds a value written partly encoded, which no single encoded form matches.
243
+ *
244
+ * The result's other readingsOf only detect. If one still holds a form, the scrub returns
245
+ * `[REDACTED_SECRET]` in place of the whole text. That happens for a value encoded twice, which
246
+ * the decoded text shows encoded once; for a value the UTF-8 or transfer reading holds, such as
247
+ * one with one of two non-ASCII characters percent-encoded; and for a value that holds part of a
248
+ * marker, such as `ET]x`, which the marker that replaced it can spell again.
111
249
  */
112
250
  export function scrubSecretValues(values) {
113
- const forms = [...new Set(values.filter((value) => value.length > 0).flatMap(encodedForms))];
251
+ const forms = formsOf(values);
114
252
  if (forms.length === 0)
115
253
  return (text) => text;
254
+ const holds = holdsSecretValue(values);
116
255
  return (text) => {
117
256
  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);
257
+ const result = replaceSpans(decoded, merged(occurrences(decoded, forms)));
258
+ return holds(result) ? REDACTED : result;
125
259
  };
126
260
  }
@@ -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;
@@ -2,6 +2,7 @@
2
2
  // participant's feedback candidate or from a reviewed analysis finding, and its markdown rendering.
3
3
  // The commands that resolve the run and read or write drafts are in feedback.ts.
4
4
  import path from "node:path";
5
+ import { findingLead } from "../analysis/finding-lead.js";
5
6
  import { analysisSharingProblems } from "../analysis/sharing.js";
6
7
  import { loadAnalysis } from "../analysis/load.js";
7
8
  import { hashAnalysisValue } from "../analysis/validation.js";
@@ -9,7 +10,9 @@ import { formatParticipantOutcomes, formatRunTaskFunnel, participantOutcomeDetai
9
10
  import { loadRunBundlePrepared } from "../run/locate.js";
10
11
  import { isFeedbackIdempotencyKey } from "../run/feedback-shape.js";
11
12
  import { runNoteFile } from "../run/note-files.js";
12
- import { formatRunTime, runParticipantCaptions } from "../run/notes.js";
13
+ import { byRunTime } from "../run/note-shape.js";
14
+ import { formatRunTime } from "../run/run-clock.js";
15
+ import { runParticipantCaptions } from "../run/participant-records.js";
13
16
  import { isRecord } from "../run/type-guards.js";
14
17
  import { feedbackProofCommands, projectFeedbackAcceptanceProof } from "./proof.js";
15
18
  export const FEEDBACK_SCHEMA = "humanish.feedback.v1";
@@ -122,8 +125,10 @@ export async function buildAnalysisDraft(context, options) {
122
125
  const root = path.dirname(context.loaded.bundlePath);
123
126
  const evidenceIds = new Set(finding.observations.flatMap((item) => item.evidenceIds));
124
127
  const evidence = analysis.evidence.filter((item) => evidenceIds.has(item.id));
125
- const amended = correction?.status === "amended";
126
- const claim = amended ? correction.replacementClaim : (finding.headline ?? finding.title);
128
+ const lead = findingLead(finding, correction);
129
+ // An analysis written before headlines has no lead, so the amended claim or the title names it.
130
+ const amendedClaim = correction?.status === "amended" ? correction.replacementClaim : null;
131
+ const claim = lead?.headline ?? amendedClaim ?? finding.title;
127
132
  const firstLine = claim.trim().split(/\r?\n/)[0] || `Reviewed finding ${finding.id}`;
128
133
  const summary = Array.from(firstLine).length > 160
129
134
  ? Array.from(firstLine).slice(0, 159).join("") + "…"
@@ -147,19 +152,11 @@ export async function buildAnalysisDraft(context, options) {
147
152
  summary,
148
153
  expected: "Review the cited behavior against the participant assignment and confirm the expected product behavior.",
149
154
  actual: [
150
- // The plain account leads; an amendment makes it stale, so the draft says so in its place.
151
- ...(finding.headline === undefined
152
- ? []
153
- : amended
154
- ? [
155
- "This finding was corrected in human review. The reviewer's claim above replaces its original headline and account.",
156
- "",
157
- ]
158
- : finding.experience === undefined
159
- ? []
160
- : [finding.experience, ""]),
155
+ ...(lead?.account ? [lead.account, ""] : []),
161
156
  "Independent study analysis; does not replace participant feedback or recorded completion outcomes.",
162
- amended ? `Amended claim: ${claim}. Original analysis: ${finding.summary}` : finding.summary,
157
+ amendedClaim === null
158
+ ? finding.summary
159
+ : `Amended claim: ${amendedClaim}. Original analysis: ${finding.summary}`,
163
160
  `Impact: ${finding.impact}. ${finding.affectedStreamIds.length} affected / ${finding.exposedStreamIds.length} observed exposed participants. ${finding.exposureReason}`,
164
161
  `Recovery: ${finding.recovery}. Confidence: ${finding.confidence}.`,
165
162
  ...finding.observations.map((item) => `${item.basis}: ${item.claim}${item.limitation ? ` Limitation: ${item.limitation}` : ""}`),
@@ -234,7 +231,7 @@ export function withReviewerNotes(draft, notes, bundle, runRoot) {
234
231
  if (notes === null || notes.notes.length === 0)
235
232
  return draft;
236
233
  const captions = runParticipantCaptions(bundle);
237
- const ordered = [...notes.notes].sort((left, right) => left.atMs - right.atMs || left.createdAt.localeCompare(right.createdAt));
234
+ const ordered = byRunTime(notes.notes);
238
235
  return {
239
236
  ...draft,
240
237
  evidence: [
@@ -10,18 +10,18 @@ import { renderObserver } from "../observer/render.js";
10
10
  import { buildObserverData } from "../observer/data.js";
11
11
  import { inventorySandboxIds, scrubSandboxIdBytes } from "../run/sandbox-ids.js";
12
12
  import { containsSensitive, redactSandboxIds, redactScreenshot, redactText, } from "../evidence/redaction.js";
13
- import { decodeEscapes, utf8ReadingOf } from "../evidence/encoded-text.js";
13
+ import { decodeEscapes, readingsOf } from "../evidence/encoded-text.js";
14
14
  import { readPlainText } from "../evidence/plain-text.js";
15
- import { streamScreenshotPaths } from "../verify/artifacts.js";
15
+ import { MAX_RUN_ENTRIES, streamScreenshotPaths } from "../verify/artifacts.js";
16
16
  import { verifyRunPrepared } from "../verify/verify.js";
17
17
  import { loadRunBundlePrepared, resolveRunPath } from "../run/locate.js";
18
18
  import {} from "../run/bundle.js";
19
19
  import { runIdOf, isPathInside, prepareRunArtifactPaths, validatePreparedRunArtifactPaths, } from "../run/paths.js";
20
20
  import { isAnalysisRecordPath } from "../analysis/sharing.js";
21
21
  import { LOCAL_ONLY_RUN_FILES } from "../run/local-only-files.js";
22
- import { assertPreparedSelectedOutputDirectory, prepareManagedHumanishOutputDirectory, prepareSelectedOutputDirectory, readContainedRegularFile, writeContainedOutputFile, } from "../run/contained-output.js";
22
+ import { readUnchangedFile } from "../run/evidence-files.js";
23
+ import { assertPreparedSelectedOutputDirectory, prepareManagedHumanishOutputDirectory, prepareSelectedOutputDirectory, writeContainedOutputFile, } from "../run/contained-output.js";
23
24
  const DERIVATION_SCHEMA = "humanish.redacted-derivation.v1";
24
- const MAX_FILES = 10_000;
25
25
  const TEXT_EXTENSIONS = new Set([
26
26
  ".json",
27
27
  ".ndjson",
@@ -69,8 +69,8 @@ async function inventory(root, maxBytes) {
69
69
  if (!before.isDirectory() || before.isSymbolicLink())
70
70
  throw new Error("Source contains an unsafe directory.");
71
71
  for (const name of (await readdir(directory)).sort()) {
72
- if (++entriesSeen > MAX_FILES)
73
- throw new Error(`Source exceeds ${MAX_FILES} inventory entries.`);
72
+ if (++entriesSeen > MAX_RUN_ENTRIES)
73
+ throw new Error(`Source exceeds ${MAX_RUN_ENTRIES} inventory entries.`);
74
74
  if (name.includes("\\") || name.includes("\0"))
75
75
  throw new Error("Source contains an unsafe path segment.");
76
76
  const rel = relative ? `${relative}/${name}` : name;
@@ -86,15 +86,17 @@ async function inventory(root, maxBytes) {
86
86
  else {
87
87
  if (path.extname(rel).toLowerCase() === ".mp4")
88
88
  throw new Error("Continuous video/audio cannot be redacted by bundle export. Use --local-only HTML export for a snapshot-only copy.");
89
- if (info.size > BigInt(maxBytes - total))
90
- throw new Error("Source inventory exceeds --max-bytes.");
91
- const bytes = await readContainedRegularFile(root, rel);
92
- if (bytes === null)
89
+ // At most what is left of --max-bytes, so a file that grows after this lstat is refused
90
+ // unread. With nothing left, an empty file still reads and any other is refused.
91
+ const read = await readUnchangedFile(root, rel, Math.max(1, maxBytes - total));
92
+ if (read.state === "unavailable")
93
93
  throw new Error("Source artifact could not be read through its bound identity.");
94
- total += bytes.length;
94
+ if (read.state === "limit")
95
+ throw new Error("Source inventory exceeds --max-bytes.");
96
+ total += read.bytes.length;
95
97
  if (total > maxBytes)
96
98
  throw new Error("Source inventory exceeds --max-bytes.");
97
- files.push({ path: rel, bytes, sha256: hash(bytes) });
99
+ files.push({ path: rel, bytes: read.bytes, sha256: hash(read.bytes) });
98
100
  }
99
101
  }
100
102
  const after = await lstat(directory, { bigint: true });
@@ -148,9 +150,9 @@ async function prospectivePhysicalPath(requested) {
148
150
  function assertNoInlineRaster(text) {
149
151
  // Decode the escapes verify decodes (decodeEscapes) before checking. Unknown embedded bytes are
150
152
  // refused: copying a text extension must not smuggle untransformed pixels.
151
- const decoded = decodeEscapes(text);
152
- if ([text, decoded, utf8ReadingOf(text, decoded) ?? ""].some(containsSensitive))
153
+ if (readingsOf(text).some(containsSensitive))
153
154
  throw new Error("Decoded text contains a secret-shaped value or private path; export refused.");
155
+ const decoded = decodeEscapes(text);
154
156
  if (/data\s*:\s*(?:image\/|application\/|text\/|[^,\s]*;base64,|,)/i.test(decoded) ||
155
157
  /iVBORw0KGgo|\/9j\/[A-Za-z0-9+/]|R0lGOD|UklGR[A-Za-z0-9+/]{4}|<svg\b/i.test(decoded)) {
156
158
  throw new Error("Text contains an inline image/data payload; embedded raster redaction is unsupported.");
@@ -541,10 +543,12 @@ async function buildDerivative(stage, source, runId, bundle, maxBytes) {
541
543
  throw new Error("Derivative Observer could not be rebuilt.");
542
544
  const generated = [];
543
545
  for (const relative of ["observer/index.html", "observer/observer-data.json"]) {
544
- const bytes = await readContainedRegularFile(stagePaths, relative);
545
- if (bytes === null)
546
+ const read = await readUnchangedFile(stagePaths, relative, maxBytes);
547
+ if (read.state === "limit")
548
+ throw new Error("The regenerated Observer exceeds --max-bytes.");
549
+ if (read.state === "unavailable")
546
550
  throw new Error("Regenerated Observer artifact could not be read safely.");
547
- generated.push({ path: relative, sha256: hash(bytes) });
551
+ generated.push({ path: relative, sha256: hash(read.bytes) });
548
552
  }
549
553
  await writeContainedOutputFile(stagePaths, "derivation.json", jsonBytes({ ...receipt, generated }));
550
554
  const derivativeVerify = await verifyRunPrepared(stage.physicalPath, runId, stagePaths);
@@ -19,7 +19,7 @@ import { verifyRun } from "../verify/verify.js";
19
19
  import {} from "../run/bundle.js";
20
20
  import { exportRedactedBundle } from "./export-bundle.js";
21
21
  import { loadAnalysis } from "../analysis/load.js";
22
- import { readNotesForSharing } from "../run/notes.js";
22
+ import { readNotesForSharing } from "../run/note-files.js";
23
23
  import { cli } from "../cli/invocation.js";
24
24
  import { analysisSharingProblems } from "../analysis/sharing.js";
25
25
  import { EVIDENCE_LIMITS, validateAnalysisEvidence } from "../analysis/evidence.js";
@@ -288,7 +288,10 @@ function makePortable(inlined, verified) {
288
288
  }
289
289
  }
290
290
  }
291
- // What verify said, in the file, so the chrome can agree with the result envelope.
291
+ recordShareSafety(inlined, verified);
292
+ }
293
+ /** What verify said, in the file, so the chrome can agree with the result envelope. */
294
+ function recordShareSafety(inlined, verified) {
292
295
  const publicSafety = (inlined.publicSafety ?? {});
293
296
  inlined.publicSafety = {
294
297
  ...publicSafety,
@@ -299,6 +302,23 @@ function makePortable(inlined, verified) {
299
302
  },
300
303
  };
301
304
  }
305
+ /**
306
+ * Adds a failed sharing check to verify's result. The reason joins verify's reasons and the status
307
+ * is the worse of the two, as verify grades an analysis. The export is refused unless --local-only,
308
+ * which writes the new status into the file.
309
+ */
310
+ function applySharingProblem(verified, inlined, options, problem) {
311
+ verified.shareSafety = {
312
+ status: problem.status === "blocked" || verified.shareSafety.status === "blocked"
313
+ ? "blocked"
314
+ : "local_only",
315
+ reasons: [...verified.shareSafety.reasons, problem.reason],
316
+ };
317
+ if (options.localOnly !== true)
318
+ return "blocked";
319
+ recordShareSafety(inlined, verified);
320
+ return "downgraded";
321
+ }
302
322
  /**
303
323
  * Revalidates the analysis against source evidence and its sharing checks. A failed check
304
324
  * downgrades share safety, which only --local-only may export.
@@ -322,24 +342,13 @@ async function recheckAnalysis(runPaths, analysis, verified, inlined, options) {
322
342
  const analysisSharing = analysisSharingProblems(analysis);
323
343
  if (!analysisSharing.sensitive && !analysisSharing.unverified)
324
344
  return "ok";
325
- verified.shareSafety = {
345
+ return applySharingProblem(verified, inlined, options, {
326
346
  status: analysisSharing.sensitive ? "blocked" : "local_only",
327
- reasons: [
328
- ...verified.shareSafety.reasons,
329
- {
330
- code: "ANALYSIS_UNVERIFIED",
331
- message: "The exact analysis snapshot being exported did not pass sharing checks.",
332
- },
333
- ],
334
- };
335
- if (options.localOnly !== true)
336
- return "blocked";
337
- inlined.publicSafety.share = {
338
- status: verified.shareSafety.status,
339
- verifiedAt: new Date().toISOString(),
340
- reasons: verified.shareSafety.reasons.map((reason) => reason.code),
341
- };
342
- return "downgraded";
347
+ reason: {
348
+ code: "ANALYSIS_UNVERIFIED",
349
+ message: "The exact analysis snapshot being exported did not pass sharing checks.",
350
+ },
351
+ });
343
352
  }
344
353
  /**
345
354
  * The run's notes for the export, read once within the notes size limit. That snapshot is scanned
@@ -347,36 +356,13 @@ async function recheckAnalysis(runPaths, analysis, verified, inlined, options) {
347
356
  * reach the file unchecked. A finding blocks the export, or downgrades it under --local-only.
348
357
  */
349
358
  async function checkedNotes(runPaths, verified, inlined, options, warnings) {
350
- const { notes, finding } = await readNotesForSharing(runPaths);
359
+ const { notes, problem } = await readNotesForSharing(runPaths);
351
360
  for (const skipped of notes.skipped)
352
361
  warnings.push(`Reviewer notes: ${skipped}`);
353
- if (finding === null)
354
- return { notes, check: "ok" };
355
- verified.shareSafety = {
356
- status: finding === "sensitive" || verified.shareSafety.status === "blocked"
357
- ? "blocked"
358
- : "local_only",
359
- reasons: [
360
- ...verified.shareSafety.reasons,
361
- finding === "sensitive"
362
- ? {
363
- code: "PUBLIC_SAFETY_FINDINGS",
364
- message: "The reviewer notes being exported match secret, token or local-path patterns.",
365
- }
366
- : {
367
- code: "UNSCANNED_ARTIFACT",
368
- message: "The reviewer notes being exported hold encoded text the scan cannot read.",
369
- },
370
- ],
371
- };
372
- if (options.localOnly !== true)
373
- return { notes, check: "blocked" };
374
- inlined.publicSafety.share = {
375
- status: verified.shareSafety.status,
376
- verifiedAt: new Date().toISOString(),
377
- reasons: verified.shareSafety.reasons.map((reason) => reason.code),
362
+ return {
363
+ notes,
364
+ check: problem === null ? "ok" : applySharingProblem(verified, inlined, options, problem),
378
365
  };
379
- return { notes, check: "downgraded" };
380
366
  }
381
367
  /** The portable Observer HTML, with the local-only banner when the export is watermarked. */
382
368
  function renderExportHtml(inlined, review, assets, verified, watermarked, omittedRecording) {