@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 +24 -4
- package/bin/harness.js +168 -52
- package/bin/seccomp-profiles/seccomp-profiles/block-af-alg.json +10 -0
- package/package.json +2 -2
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)
|
|
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
|
|
245
|
-
|
|
246
|
-
| `HARNESS_IMAGE_TAG`
|
|
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 } =
|
|
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
|
|
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)(
|
|
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:
|
|
295
|
+
console.error(`harness: ${runtime.binary()} pull failed for ${image}`);
|
|
172
296
|
process.exit(1);
|
|
173
297
|
}
|
|
174
|
-
digestRef =
|
|
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
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
...
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
"
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
const
|
|
504
|
-
|
|
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 = "";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boldblackai/harness",
|
|
3
|
-
"version": "1.
|
|
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 .",
|