@boldblackai/harness 1.10.4 → 1.11.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 +32 -0
- package/bin/harness.js +543 -2
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -221,6 +221,35 @@ One-shot runs (`-p` or piped stdin) are implicitly ephemeral — no persistence
|
|
|
221
221
|
|
|
222
222
|
If an old `.harness/` directory exists in your working directory, harness will emit a deprecation warning with migration instructions.
|
|
223
223
|
|
|
224
|
+
## Egress control
|
|
225
|
+
|
|
226
|
+
By default the agent container runs on the default bridge with unrestricted outbound access. `--network` lets you attach it to an existing network instead — for example a network wired to an existing corporate proxy — bringing your own egress topology.
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
harness --network corp-proxy-net
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
The value is forwarded verbatim to the container runtime (`docker run --network <name>` / `container run --network <name>`); no harness-side validation beyond non-empty. `--network host` proceeds with a warning that host networking removes network isolation.
|
|
233
|
+
|
|
234
|
+
**Tier 2 — `--egress <allowlist>` managed enforcement** (per [RFC: opt-in egress allowlisting](https://github.com/boldblackai/harness/blob/main/rfcs/2026-10-08_egress_allowlist.md)). Harness creates a per-run `--internal` network (no external route) plus an egress gateway container (squid, CONNECT-only) that is the only container with a route off-host. The agent container reaches the internet **only** through the gateway, and the gateway forwards only allowlisted destination hostnames. Allowlist syntax:
|
|
235
|
+
|
|
236
|
+
```text
|
|
237
|
+
--egress "api.anthropic.com,api.openai.com,openrouter.ai,.amazonaws.com"
|
|
238
|
+
--egress @/etc/harness/egress-allowlist.txt # one entry per line, # comments
|
|
239
|
+
--egress none # local-only: no gateway, host reachable
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
| Entry | Meaning |
|
|
243
|
+
|-------|---------|
|
|
244
|
+
| `host` | exact hostname, HTTPS (443) via CONNECT |
|
|
245
|
+
| `.domain` or `*.domain` | any host under `domain` (e.g. `.amazonaws.com` covers regional endpoints) |
|
|
246
|
+
| `host.docker.internal[:port]` | host-local destination: direct connection, never proxied (port advisory in v1) |
|
|
247
|
+
| `host:port` | rejected in v1 — the raw-TCP extension is deferred (see the RFC) |
|
|
248
|
+
|
|
249
|
+
`HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` (and `NODE_USE_ENV_PROXY=1` — a Node ≥24 built-in that makes `fetch`/undici honor the proxy env vars) are injected after `--env-file`, so they take precedence over same-key values in a user env file. **The boundary is the network topology, not the env vars** — an agent that ignores the proxy env vars can still only reach the gateway. On Linux, harness maps `host.docker.internal` to the egress network's own gateway to keep the host reachable from the internal network (Docker Desktop provides `host.docker.internal` natively). The egress subnet pool defaults to `192.0.2.0/24` split into /29s; override with `HARNESS_EGRESS_SUBNET=<cidr>` when it collides with your networks. `--egress` is docker-only in v1 (the apple/container runtime fails closed with an error) and mutually exclusive with `--network`.
|
|
250
|
+
|
|
251
|
+
Local-only mode pairs naturally with local mode: `harness --local --egress none` runs the agent against `host.docker.internal:1234` (LM Studio) with no route anywhere else — unlike `docker run --network none`, which would break local-LLM usage too. On Linux the host is reached via the egress network's own gateway (harness maps `host.docker.internal` to it automatically).
|
|
252
|
+
|
|
224
253
|
## Reference
|
|
225
254
|
|
|
226
255
|
### CLI flags
|
|
@@ -240,6 +269,8 @@ If an old `.harness/` directory exists in your working directory, harness will e
|
|
|
240
269
|
| `--ephemeral` | | Disable session persistence (implied by `-p` and piped stdin) |
|
|
241
270
|
| `--local` | | Force local mode even with `-e` (use LM Studio / local defaults) |
|
|
242
271
|
| `--mount-entire-home` | | Allow running from your home directory (mounts all of `$HOME` as the workspace) |
|
|
272
|
+
| `--network` | | Attach the container to an existing network (e.g. one wired to a corp proxy); see [Egress control](#egress-control) |
|
|
273
|
+
| `--egress` | | Restrict outbound traffic to an allowlist (hosts, `.suffix` domains, `@file`, or `none` for local-only); see [Egress control](#egress-control) |
|
|
243
274
|
| `--help` | `-h` | Show help |
|
|
244
275
|
|
|
245
276
|
### Environment variables
|
|
@@ -248,6 +279,7 @@ If an old `.harness/` directory exists in your working directory, harness will e
|
|
|
248
279
|
|-----------------------------|-------------|
|
|
249
280
|
| `HARNESS_IMAGE_TAG` | Override the Docker image tag (defaults to the package version). Setting this implies `--no-verify`. |
|
|
250
281
|
| `HARNESS_CONTAINER_RUNTIME` | Container runtime: `auto` (default — on macOS, prefers Apple's `container` CLI if on PATH; docker everywhere else), `docker` (force docker), or `apple` (deprecated explicit alias; warns). |
|
|
282
|
+
| `HARNESS_EGRESS_SUBNET` | Override the egress network subnet (IPv4 CIDR; default pool `192.0.2.0/24` split into /29s). |
|
|
251
283
|
| `XDG_DATA_HOME` | Override the base directory for persistence data (defaults to `~/.local/share`). |
|
|
252
284
|
| `XDG_CACHE_HOME` | Override the base directory for the cosign cache (defaults to `~/.cache`). |
|
|
253
285
|
|
package/bin/harness.js
CHANGED
|
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
};
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
7
|
const node_child_process_1 = require("node:child_process");
|
|
8
|
+
const node_crypto_1 = require("node:crypto");
|
|
8
9
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
9
10
|
const node_os_1 = __importDefault(require("node:os"));
|
|
10
11
|
const node_path_1 = __importDefault(require("node:path"));
|
|
@@ -49,6 +50,8 @@ class DockerRuntime {
|
|
|
49
50
|
"no-new-privileges:true",
|
|
50
51
|
"--security-opt",
|
|
51
52
|
`seccomp=${SECCOMP_PROFILE}`,
|
|
53
|
+
...(input.network ? ["--network", input.network] : []),
|
|
54
|
+
...input.addHostArgs,
|
|
52
55
|
...input.envFileArgs,
|
|
53
56
|
...input.envArgs,
|
|
54
57
|
...input.volumeArgs,
|
|
@@ -137,6 +140,8 @@ class AppleContainerRuntime {
|
|
|
137
140
|
// is only a minor in-guest defense-in-depth loss. Capability
|
|
138
141
|
// restrictions ARE supported, so --cap-drop=ALL --cap-add=NET_RAW stays.
|
|
139
142
|
const ttyFlags = input.interactive ? ["-i", "-t"] : ["-i"];
|
|
143
|
+
// --network is forwarded identically under both runtimes (Tier 1
|
|
144
|
+
// passthrough; apple/container accepts --network natively).
|
|
140
145
|
return [
|
|
141
146
|
"run",
|
|
142
147
|
"--rm",
|
|
@@ -145,6 +150,8 @@ class AppleContainerRuntime {
|
|
|
145
150
|
"ALL",
|
|
146
151
|
"--cap-add",
|
|
147
152
|
"NET_RAW",
|
|
153
|
+
...(input.network ? ["--network", input.network] : []),
|
|
154
|
+
...input.addHostArgs,
|
|
148
155
|
...input.envFileArgs,
|
|
149
156
|
...input.envArgs,
|
|
150
157
|
...input.volumeArgs,
|
|
@@ -431,12 +438,16 @@ Options:
|
|
|
431
438
|
--ephemeral Disable session persistence (implied by -p and piped stdin)
|
|
432
439
|
--local Force local mode even with -e (use LM Studio / local defaults)
|
|
433
440
|
--mount-entire-home Allow running from your home directory (mounts all of $HOME as the workspace)
|
|
441
|
+
--network <name> Attach the container to an existing network (e.g. one wired to a corp proxy)
|
|
442
|
+
--egress <list> Restrict outbound traffic to an allowlist (comma-separated hosts, .suffix domains,
|
|
443
|
+
host.docker.internal, @file, or "none" for airgap); managed proxy network
|
|
434
444
|
-h, --help Show this help message
|
|
435
445
|
|
|
436
446
|
Environment variables:
|
|
437
447
|
HARNESS_IMAGE_TAG Override the Docker image tag (defaults to package version)
|
|
438
448
|
HARNESS_REGISTRY Override the container registry (defaults to ghcr.io/boldblackai/harness)
|
|
439
449
|
HARNESS_CONTAINER_RUNTIME Container runtime: auto (default; prefers Apple container on macOS), docker, apple (deprecated)
|
|
450
|
+
HARNESS_EGRESS_SUBNET Override the egress network subnet pool (IPv4 CIDR, default 192.0.2.0/24 split into /29s)
|
|
440
451
|
XDG_DATA_HOME Override the base directory for persistence data (defaults to ~/.local/share)
|
|
441
452
|
XDG_CACHE_HOME Override the base directory for cosign cache (defaults to ~/.cache)
|
|
442
453
|
|
|
@@ -483,6 +494,8 @@ const MINIMIST_OPTS = {
|
|
|
483
494
|
"a",
|
|
484
495
|
"volumes",
|
|
485
496
|
"port",
|
|
497
|
+
"network",
|
|
498
|
+
"egress",
|
|
486
499
|
],
|
|
487
500
|
alias: {
|
|
488
501
|
e: "env-file",
|
|
@@ -614,10 +627,529 @@ for (const spec of portArgList) {
|
|
|
614
627
|
process.exit(1);
|
|
615
628
|
}
|
|
616
629
|
}
|
|
630
|
+
// ---------------------------------------------------------------------------
|
|
631
|
+
// Egress control (rfcs/2026-10-08_egress_allowlist.md), Tier 1: pass an
|
|
632
|
+
// existing container network through to the runtime. Brings your own
|
|
633
|
+
// topology (e.g. a network wired to an existing corp proxy). Strictly
|
|
634
|
+
// opt-in; without the flag the argv is unchanged.
|
|
635
|
+
// ---------------------------------------------------------------------------
|
|
636
|
+
const networkArg = (() => {
|
|
637
|
+
const flag = argv.network ?? null;
|
|
638
|
+
if (flag !== null && flag.trim() === "") {
|
|
639
|
+
console.error("harness: --network requires a non-empty network name");
|
|
640
|
+
process.exit(1);
|
|
641
|
+
}
|
|
642
|
+
return flag;
|
|
643
|
+
})();
|
|
644
|
+
if (networkArg === "host") {
|
|
645
|
+
console.error("harness: WARNING: --network host removes network isolation between the container and the host.");
|
|
646
|
+
}
|
|
647
|
+
const HOST_LOCAL_NAME = "host.docker.internal";
|
|
648
|
+
function fail(message) {
|
|
649
|
+
console.error(`harness: ${message}`);
|
|
650
|
+
process.exit(1);
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Like fail(), but for errors raised inside egress bring-up after the
|
|
654
|
+
* managed network exists: the network must be removed before exiting, or
|
|
655
|
+
* it is orphaned until the next --egress run's stale sweep.
|
|
656
|
+
*/
|
|
657
|
+
function failWithNetworkCleanup(message, network) {
|
|
658
|
+
console.error(`harness: ${message}`);
|
|
659
|
+
try {
|
|
660
|
+
(0, node_child_process_1.execFileSync)("docker", ["network", "rm", network], {
|
|
661
|
+
stdio: "ignore",
|
|
662
|
+
timeout: 10000,
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
catch {
|
|
666
|
+
// best-effort; the stale sweep catches survivors
|
|
667
|
+
}
|
|
668
|
+
process.exit(1);
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Parse a raw --egress entry into its typed form, or fail with RFC-cited
|
|
672
|
+
* error copy. Grammar: `host`, `.domain`/`*.domain` (suffix),
|
|
673
|
+
* `host.docker.internal[:port]` (host-local, advisory port), `host:port`
|
|
674
|
+
* (rejected in v1, deferred raw-TCP extension).
|
|
675
|
+
*/
|
|
676
|
+
function parseEgressEntry(raw) {
|
|
677
|
+
const entry = raw.trim();
|
|
678
|
+
if (entry === "") {
|
|
679
|
+
fail("--egress entry is empty.");
|
|
680
|
+
}
|
|
681
|
+
const portMatch = entry.match(/^(.*):(\d+)$/);
|
|
682
|
+
const hasPort = portMatch !== null;
|
|
683
|
+
const body = hasPort ? portMatch[1] : entry;
|
|
684
|
+
const port = hasPort ? Number.parseInt(portMatch[2], 10) : null;
|
|
685
|
+
if (hasPort && (port === null || port < 1 || port > 65535)) {
|
|
686
|
+
fail(`--egress entry "${entry}" has an invalid port.`);
|
|
687
|
+
}
|
|
688
|
+
const hostLocal = body === HOST_LOCAL_NAME;
|
|
689
|
+
if (hasPort && !hostLocal) {
|
|
690
|
+
fail(`--egress entry "${entry}": non-443 ports are not supported in v1 (HTTPS CONNECT proxying only). Host and suffix entries default to port 443; the raw-TCP extension is deferred (see rfcs/2026-10-08_egress_allowlist.md).`);
|
|
691
|
+
}
|
|
692
|
+
let host = body;
|
|
693
|
+
let suffix = false;
|
|
694
|
+
if (host.startsWith("*.") && host.length > 2) {
|
|
695
|
+
suffix = true;
|
|
696
|
+
host = host.slice(2);
|
|
697
|
+
}
|
|
698
|
+
else if (host.startsWith(".") && host.length > 1) {
|
|
699
|
+
suffix = true;
|
|
700
|
+
host = host.slice(1);
|
|
701
|
+
}
|
|
702
|
+
if (host === "" || host === "." || host.includes("/") || host.includes(" ")) {
|
|
703
|
+
fail(`--egress entry "${entry}" is not a valid hostname.`);
|
|
704
|
+
}
|
|
705
|
+
return { host, suffix, hostLocal, port };
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Parse the full --egress value: comma-separated entries, `@file` form, or
|
|
709
|
+
* the literal `none` (airgap). Empty/whitespace items between commas are
|
|
710
|
+
* tolerated; a file whose entries are all comments selects airgap.
|
|
711
|
+
*/
|
|
712
|
+
function parseEgressValue(value) {
|
|
713
|
+
let rawEntries;
|
|
714
|
+
const v = value.trim();
|
|
715
|
+
if (v === "") {
|
|
716
|
+
return fail('--egress requires a value: a comma-separated list, "@file", or "none" (airgap).');
|
|
717
|
+
}
|
|
718
|
+
if (v === "none") {
|
|
719
|
+
rawEntries = [];
|
|
720
|
+
}
|
|
721
|
+
else if (v.startsWith("@")) {
|
|
722
|
+
const file = v.slice(1);
|
|
723
|
+
let content;
|
|
724
|
+
try {
|
|
725
|
+
content = node_fs_1.default.readFileSync(file, "utf8");
|
|
726
|
+
}
|
|
727
|
+
catch {
|
|
728
|
+
return fail(`--egress: cannot read allowlist file ${file}`);
|
|
729
|
+
}
|
|
730
|
+
rawEntries = content
|
|
731
|
+
.split("\n")
|
|
732
|
+
.map((l) => l.replace(/#.*$/, "").trim())
|
|
733
|
+
.filter((l) => l !== "");
|
|
734
|
+
}
|
|
735
|
+
else {
|
|
736
|
+
rawEntries = v.split(",").map((e) => e.trim());
|
|
737
|
+
}
|
|
738
|
+
const seen = new Set();
|
|
739
|
+
const entries = [];
|
|
740
|
+
for (const raw of rawEntries) {
|
|
741
|
+
if (raw === "")
|
|
742
|
+
continue;
|
|
743
|
+
const parsed = parseEgressEntry(raw);
|
|
744
|
+
const key = `${parsed.suffix ? "." : ""}${parsed.host}${parsed.port !== null ? `:${parsed.port}` : ""}`;
|
|
745
|
+
if (seen.has(key))
|
|
746
|
+
continue;
|
|
747
|
+
seen.add(key);
|
|
748
|
+
entries.push(parsed);
|
|
749
|
+
}
|
|
750
|
+
const hostLocal = entries.filter((e) => e.hostLocal);
|
|
751
|
+
const proxied = entries.filter((e) => !e.hostLocal);
|
|
752
|
+
// Local-only (formerly "airgap") means literally nothing allowlisted — the
|
|
753
|
+
// `none` literal or a file whose entries are all comments. A host-local-only
|
|
754
|
+
// list is NOT airgap: it keeps the internal network (so the host, and only
|
|
755
|
+
// the host, is reachable) but starts no gateway. Collapsing such lists into
|
|
756
|
+
// airgap made `--egress "host.docker.internal:1234"` useless (reported in
|
|
757
|
+
// the PR review) — and it's the natural pairing with `--local`.
|
|
758
|
+
return { entries, proxied, hostLocal, airgap: entries.length === 0 };
|
|
759
|
+
}
|
|
760
|
+
// Subnet allocation from the documentation pool 192.0.2.0/24 broken into
|
|
761
|
+
// /29s (32 subnets, gateway .1, egress gateway static .2). Overridable via
|
|
762
|
+
// HARNESS_EGRESS_SUBNET=<cidr>. Collisions with live docker networks are
|
|
763
|
+
// detected before create; a fully exhausted pool is a hard error.
|
|
764
|
+
const EGRESS_SUBNET_POOL = "192.0.2.0/24";
|
|
765
|
+
const EGRESS_SUBNET_SIZE = 29; // 6 usable hosts each, plenty for one run
|
|
766
|
+
/** List docker networks as [{name, subnet}] (best-effort; [] on failure). */
|
|
767
|
+
function dockerNetworks() {
|
|
768
|
+
try {
|
|
769
|
+
const out = (0, node_child_process_1.execFileSync)("docker", ["network", "ls", "--format", "{{.Name}}"], { stdio: ["ignore", "pipe", "ignore"], timeout: 5000 })
|
|
770
|
+
.toString()
|
|
771
|
+
.trim();
|
|
772
|
+
if (out === "")
|
|
773
|
+
return [];
|
|
774
|
+
return out
|
|
775
|
+
.split("\n")
|
|
776
|
+
.map((name) => ({ name, subnet: dockerNetworkSubnet(name) }));
|
|
777
|
+
}
|
|
778
|
+
catch {
|
|
779
|
+
return [];
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
/** Inspect one network's IPv4 subnet (empty string on failure). */
|
|
783
|
+
function dockerNetworkSubnet(name) {
|
|
784
|
+
try {
|
|
785
|
+
const out = (0, node_child_process_1.execFileSync)("docker", [
|
|
786
|
+
"network",
|
|
787
|
+
"inspect",
|
|
788
|
+
"--format",
|
|
789
|
+
"{{if .IPAM.Config}}{{(index .IPAM.Config 0).Subnet}}{{end}}",
|
|
790
|
+
name,
|
|
791
|
+
], { stdio: ["ignore", "pipe", "ignore"], timeout: 5000 })
|
|
792
|
+
.toString()
|
|
793
|
+
.trim();
|
|
794
|
+
return out;
|
|
795
|
+
}
|
|
796
|
+
catch {
|
|
797
|
+
return "";
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
function cidrToIpInt(cidr) {
|
|
801
|
+
const m = cidr.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/);
|
|
802
|
+
if (!m)
|
|
803
|
+
return null;
|
|
804
|
+
const octets = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
|
|
805
|
+
if (octets.some((o) => o > 255))
|
|
806
|
+
return null;
|
|
807
|
+
return ((((octets[0] << 24) >>> 0) +
|
|
808
|
+
(octets[1] << 16) +
|
|
809
|
+
(octets[2] << 8) +
|
|
810
|
+
octets[3]) >>>
|
|
811
|
+
0);
|
|
812
|
+
}
|
|
813
|
+
function ipIntToString(int) {
|
|
814
|
+
return [
|
|
815
|
+
(int >>> 24) & 0xff,
|
|
816
|
+
(int >>> 16) & 0xff,
|
|
817
|
+
(int >>> 8) & 0xff,
|
|
818
|
+
int & 0xff,
|
|
819
|
+
].join(".");
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Pick a free /29 from the pool, skipping subnets already in use by any
|
|
823
|
+
* docker network. Returns e.g. "192.0.2.8/29" or exits with an error.
|
|
824
|
+
*/
|
|
825
|
+
function allocateEgressSubnet() {
|
|
826
|
+
const custom = process.env.HARNESS_EGRESS_SUBNET;
|
|
827
|
+
if (custom) {
|
|
828
|
+
if (cidrToIpInt(custom) === null || !custom.includes("/")) {
|
|
829
|
+
fail(`HARNESS_EGRESS_SUBNET must be an IPv4 CIDR (got "${custom}").`);
|
|
830
|
+
}
|
|
831
|
+
return custom;
|
|
832
|
+
}
|
|
833
|
+
const poolBase = cidrToIpInt(EGRESS_SUBNET_POOL);
|
|
834
|
+
if (poolBase === null)
|
|
835
|
+
return fail("internal: bad egress subnet pool");
|
|
836
|
+
const used = new Set(dockerNetworks()
|
|
837
|
+
.map((n) => n.subnet)
|
|
838
|
+
.filter(Boolean));
|
|
839
|
+
// 32 /29s in a /24
|
|
840
|
+
for (let i = 0; i < 32; i++) {
|
|
841
|
+
const candidate = (poolBase + i * 8) >>> 0;
|
|
842
|
+
const cidr = `${ipIntToString(candidate)}/${EGRESS_SUBNET_SIZE}`;
|
|
843
|
+
if (!used.has(cidr))
|
|
844
|
+
return cidr;
|
|
845
|
+
}
|
|
846
|
+
return fail(`--egress: no free subnet in the ${EGRESS_SUBNET_POOL} pool; set HARNESS_EGRESS_SUBNET to a free private /29.`);
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Generate squid.conf from the allowlist, deny-by-default. Only called when
|
|
850
|
+
* proxied entries exist (local-only mode starts no gateway).
|
|
851
|
+
*
|
|
852
|
+
* Exact hosts and dot-suffixed domains go in SEPARATE dstdomain ACLs: squid
|
|
853
|
+
* 6.x rejects a single ACL mixing bare hosts with `.suffix` entries
|
|
854
|
+
* ("Bungled ... acl" — verified live against the pinned 6.13 image; the
|
|
855
|
+
* RFC's flagship example list is exactly such a mixed list). ACLs must
|
|
856
|
+
* precede the http_access lines that reference them (squid parses
|
|
857
|
+
* sequentially).
|
|
858
|
+
* All writable paths point at /tmp: the gateway runs as the unprivileged
|
|
859
|
+
* `proxy` user, and Debian squid's defaults (/run/squid.pid, /var/log/squid)
|
|
860
|
+
* are root-owned — a fatal startup error without this.
|
|
861
|
+
*/
|
|
862
|
+
function generateSquidConfig(allowlist) {
|
|
863
|
+
const exact = allowlist.proxied.filter((e) => !e.suffix).map((e) => e.host);
|
|
864
|
+
const suffix = allowlist.proxied
|
|
865
|
+
.filter((e) => e.suffix)
|
|
866
|
+
.map((e) => `.${e.host}`);
|
|
867
|
+
return [
|
|
868
|
+
"# generated by harness --egress; do not edit",
|
|
869
|
+
"http_port 3128",
|
|
870
|
+
"pid_filename /tmp/squid.pid",
|
|
871
|
+
"cache deny all",
|
|
872
|
+
"coredump_dir /tmp",
|
|
873
|
+
...(exact.length ? [`acl exact-domains dstdomain ${exact.join(" ")}`] : []),
|
|
874
|
+
...(suffix.length
|
|
875
|
+
? [`acl suffix-domains dstdomain ${suffix.join(" ")}`]
|
|
876
|
+
: []),
|
|
877
|
+
...(exact.length ? ["http_access allow exact-domains"] : []),
|
|
878
|
+
...(suffix.length ? ["http_access allow suffix-domains"] : []),
|
|
879
|
+
"http_access deny all",
|
|
880
|
+
"access_log stdio:stderr",
|
|
881
|
+
"cache_log stdio:stderr",
|
|
882
|
+
"",
|
|
883
|
+
].join("\n");
|
|
884
|
+
}
|
|
885
|
+
/** Parse `--egress` CLI value; null when the flag is absent. */
|
|
886
|
+
const egressArg = argv.egress ?? null;
|
|
887
|
+
let egressAllowlist = null;
|
|
888
|
+
if (egressArg !== null) {
|
|
889
|
+
if (networkArg !== null) {
|
|
890
|
+
fail("--egress and --network are mutually exclusive (the managed network is the point).");
|
|
891
|
+
}
|
|
892
|
+
egressAllowlist = parseEgressValue(egressArg);
|
|
893
|
+
}
|
|
894
|
+
// ---------------------------------------------------------------------------
|
|
895
|
+
// Egress lifecycle helpers (docker-only; the apple runtime fails closed).
|
|
896
|
+
// All output is captured/discarded on stdout so e2e shim assertions on the
|
|
897
|
+
// final `run` argv stay clean; runtime errors surface on stderr.
|
|
898
|
+
// ---------------------------------------------------------------------------
|
|
899
|
+
function dockerSync(args, what, timeoutMs = 30000, cleanupNetwork) {
|
|
900
|
+
try {
|
|
901
|
+
return (0, node_child_process_1.execFileSync)("docker", args, {
|
|
902
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
903
|
+
timeout: timeoutMs,
|
|
904
|
+
}).toString();
|
|
905
|
+
}
|
|
906
|
+
catch (err) {
|
|
907
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
908
|
+
const message = `--egress: ${what} failed: ${msg}`;
|
|
909
|
+
if (cleanupNetwork) {
|
|
910
|
+
failWithNetworkCleanup(message, cleanupNetwork);
|
|
911
|
+
}
|
|
912
|
+
fail(message);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Remove harness-egress-* networks left behind by crashed runs.
|
|
917
|
+
*
|
|
918
|
+
* A crashed run (e.g. SIGTERM during the agent run) leaves its detached
|
|
919
|
+
* gateway container attached, so the network is never empty and the
|
|
920
|
+
* empty-only check would never reclaim it — while its /29 blocks the pool
|
|
921
|
+
* ("Pool overlaps") for every later run. Staleness test: a live run's
|
|
922
|
+
* network holds the gateway PLUS the agent container; a crashed run's holds
|
|
923
|
+
* only the gateway (the agent ran with --rm). Networks whose only container
|
|
924
|
+
* is a harness-egress-gw-* are stale: kill the gateway, then the network.
|
|
925
|
+
* (Best-effort: a concurrent run mid-bring-up — gateway attached, agent not
|
|
926
|
+
* yet started — is indistinguishable and may be swept; runs are expected to
|
|
927
|
+
* be sequential, matching the empty-network sweep's existing race.)
|
|
928
|
+
*/
|
|
929
|
+
function sweepStaleEgressNetworks() {
|
|
930
|
+
for (const net of dockerNetworks()) {
|
|
931
|
+
if (!net.name.startsWith("harness-egress-"))
|
|
932
|
+
continue;
|
|
933
|
+
try {
|
|
934
|
+
const out = (0, node_child_process_1.execFileSync)("docker", [
|
|
935
|
+
"network",
|
|
936
|
+
"inspect",
|
|
937
|
+
"--format",
|
|
938
|
+
"{{range .Containers}}{{.Name}} {{end}}",
|
|
939
|
+
net.name,
|
|
940
|
+
], { stdio: ["ignore", "pipe", "ignore"], timeout: 5000 })
|
|
941
|
+
.toString()
|
|
942
|
+
.trim();
|
|
943
|
+
const members = out === "" ? [] : out.trim().split(/\s+/);
|
|
944
|
+
const stale = members.every((m) => m.startsWith("harness-egress-gw-"));
|
|
945
|
+
if (!stale)
|
|
946
|
+
continue;
|
|
947
|
+
for (const gw of members) {
|
|
948
|
+
try {
|
|
949
|
+
(0, node_child_process_1.execFileSync)("docker", ["rm", "-f", gw], {
|
|
950
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
951
|
+
timeout: 15000,
|
|
952
|
+
});
|
|
953
|
+
console.error(`harness: swept stale egress gateway ${gw}`);
|
|
954
|
+
}
|
|
955
|
+
catch {
|
|
956
|
+
// best-effort; an un-removable gateway just leaves the network
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
(0, node_child_process_1.execFileSync)("docker", ["network", "rm", net.name], {
|
|
960
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
961
|
+
timeout: 5000,
|
|
962
|
+
});
|
|
963
|
+
console.error(`harness: swept stale egress network ${net.name}`);
|
|
964
|
+
}
|
|
965
|
+
catch {
|
|
966
|
+
// best-effort; a busy/in-use network survives
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Wait until the gateway's squid is up (pid file exists and responds to
|
|
972
|
+
* `squid -k check`). If the container exits first, dump its logs and fail —
|
|
973
|
+
* a dead gateway must never be treated as "slow to start".
|
|
974
|
+
*/
|
|
975
|
+
function waitForGateway(container) {
|
|
976
|
+
for (let i = 0; i < 20; i++) {
|
|
977
|
+
let running = "true";
|
|
978
|
+
try {
|
|
979
|
+
running = (0, node_child_process_1.execFileSync)("docker", ["inspect", "--format", "{{.State.Running}}", container], { stdio: ["ignore", "pipe", "ignore"], timeout: 5000 })
|
|
980
|
+
.toString()
|
|
981
|
+
.trim();
|
|
982
|
+
}
|
|
983
|
+
catch {
|
|
984
|
+
// inspect races with --rm removal; treat as not-yet-running
|
|
985
|
+
}
|
|
986
|
+
if (running === "false") {
|
|
987
|
+
let logs = "";
|
|
988
|
+
try {
|
|
989
|
+
logs = (0, node_child_process_1.execFileSync)("docker", ["logs", "--tail", "40", container], {
|
|
990
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
991
|
+
timeout: 5000,
|
|
992
|
+
}).toString();
|
|
993
|
+
}
|
|
994
|
+
catch {
|
|
995
|
+
// container already reaped by --rm
|
|
996
|
+
}
|
|
997
|
+
fail(`--egress: egress gateway exited during startup.\n${logs.trim()}`);
|
|
998
|
+
}
|
|
999
|
+
try {
|
|
1000
|
+
(0, node_child_process_1.execFileSync)("docker", ["exec", container, "squid", "-k", "check"], {
|
|
1001
|
+
stdio: "ignore",
|
|
1002
|
+
timeout: 5000,
|
|
1003
|
+
});
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
catch {
|
|
1007
|
+
syncSleep(250);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
console.error("harness: WARNING: egress gateway did not confirm readiness in 5s; continuing.");
|
|
1011
|
+
}
|
|
1012
|
+
/** Synchronous sleep (Atomics.wait on a shared buffer). */
|
|
1013
|
+
function syncSleep(ms) {
|
|
1014
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Bring up the managed egress topology: --internal network, gateway (unless
|
|
1018
|
+
* airgap), proxy env. Returns state the run() flow merges into RunInput and
|
|
1019
|
+
* a cleanup closure to invoke after the agent container exits.
|
|
1020
|
+
*/
|
|
1021
|
+
async function startEgress(runtime, allowlist, noVerifyFlag) {
|
|
1022
|
+
const runId = (0, node_crypto_1.randomUUID)().slice(0, 8);
|
|
1023
|
+
const network = `harness-egress-${runId}`;
|
|
1024
|
+
const subnet = allocateEgressSubnet();
|
|
1025
|
+
const subnetBase = cidrToIpInt(subnet);
|
|
1026
|
+
if (subnetBase === null)
|
|
1027
|
+
fail(`--egress: bad subnet "${subnet}".`);
|
|
1028
|
+
const gatewayIp = ipIntToString((subnetBase + 2) >>> 0);
|
|
1029
|
+
sweepStaleEgressNetworks();
|
|
1030
|
+
dockerSync(["network", "create", "--internal", "--subnet", subnet, network], `creating egress network (${subnet})`);
|
|
1031
|
+
return bringUpEgress(network, runId, subnetBase, allowlist, noVerifyFlag, runtime);
|
|
1032
|
+
}
|
|
1033
|
+
async function bringUpEgress(network, runId, subnetBase, allowlist, noVerifyFlag, runtime) {
|
|
1034
|
+
// All failure paths below must go through failWithNetworkCleanup so the
|
|
1035
|
+
// network created by startEgress is not orphaned.
|
|
1036
|
+
const gatewayIp = ipIntToString((subnetBase + 2) >>> 0);
|
|
1037
|
+
const envArgs = [];
|
|
1038
|
+
let gatewayContainer = null;
|
|
1039
|
+
// The gateway exists to proxy outbound destinations — start it only when
|
|
1040
|
+
// proxied entries exist. Local-only mode (`none`, or host-local-only lists)
|
|
1041
|
+
// has nothing to proxy and runs gateway-free on the internal network.
|
|
1042
|
+
if (allowlist.proxied.length > 0) {
|
|
1043
|
+
const gatewayImage = `${REGISTRY}:egress-proxy-${IMAGE_TAG}`;
|
|
1044
|
+
if (!noVerifyFlag && !process.env.HARNESS_IMAGE_TAG) {
|
|
1045
|
+
try {
|
|
1046
|
+
await verifyImage(runtime, gatewayImage);
|
|
1047
|
+
}
|
|
1048
|
+
catch {
|
|
1049
|
+
failWithNetworkCleanup(`--egress: gateway image verification failed for ${gatewayImage} (pass --no-verify or build the image locally)`, network);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
gatewayContainer = `harness-egress-gw-${runId}`;
|
|
1053
|
+
const confFile = node_path_1.default.join(node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), "harness-egress-")), "squid.conf");
|
|
1054
|
+
node_fs_1.default.writeFileSync(confFile, generateSquidConfig(allowlist), {
|
|
1055
|
+
mode: 0o644,
|
|
1056
|
+
});
|
|
1057
|
+
dockerSync([
|
|
1058
|
+
"run",
|
|
1059
|
+
"--rm",
|
|
1060
|
+
"-d",
|
|
1061
|
+
"--name",
|
|
1062
|
+
gatewayContainer,
|
|
1063
|
+
"--cap-drop=ALL",
|
|
1064
|
+
"--security-opt",
|
|
1065
|
+
"no-new-privileges:true",
|
|
1066
|
+
"--security-opt",
|
|
1067
|
+
`seccomp=${SECCOMP_PROFILE}`,
|
|
1068
|
+
"--network",
|
|
1069
|
+
network,
|
|
1070
|
+
"--ip",
|
|
1071
|
+
gatewayIp,
|
|
1072
|
+
"-v",
|
|
1073
|
+
`${confFile}:/etc/squid/squid.conf:ro`,
|
|
1074
|
+
gatewayImage,
|
|
1075
|
+
], "starting egress gateway", 120000, network);
|
|
1076
|
+
// External leg: the gateway is the only container with a route off-host.
|
|
1077
|
+
dockerSync(["network", "connect", "bridge", gatewayContainer], "attaching gateway to the default bridge", 30000, network);
|
|
1078
|
+
waitForGateway(gatewayContainer);
|
|
1079
|
+
const proxyUrl = `http://${gatewayIp}:3128`;
|
|
1080
|
+
envArgs.push("-e", `HTTPS_PROXY=${proxyUrl}`, "-e", `HTTP_PROXY=${proxyUrl}`, "-e", `NO_PROXY=localhost,127.0.0.1,${HOST_LOCAL_NAME}`,
|
|
1081
|
+
// Node's fetch/http ignore proxy env vars without this (Node 24+);
|
|
1082
|
+
// inert for non-Node agents.
|
|
1083
|
+
"-e", "NODE_USE_ENV_PROXY=1");
|
|
1084
|
+
console.error(`harness: egress allowlist active — ${allowlist.proxied.length} destination(s) via gateway ${gatewayIp}`);
|
|
1085
|
+
}
|
|
1086
|
+
else {
|
|
1087
|
+
envArgs.push("-e", `NO_PROXY=localhost,127.0.0.1,${HOST_LOCAL_NAME}`);
|
|
1088
|
+
console.error("harness: egress local-only mode — host reachable, no gateway, no external route");
|
|
1089
|
+
}
|
|
1090
|
+
// Host reachability from an --internal network. Docker Desktop provides
|
|
1091
|
+
// host.docker.internal natively. On Linux engines, host-gateway (the default
|
|
1092
|
+
// bridge's gateway) is OFF-SUBNET for the egress network and --internal
|
|
1093
|
+
// drops the forward — measured "Network unreachable" (docker 29.9.1).
|
|
1094
|
+
// Mapping host.docker.internal to the egress network's OWN gateway (the
|
|
1095
|
+
// subnet's .1) works: --internal blocks forwarding off the bridge, not the
|
|
1096
|
+
// host's own endpoint on it (measured HTTP 200). Harness reserves .2 for
|
|
1097
|
+
// the squid container; .1 is the docker-assigned network gateway = the host.
|
|
1098
|
+
const addHostArgs = process.platform === "linux"
|
|
1099
|
+
? [
|
|
1100
|
+
"--add-host",
|
|
1101
|
+
`${HOST_LOCAL_NAME}:${ipIntToString((subnetBase + 1) >>> 0)}`,
|
|
1102
|
+
]
|
|
1103
|
+
: [];
|
|
1104
|
+
const gw = gatewayContainer;
|
|
1105
|
+
const cleanup = () => {
|
|
1106
|
+
if (gw !== null) {
|
|
1107
|
+
try {
|
|
1108
|
+
(0, node_child_process_1.execFileSync)("docker", ["rm", "-f", gw], {
|
|
1109
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
1110
|
+
timeout: 15000,
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
catch {
|
|
1114
|
+
// best-effort; --rm cleans up on stop, and the stale sweep catches it
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1118
|
+
try {
|
|
1119
|
+
(0, node_child_process_1.execFileSync)("docker", ["network", "rm", network], {
|
|
1120
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
1121
|
+
timeout: 10000,
|
|
1122
|
+
});
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
catch {
|
|
1126
|
+
syncSleep(500); // gateway may still be detaching
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
};
|
|
1130
|
+
return {
|
|
1131
|
+
network,
|
|
1132
|
+
gatewayContainer,
|
|
1133
|
+
gatewayIp,
|
|
1134
|
+
envArgs,
|
|
1135
|
+
addHostArgs,
|
|
1136
|
+
cleanup,
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
617
1139
|
async function run(prompt) {
|
|
618
1140
|
const runtime = selectRuntime();
|
|
619
1141
|
runtime.ensureReady();
|
|
620
1142
|
const image = getImage(agentName);
|
|
1143
|
+
// --egress is docker-only in v1: whether apple/container supports --internal
|
|
1144
|
+
// networks and multi-network attach is unverified; fail closed (RFC D5).
|
|
1145
|
+
let egress = null;
|
|
1146
|
+
if (egressAllowlist !== null) {
|
|
1147
|
+
if (!(runtime instanceof DockerRuntime)) {
|
|
1148
|
+
fail("--egress is not yet supported under the apple/container runtime " +
|
|
1149
|
+
"(--internal networks / multi-network attach unverified); use HARNESS_CONTAINER_RUNTIME=docker.");
|
|
1150
|
+
}
|
|
1151
|
+
egress = await startEgress(runtime, egressAllowlist, noVerify);
|
|
1152
|
+
}
|
|
621
1153
|
if (!noVerify) {
|
|
622
1154
|
if (process.env.HARNESS_IMAGE_TAG) {
|
|
623
1155
|
console.error(`harness: HARNESS_IMAGE_TAG is set; skipping cosign verification for ${image}`);
|
|
@@ -731,16 +1263,25 @@ async function run(prompt) {
|
|
|
731
1263
|
const args = runtime.runArgs({
|
|
732
1264
|
interactive,
|
|
733
1265
|
envFileArgs,
|
|
734
|
-
envArgs: [
|
|
1266
|
+
envArgs: [
|
|
1267
|
+
...cloudModeEnv,
|
|
1268
|
+
...adapterDockerArgs,
|
|
1269
|
+
...(egress?.envArgs ?? []),
|
|
1270
|
+
],
|
|
735
1271
|
volumeArgs,
|
|
736
1272
|
userVolumeArgs,
|
|
737
1273
|
portArgs,
|
|
1274
|
+
network: egress ? egress.network : networkArg,
|
|
1275
|
+
addHostArgs: egress?.addHostArgs ?? [],
|
|
738
1276
|
workdir: "/workspace",
|
|
739
1277
|
image,
|
|
740
1278
|
containerCmd,
|
|
741
1279
|
});
|
|
742
1280
|
const child = (0, node_child_process_1.spawn)(runtime.binary(), args, { stdio: "inherit" });
|
|
743
|
-
child.on("exit", (code) =>
|
|
1281
|
+
child.on("exit", (code) => {
|
|
1282
|
+
egress?.cleanup();
|
|
1283
|
+
process.exit(code ?? 1);
|
|
1284
|
+
});
|
|
744
1285
|
}
|
|
745
1286
|
if (!process.stdin.isTTY && promptArg === null) {
|
|
746
1287
|
let input = "";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boldblackai/harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"description": "Portable environment for running coding agents in a container",
|
|
5
5
|
"bin": {
|
|
6
6
|
"harness": "bin/harness.js"
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"type": "git",
|
|
11
11
|
"url": "git+https://github.com/boldblackai/harness.git"
|
|
12
12
|
},
|
|
13
|
-
"packageManager": "pnpm@12.
|
|
13
|
+
"packageManager": "pnpm@12.12.1",
|
|
14
14
|
"license": "MIT",
|
|
15
15
|
"publishConfig": {
|
|
16
16
|
"access": "public"
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"lint:ts": "biome check .",
|
|
23
23
|
"lint:md": "markdownlint-cli2 \"**/*.md\" \"#node_modules\"",
|
|
24
24
|
"lint:sh": "shellcheck setup-env.sh entrypoint.sh entrypoint-opencode.sh entrypoint-hermes.sh",
|
|
25
|
-
"lint:docker": "hadolint Dockerfile Dockerfile.opencode Dockerfile.hermes",
|
|
25
|
+
"lint:docker": "hadolint Dockerfile Dockerfile.opencode Dockerfile.hermes Dockerfile.egress-proxy",
|
|
26
26
|
"lint:actions": "actionlint",
|
|
27
27
|
"format": "biome format --write .",
|
|
28
28
|
"test:e2e:cli": "node --test tests/e2e/*.test.mjs",
|