@staix/agent-hub 0.10.0 → 0.12.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.
@@ -1,5 +1,5 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
3
3
  import { homedir, tmpdir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
5
  import { realPath } from "../hub/project.ts";
@@ -26,10 +26,16 @@ const HOME_READABLE = [".bun", ".cargo", ".rustup", ".local", ".npm", ".cache",
26
26
  */
27
27
  const CA_BUNDLES = ["/private/etc/ssl/cert.pem", "/opt/homebrew/etc/ca-certificates/cert.pem", "/opt/homebrew/etc/openssl@3/cert.pem", "/usr/local/etc/ca-certificates/cert.pem", "/usr/local/etc/openssl@3/cert.pem"];
28
28
 
29
- /** The selected Xcode or Command Line Tools dir: the `/usr/bin` shims (git, clang, make, python3) run what is in it. */
29
+ /**
30
+ * The selected Xcode or Command Line Tools dir: the `/usr/bin` shims (git, clang, make, python3) run what is in it.
31
+ * Inside an app bundle that is the whole `Contents`: its tools load frameworks from `SharedFrameworks` next to it.
32
+ */
30
33
  function developerDir(): string | undefined {
31
34
  const out = spawnSync("xcode-select", ["-p"], { encoding: "utf8" });
32
- return out.status === 0 && out.stdout.trim() ? out.stdout.trim() : undefined;
35
+ let dir = out.status === 0 ? out.stdout.trim() : "";
36
+ // Seatbelt matches real paths: an Xcode selected through a symlink (`Xcode.app` -> `Xcode-26.0.app`) needs its target.
37
+ try { dir = dir && realPath(dir); } catch { /* gone since it was selected: nothing the shims could run either */ }
38
+ return dir ? dir.replace(/(\.app\/Contents)\/Developer\/?$/, "$1") : undefined;
33
39
  }
34
40
 
35
41
  /** A submodule or worktree keeps its git dir outside the project; git needs it, minus the parts that execute or reconfigure. */
@@ -49,58 +55,60 @@ const SYSTEM_READABLE = ["/usr", "/bin", "/sbin", "/System", "/Library", "/opt",
49
55
  */
50
56
  const MACH_SERVICES = ["com.apple.system.opendirectoryd.libinfo", "com.apple.system.DirectoryService.libinfo_v1", "com.apple.system.logger", "com.apple.system.notification_center"];
51
57
  const NETWORK_MACH_SERVICES = ["com.apple.dnssd.service", "com.apple.trustd", "com.apple.trustd.agent", "com.apple.networkd"];
58
+ /** Through the egress proxy (#65) the proxy resolves names: only TLS trust is looked up here, no DNS that could carry data. */
59
+ const PROXY_MACH_SERVICES = ["com.apple.trustd", "com.apple.trustd.agent"];
60
+
61
+ /**
62
+ * Network for the commands: none, direct (`local.bash_network: "direct"`, everything, until 0.13.0), or only
63
+ * the hub's egress proxy on a loopback port (#65), which opens allowlisted hosts.
64
+ */
65
+ export type SandboxNetwork = boolean | { proxyPort: number };
52
66
 
53
67
  /**
54
- * `base` "deny" (issue #39) starts from `(deny default)` and allows only what commands need: running and reading the
55
- * system, toolchain and project directories, writing the project and temp. "allow" is the profile of 0.9 and earlier,
56
- * kept for one release behind `local.sandbox: "allow-default"`. The denies at the end apply to both.
68
+ * Starts from `(deny default)` (issue #39) and allows only what commands need: running and reading the system,
69
+ * toolchain and project directories, writing the project and a temp dir of their own. The allow-default profile of
70
+ * 0.9 and earlier was removed in 0.12.0 (issue #83).
57
71
  */
58
- export function profile(cwd: string, network: boolean, readAllow: string[] = [], deny: string[] = [], base: "deny" | "allow" = "deny"): string {
72
+ export function profile(cwd: string, network: SandboxNetwork, readAllow: string[] = [], deny: string[] = []): string {
59
73
  const home = homedir();
60
74
  const inHome = (p: string) => (p.startsWith("~/") ? join(home, p.slice(2)) : p);
61
75
  const root = realPath(cwd);
62
- const tmp = realPath(tmpdir());
63
76
  const gitDirs = externalGitDirs(root);
64
77
  const creds = [".ssh", ".aws", ".gnupg", ".config/gh", ".config/gcloud", ".kube", ".docker", ".netrc", ".npmrc", ".omniroute", ".claude", ".codex", ".kimi-code", "Library/Keychains"];
65
78
  const dev = developerDir();
66
79
  const readable = [root, ...HOME_READABLE.map((p) => join(home, p)), ...readAllow.map(inHome), ...gitDirs, ...(dev ? [dev] : [])];
67
80
  const subpaths = (paths: string[]) => paths.map((p) => `(subpath ${q(p)})`).join(" ");
68
81
  const globals = (names: string[]) => names.map((n) => `(global-name ${q(n)})`).join(" ");
69
- const start = base === "deny"
70
- ? [
71
- "(deny default)",
72
- "(allow process-fork)",
73
- // Runs from the system, toolchain and project directories only; a script runs through an allowed interpreter.
74
- `(allow process-exec ${subpaths([...SYSTEM_READABLE, ...readable, tmp, "/private/tmp"])})`,
75
- "(allow signal (target same-sandbox))",
76
- "(allow process-info* (target same-sandbox))",
77
- "(allow sysctl-read)",
78
- `(allow mach-lookup ${globals([...MACH_SERVICES, ...(network ? NETWORK_MACH_SERVICES : [])])})`,
79
- '(allow ipc-posix-shm-read-data ipc-posix-shm-read-metadata (ipc-posix-name "apple.shm.notification_center"))',
80
- "(allow file-read-metadata)",
81
- `(allow file-read* (literal "/") ${subpaths([...SYSTEM_READABLE, tmp, "/private/tmp", "/dev"])} ${subpaths(readable)})`,
82
- '(allow file-ioctl (regex #"^/dev/"))',
83
- ...(network ? ["(allow network*)"] : []),
84
- ]
85
- : [
86
- "(allow default)",
87
- ...(network ? [] : ["(deny network*)"]),
88
- // Home is default-deny for reads: whatever a command reads can end up in the model's answer, and that answer
89
- // is shared with agents that run on cloud subscriptions (~/.claude.json, app tokens, browser profiles ...).
90
- `(deny file-read* (subpath ${q(home)}))`,
91
- `(allow file-read-metadata (subpath ${q(home)}))`,
92
- `(allow file-read* ${subpaths(readable)})`,
93
- ];
82
+ const proxy = typeof network === "object" ? `(allow network-outbound (remote ip ${q(`localhost:${network.proxyPort}`)}))` : undefined;
83
+ const start = [
84
+ "(deny default)",
85
+ "(allow process-fork)",
86
+ // Runs from the system, toolchain and project directories only; a script runs through an allowed interpreter.
87
+ // The user's temp dir and /private/tmp are shared with every other process: each command gets its own (#63).
88
+ `(allow process-exec ${subpaths([...SYSTEM_READABLE, ...readable])})`,
89
+ "(allow signal (target same-sandbox))",
90
+ "(allow process-info* (target same-sandbox))",
91
+ "(allow sysctl-read)",
92
+ `(allow mach-lookup ${globals([...MACH_SERVICES, ...(proxy ? PROXY_MACH_SERVICES : network ? NETWORK_MACH_SERVICES : [])])})`,
93
+ '(allow ipc-posix-shm-read-data ipc-posix-shm-read-metadata (ipc-posix-name "apple.shm.notification_center"))',
94
+ "(allow file-read-metadata)",
95
+ `(allow file-read* (literal "/") ${subpaths([...SYSTEM_READABLE, "/dev"])} ${subpaths(readable)})`,
96
+ '(allow file-ioctl (regex #"^/dev/"))',
97
+ ...(proxy ? [proxy] : network ? ["(allow network*)"] : []),
98
+ ];
99
+ const places = `${creds.map((c) => `(subpath ${q(join(home, c))})`).join(" ")} ${denyRegexes(root, deny, false).join(" ")}`;
94
100
  return [
95
101
  "(version 1)",
96
102
  ...start,
97
103
  "(deny file-write*)",
98
- `(allow file-write* (subpath ${q(root)}) (subpath ${q(tmp)}) (subpath "/private/tmp") (regex #"^/dev/") ${gitDirs.map((d) => `(subpath ${q(d)})`).join(" ")})`,
104
+ `(allow file-write* (subpath ${q(root)}) (regex #"^/dev/") ${gitDirs.map((d) => `(subpath ${q(d)})`).join(" ")})`,
99
105
  // Inside cwd: nothing that runs later outside the sandbox, nothing that reconfigures the hub.
100
106
  `(deny file-write* (subpath ${q(join(root, ".agenthub"))}) ${[join(root, ".git"), ...gitDirs].map((d) => `(subpath ${q(join(d, "hooks"))}) (literal ${q(join(d, "config"))})`).join(" ")})`,
101
107
  `(deny file-read* file-write* ${creds.map((c) => `(subpath ${q(join(home, c))})`).join(" ")})`,
102
108
  `(deny file-read* file-write* ${denyRegexes(root, deny).join(" ")})`,
103
- ...(network ? [`(allow file-read* ${CA_BUNDLES.map((p) => `(literal ${q(p)})`).join(" ")})`] : []),
109
+ // Python's own CA bundle (certifi, which pip vendors too): pip, requests and httpx read it instead of the system's (#64).
110
+ // The last match wins: the denied places come again after it, so it overrides only the `.pem` name rule.
111
+ ...(network ? [`(allow file-read* ${CA_BUNDLES.map((p) => `(literal ${q(p)})`).join(" ")} (regex ${q("/certifi/cacert\\.pem$")}))`, `(deny file-read* file-write* ${places})`] : []),
104
112
  ].join("\n");
105
113
  }
106
114
 
@@ -110,12 +118,16 @@ export interface ExecResult {
110
118
  }
111
119
 
112
120
  /** Run argv under seatbelt with a scrubbed environment. Never runs unsandboxed: no sandbox, no exec. */
113
- export function sandboxedExec(argv: string[], opts: { cwd: string; profile: string; timeoutMs?: number }): Promise<ExecResult> {
121
+ export function sandboxedExec(argv: string[], opts: { cwd: string; profile: string; timeoutMs?: number; env?: Record<string, string> }): Promise<ExecResult> {
114
122
  if (!sandboxAvailable()) return Promise.resolve({ code: null, output: "error: command execution needs macOS sandbox-exec and is disabled on this host" });
115
123
  return new Promise((resolve) => {
116
- const env = { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: homedir(), LANG: process.env.LANG ?? "en_US.UTF-8", TERM: "dumb", TMPDIR: tmpdir() };
124
+ // A temp dir of its own (#63): the user's is shared with every other process, and what a command reads can reach
125
+ // an answer shown to cloud peers. Allowed after the profile's denies, and gone when the command ends.
126
+ const own = realPath(mkdtempSync(join(tmpdir(), "ahub-cmd-")));
127
+ const sandbox = `${opts.profile}\n(allow file-read* file-write* process-exec (subpath ${q(own)}))`;
128
+ const env = { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: homedir(), LANG: process.env.LANG ?? "en_US.UTF-8", TERM: "dumb", ...opts.env, TMPDIR: `${own}/` };
117
129
  // Own process group: a timeout has to take the grandchildren too, or they keep the pipes open and the project writable.
118
- const child = spawn(SANDBOX_EXEC, ["-p", opts.profile, ...argv], { cwd: opts.cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
130
+ const child = spawn(SANDBOX_EXEC, ["-p", sandbox, ...argv], { cwd: opts.cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
119
131
  let output = "";
120
132
  const take = (d: Buffer) => {
121
133
  if (output.length < OUTPUT_CAP) output += d.toString();
@@ -129,18 +141,33 @@ export function sandboxedExec(argv: string[], opts: { cwd: string; profile: stri
129
141
  child.kill("SIGKILL");
130
142
  }
131
143
  };
144
+ // Best effort, never in the way of the result: a command can mark its files immutable (`chflags uchg`), and then
145
+ // the plain remove throws. What still fails is left to the OS temp cleanup.
146
+ const dropOwn = () => {
147
+ try { rmSync(own, { recursive: true, force: true }); } catch {
148
+ try { spawnSync("chflags", ["-R", "nouchg", own]); rmSync(own, { recursive: true, force: true }); } catch { /* left behind */ }
149
+ }
150
+ };
132
151
  const timer = setTimeout(killGroup, opts.timeoutMs ?? 120_000);
133
152
  let done = false;
134
153
  const finish = (code: number | null, signal: NodeJS.Signals | null) => {
135
154
  if (done) return;
136
155
  done = true;
137
156
  clearTimeout(timer);
157
+ dropOwn();
138
158
  const clipped = output.length >= OUTPUT_CAP ? `${output.slice(0, OUTPUT_CAP)}\n(output truncated)` : output;
139
159
  resolve({ code, output: signal ? `${clipped}\n(killed: ${signal}, timeout?)` : clipped });
140
160
  };
141
- child.on("error", (e) => (done || ((done = true), resolve({ code: null, output: `error: ${e.message}` }))));
161
+ child.on("error", (e) => (done || ((done = true), dropOwn(), resolve({ code: null, output: `error: ${e.message}` }))));
142
162
  child.on("close", finish);
143
163
  // `close` waits for every holder of the pipes; after `exit` give stragglers a moment, then stop waiting and reap them.
144
164
  child.on("exit", (code, signal) => setTimeout(() => (killGroup(), finish(code, signal)), 500).unref());
145
165
  });
146
166
  }
167
+
168
+ /** The environment that sends a command's HTTPS through the egress proxy (#65); empty without one. */
169
+ export function proxyEnv(network: SandboxNetwork): Record<string, string> {
170
+ if (typeof network !== "object") return {};
171
+ const url = `http://127.0.0.1:${network.proxyPort}`;
172
+ return { HTTPS_PROXY: url, https_proxy: url, HTTP_PROXY: url, http_proxy: url, ALL_PROXY: url, all_proxy: url, NO_PROXY: "", no_proxy: "" };
173
+ }
@@ -12,6 +12,8 @@ export interface ToolContext {
12
12
  deny: string[];
13
13
  /** Seatbelt profile for this project, built once per worker (`profile()` in sandbox.ts): it spawns git and must not run per tool call. */
14
14
  sandboxProfile: string;
15
+ /** Extra environment for sandboxed commands: the egress proxy's variables when network goes through it (#65). */
16
+ sandboxEnv?: Record<string, string>;
15
17
  /** Ask the console. Resolves false on deny or timeout. */
16
18
  permit: (title: string) => Promise<boolean>;
17
19
  /** Publish a message to other peers mid-turn. Returns a one-line receipt. */
@@ -136,7 +138,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
136
138
  // The approver sees the whole command, not a prefix: what is hidden cannot be approved.
137
139
  if (command.length > 4000) return "error: command longer than 4000 characters; put it in a script file with write, then run that";
138
140
  if (!(await ctx.permit(`bash: ${command}`))) return "error: the user did not approve this command";
139
- const res = await sandboxedExec(["/bin/bash", "-c", command], { cwd: ctx.cwd, profile: ctx.sandboxProfile, timeoutMs: (Number(a.timeout_s) || 120) * 1000 });
141
+ const res = await sandboxedExec(["/bin/bash", "-c", command], { cwd: ctx.cwd, profile: ctx.sandboxProfile, timeoutMs: (Number(a.timeout_s) || 120) * 1000, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}) });
140
142
  return `${res.output}\n(exit ${res.code})`;
141
143
  }
142
144
  case "git": {
@@ -147,7 +149,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
147
149
  if (problem) return `error: git ${sub}: ${problem}`;
148
150
  if (GIT_WRITE.has(sub) && !(await ctx.permit(`git ${args.join(" ")}`))) return "error: the user did not approve this git command";
149
151
  // Sandboxed like bash: flags such as --output or an editor cannot write outside the project or reach the network.
150
- const res = await sandboxedExec(["git", "--no-pager", ...args], { cwd: ctx.cwd, profile: ctx.sandboxProfile });
152
+ const res = await sandboxedExec(["git", "--no-pager", ...args], { cwd: ctx.cwd, profile: ctx.sandboxProfile, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}) });
151
153
  return `${res.output}\n(exit ${res.code})`;
152
154
  }
153
155
  case "hub_send":
@@ -23,7 +23,7 @@
23
23
  "cf_client_id_file": "",
24
24
  "cf_client_secret_file": ""
25
25
  },
26
- "local": { "deny": [], "bash_network": false, "max_steps": 30, "read_allow": [], "sandbox": "deny-default" },
26
+ "local": { "deny": [], "bash_network": false, "max_steps": 30, "read_allow": [] },
27
27
  "approvals": { "timeout_s": 120 },
28
28
  "tasks": { "release_after_min": 30 },
29
29
  "checks": { "timeout_s": 600 },