@ours.network/fleet 0.4.0 → 0.5.1

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/LICENSE CHANGED
@@ -6,7 +6,7 @@ FSL-1.1-Apache-2.0
6
6
 
7
7
  ## Notice
8
8
 
9
- Copyright 2026 ours.network contributors
9
+ Copyright 2026 Adapt Framework Solutions Ltd
10
10
 
11
11
  ## Terms and Conditions
12
12
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # ours-fleet
2
2
 
3
- **Run a fleet of persistent, identity-bound AI agents — across different agent
3
+ **Run a fleet of persistent, securely isolated, identity-bound AI agents — across different agent
4
4
  harnesses — from one declarative file.**
5
5
 
6
6
  ## What is this?
@@ -178,12 +178,51 @@ roles:
178
178
  # mem_palace: false # claude-code: disable memory plugin
179
179
  # permission_mode: dontAsk # claude-code: launch permission mode —
180
180
  # one of default | acceptEdits | plan | dontAsk | bypassPermissions
181
+ isolation: # OS-level sandbox (additive; omit = today's behavior)
182
+ backend: auto # auto | bubblewrap | podman | none (default auto)
183
+ on_unavailable: warn # warn (un-isolated + marker) | strict (refuse) (default warn)
184
+ network: broker # broker | deny | allow | allowlist (default broker)
185
+ fs: { read: [/opt/toolchains], write: [] } # extra binds (state dir + cwd always included)
186
+ resources: { mem: 2G, cpu: "1.5", pids: 512 }
187
+ secrets: ["/host/tok:/run/secrets/tok"] # host:container, mounted read-only
181
188
  ```
182
189
 
183
190
  Merge order: `fleet.yaml` ← `fleet.d/*.yaml`; a duplicate role name is a hard
184
191
  error naming both files. Identities and roles are decoupled — removing a role
185
192
  never deletes an identity.
186
193
 
194
+ ## Agent isolation
195
+
196
+ Each role can be sandboxed at the environment level via an `isolation:` block —
197
+ **fully additive: a role with no block behaves exactly as before.** The agent's
198
+ tmux-pane process is wrapped in [bubblewrap](https://github.com/containers/bubblewrap)
199
+ (rootless, no setuid), resource-limited by `systemd-run --user --scope`.
200
+
201
+ An empty `isolation: {}` gives a sensible default posture: filesystem-confined to
202
+ the state dir + `cwd`, the ours key store / other agents' state / `~/.ssh` / `~/.aws`
203
+ all invisible, ours messaging still works, no hard resource caps.
204
+
205
+ - **`backend`** — `auto` (bubblewrap if usable, else degrade per `on_unavailable`),
206
+ or force `bubblewrap` / `none`. (`podman` is planned.)
207
+ - **`on_unavailable`** — `warn` (default, fail-open: run un-isolated, log, and drop a
208
+ `.isolation-degraded` marker in the state dir) or `strict` (fail closed: refuse to launch).
209
+ - **`network`** — `broker` (default; ours messaging works), `deny` (no network),
210
+ `allow` (unrestricted), `allowlist` (planned). *Current status:* `deny` fully
211
+ unshares the network; `broker` keeps host networking so the loopback ours daemon
212
+ stays reachable — full broker egress-hardening is a follow-up.
213
+ - **`fs.read` / `fs.write`** — extra read-only / read-write binds on top of the durable set.
214
+ - **`resources`** — `mem` (→ `MemoryMax` + `MemorySwapMax=0`, a hard OOM bound),
215
+ `cpu` cores (→ `CPUQuota`), `pids` (→ `TasksMax`). CPU degrades to a warning if the
216
+ cpu cgroup controller isn't delegated (mem/pids still enforced).
217
+ - **`secrets`** — `host:container` pairs, mounted read-only; the only way host files
218
+ enter the sandbox.
219
+
220
+ `ours-fleet doctor` reports bubblewrap availability, cgroup delegation, and each
221
+ role's effective isolation; `ours-fleet config` prints a per-role isolation summary.
222
+ Isolation composes with `model`, `permission_mode`, and `ROUTINES.md`. See
223
+ [SECURITY.md](SECURITY.md#agent-isolation-sandboxing) for the threat model and the
224
+ rootless prerequisites.
225
+
187
226
  ## Development
188
227
 
189
228
  ```sh
@@ -202,8 +241,33 @@ software built by a small independent team, running the broker and relay service
202
241
  at their own cost. If this is useful to you, please consider chipping in:
203
242
  **→ https://github.com/adapt-toolkit/ours-donate**
204
243
 
205
- ## License
244
+ ## Licence, status & warranty
245
+
246
+ > **Alpha software.** ours-fleet is part of **ours.network**, which is early,
247
+ > experimental, **alpha-stage** software. It is under active development, its
248
+ > behaviour and interfaces may change without notice, and it is **not
249
+ > production-ready**.
250
+
251
+ > **No warranty / not security-audited.** ours.network has **not** been
252
+ > independently security-audited. It is provided **"as is", without warranty of
253
+ > any kind**, and you use it **at your own risk**. See [`LICENSE`](LICENSE) and
254
+ > [`SECURITY.md`](SECURITY.md).
255
+
256
+ **ours.network** is owned and licensed by **Adapt Framework Solutions Ltd**. It
257
+ is released under the **Functional Source License, Version 1.1
258
+ ([FSL-1.1-Apache-2.0](LICENSE))** — **source-available, not open source** during
259
+ the FSL period. Each release **converts to Apache 2.0 two years after it is
260
+ published**.
261
+
262
+ The FSL permits any use **except a Competing Use** — broadly, offering a
263
+ commercial product or service that substitutes for, or provides substantially
264
+ the same functionality as, ours.network. Competing/commercial use requires a
265
+ separate **commercial licence** from Adapt Framework Solutions Ltd — see
266
+ [`COMMERCIAL-LICENCE.md`](COMMERCIAL-LICENCE.md) (contact:
267
+ **license@adaptframework.solutions**).
268
+
269
+ ours.network builds on Adapt Framework Solutions Ltd's own FSL-licensed core (the
270
+ `@adapt-toolkit` packages); **Adapt itself is not part of this release** and is
271
+ licensed separately.
206
272
 
207
- [FSL-1.1-Apache-2.0](LICENSE) Functional Source License, converting to
208
- Apache-2.0 two years after each release. Free for any use except offering a
209
- competing product or service. Copyright 2026 ours.network contributors.
273
+ Copyright 2026 Adapt Framework Solutions Ltd.
package/dist/cli.js CHANGED
@@ -56,6 +56,16 @@ cOpt(program.command('config').description('validate + print the merged plan (no
56
56
  console.log(` mission: ${r.mission.split('\n')[0]}`);
57
57
  if (r.oversee?.length)
58
58
  console.log(` oversees: ${r.oversee.map(o => `${o.role}@${o.interval}`).join(', ')}`);
59
+ if (r.isolation) {
60
+ const iso = r.isolation;
61
+ const caps = [
62
+ iso.resources?.mem && `mem=${iso.resources.mem}`,
63
+ iso.resources?.cpu && `cpu=${iso.resources.cpu}`,
64
+ iso.resources?.pids !== undefined && `pids=${iso.resources.pids}`,
65
+ ].filter(Boolean).join(',') || 'none';
66
+ console.log(` isolation: backend=${iso.backend ?? 'auto'} net=${iso.network ?? 'broker'} `
67
+ + `on_unavailable=${iso.on_unavailable ?? 'warn'} caps=${caps}`);
68
+ }
59
69
  }
60
70
  }
61
71
  catch (e) {
package/dist/config.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { IsolationConfig } from './isolation/types.js';
1
2
  export interface OverseeEntry {
2
3
  role: string;
3
4
  interval: string;
@@ -17,6 +18,7 @@ export interface RoleConfig {
17
18
  env?: Record<string, string>;
18
19
  oversee?: OverseeEntry[];
19
20
  harness_options?: Record<string, unknown>;
21
+ isolation?: IsolationConfig;
20
22
  }
21
23
  export interface ResolvedRole extends RoleConfig {
22
24
  name: string;
package/dist/config.js CHANGED
@@ -2,12 +2,14 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { parse } from 'yaml';
4
4
  import { defaultConfigPath, fleetDDir } from './paths.js';
5
+ import { validateIsolationConfig } from './isolation/policy.js';
5
6
  export class ConfigError extends Error {
6
7
  }
7
8
  const NAME_RE = /^[A-Za-z0-9_-]+$/;
8
9
  const ROLE_KEYS = [
9
10
  'harness', 'identity', 'cwd', 'coordinator', 'mission', 'persona', 'bio',
10
11
  'briefing_file', 'model', 'max_tokens', 'autocompact_pct', 'env', 'oversee', 'harness_options',
12
+ 'isolation',
11
13
  ];
12
14
  function deepSub(v, vars) {
13
15
  if (typeof v === 'string')
@@ -59,6 +61,12 @@ export function loadConfig(configPath) {
59
61
  const bad = Object.keys(r).filter(k => !ROLE_KEYS.includes(k));
60
62
  if (bad.length)
61
63
  throw new ConfigError(`${file}: role '${name}' has unknown key(s) ${bad.join(', ')}; allowed: ${ROLE_KEYS.join(', ')}`);
64
+ const isolation = r.isolation ?? defaults.isolation;
65
+ if (isolation !== undefined) {
66
+ const problems = validateIsolationConfig(isolation);
67
+ if (problems.length)
68
+ throw new ConfigError(`${file}: role '${name}' ${problems.join('; ')}`);
69
+ }
62
70
  roles.push({
63
71
  ...r,
64
72
  name,
@@ -67,6 +75,7 @@ export function loadConfig(configPath) {
67
75
  identity: r.identity ?? name,
68
76
  model: r.model ?? defaults.model,
69
77
  max_tokens: r.max_tokens ?? defaults.max_tokens,
78
+ isolation,
70
79
  });
71
80
  }
72
81
  }
package/dist/doctor.js CHANGED
@@ -1,7 +1,24 @@
1
1
  import { userInfo } from 'node:os';
2
+ import { readFileSync } from 'node:fs';
2
3
  import { realExec } from './exec.js';
3
4
  import { loadConfig } from './config.js';
4
5
  import { getAdapter } from './harness/registry.js';
6
+ import { agentDir, home } from './paths.js';
7
+ import { resolveIsolation } from './isolation/policy.js';
8
+ import { makeBubblewrapBackend } from './isolation/bubblewrap.js';
9
+ /** Which cgroup-v2 controllers are delegated to this user manager (advisory). */
10
+ function cgroupDelegationDetail() {
11
+ try {
12
+ const uid = process.getuid?.() ?? 0;
13
+ const c = readFileSync(`/sys/fs/cgroup/user.slice/user-${uid}.slice/cgroup.controllers`, 'utf8').split(/\s+/);
14
+ const has = (n) => (c.includes(n) ? 'yes' : 'no');
15
+ return `memory=${has('memory')} pids=${has('pids')} cpu=${has('cpu')}` +
16
+ (c.includes('cpu') ? '' : ' — cpu caps degrade to a warning (one-time: Delegate=cpu)');
17
+ }
18
+ catch {
19
+ return 'unknown (not cgroup-v2 or no delegation info)';
20
+ }
21
+ }
5
22
  /** Host-level + per-harness prerequisite report with actionable messages. */
6
23
  export async function doctor(opts = {}, exec = realExec, platform = process.platform) {
7
24
  const checks = [];
@@ -37,9 +54,47 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
37
54
  : `not enabled — run: ours-fleet init (or: sudo loginctl enable-linger ${user})`,
38
55
  });
39
56
  }
57
+ // Isolation reporting (AC-9). Backend availability is advisory — isolation is
58
+ // opt-in per role (OQ-1), so a missing bwrap must not fail doctor for fleets that
59
+ // don't use it. Only a role that DECLARES isolation and cannot get it under
60
+ // `strict` is a hard failure.
61
+ const roles = loadConfigSafe(opts.configPath);
62
+ const bw = await makeBubblewrapBackend(exec).available();
63
+ checks.push({
64
+ name: 'isolation: bubblewrap', ok: true,
65
+ detail: bw.ok
66
+ ? `available — ${bw.detail}`
67
+ : `not available: ${bw.detail} (only needed for roles declaring isolation:)`,
68
+ });
69
+ if (platform === 'linux')
70
+ checks.push({ name: 'isolation: cgroup delegation', ok: true, detail: cgroupDelegationDetail() });
71
+ for (const r of roles.filter(r => r.isolation)) {
72
+ const stateDir = agentDir(r.name);
73
+ const policy = resolveIsolation(r.isolation, { stateDir, runCwd: r.cwd ?? stateDir, home: home() });
74
+ const caps = [
75
+ policy.resources.mem && `mem=${policy.resources.mem}`,
76
+ policy.resources.cpu && `cpu=${policy.resources.cpu}`,
77
+ policy.resources.pids !== undefined && `pids=${policy.resources.pids}`,
78
+ ].filter(Boolean).join(',') || 'none';
79
+ const wantsBwrap = policy.backend === 'auto' || policy.backend === 'bubblewrap';
80
+ let ok = true, detail;
81
+ if (policy.backend === 'none')
82
+ detail = 'backend=none (explicitly un-sandboxed)';
83
+ else if (wantsBwrap && bw.ok)
84
+ detail = `backend=bubblewrap net=${policy.network} caps=${caps}`;
85
+ else if (wantsBwrap && policy.onUnavailable === 'strict') {
86
+ ok = false;
87
+ detail = 'WILL REFUSE to launch (strict): bubblewrap unavailable';
88
+ }
89
+ else if (wantsBwrap)
90
+ detail = `degraded->un-isolated (warn): bubblewrap unavailable; caps=${caps} still apply`;
91
+ else
92
+ detail = `backend=${policy.backend} (not yet implemented)`;
93
+ checks.push({ name: `isolation: ${r.name}`, ok, detail });
94
+ }
40
95
  const harnesses = opts.harness
41
96
  ? [opts.harness]
42
- : [...new Set(loadConfigSafe(opts.configPath).map(r => r.harness))];
97
+ : [...new Set(roles.map(r => r.harness))];
43
98
  for (const h of harnesses) {
44
99
  try {
45
100
  const rep = await getAdapter(h).checkPrereqs();
@@ -0,0 +1,10 @@
1
+ import { type Exec } from '../exec.js';
2
+ import type { IsolationBackend, NetworkMode } from './types.js';
3
+ /**
4
+ * Phase 2 network policy: only `deny` unshares the network namespace. `broker`
5
+ * keeps the host network so ours messaging (a loopback TCP daemon on this host —
6
+ * see the Phase-0 spike) keeps working; hardening `broker` to `--unshare-net` +
7
+ * a loopback forwarder is Phase 4. `allow`/`allowlist` also keep host net.
8
+ */
9
+ export declare function unsharesNet(network: NetworkMode): boolean;
10
+ export declare function makeBubblewrapBackend(exec?: Exec): IsolationBackend;
@@ -0,0 +1,57 @@
1
+ import { realExec } from '../exec.js';
2
+ /**
3
+ * Phase 2 network policy: only `deny` unshares the network namespace. `broker`
4
+ * keeps the host network so ours messaging (a loopback TCP daemon on this host —
5
+ * see the Phase-0 spike) keeps working; hardening `broker` to `--unshare-net` +
6
+ * a loopback forwarder is Phase 4. `allow`/`allowlist` also keep host net.
7
+ */
8
+ export function unsharesNet(network) {
9
+ return network === 'deny';
10
+ }
11
+ /** Build the `bwrap … -- <argv>` sandbox launcher argv. Pure — no I/O. */
12
+ function wrap(argv, policy, ctx) {
13
+ const out = [
14
+ 'bwrap',
15
+ '--die-with-parent',
16
+ '--unshare-user', '--unshare-ipc', '--unshare-uts', '--unshare-pid',
17
+ '--proc', '/proc',
18
+ '--dev', '/dev',
19
+ '--chdir', ctx.runCwd,
20
+ ];
21
+ // Read-only system dirs (allowlist model): best-effort so a missing /lib64 etc.
22
+ // does not abort the launch.
23
+ for (const s of policy.system)
24
+ out.push('--ro-bind-try', s, s);
25
+ // Ephemeral scratch.
26
+ for (const t of policy.tmpfs)
27
+ out.push('--tmpfs', t);
28
+ // Durable + declared binds. State dir and cwd are runner-guaranteed → hard binds
29
+ // (fail loud if absent); everything else is best-effort.
30
+ for (const m of policy.mounts) {
31
+ const hard = m.src === ctx.stateDir || m.src === ctx.runCwd;
32
+ const flag = m.mode === 'rw'
33
+ ? (hard ? '--bind' : '--bind-try')
34
+ : (hard ? '--ro-bind' : '--ro-bind-try');
35
+ out.push(flag, m.src, m.dst);
36
+ }
37
+ if (unsharesNet(policy.network))
38
+ out.push('--unshare-net');
39
+ out.push('--', ...argv);
40
+ return out;
41
+ }
42
+ export function makeBubblewrapBackend(exec = realExec) {
43
+ return {
44
+ id: 'bubblewrap',
45
+ async available() {
46
+ const v = await exec('bwrap', ['--version']);
47
+ if (v.code !== 0)
48
+ return { ok: false, detail: 'bubblewrap (bwrap) not found on PATH' };
49
+ // userns smoke test: a no-op sandbox that actually creates the namespaces.
50
+ const smoke = await exec('bwrap', ['--ro-bind', '/', '/', '--unshare-user', '--unshare-net', '--', 'true']);
51
+ if (smoke.code !== 0)
52
+ return { ok: false, detail: `bwrap userns smoke test failed: ${smoke.stderr.trim() || `exit ${smoke.code}`}` };
53
+ return { ok: true, detail: v.stdout.trim() };
54
+ },
55
+ wrap,
56
+ };
57
+ }
@@ -0,0 +1,4 @@
1
+ import type { IsolationBackend } from './types.js';
2
+ /** The identity backend: no sandbox. wrap() returns the argv unchanged. Used for
3
+ * `backend: none` and as the fail-open target when `on_unavailable: warn`. */
4
+ export declare function makeNoneBackend(): IsolationBackend;
@@ -0,0 +1,9 @@
1
+ /** The identity backend: no sandbox. wrap() returns the argv unchanged. Used for
2
+ * `backend: none` and as the fail-open target when `on_unavailable: warn`. */
3
+ export function makeNoneBackend() {
4
+ return {
5
+ id: 'none',
6
+ async available() { return { ok: true, detail: 'no isolation (identity backend)' }; },
7
+ wrap(argv) { return argv; },
8
+ };
9
+ }
@@ -0,0 +1,17 @@
1
+ import { type IsolationConfig, type ResolvedIsolation, type WrapContext } from './types.js';
2
+ /**
3
+ * Validate a raw `isolation:` block. Returns a list of human-readable problems
4
+ * (empty ⇒ valid). Pure; callable from config.ts like adapter.validateOptions.
5
+ */
6
+ export declare function validateIsolationConfig(raw: unknown): string[];
7
+ /**
8
+ * Resolve a raw (already validated) isolation block against runtime context into
9
+ * a defaults-filled, backend-agnostic policy. Pure — no I/O, no probing.
10
+ *
11
+ * The mount model is an allowlist: only the durable set (state dir, cwd, Claude
12
+ * config, declared fs/secrets) plus read-only system dirs are exposed; everything
13
+ * else on the host — the ours key store, sibling agent state dirs, ~/.ssh, ~/.aws —
14
+ * is simply never mounted, and thus absent inside the sandbox (§5.2).
15
+ */
16
+ export declare function resolveIsolation(cfg: IsolationConfig, ctx: WrapContext): ResolvedIsolation;
17
+ export type { IsolationConfig };
@@ -0,0 +1,110 @@
1
+ import { join, dirname } from 'node:path';
2
+ import { BACKENDS, ON_UNAVAILABLE, NETWORK_MODES, } from './types.js';
3
+ /** Read-only system dirs exposed under the allowlist model. */
4
+ const SYSTEM_RO = ['/usr', '/bin', '/sbin', '/lib', '/lib64', '/etc'];
5
+ /** Ephemeral scratch mounts. */
6
+ const scratchTmpfs = (home) => ['/tmp', join(home, '.cache')];
7
+ /** Home-relative sensitive paths never exposed (the blocklist's teeth). */
8
+ const SENSITIVE_HOME = ['.ssh', '.aws', '.docker', '.gnupg', '.ours', 'fleet.yaml', 'fleet.d'];
9
+ const ISOLATION_KEYS = ['backend', 'on_unavailable', 'fs', 'network', 'allow_hosts', 'resources', 'secrets'];
10
+ const FS_KEYS = ['read', 'write'];
11
+ const RESOURCE_KEYS = ['cpu', 'mem', 'pids'];
12
+ const unknownKeys = (obj, allowed) => Object.keys(obj).filter(k => !allowed.includes(k));
13
+ const enumProblem = (label, value, allowed) => value === undefined || allowed.includes(value)
14
+ ? null
15
+ : `${label}: invalid value '${value}'; allowed: ${allowed.join(', ')}`;
16
+ /**
17
+ * Validate a raw `isolation:` block. Returns a list of human-readable problems
18
+ * (empty ⇒ valid). Pure; callable from config.ts like adapter.validateOptions.
19
+ */
20
+ export function validateIsolationConfig(raw) {
21
+ const problems = [];
22
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
23
+ return ['isolation: must be a mapping'];
24
+ const iso = raw;
25
+ const bad = unknownKeys(iso, ISOLATION_KEYS);
26
+ if (bad.length)
27
+ problems.push(`isolation: unknown key(s) ${bad.join(', ')}; allowed: ${ISOLATION_KEYS.join(', ')}`);
28
+ for (const p of [
29
+ enumProblem('isolation.backend', iso.backend, BACKENDS),
30
+ enumProblem('isolation.on_unavailable', iso.on_unavailable, ON_UNAVAILABLE),
31
+ enumProblem('isolation.network', iso.network, NETWORK_MODES),
32
+ ])
33
+ if (p)
34
+ problems.push(p);
35
+ if (iso.fs !== undefined) {
36
+ if (typeof iso.fs !== 'object' || iso.fs === null || Array.isArray(iso.fs))
37
+ problems.push('isolation.fs: must be a mapping');
38
+ else {
39
+ const fsBad = unknownKeys(iso.fs, FS_KEYS);
40
+ if (fsBad.length)
41
+ problems.push(`isolation.fs: unknown key(s) ${fsBad.join(', ')}; allowed: ${FS_KEYS.join(', ')}`);
42
+ }
43
+ }
44
+ if (iso.resources !== undefined) {
45
+ if (typeof iso.resources !== 'object' || iso.resources === null || Array.isArray(iso.resources))
46
+ problems.push('isolation.resources: must be a mapping');
47
+ else {
48
+ const rBad = unknownKeys(iso.resources, RESOURCE_KEYS);
49
+ if (rBad.length)
50
+ problems.push(`isolation.resources: unknown key(s) ${rBad.join(', ')}; allowed: ${RESOURCE_KEYS.join(', ')}`);
51
+ }
52
+ }
53
+ return problems;
54
+ }
55
+ /** Parse a `host:container` secret pair; a bare path maps to itself. */
56
+ function parseSecret(pair) {
57
+ const i = pair.indexOf(':');
58
+ const src = i === -1 ? pair : pair.slice(0, i);
59
+ const dst = i === -1 ? pair : pair.slice(i + 1);
60
+ return { src, dst, mode: 'ro' };
61
+ }
62
+ /**
63
+ * Resolve a raw (already validated) isolation block against runtime context into
64
+ * a defaults-filled, backend-agnostic policy. Pure — no I/O, no probing.
65
+ *
66
+ * The mount model is an allowlist: only the durable set (state dir, cwd, Claude
67
+ * config, declared fs/secrets) plus read-only system dirs are exposed; everything
68
+ * else on the host — the ours key store, sibling agent state dirs, ~/.ssh, ~/.aws —
69
+ * is simply never mounted, and thus absent inside the sandbox (§5.2).
70
+ */
71
+ export function resolveIsolation(cfg, ctx) {
72
+ const { stateDir, runCwd, home } = ctx;
73
+ const mounts = [];
74
+ const addRw = (p) => { if (!mounts.some(m => m.src === p))
75
+ mounts.push({ src: p, dst: p, mode: 'rw' }); };
76
+ const addRo = (p) => { if (!mounts.some(m => m.src === p))
77
+ mounts.push({ src: p, dst: p, mode: 'ro' }); };
78
+ // Durable set (always present, rw): state dir, cwd, Claude config.
79
+ addRw(stateDir);
80
+ addRw(runCwd);
81
+ addRw(join(home, '.claude'));
82
+ addRw(join(home, '.claude.json'));
83
+ // Declared fs extras.
84
+ for (const p of cfg.fs?.write ?? [])
85
+ addRw(p);
86
+ for (const p of cfg.fs?.read ?? [])
87
+ addRo(p);
88
+ // Declared secrets (ro, host:container).
89
+ for (const pair of cfg.secrets ?? []) {
90
+ const m = parseSecret(pair);
91
+ if (!mounts.some(x => x.src === m.src && x.dst === m.dst))
92
+ mounts.push(m);
93
+ }
94
+ const agentsRoot = dirname(stateDir);
95
+ const blocklist = [
96
+ ...SENSITIVE_HOME.map(p => join(home, p)),
97
+ agentsRoot, // sibling agents' state dirs (this agent's own is explicitly mounted)
98
+ ];
99
+ return {
100
+ backend: cfg.backend ?? 'auto',
101
+ onUnavailable: cfg.on_unavailable ?? 'warn',
102
+ network: cfg.network ?? 'broker',
103
+ allowHosts: cfg.allow_hosts ?? [],
104
+ resources: cfg.resources ?? {},
105
+ mounts,
106
+ system: SYSTEM_RO,
107
+ tmpfs: scratchTmpfs(home),
108
+ blocklist,
109
+ };
110
+ }
@@ -0,0 +1,24 @@
1
+ import { type Exec } from '../exec.js';
2
+ import type { IsolationBackend, ResolvedIsolation } from './types.js';
3
+ export type { IsolationBackend } from './types.js';
4
+ export { makeBubblewrapBackend, unsharesNet } from './bubblewrap.js';
5
+ export { makeNoneBackend } from './none.js';
6
+ /** Outcome of resolving a policy's `backend:` (incl. `auto`) against host reality. */
7
+ export interface Selection {
8
+ backend: IsolationBackend;
9
+ /** True when the requested backend was unavailable and we fell back to none. */
10
+ degraded: boolean;
11
+ detail: string;
12
+ }
13
+ /**
14
+ * Pick the isolation backend for a resolved policy, honouring `auto` (bwrap-first,
15
+ * rootless — OQ-5) and the `on_unavailable` degradation policy.
16
+ *
17
+ * - `none` → the identity backend.
18
+ * - `bubblewrap` → bwrap if available, else degrade/refuse per on_unavailable.
19
+ * - `podman` → not implemented yet (Phase 6) ⇒ treated as unavailable.
20
+ * - `auto` → bwrap if available, else degrade/refuse.
21
+ *
22
+ * On `on_unavailable: strict` with nothing available, throws (fail closed).
23
+ */
24
+ export declare function selectIsolationBackend(policy: ResolvedIsolation, exec?: Exec): Promise<Selection>;
@@ -0,0 +1,36 @@
1
+ import { realExec } from '../exec.js';
2
+ import { makeBubblewrapBackend } from './bubblewrap.js';
3
+ import { makeNoneBackend } from './none.js';
4
+ export { makeBubblewrapBackend, unsharesNet } from './bubblewrap.js';
5
+ export { makeNoneBackend } from './none.js';
6
+ /**
7
+ * Pick the isolation backend for a resolved policy, honouring `auto` (bwrap-first,
8
+ * rootless — OQ-5) and the `on_unavailable` degradation policy.
9
+ *
10
+ * - `none` → the identity backend.
11
+ * - `bubblewrap` → bwrap if available, else degrade/refuse per on_unavailable.
12
+ * - `podman` → not implemented yet (Phase 6) ⇒ treated as unavailable.
13
+ * - `auto` → bwrap if available, else degrade/refuse.
14
+ *
15
+ * On `on_unavailable: strict` with nothing available, throws (fail closed).
16
+ */
17
+ export async function selectIsolationBackend(policy, exec = realExec) {
18
+ if (policy.backend === 'none')
19
+ return { backend: makeNoneBackend(), degraded: false, detail: 'backend: none' };
20
+ const candidates = [];
21
+ if (policy.backend === 'auto' || policy.backend === 'bubblewrap')
22
+ candidates.push(makeBubblewrapBackend(exec));
23
+ // podman: Phase 6 — no candidate yet, so it falls through to on_unavailable.
24
+ let lastDetail = policy.backend === 'podman'
25
+ ? 'podman backend not implemented yet (Phase 6)'
26
+ : 'no isolation backend available';
27
+ for (const b of candidates) {
28
+ const a = await b.available();
29
+ if (a.ok)
30
+ return { backend: b, degraded: false, detail: a.detail };
31
+ lastDetail = `${b.id} unavailable: ${a.detail}`;
32
+ }
33
+ if (policy.onUnavailable === 'strict')
34
+ throw new Error(`isolation strict mode: refusing to launch un-isolated — ${lastDetail}`);
35
+ return { backend: makeNoneBackend(), degraded: true, detail: lastDetail };
36
+ }
@@ -0,0 +1,17 @@
1
+ import type { IsolationResources } from './types.js';
2
+ export interface ResourceArgs {
3
+ argv: string[];
4
+ warnings: string[];
5
+ }
6
+ /**
7
+ * Build the `systemd-run --user --scope -p … --` prefix that caps the pane's
8
+ * cgroup-v2 scope. Composed OUTSIDE the sandbox wrap (§5.3/§5.4): because tmux
9
+ * panes are children of the shared tmux server rather than the per-role unit, the
10
+ * only reliable per-agent limit is a transient scope at the pane itself.
11
+ *
12
+ * mem/pids are always enforced (their controllers are delegated to `--user` by
13
+ * default). cpu degrades to a warning when the cpu controller is not delegated.
14
+ */
15
+ export declare function resourceArgs(res: IsolationResources, cpuDelegated: boolean): ResourceArgs;
16
+ /** Whether the cpu cgroup-v2 controller is delegated to this user manager. */
17
+ export declare function cpuControllerDelegated(read?: (p: string) => string): boolean;
@@ -0,0 +1,58 @@
1
+ import { readFileSync } from 'node:fs';
2
+ /**
3
+ * Build the `systemd-run --user --scope -p … --` prefix that caps the pane's
4
+ * cgroup-v2 scope. Composed OUTSIDE the sandbox wrap (§5.3/§5.4): because tmux
5
+ * panes are children of the shared tmux server rather than the per-role unit, the
6
+ * only reliable per-agent limit is a transient scope at the pane itself.
7
+ *
8
+ * mem/pids are always enforced (their controllers are delegated to `--user` by
9
+ * default). cpu degrades to a warning when the cpu controller is not delegated.
10
+ */
11
+ export function resourceArgs(res, cpuDelegated) {
12
+ const props = [];
13
+ const warnings = [];
14
+ // MemorySwapMax=0 makes MemoryMax a hard OOM bound: without it, on a host with
15
+ // swap the overflow spills to swap instead of being killed, so the cap is soft
16
+ // and a rogue agent could exhaust host swap.
17
+ if (res.mem)
18
+ props.push(`MemoryMax=${res.mem}`, `MemorySwapMax=0`);
19
+ if (res.pids !== undefined)
20
+ props.push(`TasksMax=${res.pids}`);
21
+ if (res.cpu) {
22
+ const pct = Math.round(parseFloat(res.cpu) * 100);
23
+ if (!Number.isFinite(pct)) {
24
+ warnings.push(`ignoring unparseable cpu value '${res.cpu}'`);
25
+ }
26
+ else if (!cpuDelegated) {
27
+ warnings.push(`cpu cap '${res.cpu}' cores requested but the cpu cgroup controller is not delegated; ` +
28
+ `enforcing mem/pids only (see doctor: one-time Delegate=cpu). CPUQuota skipped.`);
29
+ }
30
+ else {
31
+ props.push(`CPUQuota=${pct}%`);
32
+ }
33
+ }
34
+ if (props.length === 0)
35
+ return { argv: [], warnings };
36
+ const argv = ['systemd-run', '--user', '--scope'];
37
+ for (const p of props)
38
+ argv.push('-p', p);
39
+ argv.push('--');
40
+ return { argv, warnings };
41
+ }
42
+ /** Whether the cpu cgroup-v2 controller is delegated to this user manager. */
43
+ export function cpuControllerDelegated(read = p => readFileSync(p, 'utf8')) {
44
+ // The user manager's own cgroup lists the controllers delegated to it.
45
+ const uid = process.getuid?.() ?? 0;
46
+ const candidates = [
47
+ `/sys/fs/cgroup/user.slice/user-${uid}.slice/cgroup.controllers`,
48
+ `/sys/fs/cgroup/user.slice/user-${uid}.slice/user@${uid}.service/cgroup.controllers`,
49
+ ];
50
+ for (const p of candidates) {
51
+ try {
52
+ if (read(p).split(/\s+/).includes('cpu'))
53
+ return true;
54
+ }
55
+ catch { /* try next */ }
56
+ }
57
+ return false;
58
+ }
@@ -0,0 +1,80 @@
1
+ /** Isolation backend selector. `auto` probes bubblewrap then podman; `none` disables wrapping. */
2
+ export type IsolationBackendId = 'auto' | 'bubblewrap' | 'podman' | 'none';
3
+ /** What to do when the requested backend is unavailable. */
4
+ export type OnUnavailable = 'warn' | 'strict';
5
+ /** Network posture inside the sandbox. */
6
+ export type NetworkMode = 'broker' | 'deny' | 'allow' | 'allowlist';
7
+ export declare const BACKENDS: IsolationBackendId[];
8
+ export declare const ON_UNAVAILABLE: OnUnavailable[];
9
+ export declare const NETWORK_MODES: NetworkMode[];
10
+ export interface IsolationFs {
11
+ /** Extra read-only bind mounts (host paths). */
12
+ read?: string[];
13
+ /** Extra read-write bind mounts (host paths). */
14
+ write?: string[];
15
+ }
16
+ export interface IsolationResources {
17
+ /** Cores, e.g. "1.5" → CPUQuota=150%. */
18
+ cpu?: string;
19
+ /** Memory, e.g. "2G" → MemoryMax=2G. */
20
+ mem?: string;
21
+ /** Max processes → TasksMax. */
22
+ pids?: number;
23
+ }
24
+ /** Raw `isolation:` block as it appears in fleet.yaml (all fields optional). */
25
+ export interface IsolationConfig {
26
+ backend?: IsolationBackendId;
27
+ on_unavailable?: OnUnavailable;
28
+ fs?: IsolationFs;
29
+ network?: NetworkMode;
30
+ allow_hosts?: string[];
31
+ resources?: IsolationResources;
32
+ secrets?: string[];
33
+ }
34
+ /** A single bind mount in the resolved sandbox. */
35
+ export interface Mount {
36
+ src: string;
37
+ dst: string;
38
+ mode: 'ro' | 'rw';
39
+ }
40
+ /**
41
+ * Runtime facts the pure resolver needs to compute the durable mount set (§5.2):
42
+ * the agent's state dir, its working dir, the fleet user's home, and (if the ours
43
+ * broker exposes one) a unix-socket endpoint to bind in.
44
+ */
45
+ export interface WrapContext {
46
+ stateDir: string;
47
+ runCwd: string;
48
+ home: string;
49
+ brokerEndpoint?: string;
50
+ }
51
+ /**
52
+ * The validated, defaults-filled isolation policy consumed by a backend's wrap().
53
+ * Pure output of resolveIsolation — no I/O, no backend probing.
54
+ */
55
+ export interface ResolvedIsolation {
56
+ backend: IsolationBackendId;
57
+ onUnavailable: OnUnavailable;
58
+ network: NetworkMode;
59
+ allowHosts: string[];
60
+ resources: IsolationResources;
61
+ /** rw + ro bind mounts: durable set, fs.* extras, secrets. */
62
+ mounts: Mount[];
63
+ /** read-only system dirs exposed under the allowlist model (/usr, /bin, …). */
64
+ system: string[];
65
+ /** ephemeral scratch tmpfs mounts (/tmp, ~/.cache). */
66
+ tmpfs: string[];
67
+ /** sensitive host paths guaranteed absent from the sandbox (observability). */
68
+ blocklist: string[];
69
+ }
70
+ /** A pluggable isolation backend (bubblewrap, podman, none). */
71
+ export interface IsolationBackend {
72
+ id: 'bubblewrap' | 'podman' | 'none';
73
+ /** Probe host support; feeds `doctor` and `auto` selection. */
74
+ available(): Promise<{
75
+ ok: boolean;
76
+ detail: string;
77
+ }>;
78
+ /** Wrap the agent argv into a sandbox-launcher argv given the resolved policy. */
79
+ wrap(argv: string[], policy: ResolvedIsolation, ctx: WrapContext): string[];
80
+ }
@@ -0,0 +1,3 @@
1
+ export const BACKENDS = ['auto', 'bubblewrap', 'podman', 'none'];
2
+ export const ON_UNAVAILABLE = ['warn', 'strict'];
3
+ export const NETWORK_MODES = ['broker', 'deny', 'allow', 'allowlist'];
package/dist/runner.d.ts CHANGED
@@ -1,15 +1,24 @@
1
1
  import { type ResolvedRole } from './config.js';
2
2
  import type { Launch } from './harness/types.js';
3
3
  import { Tmux } from './tmux.js';
4
+ import { type Exec } from './exec.js';
4
5
  export interface RunnerDeps {
5
6
  tmux: Tmux;
7
+ exec: Exec;
8
+ cpuDelegated(): boolean;
6
9
  isAlive(pid: number): boolean;
7
10
  sleep(ms: number): Promise<void>;
8
11
  now(): number;
9
12
  log(line: string): void;
10
13
  }
11
- /** Compose the tmux pane shell command: env prefix + argv + exit-status capture. */
12
- export declare function buildPaneCommand(launch: Launch, roleEnv: Record<string, string> | undefined, exitStatusPath: string): string;
14
+ /**
15
+ * Compose the tmux pane shell command: env prefix + argv + exit-status capture.
16
+ * `paneArgv` defaults to `launch.argv`; when isolation is active the caller passes
17
+ * the sandbox-wrapped argv (e.g. `bwrap … -- claude …`). The `env` prefix and the
18
+ * `echo $? > exitfile` capture stay host-side, outside the sandbox, so the runner
19
+ * still sees the real exit code.
20
+ */
21
+ export declare function buildPaneCommand(launch: Launch, roleEnv: Record<string, string> | undefined, exitStatusPath: string, paneArgv?: string[]): string;
13
22
  /** Read a temp role's config snapshot written by spawnTemp. */
14
23
  export declare function loadTempRole(name: string): ResolvedRole;
15
24
  /** One supervised session lifecycle. The supervisor re-invokes us after we return. */
package/dist/runner.js CHANGED
@@ -2,13 +2,18 @@ import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync } from 'node
2
2
  import { join } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { parse } from 'yaml';
5
- import { agentDir } from './paths.js';
5
+ import { agentDir, home } from './paths.js';
6
6
  import { loadConfig, findRole } from './config.js';
7
7
  import { getAdapter } from './harness/registry.js';
8
8
  import { Tmux } from './tmux.js';
9
- import { shq } from './exec.js';
9
+ import { realExec, shq } from './exec.js';
10
+ import { resolveIsolation } from './isolation/policy.js';
11
+ import { selectIsolationBackend } from './isolation/registry.js';
12
+ import { resourceArgs, cpuControllerDelegated } from './isolation/resources.js';
10
13
  const defaultDeps = () => ({
11
14
  tmux: new Tmux(),
15
+ exec: realExec,
16
+ cpuDelegated: () => cpuControllerDelegated(),
12
17
  isAlive: pid => { try {
13
18
  process.kill(pid, 0);
14
19
  return true;
@@ -20,11 +25,17 @@ const defaultDeps = () => ({
20
25
  now: () => Date.now(),
21
26
  log: line => process.stderr.write(line + '\n'),
22
27
  });
23
- /** Compose the tmux pane shell command: env prefix + argv + exit-status capture. */
24
- export function buildPaneCommand(launch, roleEnv, exitStatusPath) {
28
+ /**
29
+ * Compose the tmux pane shell command: env prefix + argv + exit-status capture.
30
+ * `paneArgv` defaults to `launch.argv`; when isolation is active the caller passes
31
+ * the sandbox-wrapped argv (e.g. `bwrap … -- claude …`). The `env` prefix and the
32
+ * `echo $? > exitfile` capture stay host-side, outside the sandbox, so the runner
33
+ * still sees the real exit code.
34
+ */
35
+ export function buildPaneCommand(launch, roleEnv, exitStatusPath, paneArgv = launch.argv) {
25
36
  const env = { PATH: process.env.PATH ?? '', ...launch.env, ...(roleEnv ?? {}) };
26
37
  const envPfx = 'env ' + Object.entries(env).map(([k, v]) => `${k}=${shq(v)}`).join(' ');
27
- const cmd = launch.argv.map(shq).join(' ');
38
+ const cmd = paneArgv.map(shq).join(' ');
28
39
  return `${envPfx} ${cmd}; echo $? > ${shq(exitStatusPath)}`;
29
40
  }
30
41
  /** Read a temp role's config snapshot written by spawnTemp. */
@@ -57,9 +68,34 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
57
68
  const runCwd = role.cwd && existsSync(role.cwd) ? role.cwd : dir;
58
69
  const prep = await adapter.prepareSession(role, { stateDir: dir, runCwd });
59
70
  const launch = adapter.buildLaunch(role, mode, { sessionId }, prep);
71
+ // Isolation is additive: only roles that declare `isolation:` are wrapped. The
72
+ // env prefix + exit capture in buildPaneCommand stay host-side (see §5.3).
73
+ let paneArgv = launch.argv;
74
+ if (role.isolation) {
75
+ const ctx = { stateDir: dir, runCwd, home: home() };
76
+ const policy = resolveIsolation(role.isolation, ctx);
77
+ const sel = await selectIsolationBackend(policy, deps.exec); // throws on strict + unavailable
78
+ const degradedMarker = join(dir, '.isolation-degraded');
79
+ if (sel.degraded) {
80
+ deps.log(`[${name}] WARNING isolation requested but unavailable -> running UN-ISOLATED: ${sel.detail}`);
81
+ writeFileSync(degradedMarker, `${new Date().toISOString()} ${sel.detail}\n`);
82
+ }
83
+ else {
84
+ deps.log(`[${name}] isolation: ${sel.backend.id} (net=${policy.network}) ${sel.detail}`);
85
+ rmSync(degradedMarker, { force: true });
86
+ }
87
+ paneArgv = sel.backend.wrap(launch.argv, policy, ctx);
88
+ // Resource caps wrap the sandbox from OUTSIDE, at the pane's own cgroup scope
89
+ // (§5.4). Applies even when the sandbox degraded to none.
90
+ const { argv: rprefix, warnings } = resourceArgs(policy.resources, deps.cpuDelegated());
91
+ for (const w of warnings)
92
+ deps.log(`[${name}] WARNING ${w}`);
93
+ if (rprefix.length)
94
+ paneArgv = [...rprefix, ...paneArgv];
95
+ }
60
96
  rmSync(exitFile, { force: true });
61
97
  await deps.tmux.kill(name);
62
- await deps.tmux.newSession(name, runCwd, buildPaneCommand(launch, role.env, exitFile));
98
+ await deps.tmux.newSession(name, runCwd, buildPaneCommand(launch, role.env, exitFile, paneArgv));
63
99
  let pid = null;
64
100
  for (let i = 0; i < 40 && pid === null; i++) {
65
101
  pid = await deps.tmux.panePid(name);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux consoles, systemd/launchd supervision, ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",