terminalhire 0.42.21 → 0.42.22

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.
@@ -45,7 +45,7 @@ ${facts.stderr}`))
45
45
  return null;
46
46
  }
47
47
  function isCommandUnavailable(facts, countsRead) {
48
- if (facts.exitCode === 127 || facts.exitCode === 126)
48
+ if (facts.exitCode === 127 || facts.exitCode === 126 || facts.exitCode === 125)
49
49
  return true;
50
50
  if (countsRead)
51
51
  return false;
@@ -268,6 +268,79 @@ var init_classify = __esm({
268
268
  const p = int(passed);
269
269
  return { tests_passed: p, tests_failed: Math.max(0, t - p) };
270
270
  }
271
+ },
272
+ {
273
+ /**
274
+ * BARE TAP — assertion lines with no plan and no summary comment (TERM-1138).
275
+ *
276
+ * LAST ON PURPOSE. It is the loosest reader here, so anything that a more specific
277
+ * one can read must reach that one first: `node --test` and node-tap both emit `ok`
278
+ * lines AND a closing summary, and their readers are ordered above for that reason.
279
+ * Moving this up would have it answer for both.
280
+ *
281
+ * A hand-rolled harness prints neither summary — it emits its assertions and stops.
282
+ * That is what morebetterltd/wheelhouse does: thirty `ok N - …` lines, exit 0, and
283
+ * before this every one of those runs reported `counts-unparsed`, which the claim
284
+ * page renders as "RESULT UNREADABLE — THE SUITE EXITED 0" over a suite that passed.
285
+ *
286
+ * ON THE MISSING PLAN LINE. TAP requires `1..N` so a reader can tell a finished
287
+ * stream from a truncated one, and there is none here, so this cannot prove the
288
+ * suite reached its end. That is a real limit and it is NOT a reason to refuse,
289
+ * because it holds this format to a standard no reader in this file meets — the
290
+ * jest reader greps a line out of stdout that a repository could `echo` without
291
+ * running anything. These are format adapters, not a trust boundary; the boundary
292
+ * is that the command is the repository's own or is refused a signature.
293
+ *
294
+ * CONTIGUOUS FROM 1, which buys self-consistency rather than security:
295
+ *
296
+ * - starting at 1 rejects a TRUNCATION. `testOutputTail` keeps the last lines, so
297
+ * a stream beginning `ok 47` is the middle of a longer run and counting it would
298
+ * report three tests for a suite that ran fifty.
299
+ * - no gaps rejects prose that happens to carry the word, and a stream interleaved
300
+ * with another's.
301
+ * - anchored with no leading space rejects TAP SUBTESTS, which indent. Counting
302
+ * those would double them against their parent.
303
+ */
304
+ runner: "bare TAP",
305
+ read: (out) => {
306
+ let n = 0;
307
+ let failed = 0;
308
+ let passed = 0;
309
+ let plan = null;
310
+ for (const line of out.split("\n")) {
311
+ if (/^Bail out!/.test(line))
312
+ return null;
313
+ const p = /^(\d+)\.\.(\d+)\s*$/.exec(line);
314
+ if (p) {
315
+ const declared = Number(p[2]) - Number(p[1]) + 1;
316
+ if (plan !== null && plan !== declared)
317
+ return null;
318
+ plan = declared;
319
+ continue;
320
+ }
321
+ const m = /^(not )?ok (\d+)(?:\s|$)/.exec(line);
322
+ if (!m)
323
+ continue;
324
+ if (Number(m[2]) !== n + 1)
325
+ return null;
326
+ n += 1;
327
+ const directive = /#\s*(skip|todo)\b/i.exec(line);
328
+ const kind = directive?.[1]?.toLowerCase();
329
+ if (kind === "skip")
330
+ continue;
331
+ if (m[1] !== void 0) {
332
+ if (kind !== "todo")
333
+ failed += 1;
334
+ continue;
335
+ }
336
+ passed += 1;
337
+ }
338
+ if (n === 0)
339
+ return null;
340
+ if (plan !== null && plan !== n)
341
+ return null;
342
+ return { tests_passed: passed, tests_failed: failed };
343
+ }
271
344
  }
272
345
  ];
273
346
  SUPPORTED_RUNNERS = READERS.map((r) => r.runner);
@@ -354,6 +427,10 @@ function scrubEnv(source, opts) {
354
427
  env["GOCACHE"] = join5(jailHome, ".cache", "go-build");
355
428
  env["GOFLAGS"] = "-modcacherw";
356
429
  env["CARGO_HOME"] = join5(jailHome, ".cargo");
430
+ const bundle = join5(jailHome, ".bundle");
431
+ env["GEM_HOME"] = bundle;
432
+ env["BUNDLE_PATH"] = bundle;
433
+ env["BUNDLE_APP_CONFIG"] = bundle;
357
434
  const proxy = opts.proxyUrl ?? DEAD_PROXY;
358
435
  env["HTTP_PROXY"] = proxy;
359
436
  env["HTTPS_PROXY"] = proxy;
@@ -654,6 +731,19 @@ var init_egressProxy = __esm({
654
731
  "static.crates.io",
655
732
  "index.crates.io",
656
733
  "crates.io",
734
+ // TERM-1139. `ruby` has had an image since the mapping existed and `bundle install`
735
+ // as its derived command, and this host was missing — so every ruby repo could start
736
+ // and could never install. Install is the ONLY step with a network grant at all, so a
737
+ // denial here is terminal, not a slow path.
738
+ //
739
+ // One entry, not two: `hostAllowed` is dot-anchored SUFFIX matching, so this covers
740
+ // `index.rubygems.org` as well. Listing that separately would advertise the grant as
741
+ // narrower than it is, which the `github.com` note below rejects for the same reason.
742
+ "rubygems.org",
743
+ // TERM-1139, added with the `dotnet` image mapping. `dotnet restore` fetches from
744
+ // NuGet; the coupling test refuses a mapped runtime whose registry is unreachable, and
745
+ // it caught this one the moment the image landed.
746
+ "api.nuget.org",
657
747
  // Git dependencies. Approved by Eric 2026-07-22 after a live run measured the
658
748
  // chokepoint working under load (20 events, 16 allowed / 4 denied by host).
659
749
  //
@@ -2035,7 +2125,7 @@ function imageForRuntime(runtime, override, version) {
2035
2125
  return override;
2036
2126
  const shape = RUNTIME_IMAGES[runtime];
2037
2127
  if (!shape) {
2038
- throw new RunRefusalError(`no container image is mapped for runtime ${JSON.stringify(runtime)}. Refusing to run it in the Node image: a missing interpreter surfaces as "command not found", which the classifier reads as tests-failed \u2014 a false red blamed on the repo.`);
2128
+ throw new RunRefusalError(`no container image is mapped for runtime ${JSON.stringify(runtime)}. Refusing to run it in the Node image: a bare "command not found" exits 127, which classifyVerification already owns as ours \u2014 but a test script that RUNS and fails inside on the missing tool exits with its own status and prints to stdout, and that falls through to tests-failed \u2014 a false red blamed on the repo.`);
2039
2129
  }
2040
2130
  if (version === void 0 || version === null)
2041
2131
  return unversionedImage(shape);
@@ -2286,7 +2376,37 @@ var init_execute = __esm({
2286
2376
  },
2287
2377
  go: { repository: "golang", suffix: "-bookworm", defaultVersion: "1.23", declaredIsFloor: true },
2288
2378
  ruby: { repository: "ruby", suffix: "-bookworm", defaultVersion: "3.3", declaredIsFloor: false },
2289
- rust: { repository: "rust", suffix: "-bookworm", defaultVersion: "1", declaredIsFloor: true }
2379
+ rust: { repository: "rust", suffix: "-bookworm", defaultVersion: "1", declaredIsFloor: true },
2380
+ /**
2381
+ * TERM-1139. `dotnet` was unmapped alongside `jvm`, and the recorded reason was that
2382
+ * neither had "a single obvious base image (gradle vs maven, sdk vs runtime)". That
2383
+ * reason holds for jvm and it does NOT hold here, which is why only this half moved.
2384
+ *
2385
+ * "sdk vs runtime" answers itself: the derived commands are `dotnet restore` and
2386
+ * `dotnet test`, and neither exists on the runtime image. There is no second build
2387
+ * tool competing for the slot the way maven and gradle compete for jvm's — and since
2388
+ * no official image carries both of those, jvm genuinely cannot be served by one key.
2389
+ *
2390
+ * MEASURED, not chosen by tag: `mcr.microsoft.com/dotnet/sdk:8.0` carries `git` and
2391
+ * `dotnet` (881MB). `git` is the property the whole `image-tooling-live` test exists
2392
+ * for, and `eclipse-temurin:21-jdk` was rejected here for lacking it.
2393
+ *
2394
+ * The tag shape fits without touching the builder: the SDK publishes a tag per
2395
+ * version, so `repository:VERSION` is a real image at every version envspec can
2396
+ * derive, with no suffix. That is NOT true of the maven images, where the leading
2397
+ * tag component is maven's version and Java's sits in the suffix.
2398
+ *
2399
+ * `declaredIsFloor` is true on the same reasoning as go and rust: an SDK builds
2400
+ * older target frameworks, so booting 8.0 for a repo asking 6.0 is the supported
2401
+ * case rather than a substitution. A repo asking for something NEWER than our
2402
+ * default still refuses, which is where TERM-643 put that line.
2403
+ */
2404
+ dotnet: {
2405
+ repository: "mcr.microsoft.com/dotnet/sdk",
2406
+ suffix: "",
2407
+ defaultVersion: "8.0",
2408
+ declaredIsFloor: true
2409
+ }
2290
2410
  };
2291
2411
  unversionedImage = (shape) => `${shape.repository}:${shape.defaultVersion}${shape.suffix}`;
2292
2412
  TAG_VERSION = /^\d+(?:\.\d+){0,2}$/;
@@ -2980,14 +3100,24 @@ function answerDidItPass(r) {
2980
3100
  const passed = r.status === "verified" && r.outcome !== null && isGreen(r.outcome);
2981
3101
  return { passed, summary: renderVerdictLine(r), lookAt: r.preview?.url ?? null };
2982
3102
  }
2983
- var RUN_TEST_COMMAND_SOURCES, RUN_RESULT_SCHEMA, RUN_RESULT_FIELDS, RENDER_NONE, FIELD_VIEWS;
3103
+ var RUN_TEST_COMMAND_SOURCES, RUN_IMAGE_SOURCES, RUN_RESULT_SCHEMA, RUN_RESULT_FIELDS, RENDER_NONE, FIELD_VIEWS;
2984
3104
  var init_result = __esm({
2985
3105
  "../../packages/envrun/dist/result.js"() {
2986
3106
  "use strict";
2987
3107
  init_dist2();
2988
3108
  init_classify();
2989
3109
  init_venueDescriptor();
2990
- RUN_TEST_COMMAND_SOURCES = [...TEST_COMMAND_SOURCES, "developer-declared"];
3110
+ RUN_TEST_COMMAND_SOURCES = [
3111
+ ...TEST_COMMAND_SOURCES,
3112
+ "developer-declared",
3113
+ "operator-declared"
3114
+ ];
3115
+ RUN_IMAGE_SOURCES = [
3116
+ "detected",
3117
+ "none",
3118
+ "operator-declared",
3119
+ "developer-declared"
3120
+ ];
2991
3121
  RUN_RESULT_SCHEMA = "terminalhire.verification-run/1";
2992
3122
  RUN_RESULT_FIELDS = [
2993
3123
  "schema",
@@ -3012,6 +3142,7 @@ var init_result = __esm({
3012
3142
  "preview",
3013
3143
  "containerImage",
3014
3144
  "containerImageDigest",
3145
+ "imageSource",
3015
3146
  "leaksClean",
3016
3147
  "venue"
3017
3148
  ];
@@ -3044,6 +3175,9 @@ var init_result = __esm({
3044
3175
  preview: (r) => r.preview === null ? null : `preview ${r.preview.url}`,
3045
3176
  containerImage: (r) => r.containerImage === null ? null : `image ${r.containerImage}`,
3046
3177
  containerImageDigest: (r) => r.containerImageDigest === null ? null : `image digest ${r.containerImageDigest}`,
3178
+ // Shown only when a human chose the environment. `detected` is the ordinary case and
3179
+ // saying so on every run would train the reader to skip the line that matters.
3180
+ imageSource: (r) => r.imageSource === "detected" || r.imageSource === "none" ? null : `image source ${r.imageSource} (not signed)`,
3047
3181
  leaksClean: (r) => r.leaksClean === null ? null : r.leaksClean ? null : "WARNING labelled Docker objects survived teardown",
3048
3182
  // Absent on most runs, so it prints only when there is something to say. Silence
3049
3183
  // here is the honest rendering of "no venue answered": a placeholder line would
@@ -3170,12 +3304,23 @@ function toAcceptancePredicate(pair, opts = {}) {
3170
3304
  if (why !== null)
3171
3305
  return refuse3("test-command-origin-unattested", `the ${side} run ${why}`);
3172
3306
  }
3173
- if (patched.testCommandSource === "developer-declared") {
3174
- return refuse3("test-command-origin-unattested", "the developer supplied the test command (unreachable \u2014 the source rules above cover it)");
3307
+ if (patched.testCommandSource === "developer-declared" || patched.testCommandSource === "operator-declared") {
3308
+ return refuse3("test-command-origin-unattested", `${patched.testCommandSource === "developer-declared" ? "the developer" : "an operator"} supplied the test command (unreachable \u2014 the source rules above cover it)`);
3175
3309
  }
3176
3310
  if (baseline.testCommandSource !== patched.testCommandSource) {
3177
3311
  return refuse3("test-command-origin-unattested", `the pair disagrees on where the command came from: baseline ${baseline.testCommandSource}, patched ${patched.testCommandSource}. One signed value cannot describe both.`);
3178
3312
  }
3313
+ for (const [side, source, image] of [
3314
+ ["patched", patched.imageSource, patched.containerImage],
3315
+ ["baseline", baseline.imageSource, baseline.containerImage]
3316
+ ]) {
3317
+ const why = IMAGE_SOURCE_IS_SIGNABLE[source](image);
3318
+ if (why !== null)
3319
+ return refuse3("image-origin-unattested", `the ${side} run ${why}`);
3320
+ }
3321
+ if (baseline.imageSource !== patched.imageSource) {
3322
+ return refuse3("image-origin-unattested", `the pair disagrees on where the image came from: baseline ${baseline.imageSource}, patched ${patched.imageSource}. The environment is part of what the statement says was held constant.`);
3323
+ }
3179
3324
  if (baseline.containerImage !== patched.containerImage) {
3180
3325
  return refuse3("pair-disagrees-on-image", `baseline ran in ${String(baseline.containerImage)} and patched in ${String(patched.containerImage)}. The signed measurement names one image, so a pair from two environments would attribute to the patch whatever the image changed.`);
3181
3326
  }
@@ -3232,7 +3377,7 @@ function signRunStatement(predicate, privateKey, keyid) {
3232
3377
  const statement = createAcceptanceStatement(predicate);
3233
3378
  return { statement, envelope: signStatement(statement, privateKey, keyid) };
3234
3379
  }
3235
- var OUTCOME_TO_BUDGET, BASELINE_IS_A_VERDICT, CONTRADICTS_COUNTS, SOURCE_IS_SIGNABLE, ATTEST_REFUSAL_REASONS, refuse3, LOCAL_MEASUREMENT_PREFIX, REFERENCE_DOMAIN_COMPONENT, REFERENCE_DOMAIN_NAME, REFERENCE_IPV6, REFERENCE_DOMAIN, REFERENCE_PATH_COMPONENT, REPO_DIGEST_RE;
3380
+ var OUTCOME_TO_BUDGET, BASELINE_IS_A_VERDICT, CONTRADICTS_COUNTS, SOURCE_IS_SIGNABLE, IMAGE_SOURCE_IS_SIGNABLE, ATTEST_REFUSAL_REASONS, refuse3, LOCAL_MEASUREMENT_PREFIX, REFERENCE_DOMAIN_COMPONENT, REFERENCE_DOMAIN_NAME, REFERENCE_IPV6, REFERENCE_DOMAIN, REFERENCE_PATH_COMPONENT, REPO_DIGEST_RE;
3236
3381
  var init_attestation2 = __esm({
3237
3382
  "../../packages/envrun/dist/attestation.js"() {
3238
3383
  "use strict";
@@ -3288,11 +3433,25 @@ var init_attestation2 = __esm({
3288
3433
  // The party under verification chose it, and no signed member can say so. Per ADR-005
3289
3434
  // Decision 2, emit nothing rather than pick the closest word.
3290
3435
  "developer-declared": () => "had its test command supplied by the developer whose work is under verification, and the signed vocabulary has no member for that. `founder-declared` would attribute the choice to the other party; `detected` would claim the repo chose it. Both are false.",
3436
+ // US, out of band, per posting — the twin of `IMAGE_SOURCE_IS_SIGNABLE['operator-declared']`
3437
+ // and refusing for the same reason. `founder-declared` is the nearest signed member and it
3438
+ // is still false: the poster did not choose this, we did, and signing our own choice under
3439
+ // the counterparty's name is the specific false attribution the `developer-declared` entry
3440
+ // above exists to refuse. Decision 2 — emit nothing rather than pick the closest word.
3441
+ "operator-declared": () => "had its test command chosen by an operator out of band, and the signed vocabulary has no member for that. Signing it would present a hand-picked command as one the repository chose for itself.",
3291
3442
  // `none` means nobody chose a command, so a command beside it is a contradiction. Shadowed
3292
3443
  // today — `thrun.ts` sets `none` only when the derived command is null, and `no-test-command`
3293
3444
  // fires first — but that coupling runs through `runEnvironmentSpec` and lives in no type.
3294
3445
  none: (command) => command === null ? null : `records its command source as \`none\` \u2014 nobody chose one \u2014 beside the command ${JSON.stringify(command)}`
3295
3446
  };
3447
+ IMAGE_SOURCE_IS_SIGNABLE = {
3448
+ detected: () => null,
3449
+ "operator-declared": () => "had its container image chosen by an operator out of band, and the signed vocabulary has no member for that. Signing it would present a hand-picked environment as the one the runtime mapping selected.",
3450
+ "developer-declared": () => "had its container image supplied by the developer whose work is under verification. A chosen interpreter turns a red suite green as surely as a chosen test command.",
3451
+ // `none` means no image was booted, so an image name beside it is a contradiction — the
3452
+ // `SOURCE_IS_SIGNABLE.none` argument, applied to the other field.
3453
+ none: (image) => image === null ? null : `records its image source as \`none\` \u2014 nothing was booted \u2014 beside the image ${JSON.stringify(image)}`
3454
+ };
3296
3455
  ATTEST_REFUSAL_REASONS = [
3297
3456
  "our-environment-failed",
3298
3457
  "run-was-refused",
@@ -3334,7 +3493,11 @@ var init_attestation2 = __esm({
3334
3493
  // The signed triple must be reproducible from what it names — see the guard.
3335
3494
  "patch-binding-incomplete",
3336
3495
  "outcome-contradicts-counts",
3337
- "test-command-origin-unattested"
3496
+ "test-command-origin-unattested",
3497
+ // The environment half of the pair above. `pair-disagrees-on-image` compares image NAMES;
3498
+ // this one asks who CHOSE the name, which no comparison of the two halves can reveal when
3499
+ // both were handed the same override.
3500
+ "image-origin-unattested"
3338
3501
  ];
3339
3502
  refuse3 = (reason, detail) => ({
3340
3503
  ok: false,
@@ -3838,13 +4001,20 @@ function gcpBootArgv(p) {
3838
4001
  // steal". Attaching an account removes that, so the safety now rests on two things,
3839
4002
  // and this change MUST land together with proof #2 — never ahead of it:
3840
4003
  //
3841
- // 1. `--no-scopes` + ZERO project roles bound the BLAST RADIUS of a lifted token.
3842
- // No scopes → the OAuth access-token endpoint serves nothing (measured,
3843
- // TERM-706); zero roles → the token authorizes no GCP API. But it is NOT
3844
- // valueless: it is the attestation evidence itself, so a lifted token still
3845
- // authorizes a false claim about WHERE a run happened (the steal-a-true-token
3846
- // attack in the item-2 spike). `--no-scopes` stays because it removes lateral
3847
- // GCP access; it does not make losing the identity token cheap.
4004
+ // 1. `--no-scopes` and a NARROW grant set bound the BLAST RADIUS of a lifted
4005
+ // token. No scopes → the OAuth access-token endpoint serves nothing
4006
+ // (measured, TERM-706). This said "ZERO project roles" until 2026-09-18, and
4007
+ // that was never true of the account we ship: measured live, `venue-identity@`
4008
+ // holds `roles/confidentialcomputing.workloadUser` on the project and
4009
+ // `roles/artifactregistry.reader` on the `venue-images` repository alone —
4010
+ // the same two grants the Confidential Space note below already named, so this
4011
+ // file contradicted itself. Neither reaches a second repository, a bucket or
4012
+ // another VM, which is the property "zero roles" was reaching for. But the
4013
+ // token is NOT valueless: it is the attestation evidence itself, so a lifted
4014
+ // token still authorizes a false claim about WHERE a run happened (the
4015
+ // steal-a-true-token attack in the item-2 spike). `--no-scopes` stays because
4016
+ // it removes lateral GCP access; it does not make losing the identity token
4017
+ // cheap.
3848
4018
  // 2. The untrusted code cannot reach the metadata server to lift the token at all.
3849
4019
  // The test step runs `--network=none`; the install step runs on an `--internal`
3850
4020
  // network whose only egress is a CONNECT-only, registry-allowlisted proxy that
@@ -7534,6 +7704,9 @@ function refusedRun(fields) {
7534
7704
  treeDigest: null,
7535
7705
  baselinePatchSha256: null,
7536
7706
  testCommandSource: "none",
7707
+ // Nothing was booted, so nobody chose an image. Same reasoning as `containerImage: null`
7708
+ // below: it must not read as an image we picked and cannot name.
7709
+ imageSource: "none",
7537
7710
  boundaryRefusals: fields.boundaryRefusals,
7538
7711
  touchedPaths: fields.touchedPaths,
7539
7712
  preview: null,
@@ -7882,7 +8055,7 @@ async function runVerification(req, ctx) {
7882
8055
  // that `founder-declared` (as this did for one review round) signs the counterparty's
7883
8056
  // name onto the developer's choice, which is worse than laundering it as `detected`:
7884
8057
  // it is a specific false attribution inside a field a reviewer trusts.
7885
- testCommandSource: req.testCommandOverride !== void 0 ? req.testCommandOverrideOrigin === "founder" ? "founder-declared" : "developer-declared" : derived.testCommand === null ? "none" : "detected",
8058
+ testCommandSource: req.testCommandOverride !== void 0 ? req.testCommandOverrideOrigin === "founder" ? "founder-declared" : req.testCommandOverrideOrigin === "operator" ? "operator-declared" : "developer-declared" : derived.testCommand === null ? "none" : "detected",
7886
8059
  boundaryRefusals: [],
7887
8060
  touchedPaths: pre.touchedPaths,
7888
8061
  preview: null,
@@ -7892,6 +8065,12 @@ async function runVerification(req, ctx) {
7892
8065
  // that measures one; a run without it is recordable but not attestable —
7893
8066
  // `toAcceptancePredicate` refuses `missing-image-digest` (TERM-893).
7894
8067
  containerImageDigest: null,
8068
+ // The image half of the provenance pair above. `detected` means the runtime mapping
8069
+ // chose the environment; an override names the human. Defaulting an unstated origin to
8070
+ // `developer-declared` is deliberate and matches `testCommandSource`: `th run --image`
8071
+ // runs on the developer's machine, judging the developer's work, so attributing it to
8072
+ // the operator would sign our name onto their choice.
8073
+ imageSource: req.image === void 0 ? "detected" : req.imageOrigin === "operator" ? "operator-declared" : "developer-declared",
7895
8074
  leaksClean: verdict.leaks.clean,
7896
8075
  // Built from the LEASE, over the client that ran the steps — never from
7897
8076
  // `req.placement`, which is a request. `venueDescriptor.ts` carries the
@@ -8847,6 +9026,7 @@ __export(dist_exports, {
8847
9026
  REDACTED_TARGET_SHA: () => REDACTED_TARGET_SHA,
8848
9027
  RELEASED_LEASE_CENSUS_REASON: () => RELEASED_LEASE_CENSUS_REASON,
8849
9028
  REPO_DIGEST_RE: () => REPO_DIGEST_RE,
9029
+ RUN_IMAGE_SOURCES: () => RUN_IMAGE_SOURCES,
8850
9030
  RUN_LABEL_KEY: () => RUN_LABEL_KEY,
8851
9031
  RUN_RESULT_FIELDS: () => RUN_RESULT_FIELDS,
8852
9032
  RUN_RESULT_SCHEMA: () => RUN_RESULT_SCHEMA,
@@ -9406,6 +9586,9 @@ Options:
9406
9586
  --keep <seconds> Hold the preview open this long, at most ${String(KEEP_MAX_SECONDS)}
9407
9587
  (default: until Ctrl-C).
9408
9588
  --test-command <cmd> Disclosed override of the derived test command.
9589
+ --image <ref> Disclosed override of the container image. Use when no image is
9590
+ mapped for the repo's runtime. Recorded as developer-declared
9591
+ provenance, which the attestation refuses to sign.
9409
9592
  --help
9410
9593
 
9411
9594
  Defaults are read from .th-run.json in the local checkout when present, so a
@@ -9584,6 +9767,11 @@ async function once(engine, opts) {
9584
9767
  preview: opts.preview,
9585
9768
  ...opts.placement ? { placement: opts.placement } : {},
9586
9769
  ...opts.testCommand ? { testCommandOverride: opts.testCommand } : {},
9770
+ // No `imageOrigin` beside it, deliberately. This flag runs on the DEVELOPER's machine,
9771
+ // judging the developer's work, so it takes `thrun`'s default of `developer` — the same
9772
+ // reasoning that makes `--test-command` developer-declared. An operator override is a
9773
+ // server-side caller, not this flag.
9774
+ ...opts.image ? { image: opts.image } : {},
9587
9775
  onProgress: (stage, detail) => {
9588
9776
  if (!opts.json) process.stderr.write(` ${stage.padEnd(8)} ${detail}
9589
9777
  `);
@@ -9697,6 +9885,7 @@ async function run() {
9697
9885
  watch: parsed.bools.has("watch"),
9698
9886
  json: parsed.bools.has("json"),
9699
9887
  testCommand: pick("test-command") ?? null,
9888
+ image: pick("image") ?? null,
9700
9889
  keepSeconds,
9701
9890
  scratch: runScratchRoot()
9702
9891
  };
@@ -27,6 +27,19 @@ export const DEFAULT_INSTALL_ALLOWLIST = [
27
27
  'static.crates.io',
28
28
  'index.crates.io',
29
29
  'crates.io',
30
+ // TERM-1139. `ruby` has had an image since the mapping existed and `bundle install`
31
+ // as its derived command, and this host was missing — so every ruby repo could start
32
+ // and could never install. Install is the ONLY step with a network grant at all, so a
33
+ // denial here is terminal, not a slow path.
34
+ //
35
+ // One entry, not two: `hostAllowed` is dot-anchored SUFFIX matching, so this covers
36
+ // `index.rubygems.org` as well. Listing that separately would advertise the grant as
37
+ // narrower than it is, which the `github.com` note below rejects for the same reason.
38
+ 'rubygems.org',
39
+ // TERM-1139, added with the `dotnet` image mapping. `dotnet restore` fetches from
40
+ // NuGet; the coupling test refuses a mapped runtime whose registry is unreachable, and
41
+ // it caught this one the moment the image landed.
42
+ 'api.nuget.org',
30
43
  // Git dependencies. Approved by Eric 2026-07-22 after a live run measured the
31
44
  // chokepoint working under load (20 events, 16 allowed / 4 denied by host).
32
45
  //
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminalhire",
3
- "version": "0.42.21",
3
+ "version": "0.42.22",
4
4
  "description": "Developer jobs and paid bounties, matched in the terminal — ambient matches in the Claude Code spinner. Claim a scoped bounty and get paid when the poster accepts it.",
5
5
  "repository": {
6
6
  "type": "git",