Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/rfcs/RFC-AI-0004.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ The reference implementation (see [`docs/setup/secure-agent-internals.md`](http
|---|---|---|
| **0. Clean environment** | Inherited credential-shaped env vars (`$AWS_*`, `$GH_TOKEN`, `$ANTHROPIC_API_KEY`, …). | A shell wrapper (`claude-iso`) that strips the agent's process env to a project-declared whitelist before exec. |
| **1. Filesystem + network sandbox** | Bash subprocess reads outside the project tree; outbound HTTPS to non-allowed hosts. | Linux: `bubblewrap` user-namespace + `socat` SNI proxy. macOS: `sandbox-exec`. |
| **1b. Socket gateways** | Daemon sockets that are root-equivalent over their mounts (container runtimes), and container network egress the sandbox proxy never sees. | Per-project policy proxies running outside the sandbox: `tools/egress-gateway` (host allow-list, RFC-AI-0003 § 4.4) and `tools/container-gateway` (label-scoped, mount- and privilege-checked container API, egress-gateway injected as the containers' proxy). The sandbox may reach only the gateways' own sockets, never the daemon socket. |
| **1b. Socket gateways** | Daemon sockets that are root-equivalent over their mounts (container runtimes), and container network egress the sandbox proxy never sees. | Per-project policy proxies running outside the sandbox: `tools/egress-gateway` (host allow-list, RFC-AI-0003 § 4.4) and `tools/container-gateway` (label-scoped, mount- and privilege-checked container API, egress-gateway injected as the containers' proxy). The sandbox may reach only the gateways' own sockets, never the daemon socket. The container gateway's lifetime is tied to use, not to the session that started it: harness hooks start it with the session and again on demand before a container command once it has exited, always from the operator-installed copy and never from the sandbox-writable project tree. |
| **2. Tool permissions** | The agent's own Read/Edit/Write/Bash tools touching denied paths or binaries. | The agent host's permission system (e.g., Claude Code's `permissions.deny`). |
| **3. Forced confirmation** | Visible-to-others writes that haven't been seen by a human. | `permissions.ask` for every state-mutating shell call (e.g., `gh pr create`, `gh issue edit`, `gh gist *`, `gh secret *`). Implements Principle 1 at the OS layer. |

Expand Down
4 changes: 2 additions & 2 deletions docs/setup/sandbox-troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -695,7 +695,7 @@ It runs outside the sandbox, holds the only connection to the real daemon socket
and `<git-common-dir>/apache-magpie/run/<worktree-id>/` for one that has not (`git rev-parse --git-common-dir` prints the common directory; it is the main checkout's `.git`, also from a linked worktree).
Below, `<run-dir>` is whichever of the two applies.
`<worktree-id>` is `main` for the main working tree and the `<name>` of `.git/worktrees/<name>` for a linked worktree (it must match `[A-Za-z0-9._-]+`), so each worktree has its own gateway and sockets.
`CONTAINER_HOST` and `DOCKER_HOST` point at `podman.sock` and `docker.sock` in that directory, only those two sockets are ever added to `allowUnixSockets`, and a `SessionStart` hook starts the gateway when a session begins.
`CONTAINER_HOST` and `DOCKER_HOST` point at `podman.sock` and `docker.sock` in that directory, only those two sockets are ever added to `allowUnixSockets`, a `SessionStart` hook starts the gateway when a session begins, and a `PreToolUse` hook starts it again before a container command if it exited mid-session.
See [Container gateway](secure-agent-setup.md#container-gateway) in the setup guide for the full install.

### Fix
Expand All @@ -704,7 +704,7 @@ See [Container gateway](secure-agent-setup.md#container-gateway) in the setup gu
|---|---|---|
| `failed to read identity "…/machine/machine": operation not permitted` | `CONTAINER_HOST` / `DOCKER_HOST` are unset, so the CLI fell back to its default connection instead of the gateway | Add the reference `env` block below to `.claude/settings.local.json` |
| `dial unix /.//.apache-magpie-local/run/podman.sock` — note the leading `/.//` | `CONTAINER_HOST` / `DOCKER_HOST` use a project-relative `unix://./…` value, which the CLIs do **not** resolve against the cwd | Use the absolute `unix:///<project>/…` spelling in the `env` block below |
| `dial unix /<run-dir>/podman.sock: connect: no such file or directory` | The gateway is not running for this project | Run `~/.claude/scripts/container-gateway-hook.sh start` from a terminal, or check `<run-dir>/container-gateway.log` for why it did not start |
| `dial unix /<run-dir>/podman.sock: connect: no such file or directory` | The gateway is not running for this project. It exits after its idle timeout (4 h), when another session on the project ends, or when it crashes; the `PreToolUse` `ensure` hook restarts it before the next container command, so this error means that hook is not wired or could not start it | Wire the `ensure` hook per [Container gateway](secure-agent-setup.md#container-gateway), run `~/.claude/scripts/container-gateway-hook.sh start` from a terminal, or check `<run-dir>/container-gateway.log` for why it did not start |
| `dial unix /<run-dir>/podman.sock: connect: operation not permitted` | The gateway is running but its socket is missing from `sandbox.network.allowUnixSockets` | Add both gateway sockets as absolute paths, per [Container gateway](secure-agent-setup.md#container-gateway) |
| `no podman or docker backend found; nothing to serve` in the gateway log, while `podman` works by hand | On macOS the gateway asked `podman machine inspect` for the socket path, and that command renders it from the **caller's** `TMPDIR` | Update the framework: discovery now also probes `getconf DARWIN_USER_TEMP_DIR`/`podman/`, so a hook whose `TMPDIR` differs from the machine's still finds the socket |

Expand Down
42 changes: 35 additions & 7 deletions docs/setup/secure-agent-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -2864,7 +2864,20 @@ records which main checkout it confirmed the first time, under
sandbox-writable; a later session that derives a different one is refused
until you delete that record (the log line names it).

Wire it as a `SessionStart` / `SessionEnd` pair in your user settings, alongside any other hooks already there, **by its absolute path** (your real home, `/home/<you>` on Linux). A `~` in a hook command is expanded from `$HOME` by the launching shell, and a session environment that redirects `$HOME` would run another script. Because that path is per-machine, it goes in `~/.claude/settings.local.json` when your `~/.claude/settings.json` is [synced across machines](#syncing-user-scope-config-across-machines) — a `/Users/<you>/...` path synced to a Linux box points at nothing, and the gateway never starts there; otherwise `~/.claude/settings.json` is fine. Claude Code merges hooks from both files:
Wire it in your user settings, alongside any other hooks already there, **by its absolute path** (your real home, `/home/<you>` on Linux). A `~` in a hook command is expanded from `$HOME` by the launching shell, and a session environment that redirects `$HOME` would run another script. Because that path is per-machine, it goes in `~/.claude/settings.local.json` when your `~/.claude/settings.json` is [synced across machines](#syncing-user-scope-config-across-machines) — a `/Users/<you>/...` path synced to a Linux box points at nothing, and the gateway never starts there; otherwise `~/.claude/settings.json` is fine. Claude Code merges hooks from both files.

Three events, one script:

- `SessionStart` → `start` brings the gateway up with the session.
- `SessionEnd` → `stop` ends it.
- `PreToolUse` on `Bash` → `ensure` starts it again before a `docker` or `podman` command when it is gone mid-session.
It exits after its idle timeout (default 4 h), when another session on the same project ends and stops it, or when it or its backend dies; without `ensure` it stays down until the next session.
The two `if` filters keep the hook off every other command; they are best-effort, so the script checks the command itself as well and leaves at once when it names neither CLI.
When the gateway is already running the check costs about half a second, on container commands only.
`ensure` runs every check `start` does, so it can start nothing that `start` would refuse.
It judges the directory the session started in, which Claude Code hands every hook as `CLAUDE_PROJECT_DIR`, never the directory the agent has `cd`'d into: judged from another directory, the session project's own write grants would go uncounted.
A settings `env` entry cannot override `CLAUDE_PROJECT_DIR`, and without it `ensure` does nothing.
`start` and `stop` judge it too, since `SessionStart` also fires after a compaction, `/clear` or resume, when the payload's directory has followed the agent's `cd`; they fall back to that directory only when the variable is unset.

```jsonc
{
Expand All @@ -2874,20 +2887,32 @@ Wire it as a `SessionStart` / `SessionEnd` pair in your user settings, alongside
],
"SessionEnd": [
{ "hooks": [ { "type": "command", "command": "/Users/<you>/.claude/scripts/container-gateway-hook.sh stop" } ] }
],
"PreToolUse": [
{ "matcher": "Bash", "hooks": [
{ "type": "command", "if": "Bash(docker *)", "timeout": 30, "command": "/Users/<you>/.claude/scripts/container-gateway-hook.sh ensure" },
{ "type": "command", "if": "Bash(podman *)", "timeout": 30, "command": "/Users/<you>/.claude/scripts/container-gateway-hook.sh ensure" }
] }
]
}
}
```

**Fixing your other machines.** If the hook was registered in a synced `~/.claude/settings.json`, move it: delete the two `container-gateway-hook.sh` entries from the synced file (once, on any machine), then on **every** machine add them to its own `~/.claude/settings.local.json` with that machine's home:
**Fixing your other machines.** If the hook was registered in a synced `~/.claude/settings.json`, move it: delete the `container-gateway-hook.sh` entries from the synced file (once, on any machine), then on **every** machine add them to its own `~/.claude/settings.local.json` with that machine's home:

```bash
jq --arg h "$(cd ~ && pwd -P)" '
.hooks.SessionStart += [{"hooks":[{"type":"command","command":($h+"/.claude/scripts/container-gateway-hook.sh start")}]}]
| .hooks.SessionEnd += [{"hooks":[{"type":"command","command":($h+"/.claude/scripts/container-gateway-hook.sh stop")}]}]
($h+"/.claude/scripts/container-gateway-hook.sh") as $s
| .hooks.SessionStart += [{"hooks":[{"type":"command","command":($s+" start")}]}]
| .hooks.SessionEnd += [{"hooks":[{"type":"command","command":($s+" stop")}]}]
| .hooks.PreToolUse += [{"matcher":"Bash","hooks":[
{"type":"command","if":"Bash(docker *)","timeout":30,"command":($s+" ensure")},
{"type":"command","if":"Bash(podman *)","timeout":30,"command":($s+" ensure")}]}]
' ~/.claude/settings.local.json > ~/.claude/settings.local.json.new && mv ~/.claude/settings.local.json.new ~/.claude/settings.local.json
```

A machine wired before `ensure` existed already has the first two; add only the `PreToolUse` entry there.

`setup-isolated-setup-verify` (check 12a) and `setup-isolated-setup-update` flag a hook whose registered path does not exist on the machine they run on.

Every setting that points something at a gateway socket needs that socket's **absolute** path, which is per-machine, so the whole project-settings block belongs in the gitignored `.claude/settings.local.json` — nothing is committed.
Expand Down Expand Up @@ -2930,7 +2955,7 @@ At start, the gateway resolves the egress gateway's address for each backend and
`inject-if-available` (the default) injects `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` into every container it creates when that probe succeeds, and logs a warning instead of failing when it does not.
`require` refuses container creation with `403` while the egress gateway is unreachable, for adopters who want a hard failure rather than a silent gap.
`off` never injects, for adopters running their own container-level filtering.
Set the mode with `MAGPIE_CONTAINER_GATEWAY_ARGS="--egress require"` (or `off`) before the `SessionStart` hook runs.
Set the mode with `MAGPIE_CONTAINER_GATEWAY_ARGS="--egress require"` (or `off`) in the environment the hooks run in; it applies whenever `start` or `ensure` starts the gateway.

On Linux, the address the gateway hands a container for the egress gateway is a fixed guess: `172.17.0.1` for dockerd's default bridge, `10.88.0.1` for rootless Podman.
Neither is guaranteed to match every Linux install's actual bridge address.
Expand Down Expand Up @@ -3225,7 +3250,7 @@ Three reasons make this non-negotiable:
Public dotfile repos are fine for shell aliases and editor configs;
they are the wrong shape for agent-runtime files.

**Per-machine entries stay out of the synced file.** Anything that names an absolute path under your home — the container gateway's `SessionStart` / `SessionEnd` hooks above, a pinned interpreter, socket paths — differs between macOS (`/Users/<you>`) and Linux (`/home/<you>`). Keep those in each machine's `~/.claude/settings.local.json`, which is never synced; Claude Code merges hooks and settings from both files.
**Per-machine entries stay out of the synced file.** Anything that names an absolute path under your home — the container gateway's `SessionStart` / `SessionEnd` / `PreToolUse` hooks above, a pinned interpreter, socket paths — differs between macOS (`/Users/<you>`) and Linux (`/home/<you>`). Keep those in each machine's `~/.claude/settings.local.json`, which is never synced; Claude Code merges hooks and settings from both files.

## Adopter setup

Expand Down Expand Up @@ -3620,7 +3645,10 @@ below and report ✓ done / ✗ missing / ⚠ partial, with the evidence
hook running `container-gateway-hook.sh stop`, by an absolute
path that exists on this machine (✗ if it names another
machine's home), and `~/.claude/scripts/container-gateway-hook.sh`
exists and is executable.
exists and is executable. A missing `PreToolUse` `Bash` hook
running `container-gateway-hook.sh ensure` is ◐: the gateway
still starts with each session, but stays down once it exits
mid-session.
- The project `.claude/settings.json` or
`.claude/settings.local.json` has `env.CONTAINER_HOST` and
`env.DOCKER_HOST`, and both gateway sockets appear in
Expand Down
4 changes: 2 additions & 2 deletions plugins/magpie-setup/skills/isolated-setup-doctor/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ capability:
- capability:reassess
surface_hash: sha256:7d670b572d43bbeb
license: Apache-2.0
measured_tokens: 6573
measured_tokens: 6611
---

<!-- Placeholder convention (see AGENTS.md#placeholder-convention-used-in-skill-files):
Expand Down Expand Up @@ -158,7 +158,7 @@ The `-S` test is a defensive fallback for an already-serving backend whose socke
|---|---|---|
| `✓ <rt> reaches the container gateway at <sock>` | Pass | CLI → gateway → daemon all answer. |
| `✗ … unset — gateway not wired into settings` | Fail | The reference `env` block is missing from project settings. |
| `✗ gateway socket missing` | Fail | The `SessionStart` hook did not start the gateway, or it exited; check `container-gateway.log` in the gateway's run directory — `<project>/.apache-magpie-local/run/` when adopted, else `<git-common-dir>/apache-magpie/run/<worktree-id>/`. |
| `✗ gateway socket missing` | Fail | The `SessionStart` hook did not start the gateway, or it exited and no `PreToolUse` `ensure` hook started it again before the probe's container command (check 12a of `setup-isolated-setup-verify` flags a missing one); check `container-gateway.log` in the gateway's run directory — `<project>/.apache-magpie-local/run/` when adopted, else `<git-common-dir>/apache-magpie/run/<worktree-id>/`. |
| `✗ gateway status refused the run directory` | Fail | `status` exited 2 without a report: a run-directory component is a symlink or has the wrong owner, type or mode. Inspect the run directory from outside the sandbox; do not point the CLI around it. |
| `✗ gateway status unavailable` | Fail | The probe could not get a report from `status`: no gateway source was found (install the operator copy, install Step L.1), or `status` exited without one (`rc=N`; typically a `python3` on `PATH` too old to import the gateway). The socket may still work, but nothing confirms it is the gateway's. |
| `✗ gateway running without a <rt> backend` | Fail | `status` reports the gateway up but `serving` does not list this CLI's backend — the Podman machine or Docker daemon behind it is not running. Start it from outside the sandbox, then restart the gateway. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,9 +187,7 @@ def test_status_refusal_is_reported(self) -> None:
self.assertNotIn("reaches the container gateway", out)

def test_operator_install_source_used(self) -> None:
self.setup_stub_gateway(
[], root=self.tmp / ".claude" / "scripts" / "container-gateway" / "src"
)
self.setup_stub_gateway([], root=self.tmp / ".claude" / "scripts" / "container-gateway" / "src")
self.fake_cmd("docker")
workdir = self.tmp / "project"
workdir.mkdir()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,13 @@ sandbox-writable; a later session that derives a different one is refused
until you delete that record (the log line names it).

**L.2 — Hooks.** Wire a `SessionStart` hook running
`container-gateway-hook.sh start` and a `SessionEnd` hook running
`container-gateway-hook.sh stop` into the user settings, each
`container-gateway-hook.sh start`, a `SessionEnd` hook running
`container-gateway-hook.sh stop`, and a `PreToolUse` hook (matcher
`Bash`, two handlers with `if` set to `Bash(docker *)` and
`Bash(podman *)`, `timeout` 30) running
`container-gateway-hook.sh ensure`, which starts the gateway again
before a container command when it exited mid-session, into the user
settings, each
**by the script's absolute path** with the home directory written out (a `~`
there is expanded from `$HOME`, which the session environment can redirect),
executed directly rather than through `bash`/`sh` (its `#!/bin/bash -p`
Expand Down Expand Up @@ -237,7 +242,9 @@ Tell the operator plainly, every time this step runs:
serves the `docker` CLI, translated onto the Podman backend — the
operator does not need both installed.
- The hook lives in `~/.claude/scripts/`, runs outside the sandbox
on every `SessionStart` / `SessionEnd`, and — per L.1 — only ever
on every `SessionStart` / `SessionEnd` and before every `docker` /
`podman` command (where it restarts a gateway that exited
mid-session), and — per L.1 — only ever
executes the copy installed here, never anything from the project
tree it is about to serve.

Expand Down
Loading
Loading