humanish 0.110.1 → 0.110.2

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 (85) hide show
  1. package/ARCHITECTURE.md +1 -1
  2. package/CHANGELOG.md +13 -0
  3. package/README.md +8 -7
  4. package/dist/actors/local-agent/cli.d.ts +6 -0
  5. package/dist/actors/local-agent/cli.js +8 -0
  6. package/dist/actors/local-agent/readiness.js +2 -1
  7. package/dist/cli/commands/observe.js +15 -2
  8. package/dist/cli/commands/run-signals.js +13 -4
  9. package/dist/cli/commands/runs.js +36 -13
  10. package/dist/cli/commands/setup.js +23 -14
  11. package/dist/cli/commands/study-route-computer-use.js +7 -3
  12. package/dist/cli/commands/study-route-open.js +1 -0
  13. package/dist/cli/commands/study-route-preview.js +1 -0
  14. package/dist/cli/commands/study-route-shared-world.js +4 -2
  15. package/dist/cli/commands/watch.js +14 -4
  16. package/dist/cli/doctor.js +9 -6
  17. package/dist/cli/findings.js +7 -6
  18. package/dist/cli/first-run-path.d.ts +27 -7
  19. package/dist/cli/first-run-path.js +112 -75
  20. package/dist/cli/invocation.d.ts +7 -0
  21. package/dist/cli/invocation.js +32 -0
  22. package/dist/cli/io.d.ts +3 -1
  23. package/dist/cli/io.js +6 -14
  24. package/dist/cli/observer-follow.d.ts +20 -0
  25. package/dist/cli/observer-follow.js +36 -1
  26. package/dist/cli/orientation.js +16 -14
  27. package/dist/cli/version.d.ts +1 -0
  28. package/dist/cli/version.js +11 -0
  29. package/dist/evidence/encoded-text.d.ts +9 -6
  30. package/dist/evidence/encoded-text.js +84 -19
  31. package/dist/evidence/redaction.d.ts +2 -0
  32. package/dist/evidence/redaction.js +123 -21
  33. package/dist/feedback/export-bundle.js +2 -2
  34. package/dist/keys/key-resolution.js +6 -5
  35. package/dist/observer/exposure.js +1 -1
  36. package/dist/observer/library.js +3 -2
  37. package/dist/observer/render.js +2 -1
  38. package/dist/observer/run-routes.js +8 -1
  39. package/dist/observer/serve.js +2 -1
  40. package/dist/routes/computer-use/plan.js +5 -0
  41. package/dist/routes/shared-world/plan.js +5 -1
  42. package/dist/routes/terminal/bundle.js +2 -1
  43. package/dist/routes/terminal/credentials.d.ts +5 -5
  44. package/dist/routes/terminal/credentials.js +14 -6
  45. package/dist/routes/terminal/plan.js +8 -0
  46. package/dist/routes/terminal/route.js +5 -4
  47. package/dist/routes/terminal/runtime-auth.d.ts +6 -0
  48. package/dist/routes/terminal/runtime-auth.js +5 -0
  49. package/dist/run/dry-run.js +2 -1
  50. package/dist/run/judge.js +2 -1
  51. package/dist/run/local-only-files.d.ts +13 -0
  52. package/dist/run/local-only-files.js +35 -0
  53. package/dist/run/reclaim.d.ts +5 -0
  54. package/dist/run/reclaim.js +34 -2
  55. package/dist/run/run-not-found.js +3 -2
  56. package/dist/run/sandbox-creates.js +5 -3
  57. package/dist/run/sandbox-receipts.d.ts +6 -5
  58. package/dist/run/sandbox-receipts.js +12 -7
  59. package/dist/study/agents-section.d.ts +21 -0
  60. package/dist/study/agents-section.js +86 -0
  61. package/dist/study/doctor.js +5 -4
  62. package/dist/study/init-templates.js +0 -95
  63. package/dist/study/init.js +31 -34
  64. package/dist/study/local-browser-host.d.ts +11 -0
  65. package/dist/study/local-browser-host.js +42 -0
  66. package/dist/study/parse/actors.js +9 -1
  67. package/dist/study/parse/subject.js +12 -0
  68. package/dist/study/parse/url-credentials.d.ts +9 -0
  69. package/dist/study/parse/url-credentials.js +50 -0
  70. package/dist/study/preflight-probes.js +2 -1
  71. package/dist/study/types.d.ts +8 -6
  72. package/dist/study/url-credentials.d.ts +6 -0
  73. package/dist/study/url-credentials.js +34 -0
  74. package/dist/substrates/e2b/sandbox.js +33 -15
  75. package/dist/substrates/local/firecracker-desktop.js +3 -2
  76. package/dist/substrates/local/runtime-host.js +5 -4
  77. package/dist/substrates/local/runtime.js +5 -4
  78. package/dist/tui/contract.js +5 -4
  79. package/dist/verify/liveness.js +2 -1
  80. package/dist/verify/verify.js +2 -1
  81. package/docs/architecture/terminal-product-route.md +14 -12
  82. package/docs/contracts/run-bundle.md +20 -4
  83. package/docs/contracts/schemas.md +6 -5
  84. package/package.json +4 -2
  85. package/skills/humanish/SKILL.md +22 -18
@@ -17,6 +17,7 @@ import { listRuns } from "../run/stored-runs.js";
17
17
  import { plural } from "../run/text.js";
18
18
  import { supportsLocalBrowser } from "./first-run-path.js";
19
19
  import { PRODUCT_SENTENCE } from "./product-sentence.js";
20
+ import { cli } from "./invocation.js";
20
21
  export const ORIENTATION_SCHEMA = "humanish.orientation.v1";
21
22
  /**
22
23
  * Read the project's state. Pure-ish: it only reads, never writes, and never touches the network or
@@ -69,7 +70,7 @@ function nextCommandsFor(args) {
69
70
  ? []
70
71
  : [
71
72
  {
72
- command: "humanish migrate --dry-run",
73
+ command: cli("migrate --dry-run"),
73
74
  why: `${plural(v2, "study file")} ${v2 === 1 ? "uses" : "use"} humanish.lab.v2, which humanish no longer reads; this lists the conversion, and humanish migrate writes it`,
74
75
  },
75
76
  ]),
@@ -77,41 +78,42 @@ function nextCommandsFor(args) {
77
78
  ? []
78
79
  : [
79
80
  {
80
- command: "humanish study list",
81
+ command: cli("study list"),
81
82
  why: `${plural(moved, "study file")} ${moved === 1 ? "is" : "are"} in a labs/ directory, which humanish no longer reads; this names each one and the studies/ directory to move it to`,
82
83
  },
83
84
  ]),
84
85
  ];
85
- // A fix that is already `humanish study list` replaces the plain suggestion of the same command.
86
+ // A fix that is already `study list` replaces the plain suggestion of the same command.
86
87
  const named = new Set(fixes.map((fix) => fix.command));
87
88
  return [...fixes, ...nextCommandsForStudies(args).filter((next) => !named.has(next.command))];
88
89
  }
89
90
  function nextCommandsForStudies(args) {
90
91
  const dryRun = {
91
- command: "humanish run first-run",
92
+ command: cli("run first-run"),
92
93
  why: "a dry run: no browser or model runs, no keys, no spend",
93
94
  };
94
95
  if (!args.initialized) {
95
96
  return [
96
97
  {
97
- command: "humanish init --yes",
98
+ command: cli("init --yes"),
98
99
  why: "write starter studies, personas and an AGENTS.md (--dry-run lists every file first)",
99
100
  },
100
101
  dryRun,
101
102
  ];
102
103
  }
103
- // The starter live study this host can run. The other starter studies are templates whose subject
104
- // still names your-org/your-app, so they are never suggested.
104
+ // The starter live study this host can run: the hosted try-live study first, since it needs no
105
+ // local Docker or VM, then the local one where the host shape supports it. The other starter
106
+ // studies are templates whose subject still names your-org/your-app, so they are never suggested.
105
107
  const liveStudy = (supportsLocalBrowser(args.host.platform, args.host.arch)
106
- ? ["local-browser", "try-live"]
108
+ ? ["try-live", "local-browser"]
107
109
  : ["try-live"]).find((id) => args.studyIds.includes(id));
108
110
  if (!args.hasRun) {
109
111
  return [
110
112
  dryRun,
111
113
  liveStudy === undefined
112
- ? { command: "humanish study list", why: "see the studies this project declares" }
114
+ ? { command: cli("study list"), why: "see the studies this project declares" }
113
115
  : {
114
- command: `humanish doctor --study ${liveStudy}`,
116
+ command: cli(`doctor --study ${liveStudy}`),
115
117
  why: `check what the ${liveStudy} study still needs before a live run`,
116
118
  },
117
119
  ];
@@ -119,12 +121,12 @@ function nextCommandsForStudies(args) {
119
121
  return [
120
122
  ...(liveStudy === undefined
121
123
  ? []
122
- : [{ command: `humanish run ${liveStudy}`, why: "run a real participant against an app" }]),
124
+ : [{ command: cli(`run ${liveStudy}`), why: "run a real participant against an app" }]),
123
125
  {
124
- command: "humanish verify --run latest",
126
+ command: cli("verify --run latest"),
125
127
  why: "check the last run's evidence and public-safety gates",
126
128
  },
127
- { command: "humanish observe --run latest", why: "reopen the last run's Observer" },
129
+ { command: cli("observe --run latest"), why: "reopen the last run's Observer" },
128
130
  ];
129
131
  }
130
132
  /**
@@ -151,6 +153,6 @@ export function formatOrientationHuman(state) {
151
153
  lines.push(` ${next.why}`);
152
154
  }
153
155
  lines.push("");
154
- lines.push("`humanish --help` lists every command.");
156
+ lines.push(`\`${cli("--help")}\` lists every command.`);
155
157
  return `${lines.join("\n")}\n`;
156
158
  }
@@ -0,0 +1 @@
1
+ export declare const CLI_VERSION: string;
@@ -0,0 +1,11 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ function readCliVersion() {
5
+ const packageJsonPath = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
6
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
7
+ return typeof packageJson.version === "string" && packageJson.version.trim()
8
+ ? packageJson.version
9
+ : "0.0.0";
10
+ }
11
+ export const CLI_VERSION = readCliVersion();
@@ -1,5 +1,12 @@
1
1
  /** Undoes JSON and JS escapes, percent-encoding and HTML character references, one pass each. */
2
2
  export declare function decodeEscapes(text: string): string;
3
+ /** How scanEncodedText reads text. */
4
+ export interface EncodedTextScanOptions {
5
+ /** Do not count an encoded binary run as opaque. */
6
+ readonly allowOpaqueBase64?: boolean;
7
+ /** Match secret shapes only, leaving local paths out, as a URL's path may look like one. */
8
+ readonly secretsOnly?: boolean;
9
+ }
3
10
  export interface EncodedTextScan {
4
11
  /** A secret or private path, in the text or in a decoding of it. */
5
12
  readonly sensitive: boolean;
@@ -12,9 +19,7 @@ export interface EncodedTextScan {
12
19
  * turn. A run that decodes to an archive, or to other binary past MIN_OPAQUE_BASE64_RUN characters,
13
20
  * is opaque unless the caller allows opaque runs.
14
21
  */
15
- export declare function scanEncodedText(text: string, options?: {
16
- allowOpaqueBase64?: boolean;
17
- }, depth?: number): EncodedTextScan;
22
+ export declare function scanEncodedText(text: string, options?: EncodedTextScanOptions, depth?: number): EncodedTextScan;
18
23
  /**
19
24
  * scanEncodedText, cached in this process. The key is the sha256 of the scanned string's UTF-16
20
25
  * code units, the options and ENCODED_SCAN_VERSION. Encoding as UTF-16LE keeps every code unit,
@@ -22,6 +27,4 @@ export declare function scanEncodedText(text: string, options?: {
22
27
  * scan of the same string would give. Repeated verifies of an unchanged file, as serve admission
23
28
  * and the Observer render do, skip the scan.
24
29
  */
25
- export declare function scanEncodedTextCached(text: string, options?: {
26
- allowOpaqueBase64?: boolean;
27
- }): EncodedTextScan;
30
+ export declare function scanEncodedTextCached(text: string, options?: EncodedTextScanOptions): EncodedTextScan;
@@ -4,7 +4,7 @@
4
4
  // judge the same decoded text.
5
5
  import { createHash } from "node:crypto";
6
6
  import { readPlainText } from "./plain-text.js";
7
- import { containsSensitive } from "./redaction.js";
7
+ import { containsSecret, containsSensitive } from "./redaction.js";
8
8
  // HTML5 named references that stand for printable ASCII. Letters and digits have only numeric
9
9
  // references, which decodeEscapes handles.
10
10
  const ASCII_ENTITIES = {
@@ -57,15 +57,13 @@ export function decodeEscapes(text) {
57
57
  }
58
58
  // Sixteen characters hold twelve bytes, enough for the start of a key. Shorter runs are mostly
59
59
  // words and identifiers.
60
- const BASE64_RUN = /[A-Za-z0-9+/]{16,}={0,2}/g;
60
+ // Each open-ended repeat is written `[...]{n}[...]*`: V8 runs `{n,}` with a backtrack entry per
61
+ // character and overflows its stack on a run of several megabytes.
62
+ const BASE64_RUN = /[A-Za-z0-9+/]{16}[A-Za-z0-9+/]*={0,2}/g;
61
63
  // The URL-safe alphabet swaps `+/` for `-_`. Only runs that use `-` or `_` need this pass.
62
- const BASE64URL_RUN = /[A-Za-z0-9_-]{16,}={0,2}/g;
63
- // MIME and PEM wrap base64 at 64 or 76 characters a line. The lookbehind starts a match only at the
64
- // start of a run, and each lookahead-backreference pair takes a line whole, so a long unwrapped run
65
- // cannot backtrack.
66
- const WRAPPED_BASE64 = /(?<![A-Za-z0-9+/])(?=([A-Za-z0-9+/]{16,}))\1(?:[ \t]*\r?\n[ \t]*(?=([A-Za-z0-9+/]{4,}))\2)+={0,2}/g;
64
+ const BASE64URL_RUN = /[A-Za-z0-9_-]{16}[A-Za-z0-9_-]*={0,2}/g;
67
65
  // Hex-encoded text, at least sixteen bytes of it.
68
- const HEX_RUN = /(?:[0-9a-f]{2}){16,}/gi;
66
+ const HEX_RUN = /[0-9a-f]{32}[0-9a-f]*/gi;
69
67
  // A base64 run that decodes to bytes that are neither text nor an archive is unreadable only past
70
68
  // this length. Across 173 real run folders surveyed on 2026-10-01, the longest such run outside
71
69
  // observer/index.html was a 69-character URL path in a terminal log; base64 of a 94-byte payload,
@@ -84,6 +82,61 @@ const ARCHIVE_MAGIC = [
84
82
  [0x28, 0xb5, 0x2f, 0xfd],
85
83
  ];
86
84
  const CONTROL_CHARACTERS = /[\x00-\x08\x0b\x0c\x0e-\x1f]/;
85
+ // A run such as `com/x/<base64>` from a URL path starts with segments that shift the alignment of
86
+ // the base64 after them. A run up to this long is also decoded one slash-separated piece at a time,
87
+ // and from just after each slash in its first SLASH_START_SPAN characters, for base64 that holds a
88
+ // slash itself. Longer runs are images, fonts and archives, not URLs.
89
+ const MAX_SLASH_START_RUN = 4096;
90
+ const SLASH_START_SPAN = 64;
91
+ function isBase64Code(code) {
92
+ return ((code >= 0x30 && code <= 0x39) ||
93
+ (code >= 0x41 && code <= 0x5a) ||
94
+ (code >= 0x61 && code <= 0x7a) ||
95
+ code === 0x2b ||
96
+ code === 0x2f);
97
+ }
98
+ /**
99
+ * Base64 that MIME and PEM wrap at 64 or 76 characters a line: a run of 16 or more characters that
100
+ * ends its line, then the leading run of 4 or more on each following line, for as long as a line
101
+ * holds nothing else. Read line by line, so a long wrapped block costs linear time and no regex
102
+ * stack.
103
+ */
104
+ function wrappedBase64Runs(text) {
105
+ const runs = [];
106
+ let current = [];
107
+ const flush = () => {
108
+ if (current.length > 1)
109
+ runs.push(current.join(""));
110
+ current = [];
111
+ };
112
+ for (const raw of text.split("\n")) {
113
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
114
+ let end = line.length;
115
+ while (end > 0 && (line[end - 1] === " " || line[end - 1] === "\t"))
116
+ end -= 1;
117
+ let start = 0;
118
+ while (start < end && (line[start] === " " || line[start] === "\t"))
119
+ start += 1;
120
+ if (current.length > 0) {
121
+ let lead = start;
122
+ while (lead < end && isBase64Code(line.charCodeAt(lead)))
123
+ lead += 1;
124
+ if (lead - start >= 4) {
125
+ current.push(line.slice(start, lead));
126
+ if (lead === end)
127
+ continue;
128
+ }
129
+ flush();
130
+ }
131
+ let tail = end;
132
+ while (tail > 0 && isBase64Code(line.charCodeAt(tail - 1)))
133
+ tail -= 1;
134
+ if (end - tail >= 16)
135
+ current = [line.slice(tail, end)];
136
+ }
137
+ flush();
138
+ return runs;
139
+ }
87
140
  // Base64 inside base64 is read this many levels deep.
88
141
  const MAX_DEPTH = 2;
89
142
  function startsWithArchive(bytes) {
@@ -136,9 +189,11 @@ function decodeTransferEscapes(text) {
136
189
  .replace(/=\r?\n/g, "")
137
190
  .replace(/=([0-9A-F]{2})/g, (match, hex) => codePoint(Number.parseInt(hex, 16), match));
138
191
  }
192
+ const matcherOf = (options) => options.secretsOnly === true ? containsSecret : containsSensitive;
139
193
  const CLEAN = Object.freeze({ sensitive: false, opaque: false });
140
194
  const SENSITIVE = Object.freeze({ sensitive: true, opaque: false });
141
195
  function inspectDecoded(bytes, run, options, depth) {
196
+ const matches = matcherOf(options);
142
197
  if (startsWithArchive(bytes))
143
198
  return { sensitive: false, opaque: options.allowOpaqueBase64 !== true };
144
199
  const plain = readPlainText(bytes);
@@ -147,12 +202,10 @@ function inspectDecoded(bytes, run, options, depth) {
147
202
  if (depth < MAX_DEPTH)
148
203
  return scanEncodedText(inner, options, depth + 1);
149
204
  const innerDecoded = decodeEscapes(inner);
150
- return containsSensitive(inner) || (innerDecoded !== inner && containsSensitive(innerDecoded))
151
- ? SENSITIVE
152
- : CLEAN;
205
+ return matches(inner) || (innerDecoded !== inner && matches(innerDecoded)) ? SENSITIVE : CLEAN;
153
206
  }
154
207
  // A key next to a few binary bytes is still a printable stretch.
155
- if (containsSensitive(printableStretches(bytes)))
208
+ if (matches(printableStretches(bytes)))
156
209
  return SENSITIVE;
157
210
  return {
158
211
  sensitive: false,
@@ -168,28 +221,36 @@ function inspectDecoded(bytes, run, options, depth) {
168
221
  * is opaque unless the caller allows opaque runs.
169
222
  */
170
223
  export function scanEncodedText(text, options = {}, depth = 0) {
224
+ const matches = matcherOf(options);
171
225
  const decoded = decodeEscapes(text);
172
226
  const expanded = decodeTransferEscapes(decoded);
173
227
  // A decoding that returns the same string would match the same way, so it is not matched again.
174
- if (containsSensitive(text) ||
175
- (decoded !== text && containsSensitive(decoded)) ||
176
- (expanded !== decoded && containsSensitive(expanded)))
228
+ if (matches(text) ||
229
+ (decoded !== text && matches(decoded)) ||
230
+ (expanded !== decoded && matches(expanded)))
177
231
  return SENSITIVE;
178
232
  const runs = [];
233
+ // A piece or a run decoded from a slash only adds findings; its bytes are not judged opaque.
234
+ const slashStarts = [];
179
235
  for (const [match] of expanded.matchAll(BASE64_RUN)) {
180
236
  // Hex digests and ids decode to noise; the hex pass below reads hex as hex.
181
237
  if (/^[0-9a-f]+$/i.test(match.replace(/=+$/, "")))
182
238
  continue;
183
239
  runs.push({ run: match, bytes: Buffer.from(match, "base64") });
240
+ if (match.length > MAX_SLASH_START_RUN)
241
+ continue;
242
+ for (const piece of match.split("/"))
243
+ if (piece.length >= 16 && piece.length < match.length)
244
+ slashStarts.push(Buffer.from(piece, "base64"));
245
+ for (let slash = match.indexOf("/"); slash !== -1 && slash < SLASH_START_SPAN && match.length - slash > 16; slash = match.indexOf("/", slash + 1))
246
+ slashStarts.push(Buffer.from(match.slice(slash + 1), "base64"));
184
247
  }
185
248
  for (const [match] of expanded.matchAll(BASE64URL_RUN)) {
186
249
  if (/[-_]/.test(match))
187
250
  runs.push({ run: match, bytes: Buffer.from(match, "base64url") });
188
251
  }
189
- for (const [match] of expanded.matchAll(WRAPPED_BASE64)) {
190
- const run = match.replace(/\s+/g, "");
252
+ for (const run of wrappedBase64Runs(expanded))
191
253
  runs.push({ run, bytes: Buffer.from(run, "base64") });
192
- }
193
254
  let opaque = false;
194
255
  for (const { run, bytes } of runs) {
195
256
  const result = inspectDecoded(bytes, run, options, depth);
@@ -197,6 +258,9 @@ export function scanEncodedText(text, options = {}, depth = 0) {
197
258
  return result;
198
259
  opaque ||= result.opaque;
199
260
  }
261
+ for (const bytes of slashStarts)
262
+ if (inspectDecoded(bytes, "", { ...options, allowOpaqueBase64: true }, depth).sensitive)
263
+ return SENSITIVE;
200
264
  for (const [match] of expanded.matchAll(HEX_RUN)) {
201
265
  const plain = readPlainText(Buffer.from(match, "hex"));
202
266
  if (plain.ok && depth < MAX_DEPTH && scanEncodedText(plain.text, options, depth + 1).sensitive)
@@ -207,7 +271,7 @@ export function scanEncodedText(text, options = {}, depth = 0) {
207
271
  // Bump when scanEncodedText, decodeEscapes or the sensitive patterns change what they return. The
208
272
  // cache lives in one process, so the version guards results across a hot reload or a test that
209
273
  // swaps the scanner.
210
- const ENCODED_SCAN_VERSION = 1;
274
+ const ENCODED_SCAN_VERSION = 2;
211
275
  // Distinct files one process verifies in a burst (a run's files, a serve library's runs).
212
276
  const SCAN_CACHE_LIMIT = 256;
213
277
  const scanCache = new Map();
@@ -222,6 +286,7 @@ export function scanEncodedTextCached(text, options = {}) {
222
286
  const key = [
223
287
  ENCODED_SCAN_VERSION,
224
288
  options.allowOpaqueBase64 === true ? "opaque-allowed" : "opaque-unscanned",
289
+ options.secretsOnly === true ? "secrets-only" : "secrets-and-paths",
225
290
  createHash("sha256").update(Buffer.from(text, "utf16le")).digest("hex"),
226
291
  ].join(":");
227
292
  const cached = scanCache.get(key);
@@ -1,5 +1,7 @@
1
1
  /** Every pattern containsSensitive tests. A test pins each to ASCII, which scanEncodedText relies on. */
2
2
  export declare function sensitivePatterns(): readonly RegExp[];
3
+ /** True if the text contains a secret-shaped token. Local paths do not count. */
4
+ export declare function containsSecret(text: string): boolean;
3
5
  /** True if the text contains any secret-shaped token or known local path. */
4
6
  export declare function containsSensitive(text: string): boolean;
5
7
  /**
@@ -3,26 +3,113 @@ import { realpathSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { PNG } from "pngjs";
5
5
  import { SCREENSHOT_MAX_WIDTH_CAP, pngDecodeRefusal } from "./image.js";
6
+ import { isRecord } from "../run/type-guards.js";
6
7
  // Single source of truth for public-safety redaction patterns. Producers and the verify gate
7
8
  // both use these, so the denylist cannot drift between them. See docs/contracts/policy.md for
8
9
  // the enforcement-scope policy.
10
+ // A whole value that stands in for a secret rather than being one, as written or percent-encoded:
11
+ // a redaction marker, a shell or template variable, a placeholder in angle brackets, or a mask of
12
+ // asterisks. A context pattern skips a value only when one of these is all of it, up to a
13
+ // character that ends that pattern's value, so a password that merely starts with `*` or `$`
14
+ // still counts.
15
+ const PLACEHOLDER = [
16
+ String.raw `\[REDACTED_[A-Z0-9_]{1,40}\]`,
17
+ String.raw `%5[Bb]REDACTED_[A-Z0-9_]{1,40}%5[Dd]`,
18
+ String.raw `\$\{[A-Za-z_][A-Za-z0-9_]{0,63}\}`,
19
+ String.raw `%24%7[Bb][A-Za-z_][A-Za-z0-9_]{0,63}%7[Dd]`,
20
+ String.raw `\$[A-Za-z_][A-Za-z0-9_]{0,63}`,
21
+ String.raw `%24[A-Za-z_][A-Za-z0-9_]{0,63}`,
22
+ String.raw `\{\{[A-Za-z0-9_. -]{1,64}\}\}`,
23
+ String.raw `<[A-Za-z0-9_. -]{1,64}>`,
24
+ String.raw `%3[Cc][A-Za-z0-9_.-]{1,64}%3[Ee]`,
25
+ String.raw `\*{3,64}`,
26
+ String.raw `(?:%2[Aa]){3,64}`,
27
+ ].join("|");
28
+ const notAPlaceholder = (valueEnd) => String.raw `(?!(?:${PLACEHOLDER})(?:${valueEnd}|$))`;
29
+ // An optional quote around a name or a value, also as JSON writes it inside other JSON strings,
30
+ // four levels deep (\", \\\", ...).
31
+ const QUOTE = String.raw `(?:\\{0,15}["'])?`;
32
+ // A JSON number, which a credential-named key can hold and redaction must not turn into a string.
33
+ // The bounds are far past any number a run writes and keep the lookahead linear.
34
+ const NOT_A_NUMBER = String.raw `(?!-?[0-9]{1,512}(?:\.[0-9]{0,512})?(?:[eE][+-]?[0-9]{1,64})?(?:[\s,}\]]|$))`;
35
+ // The characters that end each context pattern's value.
36
+ const USERINFO_END = String.raw `[\s@/?#"'<>\\]`;
37
+ const PARAMETER_END = String.raw `[\s&#"'<>\\]`;
38
+ const VARIABLE_END = String.raw `[\s"'\\,;&]`;
39
+ // Query and fragment parameters that carry a credential. `page_token` and other cursors match too;
40
+ // their values are long and opaque, so a reader cannot tell them from a credential either.
41
+ const CREDENTIAL_PARAMETER = [
42
+ String.raw `(?:[a-z0-9]{1,24}[_-]){0,2}token`,
43
+ String.raw `api[_-]?key`,
44
+ String.raw `(?:client[_-])?secret`,
45
+ "password",
46
+ "passwd",
47
+ "pwd",
48
+ "sig",
49
+ "signature",
50
+ String.raw `session[_-]?id`,
51
+ "jwt",
52
+ "x-vercel-protection-bypass",
53
+ "_vercel_share",
54
+ String.raw `x-amz-(?:signature|security-token|credential)`,
55
+ String.raw `x-goog-(?:signature|credential)`,
56
+ "auth",
57
+ ].join("|");
58
+ // Upper-case variable names that hold a credential, as an env file, a shell or a JSON dump writes
59
+ // them: GITHUB_TOKEN, VERCEL_TOKEN, AWS_SECRET_ACCESS_KEY, DATABASE_PASSWORD.
60
+ const CREDENTIAL_VARIABLE = String.raw `\b(?:[A-Z][A-Z0-9_]{0,48}_)?(?:TOKEN|SECRET|SECRET_KEY|PASSWORD|PASSWD|API_KEY|APIKEY|ACCESS_KEY|PRIVATE_KEY)`;
61
+ // A pattern that matches a credential by its context puts the context in a `keep` group, which
62
+ // redaction leaves in place: `?token=[REDACTED_SECRET]`. Context goes in a group and not in a
63
+ // lookbehind, because a pattern that starts with a lookbehind is tried at every position of the
64
+ // text and scans many times slower. An open-ended repeat is written `[...]{n}[...]*`: V8 runs
65
+ // `{n,}` with a backtrack entry per character and overflows its stack on a run of several
66
+ // megabytes, and a star loop does not.
9
67
  const SECRET_PATTERNS = [
10
- /\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{20,}\b/g,
11
- /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g,
12
- /\be2b_[A-Za-z0-9]{16,}\b/g,
13
- /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}\b/g,
14
- /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
15
- /\bAKIA[0-9A-Z]{16}\b/g,
16
- /\bAIza[0-9A-Za-z_-]{20,}\b/g,
17
- /\b(?:sk|rk)_live_[A-Za-z0-9]{16,}\b/g,
18
- /\bhf_[A-Za-z0-9]{30,}\b/g,
19
- /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/g,
20
- /\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\b/g,
68
+ /\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{20}[A-Za-z0-9_-]*\b/g,
69
+ /\bsk-ant-[A-Za-z0-9_-]{20}[A-Za-z0-9_-]*\b/g,
70
+ /\be2b_[A-Za-z0-9]{16}[A-Za-z0-9]*\b/g,
71
+ /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20}[A-Za-z0-9_]*\b/g,
72
+ /\bgithub_pat_[A-Za-z0-9_]{20}[A-Za-z0-9_]*\b/g,
73
+ /\bglpat-[A-Za-z0-9_-]{20}[A-Za-z0-9_-]*/g,
74
+ /\bnpm_[A-Za-z0-9]{36}\b/g,
75
+ // Vercel personal, integration, app access, app refresh and API key tokens.
76
+ /\bvc[pciark]_[A-Za-z0-9]{24}[A-Za-z0-9]*\b/g,
77
+ /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g,
78
+ /\bAIza[0-9A-Za-z_-]{20}[0-9A-Za-z_-]*\b/g,
79
+ /\bya29\.[0-9A-Za-z_-]{20}[0-9A-Za-z_-]*/g,
80
+ /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16}[A-Za-z0-9]*\b/g,
81
+ /\bwhsec_[A-Za-z0-9]{24}[A-Za-z0-9]*\b/g,
82
+ /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/g,
83
+ /\bhf_[A-Za-z0-9]{30}[A-Za-z0-9]*\b/g,
84
+ /\bxox[abeoprs]-[A-Za-z0-9-]{20}[A-Za-z0-9-]*\b/g,
85
+ /\bxapp-[0-9]-[A-Za-z0-9-]{20}[A-Za-z0-9-]*\b/g,
86
+ /\bhooks\.slack\.com\/(?:services|workflows|triggers)\/[A-Za-z0-9_/-]{20}[A-Za-z0-9_/-]*/g,
87
+ // A JWT starts only where a token run starts, so a run of `eyJ-` repeated is read once.
88
+ /eyJ(?<![A-Za-z0-9_-]eyJ)[A-Za-z0-9_-]{20}[A-Za-z0-9_-]*\.[A-Za-z0-9_-]{20}[A-Za-z0-9_-]*\.[A-Za-z0-9_-]{10}[A-Za-z0-9_-]*\b/g,
21
89
  /-----BEGIN [A-Z ]*PRIVATE KEY-----/g,
22
- /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis):\/\/[^:@/\s]+:[^@/\s]+@\S+/g,
23
- /_authToken\s*=\s*[A-Za-z0-9._~+/=-]{20,}/g,
24
- /\bBearer\s+[A-Za-z0-9._~+/-]{24,}\b/g,
25
- /https?:\/\/[^/\s]*e2b[^)\s]+/gi,
90
+ /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis):\/\/[^:@/\s"'<>\\]+:[^@/\s"'<>\\]+@[^\s"'<>\\]+/g,
91
+ /_authToken\s*=\s*[A-Za-z0-9._~+/=-]{20}[A-Za-z0-9._~+/=-]*/g,
92
+ /\bBearer\s+[A-Za-z0-9._~+/-]{24}[A-Za-z0-9._~+/-]*\b/g,
93
+ new RegExp(String.raw `(?<keep>\bAuthorization${QUOTE}[ \t]{0,4}[:=][ \t]{0,4}${QUOTE}Basic[ \t]{1,4})[A-Za-z0-9+/]{16}[A-Za-z0-9+/]*={0,2}`, "gi"),
94
+ // The password in a URL's userinfo, any scheme, with or without a user name:
95
+ // https://user:password@host, https://:password@host.
96
+ new RegExp(String.raw `(?<keep>\b[a-z][a-z0-9+.-]{0,31}:\/\/[^\s:@/?#"'<>\\[\]]{0,256}:)${notAPlaceholder(USERINFO_END)}[^\s@/?#"'<>\\]{1,256}(?=@)`, "gi"),
97
+ // A credential-named query or fragment parameter's value, up to the next delimiter, so a
98
+ // password with punctuation in it counts whole. `;` also follows an HTML `&amp;`.
99
+ new RegExp(String.raw `(?<keep>[?&#;](?:${CREDENTIAL_PARAMETER})=)${notAPlaceholder(PARAMETER_END)}[^\s&#"'<>\\]{16}[^\s&#"'<>\\]*`, "gi"),
100
+ // An Amazon Web Services secret access key next to its name, as a credentials file, an env
101
+ // line or a temporary-credentials JSON response writes it. The key is 40 characters, no prefix.
102
+ new RegExp(String.raw `(?<keep>\b(?:aws_?)?secret_?access_?key${QUOTE}[ \t]{0,4}[:=][ \t]{0,4}${QUOTE})${NOT_A_NUMBER}[A-Za-z0-9/+]{40}(?![A-Za-z0-9/+=])`, "gi"),
103
+ // A credential-named variable set to a value of 16 characters or more, up to the next space,
104
+ // quote or separator, with a digit and a letter in its first 256 characters. Words, placeholders
105
+ // such as humanish-egress-auth-placeholder and JSON numbers do not count. The lookaheads are
106
+ // bounded, so a long run of `TOKEN=` costs linear time.
107
+ new RegExp(String.raw `(?<keep>${CREDENTIAL_VARIABLE}${QUOTE}[ \t]{0,4}[:=][ \t]{0,4}${QUOTE})${notAPlaceholder(VARIABLE_END)}${NOT_A_NUMBER}(?=[^\s"'\\,;&]{0,255}[0-9])(?=[^\s"'\\,;&]{0,255}[A-Za-z])[^\s"'\\,;&]{16}[^\s"'\\,;&]*`, "g"),
108
+ // Any URL on an E2B host: a sandbox host names its sandbox, and a stream URL carries its auth
109
+ // key. One URL is exempt: E2B's dashboard, as the SDK's missing-key error names it
110
+ // (https://e2b.dev/dashboard?tab=keys), with no userinfo, no path below /dashboard, no query but
111
+ // one short `tab`, and only closing punctuation before the next space or ")".
112
+ /(?!https:\/\/(?:www\.)?e2b\.dev\/dashboard\/?(?:\?tab=[a-z]{1,16})?[.,;:!?'"]*(?:[)\s]|$))https?:\/\/[^/\s]*e2b[^)\s]+/gi,
26
113
  /BEGIN (RSA|OPENSSH|PRIVATE) KEY/gi,
27
114
  ];
28
115
  const LOCAL_PATH_PATTERNS = [
@@ -32,6 +119,12 @@ const LOCAL_PATH_PATTERNS = [
32
119
  [/\/tmp\/[^\s"'`<>)]*/g, "[REDACTED_LOCAL_PATH]"],
33
120
  [/\/Users\/[A-Za-z0-9._-]+(?:\/[^\s"'`<>)]*)?/g, "[REDACTED_LOCAL_PATH]"],
34
121
  [/\/home\/[A-Za-z0-9._-]+(?:\/[^\s"'`<>)]*)?/g, "[REDACTED_RUNTIME_PATH]"],
122
+ // A Windows profile path, with `\`, `\\` (JSON-escaped) or `/` between segments. A match never
123
+ // ends in a backslash, so it cannot swallow the escape of a closing quote.
124
+ [
125
+ /\b[A-Za-z]:(?:\\\\|\\|\/)Users(?:\\\\|\\|\/)[^\\/\s"'`<>)]+[^\s"'`<>)]*/g,
126
+ "[REDACTED_LOCAL_PATH]",
127
+ ],
35
128
  ];
36
129
  /** Every pattern containsSensitive tests. A test pins each to ASCII, which scanEncodedText relies on. */
37
130
  export function sensitivePatterns() {
@@ -43,10 +136,13 @@ function matchesPattern(pattern, text) {
43
136
  pattern.lastIndex = 0;
44
137
  return pattern.test(text);
45
138
  }
139
+ /** True if the text contains a secret-shaped token. Local paths do not count. */
140
+ export function containsSecret(text) {
141
+ return SECRET_PATTERNS.some((pattern) => matchesPattern(pattern, text));
142
+ }
46
143
  /** True if the text contains any secret-shaped token or known local path. */
47
144
  export function containsSensitive(text) {
48
- return (SECRET_PATTERNS.some((pattern) => matchesPattern(pattern, text)) ||
49
- LOCAL_PATH_PATTERNS.some(([pattern]) => matchesPattern(pattern, text)));
145
+ return (containsSecret(text) || LOCAL_PATH_PATTERNS.some(([pattern]) => matchesPattern(pattern, text)));
50
146
  }
51
147
  // A local-path match may end in the backslashes escaping its closing quote in
52
148
  // serialized JSON (including JSON nested inside a terminal event). Keep that
@@ -142,15 +238,21 @@ export function redactSandboxIds(value) {
142
238
  entries.some(([key, child]) => child !== value[key]);
143
239
  return changed ? Object.fromEntries(entries) : value;
144
240
  }
241
+ /** Each secret replaced by [REDACTED_SECRET], with the context a pattern keeps left in place. */
242
+ function redactSecrets(text) {
243
+ return SECRET_PATTERNS.reduce((current, pattern) => current.replace(pattern, (...args) => {
244
+ const groups = args.at(-1);
245
+ const keep = isRecord(groups) && typeof groups.keep === "string" ? groups.keep : "";
246
+ return `${keep}[REDACTED_SECRET]`;
247
+ }), text);
248
+ }
145
249
  /** Redact secrets to [REDACTED_SECRET] and local paths to their path labels. */
146
250
  export function redactText(text) {
147
- const withoutSecrets = SECRET_PATTERNS.reduce((current, pattern) => current.replace(pattern, "[REDACTED_SECRET]"), text);
148
- return redactLocalPaths(withoutSecrets);
251
+ return redactLocalPaths(redactSecrets(text));
149
252
  }
150
253
  /** Redact every sensitive match (secrets and paths) to a single [REDACTED_SECRET] label. */
151
254
  export function redactToSecretLabel(text) {
152
- const withoutSecrets = SECRET_PATTERNS.reduce((current, pattern) => current.replace(pattern, "[REDACTED_SECRET]"), text);
153
- return redactLocalPaths(withoutSecrets, "[REDACTED_SECRET]");
255
+ return redactLocalPaths(redactSecrets(text), "[REDACTED_SECRET]");
154
256
  }
155
257
  function canonicalizePath(value) {
156
258
  const resolved = path.resolve(value);
@@ -18,6 +18,7 @@ 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
+ import { LOCAL_ONLY_RUN_FILES } from "../run/local-only-files.js";
21
22
  import { assertPreparedSelectedOutputDirectory, prepareManagedHumanishOutputDirectory, prepareSelectedOutputDirectory, readContainedRegularFile, writeContainedOutputFile, } from "../run/contained-output.js";
22
23
  const DERIVATION_SCHEMA = "humanish.redacted-derivation.v1";
23
24
  const MAX_FILES = 10_000;
@@ -36,8 +37,7 @@ const OMITTED = new Map([
36
37
  ["observer/index.html", "regenerated Observer"],
37
38
  ["observer/observer-data.json", "regenerated Observer projection"],
38
39
  ["observer/study-analysis.json", "analysis must be regenerated against derivative evidence"],
39
- ["status.json", "local process status is not a new attempt"],
40
- ["sandbox-receipts.ndjson", "operational journal does not confer a derivative resource lease"],
40
+ ...LOCAL_ONLY_RUN_FILES,
41
41
  ]);
42
42
  const PNG_MAGIC = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
43
43
  function hash(bytes) {
@@ -21,6 +21,7 @@ import { chmodSync, closeSync, constants as fsConstants, existsSync, ftruncateSy
21
21
  import { homedir } from "node:os";
22
22
  import path from "node:path";
23
23
  import { loadEnvFile } from "./env-file.js";
24
+ import { cli } from "../cli/invocation.js";
24
25
  /** The only names implicit discovery may fill (and `humanish keys set` may store). Everything
25
26
  * else in an overlay/store file is ignored-and-named: a repo-planted NODE_OPTIONS/LD_PRELOAD
26
27
  * must never enter process env off a file the operator did not explicitly pass (an explicit
@@ -235,16 +236,16 @@ function storeLabel(env, deps) {
235
236
  export function missingKeyHint(name) {
236
237
  switch (name) {
237
238
  case "E2B_API_KEY":
238
- return "run `e2b auth login`, or `humanish keys set e2b`";
239
+ return `run \`e2b auth login\`, or \`${cli("keys set e2b")}\``;
239
240
  case "GH_TOKEN":
240
241
  case "GITHUB_TOKEN":
241
- return "run `gh auth login`, or `humanish keys set github`";
242
+ return `run \`gh auth login\`, or \`${cli("keys set github")}\``;
242
243
  case "OPENAI_API_KEY":
243
- return "run `humanish keys set openai`";
244
+ return `run \`${cli("keys set openai")}\``;
244
245
  case "ANTHROPIC_API_KEY":
245
- return "run `humanish keys set anthropic`";
246
+ return `run \`${cli("keys set anthropic")}\``;
246
247
  default:
247
- return `run \`humanish keys set ${name}\``;
248
+ return `run \`${cli(`keys set ${name}`)}\``;
248
249
  }
249
250
  }
250
251
  /** One shared suffix for *_KEYS_MISSING messages: where discovery looked, and what fills each
@@ -73,7 +73,7 @@ export function validateExposure(surface, request, live) {
73
73
  // admit nothing and expose nothing: watch --expose always requires edge auth, and the live-follow
74
74
  // preconditions are checked first so `--dry-run`/`--detach`/`--json` fail with the clearer reason.
75
75
  if (live && (live.dryRun || live.detach || live.json)) {
76
- return fail("EXPOSE_REQUIRES_LIVE_FOLLOW", "watch --expose streams a live desktop over an attached follow channel; it cannot combine with --dry-run, --detach, or --json.");
76
+ return fail("EXPOSE_REQUIRES_LIVE_FOLLOW", "watch --expose streams a live desktop over an attached follow channel. It cannot combine with --dry-run, --detach or --json, and without an interactive terminal it needs --serve.");
77
77
  }
78
78
  // --safe is a share_ready library filter for `serve`; watch streams a single live run that is
79
79
  // never share_ready, so --safe would silently do nothing here. Reject it rather than ignore it.
@@ -1,3 +1,4 @@
1
+ import { cli } from "../cli/invocation.js";
1
2
  // The pip color for each RunDisplay tone; a run with no display gets the neutral gray.
2
3
  const DISPLAY_TONES = {
3
4
  pass: "#3fb970",
@@ -8,8 +9,8 @@ const DISPLAY_TONES = {
8
9
  };
9
10
  export function renderLibraryHtml(history, opts) {
10
11
  const emptyState = opts.safe
11
- ? "No share_ready runs yet. Run `humanish verify` to see why."
12
- : "No runs yet. Run `humanish watch` to create one.";
12
+ ? `No share_ready runs yet. Run \`${cli("verify")}\` to see why.`
13
+ : `No runs yet. Run \`${cli("run first-run")}\` to create one.`;
13
14
  return `<!doctype html>
14
15
  <html lang="en" data-theme="dark">
15
16
  <head>
@@ -20,6 +20,7 @@ import { buildServeSecurityHeaders, hostAllowed, parsePublicOrigin } from "./htt
20
20
  import { listenOnLoopback } from "./listen.js";
21
21
  import { pinDirectChildDirectory, pinDirectory, readContainedFile, } from "./pinned-files.js";
22
22
  import { buildHistoryIndex, matchRunRoute, serveRunPath, writeResponse, } from "./run-routes.js";
23
+ import { cli } from "../cli/invocation.js";
23
24
  const OBSERVER_SCHEMA = "humanish.observer-result.v1";
24
25
  const observerRuntimeStreamUrls = new WeakMap();
25
26
  const observerPreparedRunPaths = new WeakMap();
@@ -136,7 +137,7 @@ export async function renderObserver(cwdInput, runInput, options = {}) {
136
137
  loaded.bundle.mode === "live"
137
138
  ? "Observer renders verified local evidence artifacts; runtime stream auth URLs are not persisted."
138
139
  : "This is a dry run: its participants are synthetic and no product behavior was tested.",
139
- "Before filing public feedback, use `humanish feedback issue` so redaction and public-safety checks gate the payload.",
140
+ `Before filing public feedback, use \`${cli("feedback issue")}\` so redaction and public-safety checks gate the payload.`,
140
141
  ...(openResult.warning ? [openResult.warning] : []),
141
142
  ],
142
143
  };