ac-teams 0.0.6 → 0.0.7

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/README.md CHANGED
@@ -4,7 +4,7 @@ Persistent agent teams for [Pi](https://github.com/earendil-works/pi): shared ta
4
4
 
5
5
  ## Install
6
6
 
7
- Requires Linux, Node 22.19 or newer, and an authenticated `pi` executable on `PATH`. Interactive teams also require `tmux` on `PATH`. Tested with Pi 1.4.0.
7
+ Requires Linux, Node 22.19 or newer, and an authenticated `pi` executable on `PATH`. Interactive teams also require `tmux` on `PATH`. Tested with Pi 1.4.0 and 1.5.0.
8
8
 
9
9
  ```sh
10
10
  pi install npm:ac-teams@0.0.4
@@ -34,7 +34,7 @@ Defaults allow up to **10 live members** and **two levels of delegation**. Use t
34
34
 
35
35
  ## Know the boundaries
36
36
 
37
- - Members share your filesystem access. All members can read all team messages, including direct messages.
37
+ - Members share your filesystem access unless launched with [enforced access](docs/access.md), an opt-in Docker sandbox that needs Docker and a locally built member image. All members can read all team messages, including direct messages.
38
38
  - Limits cover only `team_agent`, not other delegation tools. Use separate worktrees for concurrent writers.
39
39
  - Stop retains tasks, sessions, and worktrees. Worktree deletion is explicit and requires clean, integrated work.
40
40
  - Exit confirmation needs Pi's `session_before_exit` hook, which installed Pi 1.4.0 lacks. Without it, shutdown cleans up but cannot ask first. Signals always bypass confirmation.
@@ -45,6 +45,7 @@ See the [operations guide](docs/operations.md) for recovery guarantees, storage,
45
45
 
46
46
  - [Usage](docs/usage.md): tool actions, task rules, messages, and companion tools
47
47
  - [Operations](docs/operations.md): limits, stop/resume, worktrees, and security
48
+ - [Enforced access](docs/access.md): Docker-enforced read/write grants, outbound proxy and destination policy, and setup
48
49
  - [Development](docs/development.md): source setup, verification, and publishing
49
50
  - [Implementation history](docs/implementation-plan.md): design decisions and prior art
50
51
 
package/docs/access.md ADDED
@@ -0,0 +1,99 @@
1
+ # Enforce member access
2
+
3
+ By default, members are ordinary unsandboxed Pi processes with your full user permissions. Enforced access is opt-in: ac-teams runs the member's whole Pi process, including its shell, extensions, and subprocesses, in a Docker container that sees only the directories you grant. If enforcement is unavailable, the launch fails; ac-teams never falls back to an unsandboxed member.
4
+
5
+ ## Request access
6
+
7
+ `team_agent` launch accepts `access`:
8
+
9
+ ```json
10
+ { "action": "launch", "name": "reviewer", "brief": "...", "access": { "read": ["."] } }
11
+ { "action": "launch", "name": "parser", "brief": "...", "cwd": "/repo/parser", "access": { "read": ["/repo/docs", "/repo/lib"], "write": ["."] } }
12
+ ```
13
+
14
+ - Paths are directories, absolute or relative to the member's `cwd`. Write implies read. The `cwd` must be inside a grant.
15
+ - `network.allow` limits outbound destinations: `host`, or `*.host` for its subdomains, each optionally `:port`; without a port, an entry allows ports 80 and 443. `[]` allows only the model provider. Omit `allow` for any public destination. Omitting `network` keeps the requester's network access; from an unsandboxed requester that is any public destination.
16
+ - Omitting `access` inherits the requesting member's access. From an unsandboxed requester, it launches an unsandboxed member, as before.
17
+ - Members can only narrow access: a sandboxed member cannot grant a path outside its own access, give write where it has read, allow a destination its own allowlist does not, or launch or resume an unsandboxed member. Access is fixed at launch; resume keeps it.
18
+ - Refused grants: `/`, your home directory or its parents, `/tmp`, `/run`, anything under `/proc`, `/dev`, or `/sys`, and anything containing or inside `~/.pi/agent` or `~/.pi/ac-teams`. They would expose host service sockets, credentials, transcripts, or coordinator state.
19
+ - Grants must be canonical paths with no symlinks, and cannot contain commas, which Docker's mount syntax cannot escape.
20
+ - Docker resolves mount paths when a container starts, so a sandboxed writer could swap a granted path for a symlink just before another member starts. ac-teams holds each grant open from before the container is created, and after the container starts, before any member code runs, compares every mounted grant with the directory it holds; any difference removes the container. A writer of `/repo` and a reader of `/repo/src` can therefore run together. Runtime, transcript, and socket mounts are not compared this way, so none may sit strictly inside another live member's writable grant, across all teams. This protects against sandboxed members only; your own processes, Main, and unsandboxed members can change any path and are trusted.
21
+ - Sandboxed members cannot create or clean up worktrees, send user team actions (`/teams` feedback, task requests, resume), or open other members' terminals; use Main.
22
+
23
+ Before launching, check `sandbox` in `team_agent` `list` or `limits`: `{ "available": true, "backend": "docker" }`, or `available: false` with a `reason`. Every member receives `AC_TEAMS_ACCESS`: `{"enforced":true,"read":[...],"write":[...],"network":{"allow":[...]}}` (no `allow` means any public destination), or `{"enforced":false}`. `team_agent status` reports the coordinator's authoritative `access`.
24
+
25
+ ## Write grants and untrusted output
26
+
27
+ A write grant lets the member change those live files, including Git metadata such as hooks and config, Pi project resources, and build scripts. Treat the result like any untrusted contribution: review changes before you run them, commit them, or start Git or Pi in those directories. Running changed code later outside the sandbox is your choice, not a sandbox escape.
28
+
29
+ ## What an enforced member can reach
30
+
31
+ | Resource | Enforced member |
32
+ | --- | --- |
33
+ | Granted directories | Mounted at the same paths, read-only or read-write |
34
+ | Rest of the host filesystem | Not mounted. The container's system files come from the member image |
35
+ | Pi runtime | Read-only: the ac-teams package and these entries from your Pi agent directory: `npm`, `extensions`, `themes`, `skills`, `prompts`, `AGENTS.md`, `keybindings.json`, `acstack.json`, `models-store.json`. They run as trusted code inside the container and are not secret-free by design: the member can read your installed package code and lockfile, custom extension source, and global instructions. Keep secrets out of them. Your real `auth.json`, sessions, and caches are not mounted |
36
+ | Web search settings | A copy of your `web-search.json` (including any search API keys) with the proxy set |
37
+ | Trust decisions | A filtered `trust.json` with only the entries for the member's `cwd` and its parents |
38
+ | Settings | A copy of your Pi `settings.json`, including its package list and device ID, so do not store secrets there |
39
+ | Scratch | Private `/tmp` (512 MiB), home (256 MiB), and Pi agent directory (64 MiB), discarded at exit |
40
+ | Network | Outbound HTTP and HTTPS through the parent's proxy only (below); no direct network |
41
+ | Processes | Private process namespace; host processes are invisible |
42
+ | Team | The coordinator socket for team tools, and the member's own transcript directory |
43
+ | Environment | A fixed minimal set; host variables, including credentials, are not inherited |
44
+
45
+ Any Unix socket inside a granted directory remains reachable, so grant narrow directories.
46
+
47
+ Containers run as your user with all capabilities dropped, `no-new-privileges`, a read-only root filesystem, `--network none`, no published ports, no restart policy, no Docker socket, and logging disabled. Before starting a container, ac-teams inspects its effective configuration and refuses to start it on any difference in network, ports, privileges, capabilities, security options, namespaces, devices, mounts, image volumes, user, limits, or restart policy. Docker reports bind sources unresolved, so symlink swaps are caught by re-resolving sources instead. It checks network and ports again after start.
48
+
49
+ ## Outbound access goes through the parent's proxy
50
+
51
+ Containers have no network interface except loopback. A bridge inside the container exposes a proxy owned by the root Pi process on `127.0.0.1:3128`, and members get `HTTP_PROXY`, `HTTPS_PROXY`, their lowercase forms, `NO_PROXY` for loopback, and `NODE_USE_ENV_PROXY=1`. Pi, Node, curl, and other clients that honour these variables reach the web; ac-teams also points pi-web-access and billion-context at the proxy through their own settings. Clients that ignore proxy settings have no network at all; there is no transparent capture of other protocols.
52
+
53
+ The proxy:
54
+
55
+ - Handles HTTPS through `CONNECT` and plain HTTP requests, without following redirects; each new request is checked again
56
+ - Resolves names itself and refuses a destination if any resolved address is not global unicast: loopback, private, CGNAT (including Tailscale), link-local and cloud metadata, documentation, multicast, IPv4-mapped and translated forms, and every address of this host, even public ones. It connects to the address it checked, so DNS cannot change in between
57
+ - Applies the member's `network.allow` list, if any, but always allows the selected model provider's endpoint so Pi can run
58
+ - Is reachable only from that member's container, through its own socket
59
+ - Accepts at most 256 open connections per member, so one member cannot exhaust Main's file descriptors
60
+
61
+ A destination allowlist does not keep data private: an allowed host, including the model provider, receives whatever the member sends it.
62
+
63
+ ## Model credentials
64
+
65
+ The member gets an `auth.json` with only its selected provider's current OAuth access token and expiry: no refresh token and no other providers. Main's Pi refreshes the token and rewrites the member's copy when it changes. The member can use, or leak, that token until it expires, and spend your quota with it; it cannot mint new tokens. Switching to another provider inside the member fails, because no other credential is present. A member with a `network.allow` list keeps its provider: its children and its resumes must use the same provider, so the always-allowed provider endpoint cannot widen its network access. The file is removed when the member exits; if Main's Pi is killed, it stays until that team is opened again and its orphans are cleaned up.
66
+
67
+ Supported: OAuth `anthropic` and `openai` models at their default endpoints, tested with Pi 1.4.0 and 1.5.0. Other providers, API-key credentials, or a changed credential shape fail the launch.
68
+
69
+ ## Resource limits
70
+
71
+ Each member container is capped at 2 GiB of memory (no swap) and 1024 processes. ac-teams allows at most as many enforced members at once, across all teams on the host, as fit in 75% of host memory at that cap (5 on a 15 GiB host). It counts created and running member containers after creating a new one, so concurrent launches cannot both pass. This bounds the sum of container caps; it does not limit Main, unsandboxed members, or other host processes. CPU is not capped.
72
+
73
+ A new enforced member takes about 20 seconds to start, because Pi compiles TypeScript extensions into an empty cache.
74
+
75
+ ## Set up Docker
76
+
77
+ Enforced access needs a running Docker daemon your user can use, and the member image for your Pi version. Using Docker means trusting the Docker daemon, which runs as root; membership in the `docker` group is equivalent to root access on the host.
78
+
79
+ When the image is missing, `team_agent list` reports the exact build command, for example:
80
+
81
+ ```sh
82
+ docker build -t ac-teams-member:1.5.0-<hash> --build-arg PI_VERSION=1.5.0 -f <ac-teams>/src/sandbox.Dockerfile <ac-teams>/src
83
+ ```
84
+
85
+ The image is `node:22-bookworm-slim`, pinned by digest, with bash, curl, Git, ripgrep, CA certificates, and the same Pi version as Main. Rebuild after upgrading Pi.
86
+
87
+ ## Storage
88
+
89
+ - Coordinator state and settings: `~/.pi/ac-teams/teams/<team>/`, `~/.pi/ac-teams/config.json`, and `~/.pi/ac-teams/display.json`
90
+ - Generated member configuration (settings copy, provider access token, trust and web search copies, launch file): `~/.pi/ac-teams/agents/<member>/`, removed when the member exits
91
+ - Member transcripts: `<Main's session directory>/ac-teams/<team>/<member>/`, normally under `~/.pi/agent/sessions/<project>/`. Pi's `/resume` lists only top-level sessions, so member transcripts appear in `/teams`, not `/resume`
92
+
93
+ ## Known limits
94
+
95
+ - Tools outside the mounted runtime, such as the ACStack CLI at `~/.claude/acstack/bin/acstack`, need explicit read grants for their directory and any symlink target. Grant those narrow directories, never `~/.claude`, which holds credentials.
96
+ - Interactive members run in the team's tmux pane through `docker start --attach`, on the container's own terminal.
97
+ - Linux only. [Development](development.md#enforced-access-verification) records what has been verified.
98
+
99
+ [Back to README](../README.md)
@@ -48,6 +48,24 @@ Terminal UI checks used real native Pi processes in an isolated full-size Herdr
48
48
  - Task summary above the input, agent selector below status, native subcommand/name autocomplete, and same-PID/session opening by name or ID
49
49
  - Pi-reported model display names and changing context estimates in the live selector
50
50
 
51
+ ### Enforced access verification
52
+
53
+ Checks on 2026-11-02, Ubuntu 24.04, kernel 6.8, Docker 29.9.2 (rootful, default AppArmor and seccomp), Pi 1.4.0, member image `ac-teams-member:1.4.0-<hash>`:
54
+
55
+ - `test/sandbox.test.ts` with real containers for RPC and native (tmux) members: grants enforced read-only and read-write; host files, other transcripts, team state, the real `auth.json`, the Docker socket, and the tmux socket unreachable; only the selected provider's access token is present, without a refresh token, and a token change reaches the container in place; public HTTP and HTTPS work through the proxy with curl and Node; this host's addresses (including its public IP and Tailscale address), the Docker bridge, and cloud metadata are refused; no direct network, UDP, or host Unix socket; the start gate cannot be replayed; no capabilities, `NoNewPrivs`, private PIDs, read-only root, written files owned by the user; nested narrower launch works and escalation is refused; the native member's terminal is the container's own pty, and TIOCSTI did not inject; containers and member credentials are removed on close
56
+ - A grant swapped for a symlink between the checks and start is caught before any member code runs, and the container is removed; a writer of a repo and a reader of its subdirectory run together; resume gets a new gated container
57
+ - A refused second owner leaves live containers alone; after the coordinator is killed, the next owner removes the orphaned container; the enforced-member cap counts other teams' containers
58
+ - A real OpenAI OAuth member with the full installed extension set (billion-context active, ACStack, pi-web-access) answered through the proxy, ran curl (200), `web_search`, and `fetch_content` on a public documentation page; a member with `network.allow: []` still reached the model while curl to a public site failed; no refresh token or other provider in the container; network `none`, no ports, and no host listener changes
59
+
60
+ Anthropic, later on 2026-11-02 (Pi 1.4.0 for the first two checks; Pi 1.5.0 with member image `ac-teams-member:1.5.0-<hash>` for the third and for a full `npm test` rerun, 118 pass):
61
+
62
+ - A `claude-haiku-4-5` member with the full installed extension set ran curl (200), `web_search`, and `fetch_content`; with `network.allow: []` it still reached the model while curl failed; no refresh token or other provider in the container; network `none`, no ports
63
+ - A real Pi Main loading this branch from an isolated agent directory (access tokens only, no refresh tokens) launched an enforced Anthropic member with `network.allow: []` through Main's own credential code: the sandbox was available, the launch succeeded, and the member answered `curl=000`; no host listener changes and no leftover containers or member files
64
+
65
+ On the final tree with Pi 1.5.0, the OpenAI full-extension and `network.allow: []` runs passed again: curl 200, `web_search` and `fetch_content` ok, then curl `000` with the model still reachable. `npm test` passed 119 tests, and the Docker suite passed 10.
66
+
67
+ Not verified: an Anthropic Main session, because Main's own Anthropic request was refused as out of extra usage while the member's Haiku requests succeeded.
68
+
51
69
  Not verified on macOS, Windows, or network filesystems. The package is Linux-only because safe process identity uses `/proc`. No web dashboard, workflow DSL, daemon, or automatic merging is included.
52
70
 
53
71
  ## Stage releases for npm approval
@@ -1,6 +1,6 @@
1
1
  # Operate ac-teams
2
2
 
3
- ac-teams runs on Linux with Node 22.19 or newer and an authenticated `pi` executable on `PATH`. Interactive roots also require `tmux`: each team owns a private server and native member terminals. Headless roots retain RPC members. Both share your OS user's access, not a sandbox.
3
+ ac-teams runs on Linux with Node 22.19 or newer and an authenticated `pi` executable on `PATH`. Interactive roots also require `tmux`: each team owns a private server and native member terminals. Headless roots retain RPC members. Both share your OS user's access unless a member is launched with [enforced access](access.md).
4
4
 
5
5
  ## Save project trust before launching
6
6
 
@@ -15,7 +15,7 @@ Default: at most **10 live descendants**, counting starting, busy, idle, and sto
15
15
  Root policy precedence, from lowest to highest:
16
16
 
17
17
  1. Built-in defaults
18
- 2. `~/.acstack/ac-teams/config.json`
18
+ 2. `~/.pi/ac-teams/config.json`
19
19
  3. `.pi/ac-teams.json`, only with `--teams-project-config`
20
20
  4. Explicit `--teams-limit` and `--teams-depth` flags
21
21
 
@@ -33,7 +33,7 @@ Other delegation tools remain outside these limits. See [companion tools](usage.
33
33
 
34
34
  ## Display preferences are global
35
35
 
36
- `/teams config` saves task-summary and agent-selector visibility to `~/.acstack/ac-teams/display.json`:
36
+ `/teams config` saves task-summary and agent-selector visibility to `~/.pi/ac-teams/display.json`:
37
37
 
38
38
  ```json
39
39
  {"tasks":true,"agents":true}
@@ -75,10 +75,10 @@ Missing targets, dirty work, and uncertain/squashed integration retain the tree.
75
75
 
76
76
  ## Local records contain sensitive project data
77
77
 
78
- Each root session uses `~/.acstack/ac-teams/teams/<session-id>/`:
78
+ Each root session uses `~/.pi/ac-teams/teams/<session-id>/`:
79
79
 
80
80
  - `state.json` contains membership, policy, tasks, messages, claims, and notification cursors
81
- - `sessions/` contains native Pi-managed member transcripts
81
+ - Member transcripts live under Main's session directory in `ac-teams/<team>/<member>/`, normally `~/.pi/agent/sessions/<project>/ac-teams/...`; Pi's `/resume` lists only top-level sessions, so use `/teams` to find them
82
82
  - `owner.lock` and `coordinator.sock` coordinate the running root
83
83
  - Interactive teams also own `t.sock`, the private tmux socket, and per-member terminal environment files; shutdown removes those files
84
84
 
package/docs/usage.md CHANGED
@@ -133,6 +133,6 @@ Messages survive crashes; exactly-once delivery or model execution does not. Avo
133
133
 
134
134
  ac-teams works alone. ACStack adds planning, review, and shipping skills; Herdr adds terminal tabs, panes, and visible root sessions. These are companion capabilities, not automatic integrations. ac-teams does not open a Herdr pane for each member. Interactive members use a team-private tmux server and open in the current terminal when selected; headless roots use RPC members.
135
135
 
136
- Other delegation tools can coexist. ac-teams warns when `Agent`, `SubagentWorkflow`, or `subagent` is active, but does not disable them. Its limits cover only members launched through `team_agent`. Use Pi's resource settings to disable other launchers if you want one team authority. Shell access and same-user extensions are not sandboxed.
136
+ Other delegation tools can coexist. ac-teams warns when `Agent`, `SubagentWorkflow`, or `subagent` is active, but does not disable them. Its limits cover only members launched through `team_agent`. Use Pi's resource settings to disable other launchers if you want one team authority. Shell access and same-user extensions are not sandboxed unless a member is launched with [enforced access](access.md).
137
137
 
138
138
  [Back to README](../README.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ac-teams",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "Small, persistent teams for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/access.ts ADDED
@@ -0,0 +1,175 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
4
+ import { homedir, totalmem } from "node:os";
5
+ import { stateRoot } from "./config.ts";
6
+ import { assertNetworkNarrower, validatePolicy, type NetworkPolicy } from "./egress.ts";
7
+ import { dirname, join, resolve } from "node:path";
8
+
9
+ /** Canonical directory grants; write implies read. Undefined access means an unsandboxed member. */
10
+ export interface Access { read: string[]; write: string[]; network: NetworkPolicy }
11
+ export interface AccessRequest { read?: string[]; write?: string[]; network?: NetworkPolicy }
12
+ export interface SandboxStatus { available: boolean; backend: "docker"; reason?: string }
13
+
14
+ export const within = (path: string, root: string) => path === root || path.startsWith(root === "/" ? "/" : root + "/");
15
+ const inside = (path: string, roots: string[]) => roots.some(root => within(path, root));
16
+ export const readable = (access: Access, path: string) => inside(path, [...access.read, ...access.write]);
17
+ export const writable = (access: Access, path: string) => inside(path, access.write);
18
+
19
+ /** Pi's agent directory and ac-teams state hold credentials, transcripts, and coordinator tokens. */
20
+ export const privateRoots = () => [resolve(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent")), stateRoot()];
21
+
22
+ export function resolveAccess(request: AccessRequest, base: string): Access {
23
+ const canonical = (paths: string[] = []) => [...new Set(paths.map(path => {
24
+ let real: string;
25
+ try { real = realpathSync(resolve(base, path)); } catch { throw new Error(`Access path does not exist: ${path}`); }
26
+ if (!statSync(real).isDirectory()) throw new Error(`Access path must be a directory: ${path}`);
27
+ // A symlink in the path would be re-resolved by Docker at start; a sandboxed writer could have planted or could swap it.
28
+ if (real !== resolve(base, path)) throw new Error(`Access paths must be canonical, without symlinks: ${path} resolves to ${real}`);
29
+ // Docker's --mount syntax has no escaping, so a comma would inject mount options.
30
+ if (/[,\n]/.test(real)) throw new Error(`Access paths cannot contain commas or newlines: ${real}`);
31
+ // These would replace the sandbox's private /proc, /dev, /tmp, or home, or expose host sockets and credentials.
32
+ for (const unsafe of ["/proc", "/dev", "/sys", "/run"]) if (within(real, unsafe)) throw new Error(`Access to ${real} is not allowed`);
33
+ for (const covered of ["/tmp", "/run", "/var/run", homedir()]) if (within(covered, real)) throw new Error(`Access to ${real} would expose ${covered}; grant a narrower directory`);
34
+ for (const root of privateRoots()) if (within(real, root) || within(root, real)) throw new Error(`Access to ${real} would expose private Pi or ac-teams state in ${root}`);
35
+ return real;
36
+ }))].sort();
37
+ const write = canonical(request.write);
38
+ return { read: canonical(request.read).filter(path => !inside(path, write)), write, network: validatePolicy(request.network ?? {}) };
39
+ }
40
+
41
+ /** Throws unless child grants are the same as or narrower than the actor's; an unsandboxed actor may grant anything. */
42
+ export function assertNarrower(actor: Access | undefined, child: Access | undefined): void {
43
+ if (!actor) return;
44
+ if (!child) throw new Error("A sandboxed member cannot launch or resume an unsandboxed member");
45
+ for (const path of child.write) if (!writable(actor, path)) throw new Error(`Write access to ${path} exceeds the requesting member's access`);
46
+ for (const path of child.read) if (!readable(actor, path)) throw new Error(`Read access to ${path} exceeds the requesting member's access`);
47
+ assertNetworkNarrower(actor.network, child.network);
48
+ }
49
+
50
+ /** Host paths a member mounts, and the subset it can write. */
51
+ export interface MountSet { sources: string[]; writes: string[] }
52
+ /** Grants are identity-checked at start, so only a member's other mounts count as sources; its writable grants are its writes. */
53
+ export const mountSet = (mounts: { source: string; rw: boolean }[], grants: string[]): MountSet => ({
54
+ sources: mounts.filter(mount => !grants.includes(mount.source)).map(mount => mount.source),
55
+ writes: mounts.filter(mount => mount.rw && grants.includes(mount.source)).map(mount => mount.source) });
56
+ /**
57
+ * Docker resolves bind sources when a container starts. A sandboxed writer can replace any entry strictly below its
58
+ * writable directory, so no member may mount a path strictly inside another live member's writable directory, in either
59
+ * direction. Equal paths are safe: a writer cannot replace its own mount point. A member's own non-grant mounts (runtime,
60
+ * bridge) must not sit inside its own writable directory either, or it could swap them before its next start.
61
+ */
62
+ export function assertStableGrants(mine: MountSet, others: MountSet[]): void {
63
+ const below = (set: MountSet, writer: MountSet) => set.sources.find(path => writer.writes.some(root => path !== root && within(path, root)));
64
+ for (const other of [...others, mine]) {
65
+ const path = below(mine, other) ?? below(other, mine);
66
+ if (path) throw new Error(`Mount ${path} lies strictly inside a sandboxed member's writable directory; grant that directory or a path outside it`);
67
+ }
68
+ }
69
+
70
+ /** Pi agent-directory entries members need: installed packages, extensions, themes, skills, prompts, and settings Pi reads at startup. */
71
+ export const agentRuntime = ["npm", "extensions", "themes", "skills", "prompts", "AGENTS.md", "keybindings.json", "acstack.json", "models-store.json"];
72
+ export const generatedAgentFiles = ["settings.json", "auth.json", "trust.json", "web-search.json"];
73
+
74
+ export function packageRoot(file: string): string {
75
+ for (let dir = dirname(realpathSync(file)); ; dir = dirname(dir)) {
76
+ if (existsSync(join(dir, "package.json"))) return dir;
77
+ if (dir === dirname(dir)) throw new Error(`No package root for ${file}`);
78
+ }
79
+ }
80
+
81
+ // Container memory caps; the aggregate guard keeps the sum of caps within 75% of host RAM.
82
+ // shortcut: fixed per-member cap, add a setting if real workloads need more.
83
+ export const memberMemory = 2 * 1024 ** 3;
84
+ export const memberPids = 1024;
85
+ export const maxSandboxed = () => Math.max(1, Math.floor(totalmem() * 0.75 / memberMemory));
86
+
87
+ const dockerfile = new URL("./sandbox.Dockerfile", import.meta.url);
88
+ /** Image tag tied to the host Pi version and this Dockerfile, so members run the same Pi as Main. */
89
+ export function memberImage(piVersion: string): { tag: string; build: string } {
90
+ const hash = createHash("sha256").update(readFileSync(dockerfile)).digest("hex").slice(0, 12);
91
+ const tag = `ac-teams-member:${piVersion}-${hash}`, file = dockerfile.pathname;
92
+ return { tag, build: `docker build -t ${tag} --build-arg PI_VERSION=${piVersion} -f ${file} ${dirname(file)}` };
93
+ }
94
+
95
+ export function sandboxStatus(image: { tag: string; build: string }, docker = "docker"): SandboxStatus {
96
+ const status = (reason?: string): SandboxStatus => ({ available: !reason, backend: "docker", ...(reason ? { reason } : {}) });
97
+ if (process.platform !== "linux") return status("Enforced access requires Linux");
98
+ const probe = spawnSync(docker, ["image", "inspect", "--format", "{{json .Config.Volumes}}", image.tag], { encoding: "utf8", timeout: 10000 });
99
+ if (probe.error) return status(`Docker is unavailable: ${probe.error.message}`);
100
+ if (probe.status !== 0) return status(/No such image/i.test(probe.stderr) ? `Member image ${image.tag} is missing; build it with: ${image.build}` : `Docker cannot be used: ${probe.stderr.trim().slice(0, 300)}`);
101
+ if (!["null", "{}"].includes(probe.stdout.trim())) return status(`Member image ${image.tag} declares volumes; rebuild it from the shipped Dockerfile`);
102
+ return status();
103
+ }
104
+
105
+ export interface SandboxPlan {
106
+ name: string; labels: Record<string, string>; image: string; access: Access; cwd: string; home: string; terminal: boolean;
107
+ agentDir: string; configDir: string; sessionDir: string; coordinatorSocket: string; egressSocket: string;
108
+ runtime: string[]; launch: string; bridge: string;
109
+ }
110
+ export interface Mount { source: string; target: string; readonly: boolean }
111
+ /** Every host path a member container sees. Nothing else on the host is mounted. */
112
+ export function sandboxMounts(plan: SandboxPlan): Mount[] {
113
+ const mounts: Mount[] = [
114
+ ...plan.access.read.map(path => ({ source: path, target: path, readonly: true })),
115
+ ...plan.access.write.map(path => ({ source: path, target: path, readonly: false })),
116
+ // Runtime code stays read-only even inside a writable grant, because unsandboxed processes load it too.
117
+ ...plan.runtime.map(path => ({ source: path, target: path, readonly: true })),
118
+ ...agentRuntime.map(name => join(plan.agentDir, name)).filter(path => existsSync(path)).map(path => ({ source: path, target: path, readonly: true })),
119
+ ...generatedAgentFiles.map(name => ({ source: join(plan.configDir, name), target: join(plan.agentDir, name), readonly: true })),
120
+ { source: plan.launch, target: plan.launch, readonly: true },
121
+ { source: plan.sessionDir, target: plan.sessionDir, readonly: false },
122
+ { source: plan.coordinatorSocket, target: plan.coordinatorSocket, readonly: false },
123
+ { source: plan.egressSocket, target: plan.egressSocket, readonly: false },
124
+ ];
125
+ return mounts;
126
+ }
127
+
128
+ export function dockerCreateArgs(plan: SandboxPlan): string[] {
129
+ const uid = process.getuid!(), gid = process.getgid!();
130
+ const args = ["create", "--name", plan.name, "--interactive", ...(plan.terminal ? ["--tty"] : []), "--init",
131
+ "--network", "none", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--read-only", "--restart", "no",
132
+ "--user", `${uid}:${gid}`, "--memory", String(memberMemory), "--memory-swap", String(memberMemory), "--pids-limit", String(memberPids),
133
+ "--log-driver", "none", "--hostname", "ac-teams",
134
+ "--tmpfs", "/tmp:rw,nosuid,nodev,size=512m", "--tmpfs", `${plan.home}:rw,nosuid,nodev,size=256m,uid=${uid},gid=${gid},mode=0700`,
135
+ // Pi writes lock files and caches in its agent directory; this private scratch copy hides the real one.
136
+ "--tmpfs", `${plan.agentDir}:rw,nosuid,nodev,size=64m,uid=${uid},gid=${gid},mode=0700`];
137
+ for (const [key, value] of Object.entries(plan.labels)) args.push("--label", `${key}=${value}`);
138
+ // Other coordinators use this to tell grants (identity-checked at start) from runtime mounts.
139
+ args.push("--label", `ac-teams.grants=${JSON.stringify([...plan.access.read, ...plan.access.write])}`);
140
+ for (const mount of sandboxMounts(plan)) args.push("--mount", `type=bind,source=${mount.source},target=${mount.target}${mount.readonly ? ",readonly" : ""}`);
141
+ args.push("--env", "PATH=/usr/local/bin:/usr/bin:/bin", "--env", `HOME=${plan.home}`, "--env", "TMPDIR=/tmp",
142
+ "--workdir", plan.cwd, plan.image, "node", plan.bridge, plan.launch);
143
+ return args;
144
+ }
145
+
146
+ /** Checks docker inspect output against the policy; any problem means the container must not start. */
147
+ export function containerProblems(inspect: any, plan: SandboxPlan, started = false): string[] {
148
+ const problems: string[] = [], host = inspect?.HostConfig ?? {}, config = inspect?.Config ?? {};
149
+ const expect = (ok: boolean, problem: string) => { if (!ok) problems.push(problem); };
150
+ expect(host.NetworkMode === "none", `network mode ${host.NetworkMode}`);
151
+ expect(!Object.keys(host.PortBindings ?? {}).length && !host.PublishAllPorts && !Object.keys(config.ExposedPorts ?? {}).length, "ports are published or exposed");
152
+ expect(host.Privileged === false, "privileged");
153
+ expect(JSON.stringify(host.CapDrop) === '["ALL"]' && !(host.CapAdd ?? []).length, "capabilities are not all dropped");
154
+ const security: string[] = host.SecurityOpt ?? [];
155
+ expect(security.length > 0 && security.every(option => /^no-new-privileges(:true)?$/.test(option)), "security options differ from no-new-privileges");
156
+ expect(host.ReadonlyRootfs === true, "root filesystem is writable");
157
+ expect(["no", ""].includes(host.RestartPolicy?.Name ?? ""), "restart policy is set");
158
+ expect(host.LogConfig?.Type === "none", "container logging is enabled");
159
+ expect(!host.PidMode && ["", "private"].includes(host.IpcMode ?? "") && !host.UTSMode && !host.UsernsMode && !host.CgroupnsMode?.startsWith?.("host"), "host namespaces are shared");
160
+ expect(!(host.Devices ?? []).length && !(host.DeviceRequests ?? []).length && !(host.GroupAdd ?? []).length && !(host.VolumesFrom ?? []).length, "devices, groups, or volumes-from are added");
161
+ expect(host.Memory === memberMemory && host.PidsLimit === memberPids, "resource limits are missing");
162
+ expect(config.User === `${process.getuid!()}:${process.getgid!()}`, `user ${config.User}`);
163
+ expect(!Object.keys(config.Volumes ?? {}).length, "the image declares volumes");
164
+ const allowed = new Map(sandboxMounts(plan).map(mount => [mount.target, mount]));
165
+ const mounts: any[] = inspect?.Mounts ?? [];
166
+ expect(mounts.length === allowed.size, `expected ${allowed.size} mounts, found ${mounts.length}`);
167
+ for (const mount of mounts) {
168
+ const wanted = allowed.get(mount.Destination);
169
+ expect(mount.Type === "bind" && !!wanted && mount.Source === wanted.source && mount.RW === !wanted.readonly, `unexpected mount ${mount.Type} ${mount.Source} -> ${mount.Destination}`);
170
+ expect(!/docker\.sock$/.test(mount.Source ?? ""), "the Docker socket is mounted");
171
+ }
172
+ expect(JSON.stringify(Object.keys(host.Tmpfs ?? {}).sort()) === JSON.stringify(["/tmp", plan.home, plan.agentDir].sort()), "unexpected tmpfs mounts");
173
+ if (started) expect(inspect?.NetworkSettings && !Object.keys(inspect.NetworkSettings.Ports ?? {}).length, "ports are bound after start");
174
+ return problems;
175
+ }
package/src/config.ts CHANGED
@@ -6,7 +6,9 @@ import { isRecord } from "./connection.ts";
6
6
 
7
7
  export interface DisplaySettings { tasks: boolean; agents: boolean }
8
8
  export const defaultDisplaySettings: DisplaySettings = { tasks: true, agents: true };
9
- const displayPath = () => join(homedir(), ".acstack", "ac-teams", "display.json");
9
+ /** ac-teams state outside Pi's agent directory: settings, team stores, and generated member configuration. */
10
+ export const stateRoot = () => join(homedir(), ".pi", "ac-teams");
11
+ const displayPath = () => join(stateRoot(), "display.json");
10
12
 
11
13
  export function readDisplaySettings(): DisplaySettings {
12
14
  let value: unknown;
@@ -32,7 +34,7 @@ export function saveDisplaySettings(value: DisplaySettings): void {
32
34
 
33
35
  export function policy(cwd: string, trusted: boolean, flags: { limit?: string; depth?: string }) {
34
36
  let config: Record<string, unknown> = { limit: 10, depth: 2 };
35
- const files = [join(homedir(), ".acstack", "ac-teams", "config.json")];
37
+ const files = [join(stateRoot(), "config.json")];
36
38
  if (trusted) files.push(join(cwd, ".pi", "ac-teams.json"));
37
39
  for (const file of files) {
38
40
  let value: unknown;
@@ -1,8 +1,15 @@
1
1
  import { createServer, type Server, type Socket } from "node:net";
2
- import { chmodSync, mkdirSync, realpathSync, statSync, unlinkSync } from "node:fs";
2
+ import { execFile } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+ import { chmodSync, closeSync, constants, existsSync, fstatSync, mkdirSync, openSync, lstatSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
5
  import { randomUUID } from "node:crypto";
4
- import { join, resolve } from "node:path";
6
+ import { homedir } from "node:os";
7
+ import { delimiter, dirname, join, resolve } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { assertNarrower, assertStableGrants, mountSet, within, containerProblems, dockerCreateArgs, sandboxMounts, maxSandboxed, memberImage, packageRoot, readable, resolveAccess, sandboxStatus, type Access, type SandboxPlan, type SandboxStatus } from "./access.ts";
10
+ import { Egress, egressPort, providerRoutes, type Credentials } from "./egress.ts";
5
11
  import { Store, processAlive, processIdentity } from "./store.ts";
12
+ import { stateRoot } from "./config.ts";
6
13
  import { RpcAgent } from "./rpc-agent.ts";
7
14
  import { TerminalAgent, tmux } from "./terminal-agent.ts";
8
15
  import { authentic, isRecord, readLines, send } from "./connection.ts";
@@ -30,6 +37,16 @@ export interface CoordinatorOptions {
30
37
  id: string; cwd: string; sessionFile?: string; limit: number; depth: number;
31
38
  extension: string; home?: string; command?: string;
32
39
  interactive?: boolean; model?: string; thinking?: string;
40
+ /** Version of the running Pi; members use the image built for it. Defaults to the `pi` on PATH. */
41
+ piVersion?: string;
42
+ /** Test hook: provider token refresh interval (default one minute). */
43
+ refreshMs?: number;
44
+ /** Test hook: runs after a member container passes all checks and before it starts. */
45
+ beforeStart?: (member: string) => Promise<void>;
46
+ /** Main's session directory; member transcripts live under <it>/ac-teams/<team>/<member>. */
47
+ sessionDir?: string;
48
+ /** Parent-side provider credentials for sandboxed members; without it enforced access is unavailable. */
49
+ credentials?: Credentials;
33
50
  notice: (through: number, messages: Message[]) => void;
34
51
  warning: (message: string) => void;
35
52
  status?: (kind: "busy" | "blocked", active: boolean) => void;
@@ -49,6 +66,11 @@ export class Coordinator {
49
66
  private closed = false;
50
67
  private status = { busy: false, blocked: false };
51
68
  private closePromise?: Promise<void>;
69
+ private sandboxes = new Map<string, { egress: Egress; grants: { path: string; fd: number; dev: number; ino: number }[]; release: (go: boolean) => void; refresh: NodeJS.Timeout; token: string }>();
70
+ private plans = new Map<string, SandboxPlan>();
71
+ private releasing = new Map<string, Promise<void>>();
72
+ private sessionDirs = new Map<string, string>();
73
+ private sandbox?: { status: SandboxStatus; at: number };
52
74
 
53
75
  private options: CoordinatorOptions;
54
76
  private constructor(options: CoordinatorOptions) {
@@ -83,6 +105,13 @@ export class Coordinator {
83
105
  if (member.id !== "root" && isLive(member)) member.status = "interrupted";
84
106
  }
85
107
  });
108
+ // Owner lock held and no member process alive: containers left by a killed coordinator are orphans of this store.
109
+ for (const member of Object.values(coordinator.store.state.members)) if (member.access) {
110
+ const name = coordinator.containerName(member.id);
111
+ const owner = await docker(["inspect", "--format", '{{index .Config.Labels "ac-teams.store"}}', name]).catch(() => "");
112
+ if (owner.trim() === coordinator.store.dir) await docker(["rm", "-f", name]).catch(error => options.warning(`Cannot remove orphaned member container ${name}: ${String(error)}`));
113
+ rmSync(memberConfigDir(member.id), { recursive: true, force: true });
114
+ }
86
115
  try { unlinkSync(coordinator.socketPath); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
87
116
  coordinator.server = createServer(socket => coordinator.accept(socket));
88
117
  await new Promise<void>((done, fail) => {
@@ -124,6 +153,9 @@ export class Coordinator {
124
153
  if (previous && previous !== socket && !previous.destroyed) throw new Error("Member already connected");
125
154
  const args = value.args;
126
155
  if (!isRecord(args) || typeof args.sessionId !== "string" || typeof args.sessionFile !== "string" || !Array.isArray(args.tools) || !["team_agent", "team_task", "team_message"].every(name => Array.isArray(args.tools) && args.tools.includes(name))) throw new Error("Incomplete ac-teams readiness handshake");
156
+ // The session file chooses a host mount on resume, so it must stay in the directory ac-teams assigned.
157
+ const assigned = this.sessionDirs.get(member.id) ?? this.memberSessionDir(member.id);
158
+ if (!assigned || !within(resolve(args.sessionFile as string), assigned)) throw new Error("Member session file is outside its assigned directory");
127
159
  if (args.trusted !== true) throw new Error("Pi skipped protected project resources. Approve the member cwd with Pi's /trust command before retrying; ac-teams never auto-approves a project.");
128
160
  this.store.update(state => {
129
161
  state.members[member.id].sessionId = args.sessionId as string;
@@ -240,6 +272,16 @@ export class Coordinator {
240
272
  const { token: _token, identity: _identity, ...view } = member;
241
273
  return view;
242
274
  }
275
+ sandboxStatus(): SandboxStatus {
276
+ if (!this.options.credentials) return { available: false, backend: "docker", reason: "This Pi root cannot broker provider credentials" };
277
+ // Cache success; recheck failures so a fixed host is noticed without restarting.
278
+ if (!this.sandbox || (!this.sandbox.status.available && Date.now() - this.sandbox.at > 30000)) {
279
+ let status: SandboxStatus;
280
+ try { status = sandboxStatus(this.image()); } catch (error) { status = { available: false, backend: "docker", reason: String(error) }; }
281
+ this.sandbox = { status, at: Date.now() };
282
+ }
283
+ return this.sandbox.status;
284
+ }
243
285
  live(): Member[] { return Object.values(this.store.state.members).filter(member => member.id !== "root" && isLive(member)); }
244
286
  ack(id: string, through: number): void {
245
287
  const last = this.store.state.messages.at(-1)?.seq ?? 0;
@@ -267,7 +309,11 @@ export class Coordinator {
267
309
  if (!member || !isLive(member)) throw new Error("Member is not active");
268
310
  validate(operation, args);
269
311
  if (operation === "event") return this.recordEvent(actor, args);
270
- if (operation === "ui") return this.userAction(actor, args as UserAction);
312
+ if (operation === "ui") {
313
+ // User actions can resume any member as Main, so sandboxed members cannot send them.
314
+ if (member.access) throw new Error("Sandboxed members cannot send user team actions; use Main");
315
+ return this.userAction(actor, args as UserAction);
316
+ }
271
317
  if (operation === "task") return this.store.task(actor, args as TaskArgs);
272
318
  if (operation === "message") {
273
319
  const result = this.store.message(actor, args as MessageArgs);
@@ -278,12 +324,12 @@ export class Coordinator {
278
324
  }
279
325
  private async agent(actor: string, args: AgentArgs, authorizedLimit: boolean): Promise<unknown> {
280
326
  const state = this.store.state;
281
- if (args.action === "list") return { team: state.id, limit: state.limit, depth: state.depth, lastMessage: state.messages.at(-1)?.seq ?? 0, members: Object.values(state.members).map(member => this.publicMember(member)) };
327
+ if (args.action === "list") return { team: state.id, limit: state.limit, depth: state.depth, sandbox: this.sandboxStatus(), lastMessage: state.messages.at(-1)?.seq ?? 0, members: Object.values(state.members).map(member => this.publicMember(member)) };
282
328
  if (args.action === "limits") {
283
329
  if (actor !== "root") throw new Error("Only the root can change team limits");
284
330
  if ((args.limit ?? state.limit) > 10 && !authorizedLimit) throw new Error("A ceiling above 10 requires explicit user confirmation or --teams-limit");
285
331
  this.store.update(next => { next.limit = args.limit ?? next.limit; next.depth = args.depth ?? next.depth; });
286
- return { limit: this.store.state.limit, depth: this.store.state.depth };
332
+ return { limit: this.store.state.limit, depth: this.store.state.depth, sandbox: this.sandboxStatus() };
287
333
  }
288
334
  if (args.action === "launch" || args.action === "resume") {
289
335
  const launch = this.launch(actor, args);
@@ -295,6 +341,7 @@ export class Coordinator {
295
341
  if (args.action === "status") return this.publicMember(member);
296
342
  if (member.id === "root" || !isDescendant(state, member.id, actor)) throw new Error("Only a member's ancestor can stop or clean it up");
297
343
  if (args.action === "cleanup") {
344
+ if (state.members[actor].access) throw new Error("Sandboxed members cannot remove worktrees");
298
345
  if (isLive(member)) throw new Error("Stop the member before worktree cleanup");
299
346
  if (!member.worktree) throw new Error("Member has no worktree");
300
347
  if (this.cleaning.has(member.id)) throw new Error("Member cleanup is already running");
@@ -326,7 +373,22 @@ export class Coordinator {
326
373
  const id = resumed?.id ?? randomUUID().slice(0, 16);
327
374
  const cwd = realpathSync(resolve(args.cwd ?? resumed?.cwd ?? parent.cwd));
328
375
  if (!statSync(cwd).isDirectory()) throw new Error("Agent cwd must be a directory");
329
- const member: Member = resumed ? { ...resumed, token: randomUUID(), status: "starting", error: undefined, contextUsage: undefined, modelName: undefined } : { id, name: args.name, parent: actor, depth, token: randomUUID(), cwd, status: "starting", notified: 0 };
376
+ // An access request without a network policy keeps the requester's; only an unsandboxed requester's default is public.
377
+ const access = resumed ? resumed.access : args.access ? resolveAccess({ ...args.access, network: args.access.network ?? parent.access?.network }, cwd) : parent.access;
378
+ assertNarrower(parent.access, access);
379
+ if (access) {
380
+ if (!readable(access, cwd)) throw new Error("The member cwd must be inside its granted directories");
381
+ if (args.worktree) throw new Error("Worktrees cannot be combined with enforced access");
382
+ // Container memory caps must stay within host RAM in total; unsandboxed members are not limited.
383
+ if (this.live().filter(other => other.access && other.id !== resumed?.id).length >= maxSandboxed()) throw new Error(`At most ${maxSandboxed()} enforced members can run at once on this host`);
384
+ const status = this.sandboxStatus();
385
+ if (!status.available) throw new Error(`Enforced access is unavailable: ${status.reason}`);
386
+ }
387
+ // The provider endpoint bypasses the allowlist, so a restricted member keeps its provider and its children inherit it.
388
+ const provider = args.model.slice(0, args.model.indexOf("/"));
389
+ const locked = resumed ? resumed.provider : parent.access?.network.allow ? parent.provider : undefined;
390
+ if (access?.network.allow && locked && provider !== locked) throw new Error(`A member with restricted network access can only use ${locked} models`);
391
+ const member: Member = resumed ? { ...resumed, token: randomUUID(), status: "starting", error: undefined, contextUsage: undefined, modelName: undefined } : { id, name: args.name, parent: actor, depth, token: randomUUID(), cwd, status: "starting", notified: 0, access, provider: access ? provider : undefined };
330
392
  // Reserve synchronously before the first await so concurrent launches cannot oversubscribe.
331
393
  this.store.update(next => { next.members[id] = member; });
332
394
  this.publishStatus();
@@ -344,16 +406,23 @@ export class Coordinator {
344
406
  if (this.closed || this.store.state.members[id].status !== "starting") throw new Error("Launch was stopped before startup");
345
407
  const readiness = new Promise<void>((done, fail) => {
346
408
  this.ready.set(id, { resolve: done, reject: fail });
347
- timer = setTimeout(() => fail(new Error("ac-teams child readiness timed out")), 20000);
409
+ // Enforced members start with an empty compile cache, so Pi recompiles TypeScript extensions (about 20 s here).
410
+ timer = setTimeout(() => fail(new Error("ac-teams child readiness timed out")), access ? 90000 : 20000);
348
411
  });
349
412
  // Attach immediately: a spawn error must not leave a rejected readiness promise unobserved.
350
413
  void readiness.catch(() => {});
351
414
  const slash = args.model.indexOf("/");
352
- mkdirSync(join(this.store.dir, "sessions"), { recursive: true, mode: 0o700 });
353
- chmodSync(join(this.store.dir, "sessions"), 0o700);
354
- const rpcArgs = [...(this.options.interactive ? [] : ["--mode", "rpc"]), "--session-dir", join(this.store.dir, "sessions"), "-e", this.options.extension, "--provider", args.model.slice(0, slash), "--model", args.model.slice(slash + 1), "--thinking", args.thinking ?? "off"];
415
+ const modelId = args.model.slice(slash + 1);
416
+ // Each member gets its own transcript directory, so a sandboxed member cannot read or alter others.
417
+ const sessions = resumed?.sessionFile ? dirname(resumed.sessionFile) : this.memberSessionDir(id);
418
+ mkdirSync(sessions, { recursive: true, mode: 0o700 });
419
+ this.sessionDirs.set(id, sessions);
420
+ const rpcArgs = [...(this.options.interactive ? [] : ["--mode", "rpc"]), "--session-dir", sessions, "-e", this.options.extension, "--provider", provider, "--model", modelId, "--thinking", args.thinking ?? "off"];
355
421
  if (resumed?.sessionFile) rpcArgs.push("--session", resumed.sessionFile);
356
- const spawnOptions = { cwd: member.cwd, args: rpcArgs, command: this.options.command, env: { ...process.env, HERDR_ENV: "0", AC_TEAMS_SOCKET: this.socketPath, AC_TEAMS_MEMBER: id, AC_TEAMS_TOKEN: member.token, AC_TEAMS_TERMINAL_SOCKET: join(this.store.dir, "t.sock") } };
422
+ const identity = { HERDR_ENV: "0", AC_TEAMS_SOCKET: this.socketPath, AC_TEAMS_MEMBER: id, AC_TEAMS_TOKEN: member.token, AC_TEAMS_ACCESS: JSON.stringify(access ? { enforced: true, ...access } : { enforced: false }) };
423
+ const spawnOptions = access
424
+ ? await this.sandboxed(id, access, member.cwd, provider, modelId, rpcArgs, identity, sessions)
425
+ : { cwd: member.cwd, args: rpcArgs, command: this.options.command, env: { ...process.env, ...identity, AC_TEAMS_TERMINAL_SOCKET: join(this.store.dir, "t.sock") } };
357
426
  agent = this.options.interactive
358
427
  ? new TerminalAgent({ ...spawnOptions, id, dir: this.store.dir, request: (type, fields, timeout) => this.control(id, type, fields, timeout) })
359
428
  : new RpcAgent(spawnOptions);
@@ -371,6 +440,10 @@ export class Coordinator {
371
440
  });
372
441
  agent.once("exit", () => {
373
442
  this.agents.delete(id);
443
+ // Tracked so close() waits for container removal.
444
+ const release = this.releaseSandbox(id).catch(error => this.options.warning(String(error)));
445
+ this.operations.add(release); this.releasing.set(id, release);
446
+ void release.finally(() => { this.operations.delete(release); if (this.releasing.get(id) === release) this.releasing.delete(id); });
374
447
  if (this.closed) return;
375
448
  this.store.update(next => { next.members[id].status = next.members[id].status === "stopping" ? "stopped" : "interrupted"; delete next.members[id].needsInput; });
376
449
  this.publishStatus();
@@ -380,6 +453,7 @@ export class Coordinator {
380
453
  member.pid = agent.process.pid;
381
454
  member.identity = member.pid ? processIdentity(member.pid) : undefined;
382
455
  this.store.update(next => { next.members[id].pid = member.pid; next.members[id].identity = member.identity; });
456
+ if (access) await this.releaseMember(id);
383
457
  try {
384
458
  await Promise.race([readiness, agent.exited.then(() => { throw new Error("Child exited before readiness"); })]);
385
459
  } finally { if (timer) clearTimeout(timer); this.ready.delete(id); }
@@ -393,11 +467,143 @@ export class Coordinator {
393
467
  } catch (error) {
394
468
  clearTimeout(timer); this.ready.delete(id);
395
469
  if (agent) await agent.stop();
470
+ // The exit handler may already own the container removal; wait for it so a failed launch leaves nothing running.
471
+ await this.releaseSandbox(id); await this.releasing.get(id);
396
472
  this.store.update(next => { next.members[id].status = "interrupted"; delete next.members[id].needsInput; next.members[id].error = error instanceof Error ? error.message : "Startup failed"; });
397
473
  this.publishStatus();
398
474
  throw error;
399
475
  }
400
476
  }
477
+ private image() {
478
+ return memberImage(this.options.piVersion ?? JSON.parse(readFileSync(join(packageRoot(command("pi")), "package.json"), "utf8")).version);
479
+ }
480
+ /** Transcript root: Main's session directory, without any ac-teams nesting if Main itself resumed a member transcript. */
481
+ private memberSessionDir(id: string): string {
482
+ const root = resolve(this.options.sessionDir ?? join(this.store.dir, "sessions")).replace(/\/ac-teams\/[^/]+\/[^/]+$/, "");
483
+ return join(root, "ac-teams", this.store.state.id, id);
484
+ }
485
+ private containerName(id: string): string { return `ac-teams-${this.store.state.id}-${id}`; }
486
+ /** Prepares a member container whose only outside channels are the coordinator socket and this member's egress proxy. */
487
+ private async sandboxed(id: string, access: Access, cwd: string, provider: string, model: string, args: string[], identity: Record<string, string>, sessionDir: string) {
488
+ const credentials = this.options.credentials, route = providerRoutes[provider];
489
+ if (!credentials || !route) throw new Error(`Enforced access supports only ${Object.keys(providerRoutes).join("/")} models with Main's Pi login`);
490
+ const first = await credentials(provider, model); // Fails closed for unsupported providers before anything starts.
491
+ // A resume reuses the member ID, so the previous run's cleanup must finish before its files are recreated.
492
+ await this.releasing.get(id);
493
+ const home = homedir(), agentDir = resolve(process.env.PI_CODING_AGENT_DIR ?? join(home, ".pi", "agent"));
494
+ const dir = memberConfigDir(id);
495
+ rmSync(dir, { recursive: true, force: true });
496
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
497
+ const read = (name: string) => { try { return JSON.parse(readFileSync(join(agentDir, name), "utf8")) as Record<string, unknown>; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; throw error; } };
498
+ const write = (name: string, value: unknown) => writeFileSync(join(dir, name), JSON.stringify(value, null, 2) + "\n", { flag: "wx", mode: 0o600 });
499
+ // WebSocket clients may ignore proxy settings, so pin the SSE transport.
500
+ write("settings.json", { ...read("settings.json"), transport: "sse" });
501
+ // Only the selected provider's current access token: no refresh token and no other providers. The parent refreshes it.
502
+ const auth = (credential: { token: string; expires: number }) => ({ [provider]: { type: "oauth", access: credential.token, refresh: "", expires: credential.expires } });
503
+ write("auth.json", auth(first));
504
+ // Only trust decisions for the member's cwd and its ancestors; the full file lists every trusted project.
505
+ write("trust.json", Object.fromEntries(Object.entries(read("trust.json")).filter(([path]) => cwd === path || cwd.startsWith(path.endsWith("/") ? path : path + "/"))));
506
+ // pi-web-access ignores proxy environment variables but honours its own proxy setting. The container has no DNS,
507
+ // so it must leave address checks to the proxy, which refuses private and host addresses itself.
508
+ const proxy = `http://127.0.0.1:${egressPort}`, search = read("web-search.json");
509
+ write("web-search.json", { ...search, proxy, ssrf: { ...(search.ssrf as object | undefined), trustEnvProxy: true } });
510
+ // The image's Pi runs members; a test command is mounted from its own package instead.
511
+ const custom = this.options.command ?? process.env.AC_TEAMS_PI;
512
+ const pi = custom ? command(custom) : "pi";
513
+ const env: Record<string, string> = { PATH: "/usr/local/bin:/usr/bin:/bin", HOME: home, TMPDIR: "/tmp", PI_CODING_AGENT_DIR: agentDir, PI_SKIP_VERSION_CHECK: "1", PI_TELEMETRY: "0", ...identity,
514
+ HTTP_PROXY: proxy, HTTPS_PROXY: proxy, http_proxy: proxy, https_proxy: proxy, NO_PROXY: "localhost,127.0.0.1,::1", no_proxy: "localhost,127.0.0.1,::1", NODE_USE_ENV_PROXY: "1",
515
+ // billion-context's spawned proxy reads its own upstream setting rather than the inherited environment.
516
+ BILI_UPSTREAM_PROXY: proxy };
517
+ for (const key of ["USER", "LANG", "LC_ALL", "TERM", "COLORTERM"]) if (process.env[key]) env[key] = process.env[key]!;
518
+ const launch = join(dir, "launch.json");
519
+ // The bridge waits on this before running any member code; it opens only after the mounts are verified.
520
+ let release!: (go: boolean) => void;
521
+ const gate = new Promise<boolean>(resolve => { release = resolve; });
522
+ const egress = await Egress.open(join(this.store.dir, `e-${id}.sock`), access.network, { host: new URL(route.baseUrl).hostname, port: 443 }, gate);
523
+ // Rewrites in place (same inode), because the file is bind-mounted; the member never sees the refresh token.
524
+ const refresh = setInterval(() => void credentials(provider, model).then(next => {
525
+ const current = this.sandboxes.get(id);
526
+ if (current && next.token !== current.token) { writeFileSync(join(dir, "auth.json"), JSON.stringify(auth(next), null, 2) + "\n"); current.token = next.token; }
527
+ }, () => this.options.warning(`Member ${id}: provider credential refresh failed`)), this.options.refreshMs ?? 60000);
528
+ refresh.unref();
529
+ const entry = { egress, grants: [] as { path: string; fd: number; dev: number; ino: number }[], release, refresh, token: first.token };
530
+ this.sandboxes.set(id, entry);
531
+ // Registered first so releaseSandbox closes the proxy if this fails. The coordinator token travels in this private file, never in container configuration that docker inspect shows.
532
+ write("launch.json", { socket: egress.socket, port: egressPort, command: pi, args, env });
533
+ // Hold each grant open so the started container's mounts can be compared with exactly these directories.
534
+ for (const path of [...access.read, ...access.write]) {
535
+ const fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
536
+ entry.grants.push({ path, fd, ...(({ dev, ino }) => ({ dev, ino }))(fstatSync(fd)) });
537
+ if (readlinkSync(`/proc/self/fd/${fd}`) !== path) throw new Error(`Grant ${path} changed while opening`);
538
+ }
539
+ const plan: SandboxPlan = {
540
+ name: this.containerName(id), labels: { "ac-teams.team": this.store.state.id, "ac-teams.member": id, "ac-teams.store": this.store.dir },
541
+ image: this.image().tag, access, cwd, home, terminal: this.options.interactive === true, agentDir, configDir: dir, sessionDir,
542
+ coordinatorSocket: this.socketPath, egressSocket: egress.socket, launch,
543
+ runtime: [...new Set([packageRoot(this.options.extension), packageRoot(fileURLToPath(import.meta.url)), ...(custom ? [packageRoot(pi)] : [])])],
544
+ bridge: fileURLToPath(new URL("./sandbox-bridge.mjs", import.meta.url)),
545
+ };
546
+ this.plans.set(id, plan);
547
+ await docker(["rm", "-f", plan.name]).catch(() => {});
548
+ const created = (await docker(dockerCreateArgs(plan))).trim();
549
+ // Register first, then check. Grants are identity-checked at start, but runtime, transcript, and socket mounts are
550
+ // not, so none may sit strictly inside another live member's writable grant, and no writable grant may strictly
551
+ // contain another member's such mounts, across all teams.
552
+ const others = (await docker(["ps", "-aq", "--no-trunc", "--filter", "label=ac-teams.member", ...["created", "running", "paused", "restarting"].flatMap(state => ["--filter", `status=${state}`])])).trim().split("\n").filter(other => other && other !== created);
553
+ const sets = others.length ? (JSON.parse(await docker(["inspect", ...others])) as { Mounts?: { Type: string; Source: string; RW: boolean }[]; Config: { Labels?: Record<string, string> } }[])
554
+ .map(container => mountSet((container.Mounts ?? []).filter(mount => mount.Type === "bind").map(mount => ({ source: mount.Source, rw: mount.RW })), JSON.parse(container.Config.Labels?.["ac-teams.grants"] ?? "[]"))) : [];
555
+ assertStableGrants(mountSet(sandboxMounts(plan).map(mount => ({ source: mount.source, rw: !mount.readonly })), [...access.read, ...access.write]), sets);
556
+ // Admission across every coordinator on this host: count after creating, so concurrent launches cannot both slip under the cap.
557
+ const admitted = (await docker(["ps", "-aq", "--filter", "label=ac-teams.member", "--filter", "status=created", "--filter", "status=running"])).trim().split("\n").filter(Boolean).length;
558
+ if (admitted > maxSandboxed()) throw new Error(`At most ${maxSandboxed()} enforced members can run at once on this host`);
559
+ await this.verifyContainer(id, false);
560
+ await this.options.beforeStart?.(id);
561
+ return { cwd, command: "docker", args: ["start", "--attach", "--interactive", plan.name], env: { ...process.env } };
562
+ }
563
+ /** Refuses to run a container whose effective configuration differs from the policy. */
564
+ private async verifyContainer(id: string, started: boolean): Promise<void> {
565
+ const plan = this.plans.get(id)!;
566
+ const [inspect] = JSON.parse(await docker(["inspect", plan.name]));
567
+ const problems = containerProblems(inspect, plan, started);
568
+ if (problems.length) throw new Error(`Member container failed policy checks: ${problems.join("; ")}`);
569
+ }
570
+ /**
571
+ * After start, before any member code runs: each grant mounted in the container must be the directory held open
572
+ * before creation. Docker resolves bind sources at start, so this is what pins grants against symlink swaps.
573
+ */
574
+ private async releaseMember(id: string): Promise<void> {
575
+ const sandbox = this.sandboxes.get(id)!, plan = this.plans.get(id)!;
576
+ try {
577
+ let pid = 0;
578
+ for (const end = Date.now() + 30000; !pid; await new Promise(done => setTimeout(done, 100))) {
579
+ if (Date.now() > end) throw new Error("Member container did not start");
580
+ const [inspect] = JSON.parse(await docker(["inspect", plan.name]));
581
+ if (inspect?.State?.Running) pid = inspect.State.Pid;
582
+ else if (inspect?.State?.Status === "exited") throw new Error("Member container exited before release");
583
+ }
584
+ await this.verifyContainer(id, true);
585
+ for (const grant of sandbox.grants) {
586
+ const mounted = lstatSync(`/proc/${pid}/root${grant.path}`);
587
+ if (mounted.dev !== grant.dev || mounted.ino !== grant.ino) throw new Error(`Grant ${grant.path} is not the directory that was checked`);
588
+ }
589
+ sandbox.release(true);
590
+ } catch (error) { sandbox.release(false); throw error; }
591
+ finally { for (const grant of sandbox.grants) closeSync(grant.fd); sandbox.grants = []; }
592
+ }
593
+ private async releaseSandbox(id: string): Promise<void> {
594
+ const sandbox = this.sandboxes.get(id), plan = this.plans.get(id);
595
+ this.sandboxes.delete(id); this.plans.delete(id);
596
+ if (sandbox) {
597
+ sandbox.release(false); clearInterval(sandbox.refresh);
598
+ // releaseMember may still hold this entry; emptying the list keeps it from closing reused descriptors.
599
+ for (const grant of sandbox.grants) closeSync(grant.fd);
600
+ sandbox.grants = [];
601
+ }
602
+ if (plan) await docker(["rm", "-f", plan.name]).catch(error => this.options.warning(`Cannot remove member container ${plan.name}: ${String(error)}`));
603
+ await sandbox?.egress.close();
604
+ // The member's files hold its coordinator token and provider access token.
605
+ rmSync(memberConfigDir(id), { recursive: true, force: true });
606
+ }
401
607
  private async stopDescendants(id: string): Promise<void> {
402
608
  const descendants = this.live().filter(member => isDescendant(this.store.state, member.id, id)).sort((a, b) => b.depth - a.depth);
403
609
  for (const member of descendants) await this.stopOne(member.id);
@@ -408,7 +614,8 @@ export class Coordinator {
408
614
  this.store.update(next => { next.members[id].status = "stopping"; delete next.members[id].needsInput; });
409
615
  this.publishStatus();
410
616
  const agent = this.agents.get(id);
411
- if (agent) await agent.stop();
617
+ // A stopped member's container and credential files are gone before stop returns.
618
+ if (agent) { await agent.stop(); await this.releasing.get(id); }
412
619
  else if (member.pid && processAlive(member.pid, member.identity)) throw new Error("No owned transport for live process; refusing to kill by PID");
413
620
  this.store.update(next => { next.members[id].status = "stopped"; });
414
621
  }
@@ -423,6 +630,7 @@ export class Coordinator {
423
630
  for (const socket of this.connections) socket.destroy();
424
631
  if (this.server) await new Promise<void>(done => this.server!.close(() => done()));
425
632
  try { unlinkSync(this.socketPath); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
633
+ for (const id of [...this.sandboxes.keys()]) await this.releaseSandbox(id);
426
634
  for (const pending of this.controls.values()) { clearTimeout(pending.timer); pending.reject(new Error("Team stopped")); }
427
635
  this.controls.clear();
428
636
  if (this.options.interactive) {
@@ -433,3 +641,18 @@ export class Coordinator {
433
641
  this.store.close();
434
642
  }
435
643
  }
644
+
645
+ const memberConfigDir = (id: string) => join(stateRoot(), "agents", id);
646
+
647
+ function command(name: string): string {
648
+ if (name.includes("/")) return realpathSync(name);
649
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
650
+ if (dir && existsSync(join(dir, name))) return realpathSync(join(dir, name));
651
+ }
652
+ throw new Error(`Cannot find ${name} on PATH`);
653
+ }
654
+
655
+ const execFileAsync = promisify(execFile);
656
+ async function docker(args: string[]): Promise<string> {
657
+ return (await execFileAsync("docker", args, { timeout: 30000, maxBuffer: 4 * 1024 * 1024 })).stdout;
658
+ }
package/src/egress.ts ADDED
@@ -0,0 +1,187 @@
1
+ import { createServer, request as httpRequest, type IncomingMessage, type Server, type ServerResponse } from "node:http";
2
+ import { BlockList, connect, isIP, type Socket } from "node:net";
3
+ import { lookup as dnsLookup } from "node:dns/promises";
4
+ import { chmodSync, unlinkSync } from "node:fs";
5
+ import { networkInterfaces } from "node:os";
6
+
7
+ /**
8
+ * Outbound policy for a sandboxed member. `allow` undefined means any public destination; otherwise only matching
9
+ * destinations. The member's model provider is always reachable so Pi can run; that endpoint still receives whatever
10
+ * the member sends it.
11
+ */
12
+ export interface NetworkPolicy { allow?: string[] }
13
+ export const egressPort = 3128;
14
+ // Each tunnel holds two descriptors in the parent (Main's Pi), so one member cannot exhaust them.
15
+ export const maxEgressConnections = 256;
16
+
17
+ // Entries: "host", "*.host" (subdomains only), each optionally ":port". Without a port, an entry allows 80 and 443.
18
+ const entryPattern = /^(\*\.)?([a-z0-9-]+(?:\.[a-z0-9-]+)+)(?::(\d{1,5}))?$/;
19
+ function parseEntry(entry: string) {
20
+ const match = entryPattern.exec(entry.toLowerCase());
21
+ const port = match?.[3] === undefined ? undefined : Number(match[3]);
22
+ if (!match || (port !== undefined && (port < 1 || port > 65535))) throw new Error(`Invalid network allow entry: ${entry}`);
23
+ return { wildcard: !!match[1], host: match[2], ports: port === undefined ? [80, 443] : [port] };
24
+ }
25
+ export function validatePolicy(policy: NetworkPolicy): NetworkPolicy {
26
+ if (policy.allow === undefined) return {};
27
+ return { allow: [...new Set(policy.allow.map(entry => { parseEntry(entry); return entry.toLowerCase(); }))].sort() };
28
+ }
29
+ const covers = (entry: ReturnType<typeof parseEntry>, host: string, port: number) =>
30
+ entry.ports.includes(port) && (entry.wildcard ? host.endsWith("." + entry.host) : host === entry.host);
31
+ export function allowed(policy: NetworkPolicy, host: string, port: number): boolean {
32
+ return policy.allow === undefined || policy.allow.some(entry => covers(parseEntry(entry), host.toLowerCase(), port));
33
+ }
34
+ /** Throws unless the child's policy allows nothing the parent's does not. */
35
+ export function assertNetworkNarrower(parent: NetworkPolicy, child: NetworkPolicy): void {
36
+ if (parent.allow === undefined) return;
37
+ if (child.allow === undefined) throw new Error("A member with restricted network access cannot launch one with public access");
38
+ for (const text of child.allow) {
39
+ const entry = parseEntry(text);
40
+ const ok = parent.allow.some(parentText => {
41
+ const outer = parseEntry(parentText);
42
+ if (!entry.ports.every(port => outer.ports.includes(port))) return false;
43
+ if (outer.wildcard) return entry.host.endsWith("." + outer.host);
44
+ return !entry.wildcard && entry.host === outer.host;
45
+ });
46
+ if (!ok) throw new Error(`Network access to ${text} exceeds the requesting member's access`);
47
+ }
48
+ }
49
+
50
+ // Only global unicast is public. Everything else (host loopback, LAN, Docker bridges, tailscale CGNAT, link-local,
51
+ // documentation, multicast, translation prefixes) stays unreachable.
52
+ const blocked = new BlockList();
53
+ for (const [address, prefix] of [["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24],
54
+ ["192.0.2.0", 24], ["192.88.99.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24], ["203.0.113.0", 24], ["224.0.0.0", 3]] as const) blocked.addSubnet(address, prefix, "ipv4");
55
+ for (const [address, prefix] of [["64:ff9b::", 96], ["64:ff9b:1::", 48], ["100::", 64], ["2001::", 23], ["2001:db8::", 32], ["2002::", 16], ["3fff::", 20]] as const) blocked.addSubnet(address, prefix, "ipv6");
56
+ export function isPublicAddress(address: string): boolean {
57
+ const family = isIP(address);
58
+ if (family === 4) return !blocked.check(address, "ipv4");
59
+ if (family !== 6) return false;
60
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
61
+ if (mapped) return isPublicAddress(mapped[1]);
62
+ // Global unicast is 2000::/3; this also excludes ::, ::1, IPv4-compatible, ULA fc00::/7, link-local, and multicast.
63
+ const first = parseInt(address.startsWith("::") ? "0" : address.split(":")[0] || "0", 16);
64
+ return first >= 0x2000 && first <= 0x3fff && !blocked.check(address, "ipv6");
65
+ }
66
+
67
+ /** The host's own addresses are refused even when public, so services listening on all interfaces stay unreachable. */
68
+ export function isHostAddress(address: string): boolean {
69
+ const normal = (value: string) => value.toLowerCase().replace(/^::ffff:(?=\d)/, "").replace(/%.*$/, "");
70
+ return Object.values(networkInterfaces()).flat().some(entry => entry && normal(entry.address) === normal(address));
71
+ }
72
+ const reachable = (address: string) => isPublicAddress(address) && !isHostAddress(address);
73
+
74
+ type Lookup = (host: string) => Promise<string[]>;
75
+ const systemLookup: Lookup = async host => (await dnsLookup(host, { all: true, verbatim: true })).map(entry => entry.address);
76
+
77
+ /** Splits an authority (host:port, [v6]:port) strictly; returns undefined for anything else. */
78
+ export function parseAuthority(authority: string, defaultPort?: number): { host: string; port: number } | undefined {
79
+ const match = /^(?:\[([0-9a-f:.]+)\]|([a-z0-9.-]+))(?::(\d{1,5}))?$/i.exec(authority);
80
+ if (!match) return;
81
+ const host = (match[1] ?? match[2]).toLowerCase(), port = match[3] === undefined ? defaultPort : Number(match[3]);
82
+ if (port === undefined || port < 1 || port > 65535 || (match[1] && isIP(match[1]) !== 6) || (!match[1] && isIP(host) === 6)) return;
83
+ return { host, port };
84
+ }
85
+
86
+ /** One member's outbound proxy on a private Unix socket: HTTP CONNECT and plain HTTP forwarding to public addresses only. */
87
+ export class Egress {
88
+ readonly socket: string;
89
+ private server: Server;
90
+ private sockets = new Set<Socket>();
91
+ private policy: NetworkPolicy;
92
+ private model: { host: string; port: number };
93
+ private lookup: Lookup;
94
+ private isPublic: (address: string) => boolean;
95
+ private start: Promise<boolean>;
96
+ private started = false;
97
+ private constructor(socket: string, policy: NetworkPolicy, model: { host: string; port: number }, start: Promise<boolean>, lookup: Lookup, isPublic: (address: string) => boolean) {
98
+ this.socket = socket; this.policy = policy; this.model = model; this.start = start; this.lookup = lookup; this.isPublic = isPublic;
99
+ this.server = createServer({ requestTimeout: 0, headersTimeout: 30000 }, (req, res) => void this.forward(req, res).catch(() => res.destroy()));
100
+ this.server.on("connect", (req, client: Socket, head: Buffer) => void this.tunnel(req, client, head).catch(() => client.destroy()));
101
+ this.server.on("connection", socket => { if (this.sockets.size >= maxEgressConnections) return socket.destroy(); this.sockets.add(socket); socket.once("close", () => this.sockets.delete(socket)); });
102
+ }
103
+ /** `start` resolves true once the member's mounts are verified; the bridge waits on it before running any member code. */
104
+ static async open(socket: string, policy: NetworkPolicy, model: { host: string; port: number }, start: Promise<boolean>, lookup = systemLookup, isPublic = reachable): Promise<Egress> {
105
+ if (Buffer.byteLength(socket) > 107) throw new Error(`Egress socket path is too long: ${socket}`);
106
+ const egress = new Egress(socket, policy, model, start, lookup, isPublic);
107
+ try { unlinkSync(socket); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
108
+ await new Promise<void>((done, fail) => {
109
+ egress.server.once("error", fail);
110
+ egress.server.listen(socket, () => { try { chmodSync(socket, 0o600); done(); } catch (error) { egress.server.close(); fail(error); } });
111
+ });
112
+ return egress;
113
+ }
114
+ close(): Promise<void> {
115
+ return new Promise(done => {
116
+ for (const socket of this.sockets) socket.destroy();
117
+ this.server.close(() => { try { unlinkSync(this.socket); } catch { /* Already removed. */ } done(); });
118
+ });
119
+ }
120
+ private permitted(host: string, port: number): boolean {
121
+ return (host === this.model.host && port === this.model.port) || allowed(this.policy, host, port);
122
+ }
123
+ /** Resolves and validates every address, then returns one to connect to, so DNS cannot change between check and connect. */
124
+ private async resolve(host: string): Promise<string> {
125
+ const addresses = isIP(host) ? [host] : await this.lookup(host);
126
+ if (!addresses.length || !addresses.every(address => this.isPublic(address))) throw new Error("Destination is not a public address");
127
+ return addresses[0];
128
+ }
129
+ private async tunnel(req: IncomingMessage, client: Socket, head: Buffer): Promise<void> {
130
+ const target = parseAuthority(req.url ?? "");
131
+ const refuse = (status: string) => { client.end(`HTTP/1.1 ${status}\r\nConnection: close\r\n\r\n`); };
132
+ if (!target) return refuse("400 Bad Request");
133
+ if (!this.permitted(target.host, target.port)) return refuse("403 Forbidden");
134
+ let address: string;
135
+ try { address = await this.resolve(target.host); } catch { return refuse("403 Forbidden"); }
136
+ const upstream = connect({ host: address, port: target.port, timeout: 10000 });
137
+ let established = false;
138
+ upstream.once("timeout", () => upstream.destroy());
139
+ upstream.once("connect", () => {
140
+ established = true;
141
+ upstream.setTimeout(300000);
142
+ client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
143
+ // Bytes the client sent right after CONNECT (e.g. a TLS ClientHello) arrive in `head`.
144
+ if (head.length) upstream.write(head);
145
+ upstream.pipe(client).pipe(upstream);
146
+ });
147
+ // Once tunnelled, the stream belongs to the client's protocol; an HTTP error response would corrupt it.
148
+ upstream.on("error", () => { if (established) client.destroy(); else if (!client.destroyed) refuse("502 Bad Gateway"); });
149
+ client.on("error", () => upstream.destroy());
150
+ client.once("close", () => upstream.destroy());
151
+ }
152
+ private async forward(req: IncomingMessage, res: ServerResponse): Promise<void> {
153
+ const fail = (status: number) => { if (!res.headersSent) { res.writeHead(status, { "content-type": "text/plain", connection: "close" }); res.end(); } else res.destroy(); };
154
+ // Start gate for the in-container bridge: answered once, after mount verification.
155
+ if (req.url === "http://ac-teams.invalid/start") {
156
+ if (this.started) return fail(409);
157
+ this.started = true;
158
+ return (await this.start.catch(() => false)) ? (res.writeHead(200), void res.end("go")) : fail(403);
159
+ }
160
+ let url: URL;
161
+ try { url = new URL(req.url ?? ""); } catch { return fail(400); }
162
+ const target = url.protocol === "http:" && !url.username && !url.password ? parseAuthority(url.host, 80) : undefined;
163
+ if (!target) return fail(400);
164
+ if (!this.permitted(target.host, target.port)) return fail(403);
165
+ let address: string;
166
+ try { address = await this.resolve(target.host); } catch { return fail(403); }
167
+ const headers: Record<string, string | string[]> = {};
168
+ for (const [name, value] of Object.entries(req.headers)) if (value !== undefined && !/^(proxy-|connection$|keep-alive$|te$|trailer$|upgrade$|host$)/i.test(name)) headers[name] = value;
169
+ headers.host = url.host;
170
+ const upstream = httpRequest({ host: address, port: target.port, method: req.method, path: url.pathname + url.search, headers, timeout: 300000 }, response => {
171
+ res.writeHead(response.statusCode ?? 502, response.headers);
172
+ response.pipe(res);
173
+ });
174
+ upstream.on("timeout", () => upstream.destroy());
175
+ upstream.on("error", () => fail(502));
176
+ res.once("close", () => { if (!res.writableFinished) upstream.destroy(); });
177
+ req.pipe(upstream);
178
+ }
179
+ }
180
+
181
+ /** Providers whose current-token auth ac-teams can hand to a member; the endpoint stays reachable under any policy. */
182
+ export const providerRoutes: Record<string, { api: string; baseUrl: string }> = {
183
+ anthropic: { api: "anthropic-messages", baseUrl: "https://api.anthropic.com" },
184
+ openai: { api: "openai-responses", baseUrl: "https://api.openai.com/v1" },
185
+ };
186
+ /** Resolves the provider's current access token in the trusted parent, which owns OAuth refresh. */
187
+ export type Credentials = (provider: string, model: string) => Promise<{ token: string; expires: number }>;
package/src/index.ts CHANGED
@@ -12,6 +12,8 @@ import { join } from "node:path";
12
12
  import { Coordinator, assistantEvent, taskRequestGuidance } from "./coordinator.ts";
13
13
  import { Connection, isRecord } from "./connection.ts";
14
14
  import { policy, readDisplaySettings, defaultDisplaySettings } from "./config.ts";
15
+ import { providerRoutes } from "./egress.ts";
16
+ import { VERSION, readStoredCredential } from "@earendil-works/pi-coding-agent";
15
17
 
16
18
  export default function teams(pi: ExtensionAPI) {
17
19
  pi.registerFlag("teams-limit", { description: "Explicit maximum live ac-teams members (default 10, idle members count)", type: "string" });
@@ -42,10 +44,22 @@ export default function teams(pi: ExtensionAPI) {
42
44
  if (exiting) throw new Error("Team is stopping");
43
45
  context = ctx;
44
46
  opening ??= Coordinator.open({
45
- id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, sessionFile: ctx.sessionManager.getSessionFile(),
47
+ id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, sessionFile: ctx.sessionManager.getSessionFile(), sessionDir: ctx.sessionManager.getSessionDir(),
46
48
  ...policy(ctx.cwd, pi.getFlag("teams-project-config") === true, { limit: pi.getFlag("teams-limit") as string | undefined, depth: pi.getFlag("teams-depth") as string | undefined }),
47
- extension: fileURLToPath(import.meta.url), notice: notify, warning: warn,
49
+ extension: fileURLToPath(import.meta.url), notice: notify, warning: warn, piVersion: VERSION,
48
50
  status: (kind, active) => pi.events.emit(`herdr:${kind}`, { active, source: "ac-teams" }),
51
+ credentials: async (provider, modelId) => {
52
+ // Only providers whose request shape was verified are brokered; anything else fails closed.
53
+ const registry = (context ?? ctx).modelRegistry, model = registry.find(provider, modelId), route = providerRoutes[provider];
54
+ if (!model || !route || model.api !== route.api || model.baseUrl !== route.baseUrl || !registry.isUsingOAuth(model)) throw new Error(`Enforced access supports only OAuth ${Object.keys(providerRoutes).join("/")} models at their default endpoints, not ${provider}/${modelId}`);
55
+ const auth = await registry.getApiKeyAndHeaders(model);
56
+ if (!auth.ok) throw new Error(auth.error);
57
+ if (!auth.apiKey || auth.baseUrl || Object.keys(auth.headers ?? {}).length || Object.keys(auth.env ?? {}).length) throw new Error(`Unsupported credential shape for ${provider}`);
58
+ // The member gets the token's real expiry; an unknown expiry is kept short so the parent's rewrite stays ahead of it.
59
+ const stored = readStoredCredential(provider) as { type?: string; access?: string; expires?: number } | undefined;
60
+ const expires = stored?.type === "oauth" && stored.access === auth.apiKey && typeof stored.expires === "number" ? stored.expires : Date.now() + 10 * 60000;
61
+ return { token: auth.apiKey, expires };
62
+ },
49
63
  interactive: ctx.mode === "tui", model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined, thinking: pi.getThinkingLevel(),
50
64
  }).then(value => {
51
65
  coordinator = value;
@@ -0,0 +1,33 @@
1
+ // Trusted bootstrap inside a member container. It exposes the parent's egress proxy on loopback, then waits for the
2
+ // parent to verify the container's mounts before running any member code (Pi, extensions, project files).
3
+ import { spawn } from "node:child_process";
4
+ import { readFileSync } from "node:fs";
5
+ import { request } from "node:http";
6
+ import { connect, createServer } from "node:net";
7
+ import { constants } from "node:os";
8
+
9
+ const launch = JSON.parse(readFileSync(process.argv[2], "utf8"));
10
+ const fail = message => { console.error(`ac-teams sandbox bridge: ${message}`); process.exit(1); };
11
+ const server = createServer(client => {
12
+ const upstream = connect(launch.socket);
13
+ client.pipe(upstream).pipe(client);
14
+ client.on("error", () => upstream.destroy());
15
+ upstream.on("error", () => client.destroy());
16
+ });
17
+ server.on("error", error => fail(error.message));
18
+ server.listen(launch.port, "127.0.0.1", () => {
19
+ const gate = request({ socketPath: launch.socket, path: "http://ac-teams.invalid/start", method: "GET" }, response => {
20
+ let body = "";
21
+ response.on("data", chunk => { body += chunk; });
22
+ response.on("end", () => {
23
+ if (response.statusCode !== 200 || body !== "go") fail("the parent did not release this member");
24
+ const child = spawn(launch.command, launch.args, { env: launch.env, stdio: "inherit" });
25
+ for (const signal of ["SIGTERM", "SIGHUP"]) process.on(signal, () => child.kill(signal));
26
+ process.on("SIGINT", () => {}); // The terminal already delivers Ctrl+C to Pi's process group.
27
+ child.on("error", error => fail(error.message));
28
+ child.on("exit", (code, signal) => process.exit(code ?? (signal ? 128 + (constants.signals[signal] ?? 0) : 1)));
29
+ });
30
+ });
31
+ gate.on("error", error => fail(error.message));
32
+ gate.end();
33
+ });
@@ -0,0 +1,8 @@
1
+ # Image for enforced ac-teams members. Host code, grants, and Pi packages are mounted at run time.
2
+ FROM node:22-bookworm-slim@sha256:4b150b48b4116e2a22f2c39cc27025fbf4f153e2078fd3b14d159fdb5c7279da
3
+ ARG PI_VERSION
4
+ RUN apt-get update \
5
+ && apt-get install -y --no-install-recommends bash ca-certificates curl git ripgrep \
6
+ && rm -rf /var/lib/apt/lists/* \
7
+ && npm install -g --ignore-scripts "@earendil-works/pi-coding-agent@${PI_VERSION}" \
8
+ && npm cache clean --force
package/src/schemas.ts CHANGED
@@ -13,6 +13,13 @@ export const AgentSchema = Type.Object({
13
13
  worktree: Type.Optional(Type.Boolean()), base: Type.Optional(id), target: Type.Optional(id),
14
14
  limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
15
15
  depth: Type.Optional(Type.Integer({ minimum: 0, maximum: 10 })),
16
+ access: Type.Optional(Type.Object({
17
+ read: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 4096 }), { maxItems: 32 })),
18
+ write: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 4096 }), { maxItems: 32 })),
19
+ network: Type.Optional(Type.Object({
20
+ allow: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 260 }), { maxItems: 64, description: "Hosts the member may reach through its proxy: host or *.host (subdomains), optionally :port; without a port, 80 and 443. [] allows only the model provider. Omit allow for any public destination." })),
21
+ }, { additionalProperties: false, description: "Omit to keep the requester's network access. Children can only narrow it. The model provider stays reachable." })),
22
+ }, { additionalProperties: false, description: "Launch only: OS-enforced directory access (Docker container). Paths are absolute or cwd-relative directories; write implies read and permits changing those live files; everything else on the host is hidden, and outbound access goes through a proxy that reaches only public addresses. Omit to inherit the requesting member's access; an unsandboxed requester then launches an unsandboxed member. Children can only narrow access. Launch fails if enforcement is unavailable; check team_agent list/limits sandbox first." })),
16
23
  }, { additionalProperties: false });
17
24
  export const TaskSchema = Type.Object({
18
25
  revision: Type.Optional(Type.Integer({ minimum: 1 })), acceptance: Type.Optional(Type.Boolean()),
@@ -56,5 +63,6 @@ export function validate(operation: string, value: unknown): void {
56
63
  const args = value as { action: string; name?: string };
57
64
  if (args.action === "launch" && !args.name) throw new Error("A descriptive teammate name is required on launch");
58
65
  if (args.action !== "launch" && args.name !== undefined) throw new Error("A teammate name can only be set on launch");
66
+ if (args.action !== "launch" && (value as { access?: unknown }).access !== undefined) throw new Error("Access can only be set on launch; resume keeps the member's access");
59
67
  }
60
68
  }
package/src/store.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
2
  import { dirname, join, resolve } from "node:path";
4
3
  import { randomUUID } from "node:crypto";
4
+ import { stateRoot } from "./config.ts";
5
+ import { validatePolicy } from "./egress.ts";
5
6
  import { stripVTControlCharacters } from "node:util";
6
7
  import { isDescendant, isLive, memberNamePattern, memberNameMaxLength, taskLabelMaxLength, taskLabelPattern } from "./types.ts";
7
8
  import type { TeamState, TaskArgs, MessageArgs, Task, TaskView, Message, Worktree } from "./types.ts";
@@ -85,7 +86,7 @@ function validate(state: unknown): asserts state is TeamState {
85
86
  names.add(member.name);
86
87
  }
87
88
  requireValue(member.parent === undefined || (text(member.parent) && Object.hasOwn(members, member.parent)), "Invalid member parent");
88
- for (const field of ["identity", "sessionId", "sessionFile", "error", "model", "modelName", "thinking", "activity", "summary"]) requireValue(member[field] === undefined || typeof member[field] === "string", "Invalid member field");
89
+ for (const field of ["identity", "sessionId", "sessionFile", "error", "model", "modelName", "thinking", "activity", "summary", "provider"]) requireValue(member[field] === undefined || typeof member[field] === "string", "Invalid member field");
89
90
  if (member.contextUsage !== undefined) {
90
91
  const usage = member.contextUsage;
91
92
  requireValue(object(usage) && integer(usage.contextWindow, 1) && (usage.tokens === null || integer(usage.tokens)) && (usage.percent === null || typeof usage.percent === "number" && Number.isFinite(usage.percent) && usage.percent >= 0), "Invalid member context usage");
@@ -94,6 +95,9 @@ function validate(state: unknown): asserts state is TeamState {
94
95
  requireValue(member.needsInput === undefined || typeof member.needsInput === "boolean", "Invalid member input flag");
95
96
  requireValue(member.pid === undefined || integer(member.pid, 1), "Invalid member PID");
96
97
  requireValue(member.worktree === undefined || validateWorktree(member.worktree), "Invalid member worktree");
98
+ const paths = (value: unknown) => Array.isArray(value) && value.every(path => typeof path === "string" && path.startsWith("/"));
99
+ const network = (value: unknown) => { try { return object(value) && (value.allow === undefined || (Array.isArray(value.allow) && value.allow.every(entry => typeof entry === "string"))) && (validatePolicy(value), true); } catch { return false; } };
100
+ requireValue(member.access === undefined || (object(member.access) && paths(member.access.read) && paths(member.access.write) && network(member.access.network)), "Invalid member access");
97
101
  const seen = new Set<string>([key]);
98
102
  for (let parent: unknown = member.parent; parent !== undefined;) {
99
103
  requireValue(typeof parent === "string" && !seen.has(parent), "Cyclic member ancestry");
@@ -158,7 +162,7 @@ export class Store {
158
162
  private lock?: number;
159
163
  private closed = false;
160
164
 
161
- constructor(initial: TeamState, home = join(homedir(), ".acstack", "ac-teams", "teams")) {
165
+ constructor(initial: TeamState, home = join(stateRoot(), "teams")) {
162
166
  validate(initial);
163
167
  this.dir = join(resolve(home), initial.id);
164
168
  this.state = structuredClone(initial);
package/src/types.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Access, AccessRequest } from "./access.ts";
1
2
  export interface Worktree {
2
3
  repo: string;
3
4
  path: string;
@@ -39,6 +40,9 @@ export interface Member {
39
40
  summary?: string;
40
41
  terminal?: boolean;
41
42
  needsInput?: boolean;
43
+ access?: Access;
44
+ /** Provider fixed at an enforced member's first launch; member telemetry cannot change it. */
45
+ provider?: string;
42
46
  }
43
47
  export const memberNameMaxLength = 40;
44
48
  export const memberNamePattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)?(?![\s\S])/;
@@ -113,6 +117,7 @@ export interface AgentArgs {
113
117
  target?: string;
114
118
  limit?: number;
115
119
  depth?: number;
120
+ access?: AccessRequest;
116
121
  }
117
122
  export interface UserAction {
118
123
  action: "task_request" | "feedback" | "resume";