@cabane/companion 0.6.132 → 0.6.134
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 +30 -10
- package/dist/cli.js +105 -21
- package/dist/pairing-config.js +20 -3
- package/dist/runtime.js +80 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -232,8 +232,9 @@ A `coding` agent needs a directory to run in — a path that only exists on your
|
|
|
232
232
|
{
|
|
233
233
|
// …device identity, managed by `pair`; don't hand-edit those fields…
|
|
234
234
|
|
|
235
|
-
// Per-agent machine-local overrides. Only `cwd
|
|
236
|
-
// everything else about the agent (mode, MCP servers,
|
|
235
|
+
// Per-agent machine-local overrides. Only `cwd`, `prepareHook`, `claudeCode` and
|
|
236
|
+
// `sandbox` live here — everything else about the agent (mode, MCP servers,
|
|
237
|
+
// model) is set in Cabane.
|
|
237
238
|
"agents": {
|
|
238
239
|
"my-coding-agent": {
|
|
239
240
|
"cwd": "/Users/you/code/project", // working directory the agent runs in
|
|
@@ -310,18 +311,37 @@ What the sandbox allows is `runners.policy` in `~/.cabane/config.json`. Every ke
|
|
|
310
311
|
}
|
|
311
312
|
```
|
|
312
313
|
|
|
313
|
-
| Setting | Default
|
|
314
|
-
| ----------------------------------------- |
|
|
315
|
-
| **Hosts**, always reachable | Cabane, and the model provider of the turn's harness (Anthropic for Claude Code, OpenAI for Codex). `allow` and `defaults` never remove these; a `deny` entry naming one does, and blocks the turn from reaching it.
|
|
316
|
-
| **Hosts**, `defaults` | `github.com`, `*.github.com`, `*.githubusercontent.com`, `registry.npmjs.org`, `pypi.org`, `files.pythonhosted.org`, `crates.io`, `static.crates.io`, `index.crates.io`. `allow` adds to them, `deny` removes a host (and wins), `defaults: false` drops the list.
|
|
317
|
-
| **Reads**, `blocklist` mode (the default) | Everything is readable except every companion's store (`~/.cabane`, and any `.cabane` under your home or beside this companion's home; see below), the `.claude` and `.codex` of the other companion homes those stores sit in, `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.config/gh`, `~/.git-credentials`, `~/.netrc`, `~/.npmrc`, `~/.pypirc`, `~/.docker/config.json`, `~/.kube`, `~/.config/gcloud`, `~/.azure`, `~/.config/op`, `~/.password-store`,
|
|
318
|
-
| **Writes** | The working directory, the temp dir, `~/.claude`, `~/.claude.json`, `~/.codex`, plus `allowWrite`.
|
|
314
|
+
| Setting | Default |
|
|
315
|
+
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
316
|
+
| **Hosts**, always reachable | Cabane, and the model provider of the turn's harness (Anthropic for Claude Code, OpenAI for Codex). `allow` and `defaults` never remove these; a `deny` entry naming one does, and blocks the turn from reaching it. |
|
|
317
|
+
| **Hosts**, `defaults` | `github.com`, `*.github.com`, `*.githubusercontent.com`, `registry.npmjs.org`, `pypi.org`, `files.pythonhosted.org`, `crates.io`, `static.crates.io`, `index.crates.io`. `allow` adds to them, `deny` removes a host (and wins), `defaults: false` drops the list. |
|
|
318
|
+
| **Reads**, `blocklist` mode (the default) | Everything is readable except every companion's store (`~/.cabane`, and any `.cabane` under your home or beside this companion's home; see below), the `.claude` and `.codex` of the other companion homes those stores sit in, `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.config/gh`, `~/.git-credentials`, `~/.netrc`, `~/.npmrc`, `~/.pypirc`, `~/.docker/config.json`, `~/.kube`, `~/.config/gcloud`, `~/.azure`, `~/.config/op`, `~/.password-store`, and your `denyRead` paths. `allowRead` reopens a path inside a denied one. |
|
|
319
|
+
| **Writes** | The working directory, the temp dir, `~/.claude`, `~/.claude.json`, `~/.codex`, plus `allowWrite`. |
|
|
320
|
+
|
|
321
|
+
**One agent can be excepted.** The sandbox is set per device, and one agent's row in `agents` can say otherwise — for example an ops agent whose job is the machine itself, on a device whose other agents stay sandboxed:
|
|
322
|
+
|
|
323
|
+
```jsonc
|
|
324
|
+
{
|
|
325
|
+
"runners": {
|
|
326
|
+
"enabled": true,
|
|
327
|
+
"sandbox": "os",
|
|
328
|
+
"policy": {
|
|
329
|
+
/* unchanged */
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
"agents": {
|
|
333
|
+
"014d18a7-6395-48e3-a2f2-313d5baa2a5e": { "sandbox": "none" }, // the ops agent, by its id: it runs with the machine; everyone else stays sandboxed
|
|
334
|
+
},
|
|
335
|
+
}
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
It works the other way too: `"agents": { "<agent id>": { "sandbox": "os" } }` sandboxes one agent on a device that otherwise isn't. **A sandbox row is keyed by the agent's id**, the last part of the agent's settings page URL in Cabane (`/<workspace>/settings/agents/<agent id>`). A username, or `workspace-slug/username`, can match more than one agent: slugs are unique only per workspace owner, so two workspaces this device serves can share one. A `sandbox` row keyed any other way is refused at start, with the field named. `cwd` and `prepareHook` rows still take all three keys. The agent's row wins, then `runners.sandbox`, then no sandbox. The policy stays the device's. Under the `docker` provider the container is the sandbox, so a row's `sandbox` is ignored, and the log says so at start. The exception is set only here: Cabane shows it on the device page and can't change it.
|
|
319
339
|
|
|
320
340
|
Paths may start with `~/` and may be globs. An invalid `policy` doesn't stop the Companion from starting. Instead, every sandboxed turn is refused and the conversation names the field that's wrong, for example `runners.policy.filesystem.mode`.
|
|
321
341
|
|
|
322
342
|
**Other companions on the same machine.** A second companion (another account, a test device) keeps its own secrets in its own `.cabane`, so a turn can't read any companion's store, not only this device's. Every turn denies this companion's store and `~/.cabane` (both, when the Companion runs from a home set with `CABANE_COMPANION_HOME`), plus any `.cabane` up to two levels under your home or beside this companion's home, where companion homes conventionally sit. Stores anywhere deeper are found by a search of your home with `find`, along with the `.claude` and `.codex` beside each one, which hold that companion's harness logins. That search takes seconds over a large home, so the Companion runs it in the background: when it starts, and again whenever a turn starts more than 5 minutes after the last search finished. Only the first turn after a start waits for it. **A store created deeper than two levels is denied within about 5 minutes plus one search, not instantly.** If a search can't finish (`find` is missing, runs past 60 seconds, or hits a directory it can't list), the stores it did find are still denied, and the log says so. The search runs in strict mode too, because strict mode reopens the working directory, and a store inside it must stay hidden.
|
|
323
343
|
|
|
324
|
-
**`.env` files
|
|
344
|
+
**`.env` files are readable.** Beyond the credential locations above, nothing guesses which of your files hold secrets, so `.env` files, in the working directory or any other project, are readable unless you deny them. To hide them, list the roots that hold them, for example `"denyRead": ["~/work/**/.env", "~/work/**/.env.*"]`. Each glob is expanded when the turn starts, which costs a walk of that tree on every turn and misses files created during the turn, so keep the roots narrow. When other projects' secrets really matter, strict mode is the better answer, because it hides them all without a walk.
|
|
325
345
|
|
|
326
346
|
**Strict mode.** `"filesystem": { "mode": "strict" }` hides your whole home directory except the working directory, the harness state (`~/.claude`, `~/.codex`), what the Companion and its harnesses run from, and the paths you list in `allowRead`. Toolchains live all over a home directory (`~/.nvm`, `~/.local/share/pnpm`, `~/.cargo`, `~/.pyenv`, mise or asdf shims), so list yours, or builds will fail with "No such file or directory" on a path you didn't expect. On Linux a hidden path looks missing rather than denied, so the Companion log prints the readable list at the start of each strict turn. Compare it against the path the build couldn't find.
|
|
327
347
|
|
|
@@ -336,7 +356,7 @@ Paths may start with `~/` and may be globs. An invalid `policy` doesn't stop the
|
|
|
336
356
|
|
|
337
357
|
A `gh auth login` session on the machine isn't used: `~/.config/gh` is unreadable in the sandbox. Only the token in `secrets.json` is.
|
|
338
358
|
|
|
339
|
-
`cabane-companion status` prints the effective posture: the filesystem mode, extra hosts, denied hosts and reads,
|
|
359
|
+
`cabane-companion status` prints the effective posture: the filesystem mode, extra hosts, denied hosts and reads, the **names** of masked credentials, never their values, and each agent excepted on its row. The Companion also logs it once at start.
|
|
340
360
|
|
|
341
361
|
## Harnesses
|
|
342
362
|
|
package/dist/cli.js
CHANGED
|
@@ -295,6 +295,28 @@ function localAgentConfig(cfg, agent) {
|
|
|
295
295
|
if (!cfg.prepareHook || row.prepareHook) return row;
|
|
296
296
|
return { ...row, prepareHook: cfg.prepareHook };
|
|
297
297
|
}
|
|
298
|
+
function agentSandbox(cfg, agent) {
|
|
299
|
+
const sandbox = cfg.agents?.[agent.agentId]?.sandbox;
|
|
300
|
+
return sandbox ? { sandbox } : {};
|
|
301
|
+
}
|
|
302
|
+
function runnerSandboxFor(cfg, row, host) {
|
|
303
|
+
const asked = row.sandbox ?? cfg.runners?.sandbox ?? "none";
|
|
304
|
+
if (asked !== "os" || cfg.runners?.provider === "docker") return "none";
|
|
305
|
+
return host.osSandboxUsable() ? "os" : "none";
|
|
306
|
+
}
|
|
307
|
+
function runnerSandboxReport(cfg, agents, host) {
|
|
308
|
+
const device = runnerSandboxFor(cfg, {}, host);
|
|
309
|
+
const sandboxOverrides = agents.flatMap((agent) => {
|
|
310
|
+
const sandbox = runnerSandboxFor(cfg, agentSandbox(cfg, agent), host);
|
|
311
|
+
return sandbox === device ? [] : [{ agentId: agent.agentId, sandbox }];
|
|
312
|
+
});
|
|
313
|
+
return { sandbox: device, sandboxOverrides };
|
|
314
|
+
}
|
|
315
|
+
function agentSandboxRows(cfg) {
|
|
316
|
+
return Object.entries(cfg.agents ?? {}).flatMap(
|
|
317
|
+
([key, row]) => row.sandbox ? [[key, row.sandbox]] : []
|
|
318
|
+
);
|
|
319
|
+
}
|
|
298
320
|
function loadConfig() {
|
|
299
321
|
const path = configPath();
|
|
300
322
|
if (!existsSync(path)) return null;
|
|
@@ -370,7 +392,7 @@ function loadConfigTolerant() {
|
|
|
370
392
|
}
|
|
371
393
|
const obj = parsed && typeof parsed === "object" ? parsed : {};
|
|
372
394
|
const local = {};
|
|
373
|
-
const agents =
|
|
395
|
+
const agents = agentsConfigSchema.safeParse(obj.agents);
|
|
374
396
|
if (agents.success) local.agents = agents.data;
|
|
375
397
|
const prepareHook = prepareHookSchema.safeParse(obj.prepareHook);
|
|
376
398
|
if (prepareHook.success) local.prepareHook = prepareHook.data;
|
|
@@ -426,7 +448,7 @@ function deleteConfig() {
|
|
|
426
448
|
writeFileSync(path, "", { mode: 384 });
|
|
427
449
|
}
|
|
428
450
|
}
|
|
429
|
-
var realHomeCache, localAgentConfigSchema, companionConfigSchema;
|
|
451
|
+
var realHomeCache, localAgentConfigSchema, AGENT_ID_KEY, agentsConfigSchema, companionConfigSchema;
|
|
430
452
|
var init_config = __esm({
|
|
431
453
|
"src/config.ts"() {
|
|
432
454
|
"use strict";
|
|
@@ -443,8 +465,25 @@ var init_config = __esm({
|
|
|
443
465
|
// auto-memory back to your own `~/.claude/settings.json` — Cabane then stops
|
|
444
466
|
// injecting the off switch and your normal Claude Code memory workflow applies
|
|
445
467
|
// (in coding mode, where the checkout's project settings are read).
|
|
446
|
-
claudeCode: z2.object({ autoMemory: z2.boolean().optional() }).strict().optional()
|
|
468
|
+
claudeCode: z2.object({ autoMemory: z2.boolean().optional() }).strict().optional(),
|
|
469
|
+
// CT1693: this agent's exception to `runners.sandbox` — an ops agent that
|
|
470
|
+
// runs with the machine on a sandboxed device, or one sandboxed agent on a
|
|
471
|
+
// device that isn't. Absent is "inherit"; see `runnerSandboxFor`. Only on a
|
|
472
|
+
// row keyed by the agent's id (`agentsConfigSchema`).
|
|
473
|
+
sandbox: z2.enum(["os", "none"]).optional()
|
|
447
474
|
}).strict();
|
|
475
|
+
AGENT_ID_KEY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
476
|
+
agentsConfigSchema = z2.record(z2.string(), localAgentConfigSchema).superRefine((agents, ctx) => {
|
|
477
|
+
for (const [key, row] of Object.entries(agents)) {
|
|
478
|
+
if (row.sandbox === void 0) continue;
|
|
479
|
+
if (AGENT_ID_KEY.test(key)) continue;
|
|
480
|
+
ctx.addIssue({
|
|
481
|
+
code: z2.ZodIssueCode.custom,
|
|
482
|
+
path: [key, "sandbox"],
|
|
483
|
+
message: "a sandbox exception must name one agent \u2014 key the row by the agent's id (shown in Cabane), not a username or workspace-slug/username"
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
});
|
|
448
487
|
companionConfigSchema = z2.object({
|
|
449
488
|
// The cabane instance this device is paired with. SJ515: https-enforced
|
|
450
489
|
// (loopback exempt) so a hand-edited config can't smuggle a plaintext-http
|
|
@@ -465,7 +504,7 @@ var init_config = __esm({
|
|
|
465
504
|
// Optional per-agent machine-local overrides (cwd / prepareHook), keyed by
|
|
466
505
|
// agentId / username / `slug/username`. Hand-added by the operator; the companion
|
|
467
506
|
// never writes this (it only persists credentials + the device, elsewhere).
|
|
468
|
-
agents:
|
|
507
|
+
agents: agentsConfigSchema.optional(),
|
|
469
508
|
// CT1095: the DEVICE-LEVEL prepare hook — one entry that applies to every agent
|
|
470
509
|
// assigned to this device, unless that agent's own `agents` row names a hook of
|
|
471
510
|
// its own.
|
|
@@ -10520,7 +10559,6 @@ function harnessSandboxConfig(input) {
|
|
|
10520
10559
|
...policy.hosts?.allow ?? []
|
|
10521
10560
|
];
|
|
10522
10561
|
const allowedDomains = [.../* @__PURE__ */ new Set([...cabane, ...models, ...reachable])];
|
|
10523
|
-
const envGlobs = input.cwd ? [join18(input.cwd, "**/.env"), join18(input.cwd, "**/.env.*")] : [];
|
|
10524
10562
|
const secrets = [
|
|
10525
10563
|
...companionStoreDenies({
|
|
10526
10564
|
homes,
|
|
@@ -10529,7 +10567,6 @@ function harnessSandboxConfig(input) {
|
|
|
10529
10567
|
ownState: [claudeDir, codexDir]
|
|
10530
10568
|
}),
|
|
10531
10569
|
...homes.flatMap((home) => SECRET_PATHS_UNDER_HOME.map((p) => join18(home, p))),
|
|
10532
|
-
...envGlobs,
|
|
10533
10570
|
...expand(policy.filesystem?.denyRead)
|
|
10534
10571
|
];
|
|
10535
10572
|
const strict = policy.filesystem?.mode === "strict";
|
|
@@ -11652,7 +11689,7 @@ function buildCompanionManifest(opts) {
|
|
|
11652
11689
|
runtimes,
|
|
11653
11690
|
capabilities: { ...DEVICE_MANIFEST.capabilities },
|
|
11654
11691
|
runtimeIssues: opts.runtimeIssues ?? [],
|
|
11655
|
-
...opts.
|
|
11692
|
+
...opts.runner ? { runner: opts.runner } : {}
|
|
11656
11693
|
};
|
|
11657
11694
|
}
|
|
11658
11695
|
function runtimeIssuesFor(gates) {
|
|
@@ -15279,10 +15316,21 @@ var CompanionSupervisor = class {
|
|
|
15279
15316
|
}
|
|
15280
15317
|
this.latchBootBundledState();
|
|
15281
15318
|
}
|
|
15282
|
-
|
|
15319
|
+
const sandboxRows = agentSandboxRows(this.config);
|
|
15320
|
+
if (sandboxRows.length > 0 && this.dockerRunners()) {
|
|
15321
|
+
this.log.warn(
|
|
15322
|
+
{ agents: sandboxRows.map(([key]) => key) },
|
|
15323
|
+
`runners: ${sandboxRows.map(([key]) => `agents.${key}.sandbox`).join(", ")} ignored \u2014 under Docker the container is the sandbox`
|
|
15324
|
+
);
|
|
15325
|
+
}
|
|
15326
|
+
const anySandboxed = [{}, ...sandboxRows.map(([, sandbox]) => ({ sandbox }))].some(
|
|
15327
|
+
(row) => this.runnerSandboxFor(row) === "os"
|
|
15328
|
+
);
|
|
15329
|
+
if (anySandboxed) {
|
|
15283
15330
|
const policy = this.sandboxPolicy();
|
|
15284
15331
|
const posture = postureLines(policy);
|
|
15285
|
-
const
|
|
15332
|
+
const perAgent = sandboxRows.map(([key, sandbox]) => `${key} \u2192 ${sandbox}`).join(", ");
|
|
15333
|
+
const summary = posture.join("; ") + (perAgent ? `; per-agent: ${perAgent}` : "");
|
|
15286
15334
|
if (policy.ok) this.log.info({ posture }, `runners: OS sandbox policy \u2014 ${summary}`);
|
|
15287
15335
|
else this.log.error({ posture }, `runners: ${summary}`);
|
|
15288
15336
|
void this.companionStores().refresh();
|
|
@@ -15396,7 +15444,7 @@ var CompanionSupervisor = class {
|
|
|
15396
15444
|
// (fail-soft to null). Informational only — the server matches on name.
|
|
15397
15445
|
versions: this.harnessVersions,
|
|
15398
15446
|
// CT1681: shown on the device page.
|
|
15399
|
-
...runnersEnabled(this.config) ? {
|
|
15447
|
+
...runnersEnabled(this.config) ? { runner: this.runnerManifest() } : {}
|
|
15400
15448
|
}),
|
|
15401
15449
|
// CT566: echo the last classified credential state per runtime, when the
|
|
15402
15450
|
// companion has seen any. Omitted (undefined) until the first observed
|
|
@@ -15728,6 +15776,9 @@ var CompanionSupervisor = class {
|
|
|
15728
15776
|
// CT1679: `runners.sandbox`, checked against this host once. A host that
|
|
15729
15777
|
// can't make the sandbox says so once and runs its turns unwrapped.
|
|
15730
15778
|
sandboxUsable;
|
|
15779
|
+
sandboxHost = {
|
|
15780
|
+
osSandboxUsable: () => this.sandboxUsable ??= osSandboxUsable(this.log)
|
|
15781
|
+
};
|
|
15731
15782
|
// CT1684: `runners.policy`, parsed once. An invalid one is kept as the
|
|
15732
15783
|
// error, which every sandboxed turn is refused with.
|
|
15733
15784
|
parsedSandboxPolicy;
|
|
@@ -15741,10 +15792,26 @@ var CompanionSupervisor = class {
|
|
|
15741
15792
|
this.storeWatch ??= companionStoreWatch(this.log);
|
|
15742
15793
|
return this.storeWatch;
|
|
15743
15794
|
}
|
|
15744
|
-
|
|
15745
|
-
|
|
15746
|
-
|
|
15747
|
-
|
|
15795
|
+
// CT1693: the sandbox one agent's turns run under — its `agents` row, then
|
|
15796
|
+
// the device's. Resolved from the current config every time it's asked, so
|
|
15797
|
+
// a reload changes it; only the host probe is kept.
|
|
15798
|
+
runnerSandboxFor(row) {
|
|
15799
|
+
return runnerSandboxFor(this.config, row, this.sandboxHost);
|
|
15800
|
+
}
|
|
15801
|
+
// CT1693: what the heartbeat says about this device's runners, resolved
|
|
15802
|
+
// against the agents it runs right now.
|
|
15803
|
+
runnerManifest() {
|
|
15804
|
+
const agents = [...this.workspaces.values()].flatMap(
|
|
15805
|
+
(wr) => [...wr.agents.values()].map((agent) => ({
|
|
15806
|
+
agentId: agent.agentId,
|
|
15807
|
+
agentUsername: agent.username,
|
|
15808
|
+
workspaceSlug: wr.slug
|
|
15809
|
+
}))
|
|
15810
|
+
);
|
|
15811
|
+
return {
|
|
15812
|
+
provider: this.config.runners?.provider ?? "local-process",
|
|
15813
|
+
...runnerSandboxReport(this.config, agents, this.sandboxHost)
|
|
15814
|
+
};
|
|
15748
15815
|
}
|
|
15749
15816
|
// CT1679: one provider for the device, built on first use.
|
|
15750
15817
|
runnerProvider() {
|
|
@@ -15860,7 +15927,7 @@ var CompanionSupervisor = class {
|
|
|
15860
15927
|
reportRunnerGone: (report) => this.reportRunnerGone(report),
|
|
15861
15928
|
connectorHealth: this.connectorHealth,
|
|
15862
15929
|
locks: this.runnerLocks,
|
|
15863
|
-
sandbox: this.
|
|
15930
|
+
sandbox: this.runnerSandboxFor(agentSandbox(this.config, ctx)),
|
|
15864
15931
|
policy: this.sandboxPolicy(),
|
|
15865
15932
|
companionStores: () => this.companionStores().stores()
|
|
15866
15933
|
}
|
|
@@ -17205,19 +17272,36 @@ async function status() {
|
|
|
17205
17272
|
process.stdout.write(`prepare hook: ${cfg.prepareHook.command} (device-level, all agents)
|
|
17206
17273
|
`);
|
|
17207
17274
|
}
|
|
17208
|
-
|
|
17209
|
-
process.stdout.write("sandbox: os\n");
|
|
17210
|
-
for (const line of postureLines(parseSandboxPolicy(cfg.runners.policy))) {
|
|
17211
|
-
process.stdout.write(` ${line}
|
|
17275
|
+
for (const line of sandboxStatusLines(cfg)) process.stdout.write(`${line}
|
|
17212
17276
|
`);
|
|
17213
|
-
}
|
|
17214
|
-
}
|
|
17215
17277
|
const overrides2 = Object.keys(cfg.agents ?? {});
|
|
17216
17278
|
if (overrides2.length > 0) {
|
|
17217
17279
|
process.stdout.write(`local overrides for: ${overrides2.join(", ")}
|
|
17218
17280
|
`);
|
|
17219
17281
|
}
|
|
17220
17282
|
}
|
|
17283
|
+
function sandboxStatusLines(cfg) {
|
|
17284
|
+
const rows = agentSandboxRows(cfg);
|
|
17285
|
+
const device = cfg.runners?.sandbox ?? "none";
|
|
17286
|
+
if (!runnersEnabled(cfg)) {
|
|
17287
|
+
if (rows.length === 0) return [];
|
|
17288
|
+
const fields = rows.map(([key]) => `agents.${key}.sandbox`).join(", ");
|
|
17289
|
+
return [`sandbox: off (runners.enabled is false; ${fields} has no effect)`];
|
|
17290
|
+
}
|
|
17291
|
+
if (cfg.runners?.provider === "docker") {
|
|
17292
|
+
if (rows.length === 0) return [];
|
|
17293
|
+
const fields = rows.map(([key]) => `agents.${key}.sandbox`).join(", ");
|
|
17294
|
+
return [`sandbox: docker (the container is the sandbox; ${fields} is ignored)`];
|
|
17295
|
+
}
|
|
17296
|
+
if (device !== "os" && !rows.some(([, sandbox]) => sandbox === "os")) return [];
|
|
17297
|
+
return [
|
|
17298
|
+
`sandbox: ${device}`,
|
|
17299
|
+
...postureLines(parseSandboxPolicy(cfg.runners?.policy)).map((line) => ` ${line}`),
|
|
17300
|
+
...rows.map(
|
|
17301
|
+
([key, sandbox]) => ` ${key}: ${sandbox} (this agent runs ${sandbox === "os" ? "sandboxed" : "unsandboxed"})`
|
|
17302
|
+
)
|
|
17303
|
+
];
|
|
17304
|
+
}
|
|
17221
17305
|
function transcriptsLine() {
|
|
17222
17306
|
const dir2 = transcriptsDir();
|
|
17223
17307
|
let count = 0;
|
package/dist/pairing-config.js
CHANGED
|
@@ -129,8 +129,25 @@ var localAgentConfigSchema = z2.object({
|
|
|
129
129
|
// auto-memory back to your own `~/.claude/settings.json` — Cabane then stops
|
|
130
130
|
// injecting the off switch and your normal Claude Code memory workflow applies
|
|
131
131
|
// (in coding mode, where the checkout's project settings are read).
|
|
132
|
-
claudeCode: z2.object({ autoMemory: z2.boolean().optional() }).strict().optional()
|
|
132
|
+
claudeCode: z2.object({ autoMemory: z2.boolean().optional() }).strict().optional(),
|
|
133
|
+
// CT1693: this agent's exception to `runners.sandbox` — an ops agent that
|
|
134
|
+
// runs with the machine on a sandboxed device, or one sandboxed agent on a
|
|
135
|
+
// device that isn't. Absent is "inherit"; see `runnerSandboxFor`. Only on a
|
|
136
|
+
// row keyed by the agent's id (`agentsConfigSchema`).
|
|
137
|
+
sandbox: z2.enum(["os", "none"]).optional()
|
|
133
138
|
}).strict();
|
|
139
|
+
var AGENT_ID_KEY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
140
|
+
var agentsConfigSchema = z2.record(z2.string(), localAgentConfigSchema).superRefine((agents, ctx) => {
|
|
141
|
+
for (const [key, row] of Object.entries(agents)) {
|
|
142
|
+
if (row.sandbox === void 0) continue;
|
|
143
|
+
if (AGENT_ID_KEY.test(key)) continue;
|
|
144
|
+
ctx.addIssue({
|
|
145
|
+
code: z2.ZodIssueCode.custom,
|
|
146
|
+
path: [key, "sandbox"],
|
|
147
|
+
message: "a sandbox exception must name one agent \u2014 key the row by the agent's id (shown in Cabane), not a username or workspace-slug/username"
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
});
|
|
134
151
|
var companionConfigSchema = z2.object({
|
|
135
152
|
// The cabane instance this device is paired with. SJ515: https-enforced
|
|
136
153
|
// (loopback exempt) so a hand-edited config can't smuggle a plaintext-http
|
|
@@ -151,7 +168,7 @@ var companionConfigSchema = z2.object({
|
|
|
151
168
|
// Optional per-agent machine-local overrides (cwd / prepareHook), keyed by
|
|
152
169
|
// agentId / username / `slug/username`. Hand-added by the operator; the companion
|
|
153
170
|
// never writes this (it only persists credentials + the device, elsewhere).
|
|
154
|
-
agents:
|
|
171
|
+
agents: agentsConfigSchema.optional(),
|
|
155
172
|
// CT1095: the DEVICE-LEVEL prepare hook — one entry that applies to every agent
|
|
156
173
|
// assigned to this device, unless that agent's own `agents` row names a hook of
|
|
157
174
|
// its own.
|
|
@@ -343,7 +360,7 @@ function loadConfigTolerant() {
|
|
|
343
360
|
}
|
|
344
361
|
const obj = parsed && typeof parsed === "object" ? parsed : {};
|
|
345
362
|
const local = {};
|
|
346
|
-
const agents =
|
|
363
|
+
const agents = agentsConfigSchema.safeParse(obj.agents);
|
|
347
364
|
if (agents.success) local.agents = agents.data;
|
|
348
365
|
const prepareHook = prepareHookSchema.safeParse(obj.prepareHook);
|
|
349
366
|
if (prepareHook.success) local.prepareHook = prepareHook.data;
|
package/dist/runtime.js
CHANGED
|
@@ -260,8 +260,25 @@ var localAgentConfigSchema = z2.object({
|
|
|
260
260
|
// auto-memory back to your own `~/.claude/settings.json` — Cabane then stops
|
|
261
261
|
// injecting the off switch and your normal Claude Code memory workflow applies
|
|
262
262
|
// (in coding mode, where the checkout's project settings are read).
|
|
263
|
-
claudeCode: z2.object({ autoMemory: z2.boolean().optional() }).strict().optional()
|
|
263
|
+
claudeCode: z2.object({ autoMemory: z2.boolean().optional() }).strict().optional(),
|
|
264
|
+
// CT1693: this agent's exception to `runners.sandbox` — an ops agent that
|
|
265
|
+
// runs with the machine on a sandboxed device, or one sandboxed agent on a
|
|
266
|
+
// device that isn't. Absent is "inherit"; see `runnerSandboxFor`. Only on a
|
|
267
|
+
// row keyed by the agent's id (`agentsConfigSchema`).
|
|
268
|
+
sandbox: z2.enum(["os", "none"]).optional()
|
|
264
269
|
}).strict();
|
|
270
|
+
var AGENT_ID_KEY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
271
|
+
var agentsConfigSchema = z2.record(z2.string(), localAgentConfigSchema).superRefine((agents, ctx) => {
|
|
272
|
+
for (const [key, row] of Object.entries(agents)) {
|
|
273
|
+
if (row.sandbox === void 0) continue;
|
|
274
|
+
if (AGENT_ID_KEY.test(key)) continue;
|
|
275
|
+
ctx.addIssue({
|
|
276
|
+
code: z2.ZodIssueCode.custom,
|
|
277
|
+
path: [key, "sandbox"],
|
|
278
|
+
message: "a sandbox exception must name one agent \u2014 key the row by the agent's id (shown in Cabane), not a username or workspace-slug/username"
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
});
|
|
265
282
|
var companionConfigSchema = z2.object({
|
|
266
283
|
// The cabane instance this device is paired with. SJ515: https-enforced
|
|
267
284
|
// (loopback exempt) so a hand-edited config can't smuggle a plaintext-http
|
|
@@ -282,7 +299,7 @@ var companionConfigSchema = z2.object({
|
|
|
282
299
|
// Optional per-agent machine-local overrides (cwd / prepareHook), keyed by
|
|
283
300
|
// agentId / username / `slug/username`. Hand-added by the operator; the companion
|
|
284
301
|
// never writes this (it only persists credentials + the device, elsewhere).
|
|
285
|
-
agents:
|
|
302
|
+
agents: agentsConfigSchema.optional(),
|
|
286
303
|
// CT1095: the DEVICE-LEVEL prepare hook — one entry that applies to every agent
|
|
287
304
|
// assigned to this device, unless that agent's own `agents` row names a hook of
|
|
288
305
|
// its own.
|
|
@@ -421,6 +438,28 @@ function localAgentConfig(cfg, agent) {
|
|
|
421
438
|
if (!cfg.prepareHook || row.prepareHook) return row;
|
|
422
439
|
return { ...row, prepareHook: cfg.prepareHook };
|
|
423
440
|
}
|
|
441
|
+
function agentSandbox(cfg, agent) {
|
|
442
|
+
const sandbox = cfg.agents?.[agent.agentId]?.sandbox;
|
|
443
|
+
return sandbox ? { sandbox } : {};
|
|
444
|
+
}
|
|
445
|
+
function runnerSandboxFor(cfg, row, host) {
|
|
446
|
+
const asked = row.sandbox ?? cfg.runners?.sandbox ?? "none";
|
|
447
|
+
if (asked !== "os" || cfg.runners?.provider === "docker") return "none";
|
|
448
|
+
return host.osSandboxUsable() ? "os" : "none";
|
|
449
|
+
}
|
|
450
|
+
function runnerSandboxReport(cfg, agents, host) {
|
|
451
|
+
const device = runnerSandboxFor(cfg, {}, host);
|
|
452
|
+
const sandboxOverrides = agents.flatMap((agent) => {
|
|
453
|
+
const sandbox = runnerSandboxFor(cfg, agentSandbox(cfg, agent), host);
|
|
454
|
+
return sandbox === device ? [] : [{ agentId: agent.agentId, sandbox }];
|
|
455
|
+
});
|
|
456
|
+
return { sandbox: device, sandboxOverrides };
|
|
457
|
+
}
|
|
458
|
+
function agentSandboxRows(cfg) {
|
|
459
|
+
return Object.entries(cfg.agents ?? {}).flatMap(
|
|
460
|
+
([key, row]) => row.sandbox ? [[key, row.sandbox]] : []
|
|
461
|
+
);
|
|
462
|
+
}
|
|
424
463
|
function loadConfig() {
|
|
425
464
|
const path = configPath();
|
|
426
465
|
if (!existsSync(path)) return null;
|
|
@@ -814,7 +853,7 @@ function buildCompanionManifest(opts) {
|
|
|
814
853
|
runtimes,
|
|
815
854
|
capabilities: { ...DEVICE_MANIFEST.capabilities },
|
|
816
855
|
runtimeIssues: opts.runtimeIssues ?? [],
|
|
817
|
-
...opts.
|
|
856
|
+
...opts.runner ? { runner: opts.runner } : {}
|
|
818
857
|
};
|
|
819
858
|
}
|
|
820
859
|
function runtimeIssuesFor(gates) {
|
|
@@ -13173,10 +13212,21 @@ var CompanionSupervisor = class {
|
|
|
13173
13212
|
}
|
|
13174
13213
|
this.latchBootBundledState();
|
|
13175
13214
|
}
|
|
13176
|
-
|
|
13215
|
+
const sandboxRows = agentSandboxRows(this.config);
|
|
13216
|
+
if (sandboxRows.length > 0 && this.dockerRunners()) {
|
|
13217
|
+
this.log.warn(
|
|
13218
|
+
{ agents: sandboxRows.map(([key]) => key) },
|
|
13219
|
+
`runners: ${sandboxRows.map(([key]) => `agents.${key}.sandbox`).join(", ")} ignored \u2014 under Docker the container is the sandbox`
|
|
13220
|
+
);
|
|
13221
|
+
}
|
|
13222
|
+
const anySandboxed = [{}, ...sandboxRows.map(([, sandbox]) => ({ sandbox }))].some(
|
|
13223
|
+
(row) => this.runnerSandboxFor(row) === "os"
|
|
13224
|
+
);
|
|
13225
|
+
if (anySandboxed) {
|
|
13177
13226
|
const policy = this.sandboxPolicy();
|
|
13178
13227
|
const posture = postureLines(policy);
|
|
13179
|
-
const
|
|
13228
|
+
const perAgent = sandboxRows.map(([key, sandbox]) => `${key} \u2192 ${sandbox}`).join(", ");
|
|
13229
|
+
const summary = posture.join("; ") + (perAgent ? `; per-agent: ${perAgent}` : "");
|
|
13180
13230
|
if (policy.ok) this.log.info({ posture }, `runners: OS sandbox policy \u2014 ${summary}`);
|
|
13181
13231
|
else this.log.error({ posture }, `runners: ${summary}`);
|
|
13182
13232
|
void this.companionStores().refresh();
|
|
@@ -13290,7 +13340,7 @@ var CompanionSupervisor = class {
|
|
|
13290
13340
|
// (fail-soft to null). Informational only — the server matches on name.
|
|
13291
13341
|
versions: this.harnessVersions,
|
|
13292
13342
|
// CT1681: shown on the device page.
|
|
13293
|
-
...runnersEnabled(this.config) ? {
|
|
13343
|
+
...runnersEnabled(this.config) ? { runner: this.runnerManifest() } : {}
|
|
13294
13344
|
}),
|
|
13295
13345
|
// CT566: echo the last classified credential state per runtime, when the
|
|
13296
13346
|
// companion has seen any. Omitted (undefined) until the first observed
|
|
@@ -13622,6 +13672,9 @@ var CompanionSupervisor = class {
|
|
|
13622
13672
|
// CT1679: `runners.sandbox`, checked against this host once. A host that
|
|
13623
13673
|
// can't make the sandbox says so once and runs its turns unwrapped.
|
|
13624
13674
|
sandboxUsable;
|
|
13675
|
+
sandboxHost = {
|
|
13676
|
+
osSandboxUsable: () => this.sandboxUsable ??= osSandboxUsable(this.log)
|
|
13677
|
+
};
|
|
13625
13678
|
// CT1684: `runners.policy`, parsed once. An invalid one is kept as the
|
|
13626
13679
|
// error, which every sandboxed turn is refused with.
|
|
13627
13680
|
parsedSandboxPolicy;
|
|
@@ -13635,10 +13688,26 @@ var CompanionSupervisor = class {
|
|
|
13635
13688
|
this.storeWatch ??= companionStoreWatch(this.log);
|
|
13636
13689
|
return this.storeWatch;
|
|
13637
13690
|
}
|
|
13638
|
-
|
|
13639
|
-
|
|
13640
|
-
|
|
13641
|
-
|
|
13691
|
+
// CT1693: the sandbox one agent's turns run under — its `agents` row, then
|
|
13692
|
+
// the device's. Resolved from the current config every time it's asked, so
|
|
13693
|
+
// a reload changes it; only the host probe is kept.
|
|
13694
|
+
runnerSandboxFor(row) {
|
|
13695
|
+
return runnerSandboxFor(this.config, row, this.sandboxHost);
|
|
13696
|
+
}
|
|
13697
|
+
// CT1693: what the heartbeat says about this device's runners, resolved
|
|
13698
|
+
// against the agents it runs right now.
|
|
13699
|
+
runnerManifest() {
|
|
13700
|
+
const agents = [...this.workspaces.values()].flatMap(
|
|
13701
|
+
(wr) => [...wr.agents.values()].map((agent) => ({
|
|
13702
|
+
agentId: agent.agentId,
|
|
13703
|
+
agentUsername: agent.username,
|
|
13704
|
+
workspaceSlug: wr.slug
|
|
13705
|
+
}))
|
|
13706
|
+
);
|
|
13707
|
+
return {
|
|
13708
|
+
provider: this.config.runners?.provider ?? "local-process",
|
|
13709
|
+
...runnerSandboxReport(this.config, agents, this.sandboxHost)
|
|
13710
|
+
};
|
|
13642
13711
|
}
|
|
13643
13712
|
// CT1679: one provider for the device, built on first use.
|
|
13644
13713
|
runnerProvider() {
|
|
@@ -13754,7 +13823,7 @@ var CompanionSupervisor = class {
|
|
|
13754
13823
|
reportRunnerGone: (report) => this.reportRunnerGone(report),
|
|
13755
13824
|
connectorHealth: this.connectorHealth,
|
|
13756
13825
|
locks: this.runnerLocks,
|
|
13757
|
-
sandbox: this.
|
|
13826
|
+
sandbox: this.runnerSandboxFor(agentSandbox(this.config, ctx)),
|
|
13758
13827
|
policy: this.sandboxPolicy(),
|
|
13759
13828
|
companionStores: () => this.companionStores().stores()
|
|
13760
13829
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cabane/companion",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.134",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The Cabane Companion (headless): connect a coding agent on your machine to your Cabane workspace as a responder — drive work against your own codebase, files, and MCP servers without putting any of it in Cabane.",
|
|
6
6
|
"license": "UNLICENSED",
|