@boldblackai/harness 1.8.5

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Julio Capote
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,308 @@
1
+ <p align="center" style="margin-bottom: 0">
2
+ <img alt="harness" src="logo-light.png#gh-light-mode-only" width="250">
3
+ <img alt="harness" src="logo-dark.png#gh-dark-mode-only" width="250">
4
+ </p>
5
+
6
+ <p align="center">
7
+ <strong>Run agents in a sandboxed container — ready to drop into any project.</strong>
8
+ </p>
9
+
10
+ Harness conveniently wraps Docker around three open-source coding agents — [`pi`](https://pi.dev/), [`opencode`](https://opencode.ai),
11
+ and [`hermes`](https://github.com/NousResearch/hermes-agent) — so you can point one at a directory (or file) without giving it access to your entire machine.
12
+
13
+ > **Documentation:** [boldblackai.github.io/harness](https://boldblackai.github.io/harness/)
14
+
15
+ ## Features
16
+
17
+ - **Sandboxed by default** — capability-dropped container with `no-new-privileges`; the agent only sees the directory (or file) you mount.
18
+ - **Three agents, one CLI** — switch between `pi`, `opencode`, and `hermes` with `-a`. Same flags, same flow.
19
+ - **Supply-chain hardened** — the image is signed and verified with cosign and SLSA provenance on every run; dependencies installed inside the container are always pinned and verified where possible and a 7-day "cooldown" is used to mitigate supply-chain attacks.
20
+ - **Local-first** — defaults to LM Studio with `gemma-4-e4b`. Drop in an `--env-file` to use Anthropic, OpenRouter, OpenAI, Gemini, and others.
21
+ - **Stateful or one-shot** — interactive runs persist agent state under `$XDG_DATA_HOME/harness/<project>/<agent>/` (defaults to `~/.local/share/harness/`); one-shot prompts (`-p` or piped stdin) stay ephemeral.
22
+ - **User skills** — automatically mounts `~/.agents/skills` and `~/.claude/skills` into the container so agents can discover and use custom skills. Disable with `--no-skills`.
23
+ - **Context files** — automatically mounts `~/.agents/AGENTS.md` and `~/.claude/CLAUDE.md` into the agent's context directory so cross-agent rules apply inside the container. Disable with `--no-context-files` (`-nc`).
24
+ - **Zero install** — `npx @boldblackai/harness` just works.
25
+
26
+ ## Quickstart
27
+
28
+ [Docker](https://www.docker.com) is required. By default, harness uses LM Studio locally:
29
+
30
+ ```bash
31
+ lms daemon up
32
+ lms get google/gemma-4-e4b
33
+ ```
34
+
35
+ The container is preconfigured to use `gemma-4-e4b` via LM Studio's local API.
36
+
37
+ You can also specify a different local model with `-m`. HuggingFace-style names with slashes (e.g. `qwen/qwen3.5-9b`) work correctly in local mode:
38
+
39
+ ```bash
40
+ npx @boldblackai/harness -m "qwen/qwen3.5-9b" -p "write a fizzbuzz in Go"
41
+ ```
42
+
43
+ ### Using a cloud provider instead
44
+
45
+ #### pi (default agent)
46
+
47
+ If you pass an API key for a supported provider via `--env-file`, [`pi`](https://pi.dev/) will use that provider instead of the local LM Studio setup. Supported keys:
48
+
49
+ | Provider | Environment Variable |
50
+ |----------|----------------------|
51
+ | Anthropic | `ANTHROPIC_API_KEY` |
52
+ | OpenRouter | `OPENROUTER_API_KEY` |
53
+ | OpenAI | `OPENAI_API_KEY` |
54
+ | Google Gemini | `GEMINI_API_KEY` |
55
+ | Mistral | `MISTRAL_API_KEY` |
56
+ | Groq | `GROQ_API_KEY` |
57
+ | Cerebras | `CEREBRAS_API_KEY` |
58
+ | xAI | `XAI_API_KEY` |
59
+ | Hugging Face | `HF_TOKEN` |
60
+
61
+ See the [full list of supported providers](https://github.com/badlogic/pi-mono/blob/c779c14e91bc2ea65143e59b0dc1baf3646ba8c9/packages/coding-agent/docs/providers.md#api-keys) for more options. When using LM Studio locally, 16k context is sufficient.
62
+
63
+ #### opencode agent
64
+
65
+ [`opencode`](https://opencode.ai) uses LM Studio by default. Pass `--env-file` to switch to cloud mode — the agent auto-detects the provider from whichever API key is in the file:
66
+
67
+ ```bash
68
+ echo 'OPENROUTER_API_KEY=sk-...' > .env
69
+ npx @boldblackai/harness -e .env -p "write me a fizzbuzz in Go"
70
+ ```
71
+
72
+ That's it. Your current directory is mounted at `/workspace` inside the container and the agent works against it.
73
+
74
+ ## Contents
75
+
76
+ - [Examples](#examples)
77
+ - [Agents](#agents)
78
+ - [pi (default)](#pi-default)
79
+ - [opencode](#opencode)
80
+ - [hermes](#hermes)
81
+ - [Security model](#security-model)
82
+ - [Sandbox](#sandbox)
83
+ - [Image verification](#image-verification)
84
+ - [Dependency cooldown](#dependency-cooldown)
85
+ - [Persistence](#persistence)
86
+ - [Reference](#reference)
87
+ - [CLI flags](#cli-flags)
88
+ - [Environment variables](#environment-variables)
89
+ - [Agent-specific behavior](#agent-specific-behavior)
90
+ - [Deploying hermes as a claw](#deploying-hermes-as-a-claw)
91
+ - [Developing](#developing)
92
+
93
+ ## Examples
94
+
95
+ ```bash
96
+ # One-shot prompt
97
+ npx @boldblackai/harness -p "write me a fizzbuzz in Go"
98
+
99
+ # Pipe via stdin
100
+ echo "write me a fizzbuzz in Go" | npx @boldblackai/harness
101
+
102
+ # Interactive session (no -p, no piped stdin) — state persists under XDG data dir
103
+ npx @boldblackai/harness
104
+
105
+ # Use a cloud provider via env file
106
+ npx @boldblackai/harness -e .env -p "add a login endpoint"
107
+
108
+ # Override the model
109
+ npx @boldblackai/harness -m anthropic/claude-sonnet-4-5 -p "refactor the auth module"
110
+
111
+ # Mount a single file instead of the whole directory
112
+ npx @boldblackai/harness -f ./script.py -p "add type hints"
113
+
114
+ # Switch agents
115
+ npx @boldblackai/harness -a opencode -p "write me a fizzbuzz in Go"
116
+ npx @boldblackai/harness -a hermes -e .env -p "add tests"
117
+ ```
118
+
119
+ `npx`, `bunx`, and `pnpm dlx` are interchangeable. Or install globally:
120
+
121
+ ```bash
122
+ npm install -g @boldblackai/harness
123
+ # or
124
+ pnpm add -g @boldblackai/harness
125
+ # or
126
+ bun add -g @boldblackai/harness
127
+ ```
128
+
129
+ ## Agents
130
+
131
+ Pick an agent with `-a`. Default is `pi`.
132
+
133
+ ### pi (default)
134
+
135
+ [`pi`](https://pi.dev/) defaults to LM Studio with `google/gemma-4-e4b` (16k context is enough). Pass an `--env-file` containing any of the keys below and `pi` switches to that provider:
136
+
137
+ | Provider | Environment Variable |
138
+ |---------------|----------------------|
139
+ | Anthropic | `ANTHROPIC_API_KEY` |
140
+ | OpenRouter | `OPENROUTER_API_KEY` |
141
+ | OpenAI | `OPENAI_API_KEY` |
142
+ | Google Gemini | `GEMINI_API_KEY` |
143
+ | Mistral | `MISTRAL_API_KEY` |
144
+ | Groq | `GROQ_API_KEY` |
145
+ | Cerebras | `CEREBRAS_API_KEY` |
146
+ | xAI | `XAI_API_KEY` |
147
+ | Hugging Face | `HF_TOKEN` |
148
+
149
+ See the [full provider list](https://github.com/badlogic/pi-mono/blob/c779c14e91bc2ea65143e59b0dc1baf3646ba8c9/packages/coding-agent/docs/providers.md#api-keys). The `-m` flag is forwarded directly.
150
+
151
+ ### opencode
152
+
153
+ [`opencode`](https://opencode.ai) defaults to LM Studio in local mode. Pass `--env-file` to enter cloud mode — the agent auto-detects the provider from whichever API key is in the file (`ZAI_API_KEY`, `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, etc.). The `-m` flag takes a bare model name; the provider prefix is added for you.
154
+
155
+ ```bash
156
+ npx @boldblackai/harness -a opencode -e .env -p "refactor the auth module"
157
+ npx @boldblackai/harness -a opencode -e .env -m anthropic/claude-sonnet-4-5 -p "add tests"
158
+ ```
159
+
160
+ To pass env vars but stay in local mode, use `--local`:
161
+
162
+ ```bash
163
+ npx @boldblackai/harness -a opencode -e .env --local -p "refactor the auth module"
164
+ ```
165
+
166
+ When using LM Studio locally, set the model's context length to at least 32k tokens.
167
+
168
+ ### hermes
169
+
170
+ [`hermes`](https://github.com/NousResearch/hermes-agent) by NousResearch supports many providers. Pass `--env-file` to enter cloud mode — the agent auto-detects the provider from whichever API key is in the file. Use a `provider/model` for `-m`:
171
+
172
+ ```bash
173
+ npx @boldblackai/harness -a hermes -e .env -m anthropic/claude-sonnet-4-5 -p "add tests"
174
+ npx @boldblackai/harness -a hermes -e .env -m openrouter/auto -p "add tests"
175
+ ```
176
+
177
+ Common keys: `ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_API_KEY`, [and others](https://github.com/NousResearch/hermes-agent/blob/main/.env.example). LM Studio context length should be at least 64k tokens.
178
+
179
+ ## Security model
180
+
181
+ Harness layers protections at runtime, image, and dependency level.
182
+
183
+ ### Sandbox
184
+
185
+ Each run starts the container with:
186
+
187
+ - `--cap-drop=ALL --cap-add=NET_RAW` — minimal capability set
188
+ - `--security-opt no-new-privileges:true` — block privilege escalation
189
+ - `--security-opt seccomp=...` — inline seccomp profile blocks `socket(AF_ALG)` to prevent kernel crypto API access (a known container escape vector)
190
+ - Only your mounted directory (or single file with `-f`) is visible to the agent
191
+
192
+ ### Image verification
193
+
194
+ 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):
195
+
196
+ ```bash
197
+ brew install cosign
198
+ ```
199
+
200
+ Verified digests are cached at `~/.cache/harness/cosign-verified.json` so verification only runs once per image. Skip with `--no-verify` (or by setting `HARNESS_IMAGE_TAG`, which implies skip):
201
+
202
+ ```bash
203
+ npx @boldblackai/harness --no-verify -p "write me a fizzbuzz in Go"
204
+ ```
205
+
206
+ ### Dependency cooldown
207
+
208
+ The image build enforces a 7-day cooldown on dependency resolution — a guard against supply-chain compromises that are typically discovered and yanked within hours.
209
+
210
+ - **pnpm**: `PNPM_MINIMUM_RELEASE_AGE=10080` (minutes) via environment variable
211
+ - **uv**: `--exclude-newer` set to 7 days ago at image build time
212
+
213
+ The cooldown applies to transitive dependencies too. Older packages install normally.
214
+
215
+ ## Persistence
216
+
217
+ Interactive runs (no `-p` and no piped stdin) store persistence data at `$XDG_DATA_HOME/harness/<project>/<agent>/` (defaults to `~/.local/share/harness/`). The `<project>` segment is the working directory path with `/` replaced by `_` and the home prefix stripped. This lets agents resume sessions, skip database migrations on repeat runs, and retain memories across invocations. The container's `~/.config` is persisted at `<persist-root>/../xdg_config` (one level above the per-agent root, i.e. `$XDG_DATA_HOME/harness/<project>/xdg_config`), so tool configs (e.g. jj) survive across runs and are shared by every agent working in the same project. For the opencode adapter the existing per-agent `.config/opencode` mount nests inside this project-level one. Per-agent `mise` tool data and trust settings are persisted at `<persist-root>/mise/` and `<persist-root>/mise-state/` respectively. For the pi adapter, extensions/skills installed via `npm install -g` are persisted at `<persist-root>/npm/`, avoiding re-downloads on every boot.
218
+
219
+ One-shot runs (`-p` or piped stdin) are implicitly ephemeral — no persistence data is created. Use `--ephemeral` to force-disable persistence on interactive runs.
220
+
221
+ If an old `.harness/` directory exists in your working directory, harness will emit a deprecation warning with migration instructions.
222
+
223
+ ## Reference
224
+
225
+ ### CLI flags
226
+
227
+ | Flag | Alias | Description |
228
+ |---------------|-------|-------------|
229
+ | `--prompt` | `-p` | Pass a prompt directly to the agent |
230
+ | `--env-file` | `-e` | Load environment variables into the container |
231
+ | `--file` | `-f` | Mount a single file instead of the current directory |
232
+ | `--model` | `-m` | Override the model used by the agent |
233
+ | `--agent` | `-a` | Select agent: `pi`, `opencode`, `hermes` (default: `pi`) |
234
+ | `--volumes` | `-v` | Additional volume mount (`host:container[:opts]`); may be repeated |
235
+ | `--no-verify` | | Skip cosign signature and provenance verification |
236
+ | `--no-skills` | | Disable mounting user skills directories (`~/.agents/skills`, `~/.claude/skills`) |
237
+ | `--no-context-files` | `-nc` | Disable mounting global context files (`~/.agents/AGENTS.md`, `~/.claude/CLAUDE.md`) |
238
+ | `--ephemeral` | | Disable session persistence (implied by `-p` and piped stdin) |
239
+ | `--local` | | Force local mode even with `-e` (use LM Studio / local defaults) |
240
+ | `--help` | `-h` | Show help |
241
+
242
+ ### Environment variables
243
+
244
+ | Variable | Description |
245
+ |----------------------|-------------|
246
+ | `HARNESS_IMAGE_TAG` | Override the Docker image tag (defaults to the package version). Setting this implies `--no-verify`. |
247
+
248
+ ### Agent-specific behavior
249
+
250
+ - **pi** — `-m` is passed straight to the binary as `--model`.
251
+ - **opencode** — `-m` is passed via the `OPENCODE_MODEL` env var. Without `-e`, uses LM Studio locally. With `-e`, enters cloud mode and auto-detects the provider from whichever API key is in the env file. Use `--local` to force local mode even with `-e`.
252
+ - **hermes** — `-m` is passed as `--model` in `provider/model` form. Without `-e`, uses local config. With `-e`, enters cloud mode and auto-detects from env vars. Use `--local` to force local mode even with `-e`.
253
+
254
+ ## Deploying hermes as a claw
255
+
256
+ You can run `hermes` as a long-running "claw" — a persistent agent process reachable over a messaging gateway (e.g. Telegram). Three deployment targets are documented:
257
+
258
+ - [fly.io](docs/deploying-to-fly.md)
259
+ - [Kubernetes](docs/deploying-to-k8s.md)
260
+ - [AWS (ECS Fargate or EC2 + SSM)](docs/deploying-to-aws.md)
261
+
262
+ ## Developing
263
+
264
+ Link your local checkout globally:
265
+
266
+ ```bash
267
+ pnpm link --global
268
+ # unlink with:
269
+ pnpm unlink --global @boldblackai/harness
270
+ ```
271
+
272
+ ### Building the image
273
+
274
+ ```bash
275
+ make image
276
+ ```
277
+
278
+ Builds `ghcr.io/boldblackai/harness` with Debian stable-slim, Node.js v24, `git`, [`@earendil-works/pi-coding-agent`](https://pi.dev/), [`opencode-ai`](https://opencode.ai), [`hermes-agent`](https://github.com/NousResearch/hermes-agent), `fd`, `ripgrep`, `jq`, and `curl`. The hermes variant also includes the [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk) for connecting to MCP servers, and [`faster-whisper`](https://github.com/SYSTRAN/faster-whisper) for local speech-to-text.
279
+
280
+ The base image is pinned by manifest-list digest (the OCI image index, not a per-platform manifest) for reproducible multi-arch builds. To bump it:
281
+
282
+ ```bash
283
+ docker buildx imagetools inspect debian:stable-slim --format '{{.Manifest.Digest}}'
284
+ ```
285
+
286
+ ### Linting
287
+
288
+ ```bash
289
+ pnpm lint # all
290
+ pnpm lint:ts # Biome
291
+ pnpm lint:md # markdownlint
292
+ pnpm lint:sh # shellcheck
293
+ pnpm lint:docker # hadolint
294
+ pnpm lint:actions # actionlint
295
+ pnpm format # auto-format with Biome
296
+ ```
297
+
298
+ `shellcheck`, `hadolint`, and `actionlint` are system binaries. Install with [mise](https://mise.jdx.dev/) (recommended):
299
+
300
+ ```bash
301
+ mise install
302
+ ```
303
+
304
+ Or install manually:
305
+
306
+ ```bash
307
+ brew install shellcheck hadolint actionlint
308
+ ```
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/bin/harness.js ADDED
@@ -0,0 +1,516 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ const node_child_process_1 = require("node:child_process");
8
+ const node_fs_1 = __importDefault(require("node:fs"));
9
+ const node_os_1 = __importDefault(require("node:os"));
10
+ const node_path_1 = __importDefault(require("node:path"));
11
+ const minimist_1 = __importDefault(require("minimist"));
12
+ const SECCOMP_PROFILE = node_path_1.default.join(__dirname, "seccomp-profiles", "block-af-alg.json");
13
+ class PiAdapter {
14
+ buildCommand({ prompt, model, envFilePath }) {
15
+ // In local mode (no env file), pass --provider ollama so pi routes
16
+ // the model to the local LM Studio provider. Without this, model names
17
+ // containing slashes (e.g. HuggingFace IDs like "qwen/qwen3.5-9b") are
18
+ // misinterpreted as provider/model format, causing pi to silently ignore
19
+ // --model and fall back to a default that may require cloud credentials.
20
+ const providerArgs = !envFilePath && model ? ["--provider", "ollama"] : [];
21
+ const modelArgs = model ? ["--model", model] : [];
22
+ if (prompt !== null) {
23
+ return ["pi", "-p", prompt, ...providerArgs, ...modelArgs];
24
+ }
25
+ return ["pi", ...providerArgs, ...modelArgs];
26
+ }
27
+ persistMounts() {
28
+ return [
29
+ { hostSubpath: "", containerPath: "/home/harness/.pi/agent" },
30
+ { hostSubpath: "npm", containerPath: "/home/harness/.local/share/npm" },
31
+ ];
32
+ }
33
+ contextDir() {
34
+ return "/home/harness/.pi/agent";
35
+ }
36
+ }
37
+ class OpenCodeAdapter {
38
+ buildCommand({ prompt }) {
39
+ if (prompt !== null) {
40
+ return ["opencode", "run", prompt];
41
+ }
42
+ return ["opencode"];
43
+ }
44
+ extraDockerArgs({ model }) {
45
+ return model ? ["-e", `OPENCODE_MODEL=${model}`] : [];
46
+ }
47
+ persistMounts() {
48
+ return [
49
+ {
50
+ hostSubpath: "config",
51
+ containerPath: "/home/harness/.config/opencode",
52
+ },
53
+ {
54
+ hostSubpath: "share",
55
+ containerPath: "/home/harness/.local/share/opencode",
56
+ },
57
+ {
58
+ hostSubpath: "state",
59
+ containerPath: "/home/harness/.local/state/opencode",
60
+ },
61
+ ];
62
+ }
63
+ contextDir() {
64
+ return "/home/harness/.config/opencode";
65
+ }
66
+ }
67
+ class HermesAdapter {
68
+ buildCommand({ prompt, model }) {
69
+ const args = ["hermes", "chat"];
70
+ if (model)
71
+ args.push("-m", model);
72
+ if (prompt !== null)
73
+ args.push("-q", prompt);
74
+ return args;
75
+ }
76
+ persistMounts() {
77
+ return [{ hostSubpath: "", containerPath: "/home/harness/.hermes" }];
78
+ }
79
+ contextDir() {
80
+ return "/home/harness/.hermes";
81
+ }
82
+ }
83
+ const IDENTITY_REGEXP = "https://github.com/boldblackai/harness/.github/workflows/docker.yml@refs/tags/";
84
+ const OIDC_ISSUER = "https://token.actions.githubusercontent.com";
85
+ const CACHE_VERSION = 1;
86
+ function cachePath() {
87
+ const base = process.env.XDG_CACHE_HOME || node_path_1.default.join(node_os_1.default.homedir(), ".cache");
88
+ return node_path_1.default.join(base, "harness", "cosign-verified.json");
89
+ }
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
+ function readCache() {
111
+ try {
112
+ const raw = node_fs_1.default.readFileSync(cachePath(), "utf8");
113
+ const parsed = JSON.parse(raw);
114
+ if (parsed &&
115
+ parsed.version === CACHE_VERSION &&
116
+ parsed.verified &&
117
+ typeof parsed.verified === "object") {
118
+ return parsed;
119
+ }
120
+ }
121
+ catch { }
122
+ return { version: CACHE_VERSION, verified: {} };
123
+ }
124
+ function writeCacheAtomic(cache) {
125
+ try {
126
+ const file = cachePath();
127
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(file), { recursive: true, mode: 0o700 });
128
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
129
+ node_fs_1.default.writeFileSync(tmp, JSON.stringify(cache, null, 2), { mode: 0o600 });
130
+ node_fs_1.default.renameSync(tmp, file);
131
+ }
132
+ catch {
133
+ // best-effort; never break verify path
134
+ }
135
+ }
136
+ function cosign(args) {
137
+ return new Promise((resolve, reject) => {
138
+ (0, node_child_process_1.execFile)("cosign", args, { timeout: 30000 }, (err, _stdout, stderr) => {
139
+ if (err) {
140
+ const e = err;
141
+ e.stderr = stderr;
142
+ reject(e);
143
+ }
144
+ else {
145
+ resolve();
146
+ }
147
+ });
148
+ });
149
+ }
150
+ async function verifyImage(image) {
151
+ let { exists, digest: digestRef } = inspectLocalImage(image);
152
+ const cache = readCache();
153
+ if (digestRef && cache.verified[digestRef]) {
154
+ return;
155
+ }
156
+ if (exists && !digestRef) {
157
+ 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.");
159
+ console.error("harness: use --no-verify, or set HARNESS_IMAGE_TAG for an implicit skip.");
160
+ process.exit(1);
161
+ }
162
+ if (!digestRef) {
163
+ console.error(`harness: pulling ${image} for verification...`);
164
+ try {
165
+ (0, node_child_process_1.execFileSync)("docker", ["pull", image], {
166
+ stdio: ["ignore", "inherit", "inherit"],
167
+ timeout: 600000,
168
+ });
169
+ }
170
+ catch {
171
+ console.error(`harness: docker pull failed for ${image}`);
172
+ process.exit(1);
173
+ }
174
+ digestRef = inspectLocalImage(image).digest;
175
+ if (!digestRef) {
176
+ console.error(`harness: failed to resolve digest for ${image} after pull`);
177
+ process.exit(1);
178
+ }
179
+ if (cache.verified[digestRef]) {
180
+ return;
181
+ }
182
+ }
183
+ const identityArgs = [
184
+ "--certificate-identity-regexp",
185
+ IDENTITY_REGEXP,
186
+ "--certificate-oidc-issuer",
187
+ OIDC_ISSUER,
188
+ ];
189
+ const verifyP = cosign(["verify", ...identityArgs, digestRef]);
190
+ const attestP = cosign([
191
+ "verify-attestation",
192
+ "--type",
193
+ "slsaprovenance",
194
+ ...identityArgs,
195
+ digestRef,
196
+ ]);
197
+ const [verifyResult, attestResult] = await Promise.allSettled([
198
+ verifyP,
199
+ attestP,
200
+ ]);
201
+ if (verifyResult.status === "rejected") {
202
+ const e = verifyResult.reason;
203
+ if (e.code === "ENOENT") {
204
+ console.error("harness: cosign not found — cannot verify image without it.");
205
+ console.error("harness: install cosign (brew install cosign) or pass --no-verify to skip verification.");
206
+ process.exit(1);
207
+ }
208
+ console.error(`harness: image signature verification failed for ${digestRef}`);
209
+ console.error(e.stderr?.trim() || e.message);
210
+ process.exit(1);
211
+ }
212
+ if (attestResult.status === "rejected") {
213
+ console.error(`harness: WARNING: no provenance attestation found for ${digestRef}`);
214
+ }
215
+ cache.verified[digestRef] = {
216
+ tag: image,
217
+ verifiedAt: new Date().toISOString(),
218
+ };
219
+ writeCacheAtomic(cache);
220
+ }
221
+ const AGENT_NAMES = ["pi", "opencode", "hermes"];
222
+ const ADAPTERS = {
223
+ pi: new PiAdapter(),
224
+ opencode: new OpenCodeAdapter(),
225
+ hermes: new HermesAdapter(),
226
+ };
227
+ function isAgentName(name) {
228
+ return AGENT_NAMES.includes(name);
229
+ }
230
+ const USAGE = `Usage: harness [options]
231
+
232
+ Options:
233
+ -p, --prompt <text> Pass a prompt directly to the coding agent
234
+ -e, --env-file <file> Load environment variables from a file into the container
235
+ -f, --file <file> Mount a single file into the container instead of the current directory
236
+ -m, --model <model> Override the model used by the agent
237
+ -a, --agent <name> Select the coding agent adapter: pi, opencode, hermes (default: pi)
238
+ -v, --volumes <spec> Additional volume mount (host:container[:opts]); may be repeated
239
+ --no-verify Skip cosign image signature and provenance verification
240
+ --no-skills Disable mounting user skills directories (~/.agents/skills, ~/.claude/skills)
241
+ --no-context-files Disable mounting global context files (~/.agents/AGENTS.md, ~/.claude/CLAUDE.md); alias -nc
242
+ --ephemeral Disable session persistence (implied by -p and piped stdin)
243
+ --local Force local mode even with -e (use LM Studio / local defaults)
244
+ -h, --help Show this help message
245
+
246
+ 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)
250
+
251
+ Persistence data is stored at $XDG_DATA_HOME/harness/<project>/<agent>/.
252
+
253
+ You can also pipe text to harness as an implied -p:
254
+ echo "write me a fizzbuzz in Go" | harness
255
+ `;
256
+ const workspace = process.cwd();
257
+ const REGISTRY = "ghcr.io/boldblackai/harness";
258
+ const VERSION = require("../package.json").version;
259
+ const IMAGE_TAG = process.env.HARNESS_IMAGE_TAG ?? VERSION;
260
+ function normalizeCwd(cwd) {
261
+ const home = node_os_1.default.homedir();
262
+ let normalized = cwd;
263
+ if (normalized.startsWith(home)) {
264
+ normalized = normalized.slice(home.length);
265
+ }
266
+ normalized = normalized.replace(/\//g, "_");
267
+ if (normalized === "") {
268
+ normalized = "_home";
269
+ }
270
+ return normalized;
271
+ }
272
+ function xdgDataDir() {
273
+ return (process.env.XDG_DATA_HOME || node_path_1.default.join(node_os_1.default.homedir(), ".local", "share"));
274
+ }
275
+ function getImage(agent) {
276
+ const tag = agent === "pi" ? IMAGE_TAG : `${agent}-${IMAGE_TAG}`;
277
+ return `${REGISTRY}:${tag}`;
278
+ }
279
+ const MINIMIST_OPTS = {
280
+ boolean: ["help", "h", "ephemeral", "local"],
281
+ string: [
282
+ "env-file",
283
+ "e",
284
+ "file",
285
+ "f",
286
+ "prompt",
287
+ "p",
288
+ "model",
289
+ "m",
290
+ "agent",
291
+ "a",
292
+ "volumes",
293
+ ],
294
+ alias: {
295
+ e: "env-file",
296
+ f: "file",
297
+ p: "prompt",
298
+ m: "model",
299
+ h: "help",
300
+ a: "agent",
301
+ v: "volumes",
302
+ },
303
+ };
304
+ // Translate pi's short context-files flag (-nc) to its long form before
305
+ // minimist, which would otherwise split -nc into clustered -n -c flags.
306
+ const rawArgs = process.argv
307
+ .slice(2)
308
+ .map((a) => (a === "-nc" ? "--no-context-files" : a));
309
+ const argv = (0, minimist_1.default)(rawArgs, MINIMIST_OPTS);
310
+ // Warn on unrecognized flags
311
+ {
312
+ const knownKeys = new Set([
313
+ "_", // minimist internal positional args
314
+ ...MINIMIST_OPTS.boolean,
315
+ ...MINIMIST_OPTS.string,
316
+ ...Object.keys(MINIMIST_OPTS.alias),
317
+ ...Object.values(MINIMIST_OPTS.alias),
318
+ "verify", // --no-verify sets verify=false
319
+ "skills", // --no-skills sets skills=false
320
+ "context-files", // --no-context-files / -nc sets context-files=false
321
+ "local",
322
+ ]);
323
+ const unknown = Object.keys(argv).filter((k) => !knownKeys.has(k));
324
+ if (unknown.length > 0) {
325
+ process.stderr.write(`warning: unrecognized flag(s): ${unknown.map((k) => `--${k}`).join(", ")}\n`);
326
+ }
327
+ }
328
+ if (argv.help) {
329
+ process.stdout.write(USAGE);
330
+ process.exit(0);
331
+ }
332
+ const noVerify = argv.verify === false;
333
+ const noSkills = argv.skills === false;
334
+ const noContextFiles = argv["context-files"] === false;
335
+ const localMode = argv.local;
336
+ const envFilePath = argv["env-file"] || null;
337
+ const fileArg = argv.file || null;
338
+ const promptArg = argv.prompt || null;
339
+ const modelArg = argv.model || null;
340
+ const effectiveEphemeral = argv.ephemeral || promptArg !== null || !process.stdin.isTTY;
341
+ const agentName = (() => {
342
+ const name = argv.agent ?? "pi";
343
+ if (!isAgentName(name)) {
344
+ console.error(`harness: unknown agent: "${name}". Available: ${Object.keys(ADAPTERS).join(", ")}`);
345
+ process.exit(1);
346
+ }
347
+ return name;
348
+ })();
349
+ if (envFilePath && !node_fs_1.default.existsSync(envFilePath)) {
350
+ console.error(`harness: env file not found: ${envFilePath}`);
351
+ process.exit(1);
352
+ }
353
+ if (fileArg && !node_fs_1.default.existsSync(fileArg)) {
354
+ console.error(`harness: file not found: ${fileArg}`);
355
+ process.exit(1);
356
+ }
357
+ if (fileArg && node_fs_1.default.statSync(fileArg).isDirectory()) {
358
+ console.error(`harness: --file requires a file, not a directory: ${fileArg}`);
359
+ process.exit(1);
360
+ }
361
+ const volumeArgList = Array.isArray(argv.volumes)
362
+ ? argv.volumes
363
+ : argv.volumes
364
+ ? [argv.volumes]
365
+ : [];
366
+ for (const spec of volumeArgList) {
367
+ const parts = spec.split(":");
368
+ if (parts.length < 2) {
369
+ console.error(`harness: invalid volume spec "${spec}" (expected host:container[:opts])`);
370
+ process.exit(1);
371
+ }
372
+ if (parts[0] && !node_fs_1.default.existsSync(parts[0])) {
373
+ console.error(`harness: volume source path does not exist: ${parts[0]}`);
374
+ process.exit(1);
375
+ }
376
+ }
377
+ async function run(prompt) {
378
+ const image = getImage(agentName);
379
+ if (!noVerify) {
380
+ if (process.env.HARNESS_IMAGE_TAG) {
381
+ console.error(`harness: HARNESS_IMAGE_TAG is set; skipping cosign verification for ${image}`);
382
+ }
383
+ else {
384
+ await verifyImage(image);
385
+ }
386
+ }
387
+ const envFileArgs = envFilePath
388
+ ? ["--env-file", node_path_1.default.resolve(envFilePath)]
389
+ : [];
390
+ // Cloud mode: -e without --local signals entrypoints to skip local/defaults
391
+ // and let agents auto-detect providers from env vars in the file.
392
+ const cloudModeEnv = envFilePath && !localMode ? ["-e", "HARNESS_CLOUD_MODE=1"] : [];
393
+ const adapter = ADAPTERS[agentName];
394
+ const adapterOptions = { prompt, model: modelArg, envFilePath };
395
+ const containerCmd = adapter.buildCommand(adapterOptions);
396
+ const adapterDockerArgs = adapter.extraDockerArgs?.(adapterOptions) ?? [];
397
+ const interactive = process.stdin.isTTY;
398
+ const ttyFlags = interactive ? ["-it"] : ["-i"];
399
+ let volumeArgs;
400
+ if (fileArg) {
401
+ const absFile = node_path_1.default.resolve(fileArg);
402
+ const fileName = node_path_1.default.basename(absFile);
403
+ volumeArgs = ["-v", `${absFile}:/workspace/${fileName}`];
404
+ }
405
+ else {
406
+ volumeArgs = ["-v", `${workspace}:/workspace`];
407
+ if (!effectiveEphemeral) {
408
+ const persistRoot = node_path_1.default.join(xdgDataDir(), "harness", normalizeCwd(workspace), agentName);
409
+ // Deprecation warning for old .harness/ directory
410
+ const oldHarnessDir = node_path_1.default.join(workspace, ".harness");
411
+ if (node_fs_1.default.existsSync(oldHarnessDir)) {
412
+ console.error(`harness: WARNING: found ${oldHarnessDir}/ — persistence data now lives at ${persistRoot}. To migrate session data, copy the contents of .harness/${agentName}/ to the new location. Otherwise this directory can be safely deleted.`);
413
+ }
414
+ const mounts = adapter.persistMounts?.() ?? [];
415
+ for (const mount of mounts) {
416
+ const hostFullPath = node_path_1.default.join(persistRoot, mount.hostSubpath);
417
+ node_fs_1.default.mkdirSync(hostFullPath, { recursive: true });
418
+ volumeArgs.push("-v", `${hostFullPath}:${mount.containerPath}`);
419
+ }
420
+ // Project-level (agent-independent) XDG config persistence. Tools like
421
+ // jj write to ~/.config; persisting it one level above the per-agent
422
+ // persistRoot lets that config survive across runs and be shared by every
423
+ // agent working in this project. For opencode the more-specific
424
+ // .config/opencode mount above nests inside this one (Docker mounts
425
+ // parents before children), so opencode keeps its own per-agent bucket.
426
+ const xdgConfigPath = node_path_1.default.join(node_path_1.default.dirname(persistRoot), "xdg_config");
427
+ node_fs_1.default.mkdirSync(xdgConfigPath, { recursive: true });
428
+ volumeArgs.push("-v", `${xdgConfigPath}:/home/harness/.config`);
429
+ // Per-agent mise persistence (data: tools/plugins, state: trust settings)
430
+ const miseDataPath = node_path_1.default.join(persistRoot, "mise");
431
+ node_fs_1.default.mkdirSync(miseDataPath, { recursive: true });
432
+ volumeArgs.push("-v", `${miseDataPath}:/home/harness/.local/share/mise`);
433
+ const miseStatePath = node_path_1.default.join(persistRoot, "mise-state");
434
+ node_fs_1.default.mkdirSync(miseStatePath, { recursive: true });
435
+ volumeArgs.push("-v", `${miseStatePath}:/home/harness/.local/state/mise`);
436
+ }
437
+ }
438
+ if (!noSkills) {
439
+ const skillDirs = [
440
+ {
441
+ host: node_path_1.default.resolve(node_os_1.default.homedir(), ".agents", "skills"),
442
+ container: "/home/harness/.agents/skills",
443
+ },
444
+ {
445
+ host: node_path_1.default.resolve(node_os_1.default.homedir(), ".claude", "skills"),
446
+ container: "/home/harness/.claude/skills",
447
+ },
448
+ ];
449
+ for (const sd of skillDirs) {
450
+ if (node_fs_1.default.existsSync(sd.host) && node_fs_1.default.statSync(sd.host).isDirectory()) {
451
+ volumeArgs.push("-v", `${sd.host}:${sd.container}`);
452
+ }
453
+ }
454
+ }
455
+ // Mount the user's global context files into the adapter's context directory
456
+ // so cross-agent rules apply inside the container. Mirrors pi's context-files
457
+ // model (AGENTS.md + CLAUDE.md). Each file is skipped when absent (like skills
458
+ // dirs); --no-context-files / -nc disables all of them.
459
+ if (!noContextFiles) {
460
+ const contextDir = adapter.contextDir?.();
461
+ if (contextDir) {
462
+ const contextFiles = [
463
+ {
464
+ host: node_path_1.default.resolve(node_os_1.default.homedir(), ".agents", "AGENTS.md"),
465
+ name: "AGENTS.md",
466
+ },
467
+ {
468
+ host: node_path_1.default.resolve(node_os_1.default.homedir(), ".claude", "CLAUDE.md"),
469
+ name: "CLAUDE.md",
470
+ },
471
+ ];
472
+ for (const cf of contextFiles) {
473
+ if (node_fs_1.default.existsSync(cf.host) && node_fs_1.default.statSync(cf.host).isFile()) {
474
+ volumeArgs.push("-v", `${cf.host}:${contextDir}/${cf.name}`);
475
+ }
476
+ }
477
+ }
478
+ }
479
+ const userVolumeArgs = [];
480
+ for (const spec of volumeArgList) {
481
+ userVolumeArgs.push("-v", `${node_path_1.default.resolve(spec.split(":")[0])}:${spec.split(":").slice(1).join(":")}`);
482
+ }
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",
500
+ 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));
505
+ }
506
+ if (!process.stdin.isTTY && promptArg === null) {
507
+ let input = "";
508
+ process.stdin.setEncoding("utf8");
509
+ process.stdin.on("data", (chunk) => {
510
+ input += chunk;
511
+ });
512
+ process.stdin.on("end", () => run(input.trim() ? input : null).catch(() => process.exit(1)));
513
+ }
514
+ else {
515
+ run(promptArg).catch(() => process.exit(1));
516
+ }
@@ -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 ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@boldblackai/harness",
3
+ "version": "1.8.5",
4
+ "description": "Portable environment for running coding agents in a container",
5
+ "bin": {
6
+ "harness": "bin/harness.js"
7
+ },
8
+ "files": ["bin/"],
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/boldblackai/harness.git"
12
+ },
13
+ "packageManager": "pnpm@10.33.2",
14
+ "license": "MIT",
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "scripts": {
19
+ "build": "tsc && cp -r src/seccomp-profiles bin/seccomp-profiles",
20
+ "prepare": "tsc",
21
+ "lint": "pnpm lint:ts && pnpm lint:md && pnpm lint:sh && pnpm lint:docker && pnpm lint:actions",
22
+ "lint:ts": "biome check .",
23
+ "lint:md": "markdownlint-cli2 \"**/*.md\" \"#node_modules\"",
24
+ "lint:sh": "shellcheck entrypoint.sh entrypoint-opencode.sh entrypoint-hermes.sh",
25
+ "lint:docker": "hadolint Dockerfile Dockerfile.opencode Dockerfile.hermes",
26
+ "lint:actions": "actionlint",
27
+ "format": "biome format --write .",
28
+ "test:e2e:cli": "node --test tests/e2e/cli.test.mjs",
29
+ "test:e2e": "node --test tests/e2e/cli.test.mjs",
30
+ "test:coverage": "node --experimental-test-coverage --test-coverage-include=\"src/**\" --test-coverage-lines=80 --test-coverage-functions=80 --test-coverage-branches=80 tests/e2e/cli.test.mjs"
31
+ },
32
+ "dependencies": {
33
+ "minimist": "^1.2.8"
34
+ },
35
+ "devDependencies": {
36
+ "@biomejs/biome": "1.9.4",
37
+ "@types/minimist": "^1.2.5",
38
+ "@types/node": "^25.6.0",
39
+ "markdownlint-cli2": "0.17.2",
40
+ "typescript": "^6.0.2"
41
+ }
42
+ }