@boldblackai/harness 1.8.5 → 1.9.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/README.md CHANGED
@@ -25,7 +25,7 @@ and [`hermes`](https://github.com/NousResearch/hermes-agent) — so you can poin
25
25
 
26
26
  ## Quickstart
27
27
 
28
- [Docker](https://www.docker.com) is required. By default, harness uses LM Studio locally:
28
+ A container runtime is required. By default harness uses [Docker](https://www.docker.com); on macOS 26 / Apple Silicon you can also use Apple's native [`container`](https://github.com/apple/container) CLI — see [Container runtime](#container-runtime). With the default runtime and LM Studio locally:
29
29
 
30
30
  ```bash
31
31
  lms daemon up
@@ -189,6 +189,8 @@ Each run starts the container with:
189
189
  - `--security-opt seccomp=...` — inline seccomp profile blocks `socket(AF_ALG)` to prevent kernel crypto API access (a known container escape vector)
190
190
  - Only your mounted directory (or single file with `-f`) is visible to the agent
191
191
 
192
+ These hardening flags are docker-specific. Under `HARNESS_CONTAINER_RUNTIME=apple`, `--security-opt` is not applied (apple/container workloads are microVMs with their own guest kernel, so the seccomp profile's host-kernel role is subsumed by the VM boundary); capability restrictions remain. See [Container runtime](#container-runtime).
193
+
192
194
  ### Image verification
193
195
 
194
196
  By default, harness verifies that the container image was signed by the official CI workflow and carries a valid SLSA provenance attestation. This requires [cosign](https://github.com/sigstore/cosign):
@@ -241,9 +243,27 @@ If an old `.harness/` directory exists in your working directory, harness will e
241
243
 
242
244
  ### Environment variables
243
245
 
244
- | Variable | Description |
245
- |----------------------|-------------|
246
- | `HARNESS_IMAGE_TAG` | Override the Docker image tag (defaults to the package version). Setting this implies `--no-verify`. |
246
+ | Variable | Description |
247
+ |-----------------------------|-------------|
248
+ | `HARNESS_IMAGE_TAG` | Override the Docker image tag (defaults to the package version). Setting this implies `--no-verify`. |
249
+ | `HARNESS_CONTAINER_RUNTIME` | Container runtime to use: `docker` (default) or `apple` (Apple's [`container`](https://github.com/apple/container) CLI). |
250
+ | `XDG_DATA_HOME` | Override the base directory for persistence data (defaults to `~/.local/share`). |
251
+ | `XDG_CACHE_HOME` | Override the base directory for the cosign cache (defaults to `~/.cache`). |
252
+
253
+ #### Container runtime
254
+
255
+ By default harness runs images with `docker`. On macOS 26 / Apple Silicon you can opt into Apple's native [`container`](https://github.com/apple/container) CLI (v1.0.0+) instead, which runs OCI images as lightweight Linux microVMs:
256
+
257
+ ```bash
258
+ brew install container # install Apple's container CLI (v1.0.0+)
259
+ export HARNESS_CONTAINER_RUNTIME=apple
260
+ container system start # one-time: start the container system service
261
+ harness -p "write me a fizzbuzz in Go"
262
+ ```
263
+
264
+ The value is **named, not boolean** (`apple` or `docker`, case-insensitive); any other value is a hard error. harness never auto-detects the runtime — you must opt in. Image verification (cosign + SLSA provenance) works identically under both runtimes; the verified-digest cache is keyed by digest, so a digest verified under one runtime is a cache hit under the other.
265
+
266
+ **Security note.** Under `=apple`, harness does not apply the `--security-opt no-new-privileges` and `--security-opt seccomp=...` flags it uses under docker, because `apple/container` has no `--security-opt` option. This is not a security regression: each apple/container workload is a microVM with its own ephemeral guest kernel (Apple Virtualization framework), so the `block-af-alg.json` profile's host-kernel role — blocking `socket(AF_ALG)` — is subsumed by the VM boundary itself (hardware-assisted isolation, strictly stronger than a syscall filter). Capability restrictions (`--cap-drop=ALL --cap-add=NET_RAW`) **are** supported and stay on. Only `ro`/`readonly` is honored for volume options under the apple path (SELinux relabel flags like `:Z` are meaningless under macOS virtiofs).
247
267
 
248
268
  ### Agent-specific behavior
249
269
 
package/bin/harness.js CHANGED
@@ -10,6 +10,150 @@ const node_os_1 = __importDefault(require("node:os"));
10
10
  const node_path_1 = __importDefault(require("node:path"));
11
11
  const minimist_1 = __importDefault(require("minimist"));
12
12
  const SECCOMP_PROFILE = node_path_1.default.join(__dirname, "seccomp-profiles", "block-af-alg.json");
13
+ class DockerRuntime {
14
+ binary() {
15
+ return "docker";
16
+ }
17
+ pullArgs(image) {
18
+ return ["pull", image];
19
+ }
20
+ inspectImage(image) {
21
+ try {
22
+ const out = (0, node_child_process_1.execFileSync)("docker", [
23
+ "image",
24
+ "inspect",
25
+ "--format",
26
+ "{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}",
27
+ image,
28
+ ], { stdio: ["ignore", "pipe", "ignore"], timeout: 5000 })
29
+ .toString()
30
+ .trim();
31
+ return {
32
+ exists: true,
33
+ digest: /@sha256:[0-9a-f]{64}$/.test(out) ? out : null,
34
+ };
35
+ }
36
+ catch {
37
+ return { exists: false, digest: null };
38
+ }
39
+ }
40
+ runArgs(input) {
41
+ const ttyFlags = input.interactive ? ["-it"] : ["-i"];
42
+ return [
43
+ "run",
44
+ "--rm",
45
+ ...ttyFlags,
46
+ "--cap-drop=ALL",
47
+ "--cap-add=NET_RAW",
48
+ "--security-opt",
49
+ "no-new-privileges:true",
50
+ "--security-opt",
51
+ `seccomp=${SECCOMP_PROFILE}`,
52
+ ...input.envFileArgs,
53
+ ...input.envArgs,
54
+ ...input.volumeArgs,
55
+ ...input.userVolumeArgs,
56
+ "-w",
57
+ input.workdir,
58
+ input.image,
59
+ ...input.containerCmd,
60
+ ];
61
+ }
62
+ ensureReady() {
63
+ // docker is the default; assume present. (A missing docker surfaces as a
64
+ // clear spawn ENOENT at run time, same as pre-runtime-abstraction behavior.)
65
+ }
66
+ }
67
+ class AppleContainerRuntime {
68
+ binary() {
69
+ return "container";
70
+ }
71
+ pullArgs(image) {
72
+ // apple/container nests pull under the `image` subgroup.
73
+ return ["image", "pull", image];
74
+ }
75
+ inspectImage(image) {
76
+ let out;
77
+ try {
78
+ out = (0, node_child_process_1.execFileSync)("container", ["image", "inspect", image], {
79
+ stdio: ["ignore", "pipe", "ignore"],
80
+ timeout: 5000,
81
+ }).toString();
82
+ }
83
+ catch {
84
+ return { exists: false, digest: null };
85
+ }
86
+ // apple/container's `image inspect` always emits a JSON array (no
87
+ // --format flag). The image-index/manifest-list digest it reports at
88
+ // data[0].configuration.descriptor.digest is exactly what cosign signs
89
+ // for a multi-arch image and what docker exposes as the RepoDigest.
90
+ try {
91
+ const parsed = JSON.parse(out);
92
+ const digest = Array.isArray(parsed)
93
+ ? parsed[0]?.configuration?.descriptor?.digest
94
+ : null;
95
+ if (typeof digest === "string" && /^sha256:[0-9a-f]{64}$/.test(digest)) {
96
+ const repo = image.split(":")[0];
97
+ return { exists: true, digest: `${repo}@${digest}` };
98
+ }
99
+ return { exists: true, digest: null };
100
+ }
101
+ catch {
102
+ return { exists: true, digest: null };
103
+ }
104
+ }
105
+ runArgs(input) {
106
+ // apple/container (Swift ArgumentParser) wants -i and -t as separate
107
+ // tokens (no clustered -it) and space-separated capability values (no
108
+ // `=` join). It has no --security-opt at all: each workload is a microVM
109
+ // with its own guest kernel, so the block-af-alg seccomp profile's
110
+ // host-kernel role is subsumed by the VM boundary and no-new-privileges
111
+ // is only a minor in-guest defense-in-depth loss. Capability
112
+ // restrictions ARE supported, so --cap-drop=ALL --cap-add=NET_RAW stays.
113
+ const ttyFlags = input.interactive ? ["-i", "-t"] : ["-i"];
114
+ return [
115
+ "run",
116
+ "--rm",
117
+ ...ttyFlags,
118
+ "--cap-drop",
119
+ "ALL",
120
+ "--cap-add",
121
+ "NET_RAW",
122
+ ...input.envFileArgs,
123
+ ...input.envArgs,
124
+ ...input.volumeArgs,
125
+ ...input.userVolumeArgs,
126
+ "-w",
127
+ input.workdir,
128
+ input.image,
129
+ ...input.containerCmd,
130
+ ];
131
+ }
132
+ ensureReady() {
133
+ try {
134
+ (0, node_child_process_1.execFileSync)("container", ["--version"], {
135
+ stdio: ["ignore", "ignore", "ignore"],
136
+ timeout: 5000,
137
+ });
138
+ }
139
+ catch {
140
+ console.error("harness: HARNESS_CONTAINER_RUNTIME=apple requires the `container` CLI (Apple container, v1.0.0+). Install from https://github.com/apple/container/releases and run `container system start`, or unset HARNESS_CONTAINER_RUNTIME to use docker.");
141
+ process.exit(1);
142
+ }
143
+ }
144
+ }
145
+ function selectRuntime() {
146
+ const raw = (process.env.HARNESS_CONTAINER_RUNTIME ?? "docker").toLowerCase();
147
+ switch (raw) {
148
+ case "docker":
149
+ return new DockerRuntime();
150
+ case "apple":
151
+ return new AppleContainerRuntime();
152
+ default:
153
+ console.error(`harness: unknown HARNESS_CONTAINER_RUNTIME="${raw}". Valid values: apple, docker (or unset for docker).`);
154
+ process.exit(1);
155
+ }
156
+ }
13
157
  class PiAdapter {
14
158
  buildCommand({ prompt, model, envFilePath }) {
15
159
  // In local mode (no env file), pass --provider ollama so pi routes
@@ -87,26 +231,6 @@ function cachePath() {
87
231
  const base = process.env.XDG_CACHE_HOME || node_path_1.default.join(node_os_1.default.homedir(), ".cache");
88
232
  return node_path_1.default.join(base, "harness", "cosign-verified.json");
89
233
  }
90
- function inspectLocalImage(image) {
91
- try {
92
- const out = (0, node_child_process_1.execFileSync)("docker", [
93
- "image",
94
- "inspect",
95
- "--format",
96
- "{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}",
97
- image,
98
- ], { stdio: ["ignore", "pipe", "ignore"], timeout: 5000 })
99
- .toString()
100
- .trim();
101
- return {
102
- exists: true,
103
- digest: /@sha256:[0-9a-f]{64}$/.test(out) ? out : null,
104
- };
105
- }
106
- catch {
107
- return { exists: false, digest: null };
108
- }
109
- }
110
234
  function readCache() {
111
235
  try {
112
236
  const raw = node_fs_1.default.readFileSync(cachePath(), "utf8");
@@ -147,31 +271,31 @@ function cosign(args) {
147
271
  });
148
272
  });
149
273
  }
150
- async function verifyImage(image) {
151
- let { exists, digest: digestRef } = inspectLocalImage(image);
274
+ async function verifyImage(runtime, image) {
275
+ let { exists, digest: digestRef } = runtime.inspectImage(image);
152
276
  const cache = readCache();
153
277
  if (digestRef && cache.verified[digestRef]) {
154
278
  return;
155
279
  }
156
280
  if (exists && !digestRef) {
157
281
  console.error(`harness: refusing to verify ${image}: image exists locally but has no registry digest (locally-built?).`);
158
- console.error("harness: verifying the tag would check registry bytes, not the local image docker would run.");
282
+ console.error("harness: verifying the tag would check registry bytes, not the local image the runtime would run.");
159
283
  console.error("harness: use --no-verify, or set HARNESS_IMAGE_TAG for an implicit skip.");
160
284
  process.exit(1);
161
285
  }
162
286
  if (!digestRef) {
163
287
  console.error(`harness: pulling ${image} for verification...`);
164
288
  try {
165
- (0, node_child_process_1.execFileSync)("docker", ["pull", image], {
289
+ (0, node_child_process_1.execFileSync)(runtime.binary(), runtime.pullArgs(image), {
166
290
  stdio: ["ignore", "inherit", "inherit"],
167
291
  timeout: 600000,
168
292
  });
169
293
  }
170
294
  catch {
171
- console.error(`harness: docker pull failed for ${image}`);
295
+ console.error(`harness: ${runtime.binary()} pull failed for ${image}`);
172
296
  process.exit(1);
173
297
  }
174
- digestRef = inspectLocalImage(image).digest;
298
+ digestRef = runtime.inspectImage(image).digest;
175
299
  if (!digestRef) {
176
300
  console.error(`harness: failed to resolve digest for ${image} after pull`);
177
301
  process.exit(1);
@@ -244,9 +368,10 @@ Options:
244
368
  -h, --help Show this help message
245
369
 
246
370
  Environment variables:
247
- HARNESS_IMAGE_TAG Override the Docker image tag (defaults to package version)
248
- XDG_DATA_HOME Override the base directory for persistence data (defaults to ~/.local/share)
249
- XDG_CACHE_HOME Override the base directory for cosign cache (defaults to ~/.cache)
371
+ HARNESS_IMAGE_TAG Override the Docker image tag (defaults to package version)
372
+ HARNESS_CONTAINER_RUNTIME Container runtime to use: docker (default) or apple (Apple container CLI)
373
+ XDG_DATA_HOME Override the base directory for persistence data (defaults to ~/.local/share)
374
+ XDG_CACHE_HOME Override the base directory for cosign cache (defaults to ~/.cache)
250
375
 
251
376
  Persistence data is stored at $XDG_DATA_HOME/harness/<project>/<agent>/.
252
377
 
@@ -375,13 +500,15 @@ for (const spec of volumeArgList) {
375
500
  }
376
501
  }
377
502
  async function run(prompt) {
503
+ const runtime = selectRuntime();
504
+ runtime.ensureReady();
378
505
  const image = getImage(agentName);
379
506
  if (!noVerify) {
380
507
  if (process.env.HARNESS_IMAGE_TAG) {
381
508
  console.error(`harness: HARNESS_IMAGE_TAG is set; skipping cosign verification for ${image}`);
382
509
  }
383
510
  else {
384
- await verifyImage(image);
511
+ await verifyImage(runtime, image);
385
512
  }
386
513
  }
387
514
  const envFileArgs = envFilePath
@@ -395,7 +522,6 @@ async function run(prompt) {
395
522
  const containerCmd = adapter.buildCommand(adapterOptions);
396
523
  const adapterDockerArgs = adapter.extraDockerArgs?.(adapterOptions) ?? [];
397
524
  const interactive = process.stdin.isTTY;
398
- const ttyFlags = interactive ? ["-it"] : ["-i"];
399
525
  let volumeArgs;
400
526
  if (fileArg) {
401
527
  const absFile = node_path_1.default.resolve(fileArg);
@@ -480,28 +606,18 @@ async function run(prompt) {
480
606
  for (const spec of volumeArgList) {
481
607
  userVolumeArgs.push("-v", `${node_path_1.default.resolve(spec.split(":")[0])}:${spec.split(":").slice(1).join(":")}`);
482
608
  }
483
- const args = [
484
- "run",
485
- "--rm",
486
- ...ttyFlags,
487
- "--cap-drop=ALL",
488
- "--cap-add=NET_RAW",
489
- "--security-opt",
490
- "no-new-privileges:true",
491
- "--security-opt",
492
- `seccomp=${SECCOMP_PROFILE}`,
493
- ...envFileArgs,
494
- ...cloudModeEnv,
495
- ...adapterDockerArgs,
496
- ...volumeArgs,
497
- ...userVolumeArgs,
498
- "-w",
499
- "/workspace",
609
+ const args = runtime.runArgs({
610
+ interactive,
611
+ envFileArgs,
612
+ envArgs: [...cloudModeEnv, ...adapterDockerArgs],
613
+ volumeArgs,
614
+ userVolumeArgs,
615
+ workdir: "/workspace",
500
616
  image,
501
- ...containerCmd,
502
- ];
503
- const docker = (0, node_child_process_1.spawn)("docker", args, { stdio: "inherit" });
504
- docker.on("exit", (code) => process.exit(code ?? 1));
617
+ containerCmd,
618
+ });
619
+ const child = (0, node_child_process_1.spawn)(runtime.binary(), args, { stdio: "inherit" });
620
+ child.on("exit", (code) => process.exit(code ?? 1));
505
621
  }
506
622
  if (!process.stdin.isTTY && promptArg === null) {
507
623
  let input = "";
@@ -0,0 +1,10 @@
1
+ {
2
+ "defaultAction": "SCMP_ACT_ALLOW",
3
+ "syscalls": [
4
+ {
5
+ "names": ["socket"],
6
+ "action": "SCMP_ACT_ERRNO",
7
+ "args": [{ "index": 0, "op": "SCMP_CMP_EQ", "value": 38 }]
8
+ }
9
+ ]
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boldblackai/harness",
3
- "version": "1.8.5",
3
+ "version": "1.9.0",
4
4
  "description": "Portable environment for running coding agents in a container",
5
5
  "bin": {
6
6
  "harness": "bin/harness.js"
@@ -21,7 +21,7 @@
21
21
  "lint": "pnpm lint:ts && pnpm lint:md && pnpm lint:sh && pnpm lint:docker && pnpm lint:actions",
22
22
  "lint:ts": "biome check .",
23
23
  "lint:md": "markdownlint-cli2 \"**/*.md\" \"#node_modules\"",
24
- "lint:sh": "shellcheck entrypoint.sh entrypoint-opencode.sh entrypoint-hermes.sh",
24
+ "lint:sh": "shellcheck setup-env.sh entrypoint.sh entrypoint-opencode.sh entrypoint-hermes.sh",
25
25
  "lint:docker": "hadolint Dockerfile Dockerfile.opencode Dockerfile.hermes",
26
26
  "lint:actions": "actionlint",
27
27
  "format": "biome format --write .",