humanish 0.113.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.
- package/CHANGELOG.md +41 -0
- package/CONTEXT.md +1 -0
- package/README.md +1 -1
- 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/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 +10 -46
- 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 +12 -36
- package/dist/actors/computer-use/openai-provider.js +11 -5
- package/dist/actors/contract.d.ts +6 -22
- package/dist/actors/contract.js +0 -13
- package/dist/analysis/admission.d.ts +47 -0
- package/dist/analysis/admission.js +65 -0
- package/dist/analysis/automatic-completion.d.ts +0 -6
- package/dist/analysis/automatic-completion.js +3 -3
- package/dist/analysis/automatic-config.d.ts +10 -2
- package/dist/analysis/automatic-config.js +29 -14
- package/dist/analysis/automatic.js +7 -0
- package/dist/analysis/execute.d.ts +24 -5
- package/dist/analysis/execute.js +61 -27
- package/dist/analysis/finding-lead.d.ts +27 -0
- package/dist/analysis/finding-lead.js +23 -0
- package/dist/analysis/image-tokens.d.ts +2 -0
- package/dist/analysis/image-tokens.js +1 -1
- package/dist/analysis/job.d.ts +9 -1
- package/dist/analysis/job.js +11 -2
- package/dist/analysis/service.js +8 -3
- package/dist/cli/commands/analyze.js +7 -2
- package/dist/cli/commands/notes.d.ts +3 -0
- package/dist/cli/commands/notes.js +145 -0
- package/dist/cli/commands/observe.js +1 -1
- package/dist/cli/commands/study-run.js +2 -1
- package/dist/cli/findings.js +11 -30
- package/dist/cli/io.d.ts +1 -0
- package/dist/cli/io.js +8 -12
- package/dist/cli/program.js +2 -0
- package/dist/cli/telemetry.js +10 -22
- package/dist/cli/update-check.js +6 -18
- package/dist/cli/user-config.d.ts +11 -0
- package/dist/cli/user-config.js +25 -0
- package/dist/comms/email-catch.js +7 -31
- package/dist/evidence/encoded-text.d.ts +17 -3
- package/dist/evidence/encoded-text.js +121 -46
- package/dist/evidence/redaction.d.ts +11 -0
- package/dist/evidence/redaction.js +21 -10
- package/dist/evidence/secret-scrub.d.ts +14 -1
- package/dist/evidence/secret-scrub.js +125 -28
- package/dist/feedback/draft.d.ts +19 -0
- package/dist/feedback/draft.js +62 -15
- package/dist/feedback/export-bundle.js +3 -3
- package/dist/feedback/export.js +50 -20
- package/dist/feedback/feedback.js +29 -2
- package/dist/keys/key-resolution.js +12 -17
- package/dist/observer/artifact.d.ts +5 -0
- package/dist/observer/artifact.js +18 -2
- package/dist/observer/http.d.ts +7 -0
- package/dist/observer/http.js +19 -1
- package/dist/observer/notes-route.d.ts +20 -0
- package/dist/observer/notes-route.js +120 -0
- package/dist/observer/pinned-files.d.ts +8 -1
- package/dist/observer/pinned-files.js +17 -3
- package/dist/observer/render.js +39 -14
- package/dist/observer/run-routes.d.ts +8 -2
- package/dist/observer/run-routes.js +32 -6
- package/dist/observer/serve.d.ts +3 -0
- package/dist/observer/serve.js +29 -3
- package/dist/observer-app.html +7 -6
- package/dist/run/contained-output.d.ts +8 -0
- package/dist/run/contained-output.js +27 -0
- package/dist/run/evidence-files.d.ts +7 -0
- package/dist/run/evidence-files.js +24 -14
- package/dist/run/note-files.d.ts +54 -0
- package/dist/run/note-files.js +212 -0
- package/dist/run/note-shape.d.ts +35 -0
- package/dist/run/note-shape.js +20 -0
- package/dist/run/notes.d.ts +34 -0
- package/dist/run/notes.js +192 -0
- package/dist/run/participant-caption.d.ts +9 -0
- package/dist/run/participant-caption.js +19 -0
- package/dist/run/participant-records.d.ts +2 -0
- package/dist/run/participant-records.js +17 -2
- package/dist/run/transient-comms-secrets.d.ts +6 -5
- package/dist/run/transient-comms-secrets.js +19 -24
- package/dist/study/doctor.js +1 -1
- package/dist/study/preflight-result.js +2 -1
- package/dist/study/summary.d.ts +4 -5
- package/dist/study/summary.js +6 -4
- package/dist/study/warnings.js +2 -1
- package/dist/tui-app.js +1 -1
- package/docs/architecture/actor-contract.md +3 -3
- package/docs/architecture/restricted-codex-analysis.md +1 -1
- package/docs/architecture/state-driven-executor.md +1 -1
- package/docs/contracts/errors.md +2 -2
- package/docs/contracts/run-bundle.md +61 -0
- package/docs/contracts/schemas.md +17 -14
- package/docs/contracts/study-analysis.md +35 -9
- package/docs/principles/invariants-and-defaults.md +2 -2
- package/docs/product/automatic-analysis.md +22 -16
- package/package.json +8 -7
- package/skills/humanish/SKILL.md +6 -2
package/dist/cli/telemetry.js
CHANGED
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
import { isCuaDiagnosticCategory, isCuaDiagnosticStopCause, } from "../routes/computer-use/diagnostics.js";
|
|
13
13
|
import { randomUUID } from "node:crypto";
|
|
14
14
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
15
|
-
import { homedir } from "node:os";
|
|
16
15
|
import path from "node:path";
|
|
17
16
|
import { STUDY_RESULT_SCHEMA } from "../run/study-result.js";
|
|
17
|
+
import { envFlag, humanishConfigFile } from "./user-config.js";
|
|
18
18
|
/** Write-only PostHog project key. Public by design: it can ingest, and can read nothing. */
|
|
19
19
|
const INGEST_KEY = "phc_oeMeBqxDZhZ9tCHMSnuDFimLqHpU5Myc847WD33hAh4C";
|
|
20
20
|
const INGEST_HOST = "https://us.i.posthog.com";
|
|
@@ -26,26 +26,16 @@ const STARTER_STUDIES = new Set([
|
|
|
26
26
|
"local-browser",
|
|
27
27
|
"lobby-trivia-3player",
|
|
28
28
|
]);
|
|
29
|
-
export function telemetryStatePath(env = process.env, home
|
|
30
|
-
|
|
31
|
-
// Same XDG rule the key store uses: a relative value must be ignored, or state becomes
|
|
32
|
-
// cwd-relative and follows people between projects.
|
|
33
|
-
const configHome = declared !== undefined && declared !== "" && path.isAbsolute(declared)
|
|
34
|
-
? declared
|
|
35
|
-
: path.join(home, ".config");
|
|
36
|
-
return path.join(configHome, "humanish", "telemetry.json");
|
|
29
|
+
export function telemetryStatePath(env = process.env, home) {
|
|
30
|
+
return humanishConfigFile(env, "telemetry.json", home);
|
|
37
31
|
}
|
|
38
32
|
/**
|
|
39
33
|
* Off means off, from any of the three switches people already expect: the cross-tool
|
|
40
34
|
* `DO_NOT_TRACK` standard, our own env var, and the persisted opt-out.
|
|
41
35
|
*/
|
|
42
36
|
export function disabledByEnvironment(env) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
value.trim() !== "0" &&
|
|
46
|
-
value.trim().toLowerCase() !== "false";
|
|
47
|
-
return (truthy(env.DO_NOT_TRACK) ||
|
|
48
|
-
truthy(env.HUMANISH_TELEMETRY_DISABLED) ||
|
|
37
|
+
return (envFlag(env.DO_NOT_TRACK) ||
|
|
38
|
+
envFlag(env.HUMANISH_TELEMETRY_DISABLED) ||
|
|
49
39
|
// Our own development and test runs must never reach the adoption dataset. In the first two
|
|
50
40
|
// days after telemetry shipped, 82% of events (4,042 of 4,932, from 49 of 59 anonymous ids)
|
|
51
41
|
// came from humanish's own CI and suite: ~50 ids each running nearly every subcommand about
|
|
@@ -55,7 +45,7 @@ export function disabledByEnvironment(env) {
|
|
|
55
45
|
// Deliberately not keyed on CI. An adopter running humanish in their pipeline is real usage
|
|
56
46
|
// and stays countable; the `ci` property already separates it, which is what Next.js does.
|
|
57
47
|
// This keys on being inside the humanish source tree, which only we ever are.
|
|
58
|
-
|
|
48
|
+
envFlag(env.HUMANISH_DEV));
|
|
59
49
|
}
|
|
60
50
|
/**
|
|
61
51
|
* True when this process is running from a humanish source checkout rather than an install.
|
|
@@ -101,7 +91,7 @@ export function inHumanishCheckout(startDir, readFileSyncFn) {
|
|
|
101
91
|
export function isOwnCheckoutRun(cwd, cliDir, readFileSyncFn) {
|
|
102
92
|
return inHumanishCheckout(cwd, readFileSyncFn) || inHumanishCheckout(cliDir, readFileSyncFn);
|
|
103
93
|
}
|
|
104
|
-
export async function readTelemetryState(env = process.env, home
|
|
94
|
+
export async function readTelemetryState(env = process.env, home) {
|
|
105
95
|
try {
|
|
106
96
|
const raw = await readFile(telemetryStatePath(env, home), "utf8");
|
|
107
97
|
const parsed = JSON.parse(raw);
|
|
@@ -115,7 +105,7 @@ export async function readTelemetryState(env = process.env, home = homedir()) {
|
|
|
115
105
|
return { enabled: true, anonymousId: randomUUID(), noticed: false };
|
|
116
106
|
}
|
|
117
107
|
}
|
|
118
|
-
export async function writeTelemetryState(state, env = process.env, home
|
|
108
|
+
export async function writeTelemetryState(state, env = process.env, home) {
|
|
119
109
|
const file = telemetryStatePath(env, home);
|
|
120
110
|
await mkdir(path.dirname(file), { recursive: true });
|
|
121
111
|
await writeFile(file, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
@@ -162,15 +152,13 @@ export function buildPayload(args) {
|
|
|
162
152
|
version: args.version,
|
|
163
153
|
os: args.platform ?? process.platform,
|
|
164
154
|
node: (args.nodeVersion ?? process.version).split(".")[0].replace("v", ""),
|
|
165
|
-
ci: env.CI
|
|
155
|
+
ci: envFlag(env.CI),
|
|
166
156
|
// A humanish study participant is a real cold install on a machine we do not own, so it emits
|
|
167
157
|
// like any other new adopter and is indistinguishable from one. It is also the exact
|
|
168
158
|
// population we are trying to count, so a busy self-study day reads as an adoption spike.
|
|
169
159
|
// Stamped rather than suppressed, the same shape as `ci`: participant runs stay visible and
|
|
170
160
|
// stay separable, and we keep a genuine measurement of what a cold install does.
|
|
171
|
-
studyParticipant: env.HUMANISH_STUDY_PARTICIPANT
|
|
172
|
-
env.HUMANISH_STUDY_PARTICIPANT !== "" &&
|
|
173
|
-
env.HUMANISH_STUDY_PARTICIPANT !== "0",
|
|
161
|
+
studyParticipant: envFlag(env.HUMANISH_STUDY_PARTICIPANT),
|
|
174
162
|
};
|
|
175
163
|
const given = args.properties ?? {};
|
|
176
164
|
if (given.command !== undefined)
|
package/dist/cli/update-check.js
CHANGED
|
@@ -4,20 +4,12 @@
|
|
|
4
4
|
// the command that started it.
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
-
import { homedir } from "node:os";
|
|
8
7
|
import path from "node:path";
|
|
9
8
|
import { fileURLToPath } from "node:url";
|
|
10
9
|
import { disabledByEnvironment } from "./telemetry.js";
|
|
10
|
+
import { envFlag, humanishConfigFile } from "./user-config.js";
|
|
11
11
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
12
12
|
const REGISTRY_DIST_TAGS = "https://registry.npmjs.org/-/package/humanish/dist-tags";
|
|
13
|
-
/** Next to telemetry.json, under the same XDG rule the key store uses. */
|
|
14
|
-
function updateCachePath(env, home = homedir()) {
|
|
15
|
-
const declared = env.XDG_CONFIG_HOME?.trim();
|
|
16
|
-
const configHome = declared !== undefined && declared !== "" && path.isAbsolute(declared)
|
|
17
|
-
? declared
|
|
18
|
-
: path.join(home, ".config");
|
|
19
|
-
return path.join(configHome, "humanish", "update-check.json");
|
|
20
|
-
}
|
|
21
13
|
function readUpdateCache(cachePath) {
|
|
22
14
|
try {
|
|
23
15
|
const parsed = JSON.parse(readFileSync(cachePath, "utf8"));
|
|
@@ -65,10 +57,6 @@ function dayPassed(timestamp, now) {
|
|
|
65
57
|
const then = timestamp === undefined ? Number.NaN : Date.parse(timestamp);
|
|
66
58
|
return Number.isNaN(then) || now - then >= DAY_MS;
|
|
67
59
|
}
|
|
68
|
-
const truthy = (value) => value !== undefined &&
|
|
69
|
-
value.trim() !== "" &&
|
|
70
|
-
value.trim() !== "0" &&
|
|
71
|
-
value.trim().toLowerCase() !== "false";
|
|
72
60
|
/**
|
|
73
61
|
* Only a person at a terminal is told, and only when nothing has switched the check off. A pipe,
|
|
74
62
|
* CI and a JSON document have a program reading them. A study participant runs the version the
|
|
@@ -80,9 +68,9 @@ function checkAllowed(context) {
|
|
|
80
68
|
return (context.terminal &&
|
|
81
69
|
!context.json &&
|
|
82
70
|
!context.ownCheckout &&
|
|
83
|
-
!
|
|
84
|
-
!
|
|
85
|
-
!
|
|
71
|
+
!envFlag(env.CI) &&
|
|
72
|
+
!envFlag(env.HUMANISH_NO_UPDATE_CHECK) &&
|
|
73
|
+
!envFlag(env.HUMANISH_STUDY_PARTICIPANT) &&
|
|
86
74
|
!disabledByEnvironment(env));
|
|
87
75
|
}
|
|
88
76
|
/**
|
|
@@ -94,7 +82,7 @@ export function checkForUpdate(context) {
|
|
|
94
82
|
try {
|
|
95
83
|
if (!checkAllowed(context))
|
|
96
84
|
return undefined;
|
|
97
|
-
const cachePath =
|
|
85
|
+
const cachePath = humanishConfigFile(context.env, "update-check.json", context.home);
|
|
98
86
|
const cache = readUpdateCache(cachePath);
|
|
99
87
|
const stamp = new Date(context.now).toISOString();
|
|
100
88
|
const next = { ...cache };
|
|
@@ -126,7 +114,7 @@ export function checkForUpdate(context) {
|
|
|
126
114
|
* cache only; undefined until a check has succeeded on this machine.
|
|
127
115
|
*/
|
|
128
116
|
export function recordedVersion(installed, env, home) {
|
|
129
|
-
const cache = readUpdateCache(
|
|
117
|
+
const cache = readUpdateCache(humanishConfigFile(env, "update-check.json", home));
|
|
130
118
|
if (cache.latest === undefined)
|
|
131
119
|
return undefined;
|
|
132
120
|
const checked = cache.checkedAt === undefined ? "" : ` (checked ${cache.checkedAt.slice(0, 10)})`;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `$XDG_CONFIG_HOME/humanish/<fileName>`, or `<home>/.config/humanish/<fileName>`. The XDG spec
|
|
3
|
+
* says to ignore a relative XDG_CONFIG_HOME. Honoring one would put the file in whichever project
|
|
4
|
+
* is open, and for the key store that writes a secret into the repo.
|
|
5
|
+
*/
|
|
6
|
+
export declare function humanishConfigFile(env: NodeJS.ProcessEnv, fileName: string, home?: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* Whether a flag variable such as `CI` or `DO_NOT_TRACK` is on. Unset, blank, `0` and `false` (any
|
|
9
|
+
* case, surrounding spaces ignored) are off, as is-in-ci reads `CI`; any other value is on.
|
|
10
|
+
*/
|
|
11
|
+
export declare function envFlag(value: string | undefined): boolean;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Where humanish keeps per-user files (the key store, telemetry state, the update-check cache) and
|
|
2
|
+
// how it reads an on/off environment variable. The key store, telemetry and the update check each
|
|
3
|
+
// had their own copy of both.
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
/**
|
|
7
|
+
* `$XDG_CONFIG_HOME/humanish/<fileName>`, or `<home>/.config/humanish/<fileName>`. The XDG spec
|
|
8
|
+
* says to ignore a relative XDG_CONFIG_HOME. Honoring one would put the file in whichever project
|
|
9
|
+
* is open, and for the key store that writes a secret into the repo.
|
|
10
|
+
*/
|
|
11
|
+
export function humanishConfigFile(env, fileName, home = homedir()) {
|
|
12
|
+
const declared = env.XDG_CONFIG_HOME?.trim();
|
|
13
|
+
const configHome = declared !== undefined && declared !== "" && path.isAbsolute(declared)
|
|
14
|
+
? declared
|
|
15
|
+
: path.join(home, ".config");
|
|
16
|
+
return path.join(configHome, "humanish", fileName);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Whether a flag variable such as `CI` or `DO_NOT_TRACK` is on. Unset, blank, `0` and `false` (any
|
|
20
|
+
* case, surrounding spaces ignored) are off, as is-in-ci reads `CI`; any other value is on.
|
|
21
|
+
*/
|
|
22
|
+
export function envFlag(value) {
|
|
23
|
+
const trimmed = value?.trim() ?? "";
|
|
24
|
+
return trimmed !== "" && trimmed !== "0" && trimmed.toLowerCase() !== "false";
|
|
25
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
//
|
|
10
10
|
// 127.0.0.1 only; request bodies are never logged; request size is capped.
|
|
11
11
|
import { createServer } from "node:http";
|
|
12
|
+
import { readBodyAtMost } from "../observer/http.js";
|
|
12
13
|
import { capturedInlineImages } from "./images.js";
|
|
13
14
|
// ---------------------------------------------------------------- shared parse helpers
|
|
14
15
|
function str(value) {
|
|
@@ -116,26 +117,6 @@ export const DEFAULT_EMAIL_PROFILES = [
|
|
|
116
117
|
sendgridEmailProfile,
|
|
117
118
|
];
|
|
118
119
|
// ---------------------------------------------------------------- server
|
|
119
|
-
class BodyTooLargeError extends Error {
|
|
120
|
-
}
|
|
121
|
-
function readBody(req, limit) {
|
|
122
|
-
return new Promise((resolve, reject) => {
|
|
123
|
-
let size = 0;
|
|
124
|
-
const chunks = [];
|
|
125
|
-
req.on("data", (chunk) => {
|
|
126
|
-
size += chunk.length;
|
|
127
|
-
if (size > limit) {
|
|
128
|
-
// Reject without destroying the socket here: the handler responds 413 cleanly first (a
|
|
129
|
-
// write-after-destroy would otherwise reset the connection), then tears the request down.
|
|
130
|
-
reject(new BodyTooLargeError("request body too large"));
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
chunks.push(chunk);
|
|
134
|
-
});
|
|
135
|
-
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
136
|
-
req.on("error", reject);
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
120
|
const MAX_BODY_BYTES = 5 * 1024 * 1024;
|
|
140
121
|
/**
|
|
141
122
|
* Start the vendor-neutral email catch, routing accepted sends into `channel`. Returns the loopback
|
|
@@ -170,21 +151,16 @@ export async function startEmailCatchServer(channel, options = {}) {
|
|
|
170
151
|
respondJson(res, 404, { error: "not found" });
|
|
171
152
|
return;
|
|
172
153
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (!res.headersSent)
|
|
179
|
-
respondJson(res, error instanceof BodyTooLargeError ? 413 : 400, {
|
|
180
|
-
error: "request body could not be read",
|
|
181
|
-
});
|
|
182
|
-
req.destroy();
|
|
154
|
+
const body = await readBodyAtMost(req, MAX_BODY_BYTES).catch(() => undefined);
|
|
155
|
+
if (body === null || body === undefined) {
|
|
156
|
+
// The rest of the body is never read, so the connection closes after this answer.
|
|
157
|
+
res.setHeader("connection", "close");
|
|
158
|
+
respondJson(res, body === null ? 413 : 400, { error: "request body could not be read" });
|
|
183
159
|
return;
|
|
184
160
|
}
|
|
185
161
|
let parsed;
|
|
186
162
|
try {
|
|
187
|
-
parsed = JSON.parse(
|
|
163
|
+
parsed = JSON.parse(body.length > 0 ? body.toString("utf8") : "{}");
|
|
188
164
|
}
|
|
189
165
|
catch {
|
|
190
166
|
respondJson(res, 422, { error: "invalid JSON body" });
|
|
@@ -1,5 +1,19 @@
|
|
|
1
|
-
/**
|
|
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;
|
|
3
|
+
/**
|
|
4
|
+
* Undoes JSON and JS escapes, percent-encoding and HTML character references, one pass each. Each
|
|
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é`.
|
|
7
|
+
*/
|
|
2
8
|
export declare function decodeEscapes(text: string): string;
|
|
9
|
+
/**
|
|
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.
|
|
15
|
+
*/
|
|
16
|
+
export declare function readingsOf(text: string): string[];
|
|
3
17
|
/** How scanEncodedText reads text. */
|
|
4
18
|
export interface EncodedTextScanOptions {
|
|
5
19
|
/** Do not count an encoded binary run as opaque. */
|
|
@@ -14,8 +28,8 @@ export interface EncodedTextScan {
|
|
|
14
28
|
readonly opaque: boolean;
|
|
15
29
|
}
|
|
16
30
|
/**
|
|
17
|
-
* Scans text for secrets
|
|
18
|
-
*
|
|
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
|
|
19
33
|
* turn. A run that decodes to an archive, or to other binary past MIN_OPAQUE_BASE64_RUN characters,
|
|
20
34
|
* is opaque unless the caller allows opaque runs.
|
|
21
35
|
*/
|
|
@@ -2,58 +2,138 @@
|
|
|
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)
|
|
45
46
|
: original;
|
|
46
47
|
}
|
|
47
|
-
|
|
48
|
+
// The length of the UTF-8 sequence a lead byte starts, or 0 for a byte that starts none.
|
|
49
|
+
function sequenceLength(lead) {
|
|
50
|
+
if (lead < 0x80)
|
|
51
|
+
return 1;
|
|
52
|
+
if (lead >= 0xc2 && lead <= 0xdf)
|
|
53
|
+
return 2;
|
|
54
|
+
if (lead >= 0xe0 && lead <= 0xef)
|
|
55
|
+
return 3;
|
|
56
|
+
if (lead >= 0xf0 && lead <= 0xf4)
|
|
57
|
+
return 4;
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
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.
|
|
63
|
+
*/
|
|
64
|
+
function utf8Characters(bytes) {
|
|
65
|
+
if (isUtf8(bytes))
|
|
66
|
+
return bytes.toString("utf8");
|
|
67
|
+
let text = "";
|
|
68
|
+
for (let at = 0; at < bytes.length;) {
|
|
69
|
+
const length = sequenceLength(bytes[at]);
|
|
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
|
+
at += length;
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
text += String.fromCharCode(bytes[at]);
|
|
78
|
+
at += 1;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return text;
|
|
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")));
|
|
89
|
+
function decodePercentBytes(run) {
|
|
90
|
+
return Buffer.from(run.replace(/%/g, ""), "hex").toString("latin1");
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Undoes JSON and JS escapes, percent-encoding and HTML character references, one pass each. Each
|
|
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é`.
|
|
96
|
+
*/
|
|
48
97
|
export function decodeEscapes(text) {
|
|
98
|
+
return decodeWith(text, decodePercentBytes);
|
|
99
|
+
}
|
|
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
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
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.
|
|
121
|
+
*/
|
|
122
|
+
export function readingsOf(text) {
|
|
123
|
+
return readings(text).all;
|
|
124
|
+
}
|
|
125
|
+
function decodeWith(text, percent) {
|
|
49
126
|
return text
|
|
50
127
|
.replace(/\\u([0-9a-f]{4})/gi, (match, hex) => codePoint(Number.parseInt(hex, 16), match))
|
|
51
128
|
.replace(/\\x([0-9a-f]{2})/gi, (match, hex) => codePoint(Number.parseInt(hex, 16), match))
|
|
52
129
|
.replace(/\\\//g, "/")
|
|
53
|
-
.replace(
|
|
130
|
+
.replace(/(?:%[0-9a-f]{2})+/gi, percent)
|
|
54
131
|
.replace(/&#x([0-9a-f]{1,6});?/gi, (match, hex) => codePoint(Number.parseInt(hex, 16), match))
|
|
55
132
|
.replace(/&#(\d{1,7});?/g, (match, digits) => codePoint(Number.parseInt(digits, 10), match))
|
|
56
|
-
.replace(/&([a-z]+);/gi,
|
|
133
|
+
.replace(/&([a-z]+);/gi, namedReference);
|
|
134
|
+
}
|
|
135
|
+
function namedReference(match, name) {
|
|
136
|
+
return ASCII_ENTITIES.get(name.toLowerCase()) ?? match;
|
|
57
137
|
}
|
|
58
138
|
// Sixteen characters hold twelve bytes, enough for the start of a key. Shorter runs are mostly
|
|
59
139
|
// words and identifiers.
|
|
@@ -201,8 +281,7 @@ function inspectDecoded(bytes, run, options, depth) {
|
|
|
201
281
|
if (inner !== undefined) {
|
|
202
282
|
if (depth < MAX_DEPTH)
|
|
203
283
|
return scanEncodedText(inner, options, depth + 1);
|
|
204
|
-
|
|
205
|
-
return matches(inner) || (innerDecoded !== inner && matches(innerDecoded)) ? SENSITIVE : CLEAN;
|
|
284
|
+
return readingsOf(inner).some(matches) ? SENSITIVE : CLEAN;
|
|
206
285
|
}
|
|
207
286
|
// A key next to a few binary bytes is still a printable stretch.
|
|
208
287
|
if (matches(printableStretches(bytes)))
|
|
@@ -215,19 +294,15 @@ function inspectDecoded(bytes, run, options, depth) {
|
|
|
215
294
|
};
|
|
216
295
|
}
|
|
217
296
|
/**
|
|
218
|
-
* Scans text for secrets
|
|
219
|
-
*
|
|
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
|
|
220
299
|
* turn. A run that decodes to an archive, or to other binary past MIN_OPAQUE_BASE64_RUN characters,
|
|
221
300
|
* is opaque unless the caller allows opaque runs.
|
|
222
301
|
*/
|
|
223
302
|
export function scanEncodedText(text, options = {}, depth = 0) {
|
|
224
303
|
const matches = matcherOf(options);
|
|
225
|
-
const decoded =
|
|
226
|
-
|
|
227
|
-
// A decoding that returns the same string would match the same way, so it is not matched again.
|
|
228
|
-
if (matches(text) ||
|
|
229
|
-
(decoded !== text && matches(decoded)) ||
|
|
230
|
-
(expanded !== decoded && matches(expanded)))
|
|
304
|
+
const { decoded, expanded, all } = readings(text);
|
|
305
|
+
if (all.some(matches))
|
|
231
306
|
return SENSITIVE;
|
|
232
307
|
const runs = [];
|
|
233
308
|
// A piece or a run decoded from a slash only adds findings; its bytes are not judged opaque.
|
|
@@ -277,7 +352,7 @@ export function scanEncodedText(text, options = {}, depth = 0) {
|
|
|
277
352
|
// Bump when scanEncodedText, decodeEscapes or the sensitive patterns change what they return. The
|
|
278
353
|
// cache lives in one process, so the version guards results across a hot reload or a test that
|
|
279
354
|
// swaps the scanner.
|
|
280
|
-
const ENCODED_SCAN_VERSION =
|
|
355
|
+
const ENCODED_SCAN_VERSION = 5;
|
|
281
356
|
// Distinct files one process verifies in a burst (a run's files, a serve library's runs).
|
|
282
357
|
const SCAN_CACHE_LIMIT = 256;
|
|
283
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,
|
|
131
|
-
[/\/var\/folders\/[^\s"'`<>)]*/g,
|
|
132
|
-
[/\/private\/tmp\/[^\s"'`<>)]*/g,
|
|
133
|
-
[/\/tmp\/[^\s"'`<>)]*/g,
|
|
134
|
-
[/\/Users\/[A-Za-z0-9._-]+(?:\/[^\s"'`<>)]*)?/g,
|
|
135
|
-
[/\/home\/[A-Za-z0-9._-]+(?:\/[^\s"'`<>)]*)?/g,
|
|
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
|
-
|
|
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}
|
|
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),
|
|
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 =
|
|
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,
|
|
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;
|