@staix/agent-hub 0.9.0 → 0.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.
@@ -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";
@@ -18,7 +18,25 @@ const q = sbplString;
18
18
  * no network unless asked. In SBPL the last matching rule wins, so the denies come last.
19
19
  */
20
20
  /** Toolchains and git identity: the only parts of the home directory a sandboxed command may read besides the project. */
21
- const HOME_READABLE = [".bun", ".cargo", ".rustup", ".local", ".npm", ".cache", ".pyenv", ".nvm", ".deno", "go", ".gitconfig", ".config/git", "Library/Caches"];
21
+ const HOME_READABLE = [".bun", ".cargo", ".rustup", ".local", ".npm", ".cache", ".pyenv", ".nvm", ".deno", "go", ".gitconfig", ".config/git", "Library/Caches", ".volta", ".asdf", ".nodenv", ".rbenv", ".sdkman", "Library/Application Support/fnm", "Library/pnpm"];
22
+
23
+ /**
24
+ * Public CA bundles: the name denies (`*.pem` for keys) also match these, and TLS needs them once network is on.
25
+ * Allowed after the denies, by exact path.
26
+ */
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
+
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
+ */
33
+ function developerDir(): string | undefined {
34
+ const out = spawnSync("xcode-select", ["-p"], { encoding: "utf8" });
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;
39
+ }
22
40
 
23
41
  /** A submodule or worktree keeps its git dir outside the project; git needs it, minus the parts that execute or reconfigure. */
24
42
  function externalGitDirs(root: string): string[] {
@@ -28,28 +46,80 @@ function externalGitDirs(root: string): string[] {
28
46
  return [...new Set(dirs)].filter((d) => !d.startsWith(`${root}/`));
29
47
  }
30
48
 
31
- export function profile(cwd: string, network: boolean, readAllow: string[] = [], deny: string[] = []): string {
49
+ /** The system directories a deny-default profile lets commands read and run from: dyld, frameworks, toolchains. */
50
+ const SYSTEM_READABLE = ["/usr", "/bin", "/sbin", "/System", "/Library", "/opt", "/private/etc", "/private/var/db/timezone", "/private/var/db/dyld"];
51
+ /**
52
+ * Directory lookups (users, groups), logging and notifications; plus name resolution and TLS trust when network is on.
53
+ * No brokers that act outside the sandbox: LaunchServices would let `open` start a browser with network, and
54
+ * SecurityServer would answer Keychain queries the credential-path denies are there to stop.
55
+ */
56
+ const MACH_SERVICES = ["com.apple.system.opendirectoryd.libinfo", "com.apple.system.DirectoryService.libinfo_v1", "com.apple.system.logger", "com.apple.system.notification_center"];
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, kept for one release), 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 };
66
+
67
+ /**
68
+ * `base` "deny" (issue #39) starts from `(deny default)` and allows only what commands need: running and reading the
69
+ * system, toolchain and project directories, writing the project and temp. "allow" is the profile of 0.9 and earlier,
70
+ * kept for one release behind `local.sandbox: "allow-default"`. The denies at the end apply to both.
71
+ */
72
+ export function profile(cwd: string, network: SandboxNetwork, readAllow: string[] = [], deny: string[] = [], base: "deny" | "allow" = "deny"): string {
32
73
  const home = homedir();
33
74
  const inHome = (p: string) => (p.startsWith("~/") ? join(home, p.slice(2)) : p);
34
75
  const root = realPath(cwd);
35
76
  const tmp = realPath(tmpdir());
36
77
  const gitDirs = externalGitDirs(root);
37
78
  const creds = [".ssh", ".aws", ".gnupg", ".config/gh", ".config/gcloud", ".kube", ".docker", ".netrc", ".npmrc", ".omniroute", ".claude", ".codex", ".kimi-code", "Library/Keychains"];
79
+ const dev = developerDir();
80
+ const readable = [root, ...HOME_READABLE.map((p) => join(home, p)), ...readAllow.map(inHome), ...gitDirs, ...(dev ? [dev] : [])];
81
+ const subpaths = (paths: string[]) => paths.map((p) => `(subpath ${q(p)})`).join(" ");
82
+ const globals = (names: string[]) => names.map((n) => `(global-name ${q(n)})`).join(" ");
83
+ const proxy = typeof network === "object" ? `(allow network-outbound (remote ip ${q(`localhost:${network.proxyPort}`)}))` : undefined;
84
+ const start = base === "deny"
85
+ ? [
86
+ "(deny default)",
87
+ "(allow process-fork)",
88
+ // Runs from the system, toolchain and project directories only; a script runs through an allowed interpreter.
89
+ // The user's temp dir and /private/tmp are shared with every other process: each command gets its own (#63).
90
+ `(allow process-exec ${subpaths([...SYSTEM_READABLE, ...readable])})`,
91
+ "(allow signal (target same-sandbox))",
92
+ "(allow process-info* (target same-sandbox))",
93
+ "(allow sysctl-read)",
94
+ `(allow mach-lookup ${globals([...MACH_SERVICES, ...(proxy ? PROXY_MACH_SERVICES : network ? NETWORK_MACH_SERVICES : [])])})`,
95
+ '(allow ipc-posix-shm-read-data ipc-posix-shm-read-metadata (ipc-posix-name "apple.shm.notification_center"))',
96
+ "(allow file-read-metadata)",
97
+ `(allow file-read* (literal "/") ${subpaths([...SYSTEM_READABLE, "/dev"])} ${subpaths(readable)})`,
98
+ '(allow file-ioctl (regex #"^/dev/"))',
99
+ ...(proxy ? [proxy] : network ? ["(allow network*)"] : []),
100
+ ]
101
+ : [
102
+ "(allow default)",
103
+ ...(network === true ? [] : ["(deny network*)", ...(proxy ? [proxy] : [])]),
104
+ // Home is default-deny for reads: whatever a command reads can end up in the model's answer, and that answer
105
+ // is shared with agents that run on cloud subscriptions (~/.claude.json, app tokens, browser profiles ...).
106
+ `(deny file-read* (subpath ${q(home)}))`,
107
+ `(allow file-read-metadata (subpath ${q(home)}))`,
108
+ `(allow file-read* ${subpaths(readable)})`,
109
+ ];
110
+ const places = `${creds.map((c) => `(subpath ${q(join(home, c))})`).join(" ")} ${denyRegexes(root, deny, false).join(" ")}`;
38
111
  return [
39
112
  "(version 1)",
40
- "(allow default)",
41
- ...(network ? [] : ["(deny network*)"]),
42
- // Home is default-deny for reads: whatever a command reads can end up in the model's answer, and that answer
43
- // is shared with agents that run on cloud subscriptions (~/.claude.json, app tokens, browser profiles ...).
44
- `(deny file-read* (subpath ${q(home)}))`,
45
- `(allow file-read-metadata (subpath ${q(home)}))`,
46
- `(allow file-read* (subpath ${q(root)}) ${[...HOME_READABLE.map((p) => join(home, p)), ...readAllow.map(inHome), ...gitDirs].map((p) => `(subpath ${q(p)})`).join(" ")})`,
113
+ ...start,
47
114
  "(deny file-write*)",
48
- `(allow file-write* (subpath ${q(root)}) (subpath ${q(tmp)}) (subpath "/private/tmp") (regex #"^/dev/") ${gitDirs.map((d) => `(subpath ${q(d)})`).join(" ")})`,
115
+ `(allow file-write* (subpath ${q(root)}) ${base === "deny" ? "" : `(subpath ${q(tmp)}) (subpath "/private/tmp") `}(regex #"^/dev/") ${gitDirs.map((d) => `(subpath ${q(d)})`).join(" ")})`,
49
116
  // Inside cwd: nothing that runs later outside the sandbox, nothing that reconfigures the hub.
50
117
  `(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(" ")})`,
51
118
  `(deny file-read* file-write* ${creds.map((c) => `(subpath ${q(join(home, c))})`).join(" ")})`,
52
119
  `(deny file-read* file-write* ${denyRegexes(root, deny).join(" ")})`,
120
+ // Python's own CA bundle (certifi, which pip vendors too): pip, requests and httpx read it instead of the system's (#64).
121
+ // The last match wins: the denied places come again after it, so it overrides only the `.pem` name rule.
122
+ ...(network ? [`(allow file-read* ${CA_BUNDLES.map((p) => `(literal ${q(p)})`).join(" ")} (regex ${q("/certifi/cacert\\.pem$")}))`, `(deny file-read* file-write* ${places})`] : []),
53
123
  ].join("\n");
54
124
  }
55
125
 
@@ -59,12 +129,16 @@ export interface ExecResult {
59
129
  }
60
130
 
61
131
  /** Run argv under seatbelt with a scrubbed environment. Never runs unsandboxed: no sandbox, no exec. */
62
- export function sandboxedExec(argv: string[], opts: { cwd: string; profile: string; timeoutMs?: number }): Promise<ExecResult> {
132
+ export function sandboxedExec(argv: string[], opts: { cwd: string; profile: string; timeoutMs?: number; env?: Record<string, string> }): Promise<ExecResult> {
63
133
  if (!sandboxAvailable()) return Promise.resolve({ code: null, output: "error: command execution needs macOS sandbox-exec and is disabled on this host" });
64
134
  return new Promise((resolve) => {
65
- const env = { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: homedir(), LANG: process.env.LANG ?? "en_US.UTF-8", TERM: "dumb", TMPDIR: tmpdir() };
135
+ // A temp dir of its own (#63): the user's is shared with every other process, and what a command reads can reach
136
+ // an answer shown to cloud peers. Allowed after the profile's denies, and gone when the command ends.
137
+ const own = realPath(mkdtempSync(join(tmpdir(), "ahub-cmd-")));
138
+ const sandbox = `${opts.profile}\n(allow file-read* file-write* process-exec (subpath ${q(own)}))`;
139
+ 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}/` };
66
140
  // Own process group: a timeout has to take the grandchildren too, or they keep the pipes open and the project writable.
67
- const child = spawn(SANDBOX_EXEC, ["-p", opts.profile, ...argv], { cwd: opts.cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
141
+ const child = spawn(SANDBOX_EXEC, ["-p", sandbox, ...argv], { cwd: opts.cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
68
142
  let output = "";
69
143
  const take = (d: Buffer) => {
70
144
  if (output.length < OUTPUT_CAP) output += d.toString();
@@ -78,18 +152,33 @@ export function sandboxedExec(argv: string[], opts: { cwd: string; profile: stri
78
152
  child.kill("SIGKILL");
79
153
  }
80
154
  };
155
+ // Best effort, never in the way of the result: a command can mark its files immutable (`chflags uchg`), and then
156
+ // the plain remove throws. What still fails is left to the OS temp cleanup.
157
+ const dropOwn = () => {
158
+ try { rmSync(own, { recursive: true, force: true }); } catch {
159
+ try { spawnSync("chflags", ["-R", "nouchg", own]); rmSync(own, { recursive: true, force: true }); } catch { /* left behind */ }
160
+ }
161
+ };
81
162
  const timer = setTimeout(killGroup, opts.timeoutMs ?? 120_000);
82
163
  let done = false;
83
164
  const finish = (code: number | null, signal: NodeJS.Signals | null) => {
84
165
  if (done) return;
85
166
  done = true;
86
167
  clearTimeout(timer);
168
+ dropOwn();
87
169
  const clipped = output.length >= OUTPUT_CAP ? `${output.slice(0, OUTPUT_CAP)}\n(output truncated)` : output;
88
170
  resolve({ code, output: signal ? `${clipped}\n(killed: ${signal}, timeout?)` : clipped });
89
171
  };
90
- child.on("error", (e) => (done || ((done = true), resolve({ code: null, output: `error: ${e.message}` }))));
172
+ child.on("error", (e) => (done || ((done = true), dropOwn(), resolve({ code: null, output: `error: ${e.message}` }))));
91
173
  child.on("close", finish);
92
174
  // `close` waits for every holder of the pipes; after `exit` give stragglers a moment, then stop waiting and reap them.
93
175
  child.on("exit", (code, signal) => setTimeout(() => (killGroup(), finish(code, signal)), 500).unref());
94
176
  });
95
177
  }
178
+
179
+ /** The environment that sends a command's HTTPS through the egress proxy (#65); empty without one. */
180
+ export function proxyEnv(network: SandboxNetwork): Record<string, string> {
181
+ if (typeof network !== "object") return {};
182
+ const url = `http://127.0.0.1:${network.proxyPort}`;
183
+ return { HTTPS_PROXY: url, https_proxy: url, HTTP_PROXY: url, http_proxy: url, ALL_PROXY: url, all_proxy: url, NO_PROXY: "", no_proxy: "" };
184
+ }
@@ -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,10 +23,13 @@
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": [] },
26
+ "local": { "deny": [], "bash_network": false, "max_steps": 30, "read_allow": [], "sandbox": "deny-default" },
27
27
  "approvals": { "timeout_s": 120 },
28
28
  "tasks": { "release_after_min": 30 },
29
29
  "checks": { "timeout_s": 600 },
30
30
  "snapshots": { "enabled": true, "keep": 20 },
31
+ "review": { "adaptive": false, "min_reviews": 5 },
32
+ "recovery": { "auto_resume_after_crash": false },
33
+ "capabilities": {},
31
34
  "limits": { "sender_per_min": 12, "pair_per_min": 6, "important_per_hour": 6, "repeat_window_s": 120 }
32
35
  }