@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.
- package/CHANGELOG.md +18 -0
- package/README.md +2 -2
- package/docs/operations.md +64 -19
- package/docs/quickstart.md +1 -1
- package/docs/security.md +3 -2
- package/docs/smoke.md +54 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +42 -5
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1 -1
- package/src/adapters/local-worker.ts +4 -3
- package/src/cli/main.ts +1 -0
- package/src/hub/ask.ts +7 -2
- package/src/hub/board.ts +3 -1
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/crash.ts +10 -5
- package/src/hub/daemon.ts +78 -20
- package/src/hub/tasks.ts +48 -15
- package/src/local/deny.ts +2 -2
- package/src/local/proxy.ts +169 -0
- package/src/local/sandbox.ts +66 -39
- package/src/local/tools.ts +4 -2
- package/templates/config.json +1 -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";
|
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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:
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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)}) (
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
+
}
|
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,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": []
|
|
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 },
|