@ours.network/fleet 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
  }
@@ -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,29 @@ 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
+ if (sel.degraded)
79
+ deps.log(`[${name}] WARNING isolation requested but unavailable -> running UN-ISOLATED: ${sel.detail}`);
80
+ else
81
+ deps.log(`[${name}] isolation: ${sel.backend.id} (net=${policy.network}) ${sel.detail}`);
82
+ paneArgv = sel.backend.wrap(launch.argv, policy, ctx);
83
+ // Resource caps wrap the sandbox from OUTSIDE, at the pane's own cgroup scope
84
+ // (§5.4). Applies even when the sandbox degraded to none.
85
+ const { argv: rprefix, warnings } = resourceArgs(policy.resources, deps.cpuDelegated());
86
+ for (const w of warnings)
87
+ deps.log(`[${name}] WARNING ${w}`);
88
+ if (rprefix.length)
89
+ paneArgv = [...rprefix, ...paneArgv];
90
+ }
60
91
  rmSync(exitFile, { force: true });
61
92
  await deps.tmux.kill(name);
62
- await deps.tmux.newSession(name, runCwd, buildPaneCommand(launch, role.env, exitFile));
93
+ await deps.tmux.newSession(name, runCwd, buildPaneCommand(launch, role.env, exitFile, paneArgv));
63
94
  let pid = null;
64
95
  for (let i = 0; i < 40 && pid === null; i++) {
65
96
  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.0",
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",