@boldblackai/harness 1.10.4 → 1.11.1

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.
Files changed (3) hide show
  1. package/README.md +32 -0
  2. package/bin/harness.js +543 -2
  3. 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: [...cloudModeEnv, ...adapterDockerArgs],
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) => process.exit(code ?? 1));
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.10.4",
3
+ "version": "1.11.1",
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.7.0",
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",