@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.
- package/CHANGELOG.md +18 -0
- package/README.md +2 -2
- package/docs/operations.md +115 -9
- package/docs/quickstart.md +1 -1
- package/docs/security.md +5 -2
- package/docs/smoke.md +63 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +111 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +2 -2
- package/src/adapters/acp.ts +10 -2
- package/src/adapters/local-worker.ts +4 -3
- package/src/cli/main.ts +6 -4
- package/src/hub/ask.ts +7 -2
- package/src/hub/board.ts +29 -1
- package/src/hub/config-trust.ts +2 -0
- package/src/hub/crash.ts +109 -0
- package/src/hub/daemon.ts +164 -15
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/routing.ts +21 -3
- package/src/hub/tasks.ts +120 -16
- package/src/local/deny.ts +2 -2
- package/src/local/proxy.ts +142 -0
- package/src/local/sandbox.ts +104 -15
- package/src/local/tools.ts +4 -2
- package/templates/config.json +4 -1
package/src/local/sandbox.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
+
}
|
package/src/local/tools.ts
CHANGED
|
@@ -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":
|
package/templates/config.json
CHANGED
|
@@ -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
|
}
|