@cabane/companion 0.6.124 → 0.6.126

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -287,6 +287,55 @@ When an agent's MCP servers in Cabane need a credential, that credential is writ
287
287
 
288
288
  A `${VAR}` the store doesn't declare fails the turn loudly — it is **never** read from your shell environment, so a config can't smuggle out an ambient credential. The Companion reports the declared **names** (never values) to Cabane on its heartbeat, so the settings UI can warn "this agent needs `${GITHUB_TOKEN}`, this device doesn't expose it" before a turn ever runs.
289
289
 
290
+ ### Sandbox (set on this machine)
291
+
292
+ With `"runners": { "enabled": true, "sandbox": "os" }`, each turn's harness — the model's process and every shell it starts — runs inside an OS sandbox (bubblewrap on Linux, Seatbelt on macOS) whose network goes through a local proxy. It's a guardrail on your own machine: it stops a turn from reading the secrets named below or reaching hosts you didn't allow. It runs as your user on your kernel, so it isn't isolation. _Host access_ on the agent in Cabane decides whether the agent gets shell and file tools at all; the sandbox decides what those tools can touch. The two are independent.
293
+
294
+ What the sandbox allows is `runners.policy` in `~/.cabane/config.json`. Every key is optional, and with no `policy` block you get the defaults:
295
+
296
+ ```jsonc
297
+ {
298
+ "runners": {
299
+ "enabled": true,
300
+ "sandbox": "os",
301
+ "policy": {
302
+ "hosts": { "allow": ["gitlab.example.com"], "deny": [], "defaults": true },
303
+ "filesystem": { "mode": "blocklist", "denyRead": [], "allowRead": [], "allowWrite": [] },
304
+ "credentials": {
305
+ "env": { "GH_TOKEN": { "secret": "${GITHUB_TOKEN}", "hosts": ["api.github.com"] } },
306
+ "git": [{ "host": "github.com", "secret": "${GITHUB_TOKEN}" }],
307
+ },
308
+ },
309
+ },
310
+ }
311
+ ```
312
+
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 `~/.cabane`, `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.config/gh`, `~/.git-credentials`, `~/.netrc`, `~/.npmrc`, `~/.pypirc`, `~/.docker/config.json`, `~/.kube`, `~/.config/gcloud`, `~/.azure`, `~/.config/op`, `~/.password-store`, any `.env` or `.env.*` in the working directory, and your `denyRead` paths. `allowRead` reopens a path inside a denied one. |
318
+ | **Writes** | The working directory, the temp dir, `~/.claude`, `~/.claude.json`, `~/.codex`, plus `allowWrite`. |
319
+
320
+ 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
+
322
+ **`.env` files in other projects.** Only the working directory's `.env` files are denied by default. Another project's `.env` stays readable by its absolute path. If your other projects hold secrets, list their roots, 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. When other projects' secrets really matter, strict mode is the better answer, because it hides them all without a walk.
323
+
324
+ **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.
325
+
326
+ **Credentials, without the harness ever holding them.** A `credentials.env` entry sets that variable in the sandbox to a placeholder like `fake_value_…`, and the proxy swaps in the real value from `~/.cabane/secrets.json` only on requests to the hosts the entry names in `hosts`. `hosts` is required, and it's not a formality: the entry's hosts are the only places the real value goes, so a GitHub token should name `api.github.com` and nothing else. A `credentials.git` entry does the same for git over HTTPS. git base64-encodes its credentials, so the placeholder can't be found inside them, and the Companion instead has git send a ready-made basic-auth header for that host (`username` defaults to `x-access-token`). If a name isn't in `secrets.json`, the turn is refused with the name in the conversation. It is never left unset without telling you. With `sandbox: "none"`, or on a host that can't run the sandbox, credentials aren't given to the harness at all. If the sandbox fails to start for a turn that holds credentials, the turn is refused rather than run unsandboxed. Masking keeps the harness from reading a credential, not from using it. The proxy also swaps the value in request bodies, so a turn can still put the real token into something it sends to one of the entry's hosts, such as a pull request comment. Give a token only the permissions its agents need.
327
+
328
+ **Recipe: GitHub with `gh` and `git push`.**
329
+
330
+ 1. Create a fine-grained personal access token scoped to your repositories, with Contents and Pull requests set to read and write.
331
+ 2. Put it in `~/.cabane/secrets.json` as `"GITHUB_TOKEN": "github_pat_…"`.
332
+ 3. Add both credential entries from the example above: `env.GH_TOKEN` with `"hosts": ["api.github.com"]` for `gh`, and a `git` entry for `github.com`.
333
+ 4. Use HTTPS remotes, because `~/.ssh` is unreadable in the sandbox. Rewrite existing SSH remotes once on the machine: `git config --global url."https://github.com/".insteadOf git@github.com:`.
334
+
335
+ 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.
336
+
337
+ `cabane-companion status` prints the effective posture: the filesystem mode, extra hosts, denied hosts and reads, and the **names** of masked credentials, never their values. The Companion also logs it once at start.
338
+
290
339
  ## Harnesses
291
340
 
292
341
  A **harness** is the local tool that runs a model — **[Claude Code](https://www.npmjs.com/package/@anthropic-ai/claude-code)**, **[Codex](https://developers.openai.com/codex/cli)**, or **[opencode](https://opencode.ai)** — together with the credential behind it. A harness connected to Cabane on this device is a **Connector**, and one device runs as many Connectors as it has harnesses. None is privileged: a device advertises exactly the harnesses it actually has.