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.
- package/CHANGELOG.md +41 -0
- package/CONTEXT.md +1 -1
- package/README.md +7 -6
- package/TELEMETRY.md +6 -0
- package/dist/actors/closing-report-limits.d.ts +11 -0
- package/dist/actors/closing-report-limits.js +13 -0
- package/dist/actors/closing-report.d.ts +68 -0
- package/dist/actors/closing-report.js +55 -0
- package/dist/actors/codex/app-server-ui.js +11 -4
- package/dist/actors/codex/restricted-participant-policy.d.ts +4 -47
- package/dist/actors/codex/restricted-participant-policy.js +12 -40
- package/dist/actors/computer-use/loop/debrief.d.ts +1 -10
- package/dist/actors/computer-use/loop/debrief.js +68 -70
- package/dist/actors/computer-use/loop/impressions.d.ts +2 -2
- package/dist/actors/computer-use/loop/impressions.js +6 -1
- package/dist/actors/computer-use/loop.d.ts +0 -1
- package/dist/actors/computer-use/loop.js +3 -3
- package/dist/actors/computer-use/openai-conversation.d.ts +1 -1
- package/dist/actors/computer-use/openai-conversation.js +3 -29
- package/dist/actors/computer-use/openai-provider.js +11 -5
- package/dist/actors/contract.d.ts +2 -20
- package/dist/actors/contract.js +0 -13
- package/dist/actors/scripted-browser/steps.js +6 -5
- package/dist/analysis/admission.d.ts +14 -4
- package/dist/analysis/admission.js +14 -9
- package/dist/analysis/analysis-limits.d.ts +35 -0
- package/dist/analysis/analysis-limits.js +41 -0
- package/dist/analysis/execute.d.ts +5 -0
- package/dist/analysis/execute.js +3 -0
- package/dist/analysis/finding-lead.d.ts +27 -0
- package/dist/analysis/finding-lead.js +23 -0
- package/dist/analysis/service.js +5 -3
- package/dist/analysis/validation.js +14 -21
- package/dist/cli/commands/analyze.js +2 -1
- package/dist/cli/commands/notes.js +5 -3
- package/dist/cli/commands/run-signals.js +5 -1
- package/dist/cli/commands/runs.js +2 -1
- package/dist/cli/commands/setup.js +3 -3
- package/dist/cli/commands/study-format.d.ts +5 -2
- package/dist/cli/commands/study-format.js +29 -9
- package/dist/cli/commands/study-route-computer-use.js +2 -2
- package/dist/cli/commands/study-route-scripted.js +1 -1
- package/dist/cli/commands/tui.js +59 -17
- package/dist/cli/doctor.js +9 -2
- package/dist/cli/findings-text.d.ts +5 -0
- package/dist/cli/findings-text.js +169 -0
- package/dist/cli/findings.d.ts +0 -4
- package/dist/cli/findings.js +11 -195
- package/dist/cli/io.js +10 -8
- package/dist/cli/telemetry.js +10 -22
- package/dist/cli/update-check.js +10 -19
- package/dist/cli/user-config.d.ts +11 -0
- package/dist/cli/user-config.js +25 -0
- package/dist/comms/connections.js +4 -3
- package/dist/comms/email-catch.js +7 -31
- package/dist/comms/setup.js +18 -12
- package/dist/evidence/encoded-text.d.ts +12 -11
- package/dist/evidence/encoded-text.js +81 -70
- package/dist/evidence/redaction.d.ts +11 -0
- package/dist/evidence/redaction.js +30 -11
- package/dist/evidence/secret-scrub.d.ts +26 -3
- package/dist/evidence/secret-scrub.js +172 -38
- package/dist/feedback/draft.d.ts +1 -1
- package/dist/feedback/draft.js +13 -16
- package/dist/feedback/export-bundle.js +21 -17
- package/dist/feedback/export.js +32 -46
- package/dist/feedback/feedback.js +31 -26
- package/dist/guest/desktop-keys.js +59 -5
- package/dist/keys/key-resolution.d.ts +9 -0
- package/dist/keys/key-resolution.js +62 -55
- package/dist/keys/key-status.d.ts +2 -0
- package/dist/keys/key-status.js +9 -16
- package/dist/observer/artifact.d.ts +1 -1
- package/dist/observer/http.d.ts +7 -0
- package/dist/observer/http.js +19 -1
- package/dist/observer/notes-route.d.ts +0 -1
- package/dist/observer/notes-route.js +24 -44
- package/dist/observer/pinned-files.js +4 -19
- package/dist/observer/render.js +6 -4
- package/dist/observer/run-routes.js +13 -28
- package/dist/observer/serve.js +2 -1
- package/dist/observer-app.html +7 -7
- package/dist/routes/computer-use/bundle-parts.d.ts +4 -4
- package/dist/routes/computer-use/bundle-parts.js +2 -2
- package/dist/routes/computer-use/bundle.d.ts +4 -5
- package/dist/routes/computer-use/bundle.js +1 -3
- package/dist/routes/computer-use/fanout-records.js +10 -11
- package/dist/routes/computer-use/live-phase.d.ts +1 -1
- package/dist/routes/computer-use/live-phase.js +0 -1
- package/dist/routes/computer-use/participant-execution.d.ts +3 -2
- package/dist/routes/computer-use/participant-runs.d.ts +6 -2
- package/dist/routes/computer-use/participant-runs.js +9 -5
- package/dist/routes/computer-use/plan.d.ts +3 -0
- package/dist/routes/computer-use/plan.js +14 -11
- package/dist/routes/computer-use/rerun-selection.js +2 -2
- package/dist/routes/computer-use/result.js +3 -6
- package/dist/routes/computer-use/setup.d.ts +4 -6
- package/dist/routes/computer-use/setup.js +2 -6
- package/dist/routes/computer-use/single-bundle.d.ts +4 -5
- package/dist/routes/computer-use/single-bundle.js +22 -25
- package/dist/routes/computer-use/subject-projection.d.ts +4 -8
- package/dist/routes/computer-use/subject-projection.js +9 -31
- package/dist/routes/computer-use/types.d.ts +4 -41
- package/dist/routes/scripted/scenario.js +28 -12
- package/dist/routes/scripted/session-result.js +6 -3
- package/dist/routes/shared-world/bundle-records.js +2 -8
- package/dist/routes/shared-world/participant-specs.d.ts +0 -2
- package/dist/routes/shared-world/participant-specs.js +1 -20
- package/dist/routes/shared-world/plan.d.ts +3 -0
- package/dist/routes/shared-world/plan.js +23 -3
- package/dist/routes/shared-world/setup.js +2 -2
- package/dist/routes/terminal/ledger.d.ts +9 -9
- package/dist/routes/terminal/ledger.js +63 -40
- package/dist/routes/terminal/live-finish.js +7 -10
- package/dist/routes/terminal/plan.d.ts +3 -0
- package/dist/routes/terminal/plan.js +8 -5
- package/dist/routes/terminal/token-usage.d.ts +1 -2
- package/dist/routes/terminal/token-usage.js +4 -6
- package/dist/routes/terminal/types.d.ts +9 -1
- package/dist/run/bundle-shape.js +6 -1
- package/dist/run/bundle.d.ts +5 -3
- package/dist/run/contained-output.d.ts +59 -10
- package/dist/run/contained-output.js +160 -68
- package/dist/run/detail.d.ts +2 -1
- package/dist/run/detail.js +12 -7
- package/dist/run/escape-sequences.d.ts +11 -0
- package/dist/run/escape-sequences.js +93 -0
- package/dist/run/evidence-files.d.ts +8 -1
- package/dist/run/evidence-files.js +24 -18
- package/dist/run/locate.d.ts +32 -3
- package/dist/run/locate.js +45 -40
- package/dist/run/note-files.d.ts +26 -45
- package/dist/run/note-files.js +86 -58
- package/dist/run/note-shape.d.ts +35 -0
- package/dist/run/note-shape.js +20 -0
- package/dist/run/notes.d.ts +3 -14
- package/dist/run/notes.js +23 -75
- package/dist/run/outcomes.d.ts +24 -0
- package/dist/run/outcomes.js +19 -0
- package/dist/run/participant-records.d.ts +2 -0
- package/dist/run/participant-records.js +17 -2
- package/dist/run/paths.d.ts +2 -0
- package/dist/run/paths.js +5 -1
- package/dist/run/preflight-receipts.js +13 -8
- package/dist/run/project.js +7 -4
- package/dist/run/reclaim-outcomes.d.ts +8 -6
- package/dist/run/reclaim-outcomes.js +10 -6
- package/dist/run/reclaim.d.ts +5 -0
- package/dist/run/reclaim.js +43 -16
- package/dist/run/run-clock.d.ts +50 -0
- package/dist/run/run-clock.js +66 -0
- package/dist/run/run.js +5 -2
- package/dist/run/sandbox-ids.d.ts +7 -2
- package/dist/run/sandbox-ids.js +27 -11
- package/dist/run/secrets.d.ts +2 -1
- package/dist/run/secrets.js +18 -22
- package/dist/run/stored-runs.js +14 -7
- package/dist/run/transient-comms-secrets.d.ts +6 -5
- package/dist/run/transient-comms-secrets.js +23 -39
- package/dist/run-study.js +22 -13
- package/dist/study/adapter-scorer-loader.js +6 -4
- package/dist/study/discover.js +9 -6
- package/dist/study/init-paths.js +7 -5
- package/dist/study/persona-resolve.d.ts +2 -2
- package/dist/study/persona-resolve.js +9 -5
- package/dist/study/plan-types.d.ts +2 -2
- package/dist/study/plan.d.ts +5 -3
- package/dist/study/plan.js +18 -9
- package/dist/study/preflight-probes.js +11 -3
- package/dist/study/preflight.js +9 -5
- package/dist/study/summary.d.ts +12 -7
- package/dist/study/summary.js +24 -24
- package/dist/study/warnings.d.ts +19 -12
- package/dist/study/warnings.js +39 -17
- package/dist/substrates/e2b/connection.d.ts +12 -0
- package/dist/substrates/e2b/connection.js +35 -0
- package/dist/substrates/e2b/desktop-executor.d.ts +1 -3
- package/dist/substrates/e2b/desktop-executor.js +42 -9
- package/dist/substrates/e2b/lifetime.d.ts +24 -4
- package/dist/substrates/e2b/lifetime.js +41 -4
- package/dist/substrates/e2b/sandbox.d.ts +4 -3
- package/dist/substrates/e2b/sandbox.js +13 -18
- package/dist/substrates/e2b/sdk.d.ts +20 -9
- package/dist/substrates/local/capacity.d.ts +18 -2
- package/dist/substrates/local/capacity.js +21 -2
- package/dist/substrates/local/runtime-host.js +21 -14
- package/dist/substrates/local/runtime.js +3 -11
- package/dist/tui/contract.d.ts +30 -5
- package/dist/tui/contract.js +4 -1
- package/dist/tui-app.js +149 -147
- package/dist/verify/artifacts.d.ts +9 -2
- package/dist/verify/artifacts.js +37 -13
- package/dist/verify/liveness.js +19 -5
- package/dist/verify/verify.js +54 -27
- package/docs/architecture/actor-contract.md +3 -3
- package/docs/architecture/guest-desktop.md +7 -2
- package/docs/architecture/restricted-codex-analysis.md +1 -1
- package/docs/architecture/state-driven-executor.md +5 -3
- package/docs/contracts/run-bundle.md +25 -13
- package/docs/contracts/schemas.md +38 -29
- package/docs/contracts/study-analysis.md +22 -5
- package/docs/product/automatic-analysis.md +7 -1
- package/package.json +8 -7
- 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 {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
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
|
|
31
|
-
*
|
|
32
|
-
* is
|
|
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
|
-
|
|
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)
|
|
64
|
-
* disjoint: only the first marker that ends after `at` can
|
|
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
|
|
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]
|
|
93
|
+
return low < markers.length && markers[low][0] <= at && end <= markers[low][1];
|
|
77
94
|
}
|
|
78
|
-
/**
|
|
79
|
-
|
|
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
|
|
87
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
119
|
-
|
|
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
|
}
|
package/dist/feedback/draft.d.ts
CHANGED
|
@@ -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/
|
|
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;
|
package/dist/feedback/draft.js
CHANGED
|
@@ -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 {
|
|
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
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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 {
|
|
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 >
|
|
73
|
-
throw new Error(`Source exceeds ${
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
545
|
-
if (
|
|
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);
|
package/dist/feedback/export.js
CHANGED
|
@@ -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/
|
|
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
|
-
|
|
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
|
|
345
|
+
return applySharingProblem(verified, inlined, options, {
|
|
326
346
|
status: analysisSharing.sensitive ? "blocked" : "local_only",
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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,
|
|
359
|
+
const { notes, problem } = await readNotesForSharing(runPaths);
|
|
351
360
|
for (const skipped of notes.skipped)
|
|
352
361
|
warnings.push(`Reviewer notes: ${skipped}`);
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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) {
|