diff --git a/docs/setup/secure-agent-setup.md b/docs/setup/secure-agent-setup.md index 4e30c6b13..1a326c65f 100644 --- a/docs/setup/secure-agent-setup.md +++ b/docs/setup/secure-agent-setup.md @@ -2877,7 +2877,7 @@ Three events, one script: - `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. + It is registered for every `Bash` call, with no `if` filter: a filter such as `Bash(docker *)` would miss `docker-compose`, `podman-compose` and `/usr/local/bin/docker`. The script reads the command itself and leaves within about 10 ms 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. @@ -2894,10 +2894,7 @@ Three events, one script: { "hooks": [ { "type": "command", "command": "/Users//.claude/scripts/container-gateway-hook.sh stop" } ] } ], "PreToolUse": [ - { "matcher": "Bash", "hooks": [ - { "type": "command", "if": "Bash(docker *)", "timeout": 30, "command": "/Users//.claude/scripts/container-gateway-hook.sh ensure" }, - { "type": "command", "if": "Bash(podman *)", "timeout": 30, "command": "/Users//.claude/scripts/container-gateway-hook.sh ensure" } - ] } + { "matcher": "Bash", "hooks": [ { "type": "command", "timeout": 30, "command": "/Users//.claude/scripts/container-gateway-hook.sh ensure" } ] } ] } } @@ -2910,9 +2907,7 @@ jq --arg h "$(cd ~ && pwd -P)" ' ($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")}]}] + | .hooks.PreToolUse += [{"matcher":"Bash","hooks":[{"type":"command","timeout":30,"command":($s+" ensure")}]}] ' ~/.claude/settings.local.json > ~/.claude/settings.local.json.new && mv ~/.claude/settings.local.json.new ~/.claude/settings.local.json ``` diff --git a/plugins/magpie-setup/skills/isolated-setup-install/optional-steps.md b/plugins/magpie-setup/skills/isolated-setup-install/optional-steps.md index 60bbea0ec..119d22118 100644 --- a/plugins/magpie-setup/skills/isolated-setup-install/optional-steps.md +++ b/plugins/magpie-setup/skills/isolated-setup-install/optional-steps.md @@ -178,8 +178,7 @@ until you delete that record (the log line names it). **L.2 — Hooks.** Wire a `SessionStart` hook running `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 +`Bash`, no `if` filter, `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 diff --git a/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py b/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py index 5c52022ab..df72fc7bd 100644 --- a/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py +++ b/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py @@ -21,4 +21,4 @@ for installs that do not carry the framework source (see `isolated.py`). """ -FRAMEWORK_FINGERPRINT = "sha256:a19871219995dc10" +FRAMEWORK_FINGERPRINT = "sha256:712e4acb2792c387" diff --git a/tools/agent-isolation/README.md b/tools/agent-isolation/README.md index 89ed72b87..7bdedc891 100644 --- a/tools/agent-isolation/README.md +++ b/tools/agent-isolation/README.md @@ -90,7 +90,7 @@ per runtime — see [`docs/adapters/add-a-harness.md`](../../docs/adapters/add-a | [`gpg-touch-overlay.sh`](gpg-touch-overlay.sh) | Claude Code `PreToolUse`/`PostToolUse` hook (Bash matcher). Puts a window on screen while a hardware signing key blocks waiting for a touch — the case pinentry never prompts for, and which is indistinguishable from a hung `git commit`. `arm` starts a watcher before a git command that could reach the key — one that signs, or one that talks to a remote over ssh; the watcher shows the window only once the key has actually blocked, stays quiet while pinentry owns the screen, and `disarm` tears the whole process group down afterwards. Watches both signing commands — `gpg`, and the `ssh-keygen -Y sign` git runs under `gpg.format=ssh` — and, for the authentication touch a `git pull` / `push` / `fetch` over ssh asks for, the connection ssh holds open to the agent's socket while its request is out. Neither `arm` nor `wrap` starts a watcher when the card's signature and authentication touch policies are both off (`gpg --card-status --with-colons`, cached for ten minutes, or one for a both-off answer); an unknown policy keeps it. `wrap` is the same watcher for git commands the agent never runs — a commit or push from the operator's own terminal: git is pointed at the script as its signing program (`gpg.ssh.program` via the argument-free `gpg-touch-wrap-ssh-keygen` symlink, or `gpg.program`) and its ssh command (`core.sshCommand … wrap ssh`), and the script runs the real program with a watcher alive for exactly that long. The window names the blocked command and the directory it runs in — recorded per signing context under `context/` beside the registration, re-read each time the window is raised so a second command in the same session replaces the text rather than inheriting the first one's — with a password in a URL masked before it is written. See [`docs/setup/secure-agent-setup.md` → *Hardware-key touch overlay*](../../docs/setup/secure-agent-setup.md#hardware-key-touch-overlay) and [→ *From your own terminal*](../../docs/setup/secure-agent-setup.md#from-your-own-terminal--gits-program-config). | | [`gpg-touch-overlay-window.py`](gpg-touch-overlay-window.py) | The window itself on Linux: a GTK overlay, one per monitor, that dims the desktop around a pulsing contact ring, over the blocked command and its directory. Spawned by the watcher, killed by it when the touch lands. Falls back to a `zenity` dialog on a host without PyGObject. | | [`gpg-touch-overlay-window-macos.py`](gpg-touch-overlay-window-macos.py) | The same window on macOS, drawn with Tk — a Mac has neither PyGObject nor zenity, so without this the hook has nothing to show. Main display only, and borderless rather than natively fullscreen so macOS does not switch Spaces out from under the terminal. Takes the keyboard while it is up, so a touch that lands before the key asks for one — which fires the key's OTP slot — types into the overlay instead of whatever was in front. | -| [`container-gateway-hook.sh`](container-gateway-hook.sh) | Claude Code `SessionStart` / `SessionEnd` / `PreToolUse` hook. `start` launches the per-project [container gateway](../container-gateway/) as a detached daemon so sandboxed `podman` / `docker` commands have a policy-checked socket to talk to; `stop` ends it with the session; `ensure`, wired on `Bash`, starts it again before a `docker` / `podman` command when it exited mid-session (idle timeout, another session on the project ending, a crash), with the same checks as `start`. Finds the gateway in the operator's installed copy (`~/.claude/scripts/container-gateway/src`) or the adopter's `.apache-magpie/` pinned snapshot, and is a silent no-op when neither is present; in-repo copies are never trusted. See [`docs/setup/secure-agent-setup.md` → *Container gateway*](../../docs/setup/secure-agent-setup.md#container-gateway). | +| [`container-gateway-hook.sh`](container-gateway-hook.sh) | Claude Code `SessionStart` / `SessionEnd` / `PreToolUse` hook. `start` launches the per-project [container gateway](../container-gateway/) as a detached daemon so sandboxed `podman` / `docker` commands have a policy-checked socket to talk to; `stop` ends it with the session; `ensure`, wired on `Bash`, starts it again before a `docker` / `podman` command when it exited mid-session (idle timeout, another session on the project ending, a crash), with the same checks as `start`. Runs only the operator's installed copy of the gateway (`~/.claude/scripts/container-gateway/src`, or a development override outside every sandbox-writable directory) and is a silent no-op without one; in-repo copies, an `.apache-magpie/` snapshot included, are never trusted. Every action judges the session's project from `CLAUDE_PROJECT_DIR`, never the directory the agent has `cd`'d into. See [`docs/setup/secure-agent-setup.md` → *Container gateway*](../../docs/setup/secure-agent-setup.md#container-gateway). | | [`claude-term-bg.sh`](claude-term-bg.sh) | **Opt-in quality-of-life helper (not a security control).** Keeps a calm baseline background and tints it only when Claude genuinely wants you to act (never while working, and never when it merely *finished* a turn), so a window you've tabbed away from can't sit blocked unnoticed. Distinguishes "blocked on a decision" from "finished and idle" — which look identical at the `Stop` event — via three signals across six hooks: `Stop` → `stop` (heuristic — tints only if the final assistant message reads as a question/request; a completion stays calm; needs `python3`/`python`, else defaults calm); `PreToolUse` (matcher `AskUserQuestion`) → `wait` (exact — a structured question was posed); `PostToolUse` (matcher `*`) → `reset` (calm while working, and clears the tint the instant you approve a permission prompt or answer a question); `Notification` → `notify` (tints for permission prompts only — the plain idle ping is a no-op so it can't wipe a pending question's tint); and `UserPromptSubmit` + `SessionStart` → `reset` (you replied / fresh session clears any stale tint). Writes the OSC escape to the Claude pty discovered by walking the process tree (hooks have no controlling tty); the only deterministic reset is an explicit `CLAUDE_RESET_BG` colour via OSC 11 (iTerm2 ignores OSC 111). Colours overridable via `CLAUDE_WAIT_BG` / `CLAUDE_RESET_BG`. Tested on iTerm2 + macOS; fail-soft elsewhere. See [`docs/setup/secure-agent-setup.md` → *Waiting-for-input terminal tint*](../../docs/setup/secure-agent-setup.md#waiting-for-input-terminal-tint). | | [`sandbox-add-project-root.sh`](sandbox-add-project-root.sh) | Adds the current adopter repo's project root (and, with `--all-worktrees`, every linked git worktree's working dir) as an explicit absolute path to `sandbox.filesystem.allowRead` and `allowWrite` in the project-local, gitignored `/.claude/settings.local.json` — one entry per worktree, each in that worktree's own settings file. Defensive against [issue #197](https://github.com/apache/magpie/issues/197) — `allowRead: ["."]` does not in practice cover CWD because the harness pre-resolves the `.` literal away from the read side. The same harness behaviour drops the committed home-directory entries, so it also adds the dev-tool paths `prek` and `uv` need (`~/.gitconfig`, `~/.config/git`, `~/.cache`, `~/.local/share/uv`, `~/.local/bin`; the cache and uv dirs writable) as absolute paths — never the credential paths; `--no-tool-paths` skips them. See [`prek` or `uv` not found inside the sandbox](../../docs/setup/sandbox-troubleshooting.md#prek-or-uv-not-found-or-cannot-write-its-cache-inside-the-sandbox). Never modifies user-scope or committed project-scope. Idempotent, atomic, tolerant of missing prereqs. Invoked from `setup-isolated-setup-install`, `/magpie-setup:setup` (adopt / upgrade / worktree-init), and the `post-checkout` git hook installed by `/magpie-setup:setup adopt`. | | [`git-global-post-checkout.sh`](git-global-post-checkout.sh) | Universal `post-checkout` git hook installed at `~/.claude/git-hooks/post-checkout` when the operator picks the **simple whole-user** flavour in `setup-isolated-setup-install`. Activated by `git config --global core.hooksPath ~/.claude/git-hooks/` so every `git checkout` / `git clone` / `git worktree add` across the host invokes it. Best-effort + idempotent + `\|\| true`: invokes `sandbox-add-project-root.sh` for any worktree with a `.claude/` directory. Trade-off documented in [`docs/setup/secure-agent-setup.md` → *Per-project vs whole-user scope*](../../docs/setup/secure-agent-setup.md#per-project-vs-whole-user-scope): `core.hooksPath` shadows per-repo `.git/hooks/*` across every repo on the host. The **dispatcher** flavour (below) supersedes this file. | diff --git a/tools/agent-isolation/container-gateway-hook.sh b/tools/agent-isolation/container-gateway-hook.sh index b0fbe7e1e..cfca273bc 100755 --- a/tools/agent-isolation/container-gateway-hook.sh +++ b/tools/agent-isolation/container-gateway-hook.sh @@ -380,7 +380,14 @@ settings_specs+=("/Library/Application Support/ClaudeCode/managed-settings.json| "/etc/claude-code/managed-settings.json|/etc/claude-code") for spec in "${settings_specs[@]}"; do settings="${spec%|*}" base="${spec##*|}" - [[ -r $settings ]] || continue + [[ -e $settings || -L $settings ]] || continue + # Never opened unless it is a regular file: a FIFO planted by sandboxed + # code (in a subdirectory the agent `cd`s into, say) would block jq until + # the hook timed out. Refused rather than skipped, so a grant can't hide. + if [[ ! -f $settings || ! -r $settings ]]; then + log "$settings is not a readable regular file; not starting the container gateway" + exit 0 + fi if ! command -v jq >/dev/null 2>&1; then log "no jq in /usr/bin or /bin to read $settings; not starting the container gateway" exit 0 @@ -671,22 +678,41 @@ fi # source directory there and nothing else. bootstrap='import runpy, sys; sys.path.insert(0, sys.argv.pop(1)); runpy.run_module("container_gateway", run_name="__main__", alter_sys=True)' -# The gateway's own pid-lock probe: true once it serves, since it binds its -# sockets before it writes the pid file. -gateway_running() { - "$python" -I -S -c "$bootstrap" "$src" status --project="$root" 2>/dev/null \ - | jq -e '.running == true' >/dev/null 2>&1 +# The gateway's own view of itself, from `status`: "serving" once a backend +# socket is bound; "starting" while it holds its pid lock but has not bound +# yet (it takes the lock first, then discovers backends and probes egress); +# "down" otherwise. +gateway_state() { + local state + state="$("$python" -I -S -c "$bootstrap" "$src" status --project="$root" 2>/dev/null \ + | jq -r 'if ((.serving // []) | length) > 0 then "serving" + elif .running == true then "starting" else "down" end' 2>/dev/null)" || true + printf '%s\n' "${state:-down}" } -# Running already is the common case for `ensure`: one interpreter start. -[[ $action == ensure ]] && gateway_running && exit 0 -"$python" -I -S -c "$bootstrap" "$src" "${args[@]}" >/dev/null 2>&1 || exit 0 -if [[ $action == ensure ]]; then - # --daemon returns before the sockets are bound; give the gateway up to - # five seconds so the command that triggered this does not race it. - for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25; do - gateway_running && break - sleep 0.2 - done +if [[ $action != ensure ]]; then + "$python" -I -S -c "$bootstrap" "$src" "${args[@]}" >/dev/null 2>&1 || true + exit 0 fi +# Serving already is the common case: one interpreter start. +state="$(gateway_state)" +[[ $state == serving ]] && exit 0 +if [[ $state == down ]]; then + "$python" -I -S -c "$bootstrap" "$src" "${args[@]}" >/dev/null 2>&1 || exit 0 +fi +# --daemon returns before the sockets are bound; give the gateway up to five +# seconds to serve, so the command that triggered this does not race it. Stop +# early once it has come up and gone again (no backend: it logs why and exits), +# or when it never came up within the first second. +seen_starting=0 +for poll in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25; do + state="$(gateway_state)" + [[ $state == serving ]] && break + if [[ $state == starting ]]; then + seen_starting=1 + elif (( seen_starting || poll >= 5 )); then + break + fi + sleep 0.2 +done exit 0 diff --git a/tools/agent-isolation/tests/test_container_gateway_hook.py b/tools/agent-isolation/tests/test_container_gateway_hook.py index 7bca09adf..cd1c5a61d 100644 --- a/tools/agent-isolation/tests/test_container_gateway_hook.py +++ b/tools/agent-isolation/tests/test_container_gateway_hook.py @@ -27,6 +27,7 @@ import os import subprocess import sys +import time from pathlib import Path import pytest @@ -50,7 +51,7 @@ def run(action: str, cwd: Path, env_extra: dict[str, str] | None = None, home: P if payload is None: payload = json.dumps({"cwd": str(cwd)}) return subprocess.run(["/bin/bash", str(SCRIPT), action], input=payload, - capture_output=True, text=True, env=env, cwd=cwd, check=False) + capture_output=True, text=True, env=env, cwd=cwd, check=False, timeout=60) def bash_payload(cwd: Path, command: str) -> str: @@ -971,3 +972,85 @@ def test_a_planted_bare_repository_is_not_discovered(tmp_path: Path) -> None: # Either refused, or served for the directory itself: never for the parent. assert done.stdout == "" or f"--project={plain.resolve()} " in done.stdout assert f"--project={lay.base} " not in done.stdout + + +# --- ensure: waiting for the gateway it started --- + +_FAKE_GATEWAY = ''' +import json, os, sys, time +ctl = os.environ["FAKE_GW_DIR"] +mode = open(os.path.join(ctl, "mode")).read().strip() +cmd = sys.argv[1] +with open(os.path.join(ctl, "calls"), "a") as log: + log.write(cmd + "\\n") +started = os.path.join(ctl, "started") +if cmd == "serve": + if mode != "never": + open(started, "w").write(str(time.time())) + sys.exit(0) +if cmd == "status": + if mode == "already": + state = "serving" + elif not os.path.exists(started): + state = "down" + else: + age = time.time() - float(open(started).read()) + if mode == "dies": + state = "starting" if age < 0.3 else "down" + else: + state = "starting" if age < 0.6 else "serving" + print(json.dumps({"running": state != "down", "serving": ["docker"] if state == "serving" else []})) + sys.exit(0 if state != "down" else 3) +''' + + +def _fake_gateway(lay: Layout, mode: str) -> Path: + (lay.src / "container_gateway" / "__init__.py").write_text("") + (lay.src / "container_gateway" / "__main__.py").write_text(_FAKE_GATEWAY) + ctl = lay.base / "fake-gw" + ctl.mkdir() + (ctl / "mode").write_text(mode) + return ctl + + +def _ensure_for_real(lay: Layout, ctl: Path) -> tuple[subprocess.CompletedProcess[str], float, list[str]]: + env = {"CLAUDE_PROJECT_DIR": str(lay.project), "FAKE_GW_DIR": str(ctl)} + began = time.monotonic() + done = run("ensure", lay.project, env, home=lay.home, dry_run=False, + payload=bash_payload(lay.project, "docker ps")) + calls = (ctl / "calls").read_text().split() if (ctl / "calls").exists() else [] + return done, time.monotonic() - began, calls + + +def test_ensure_leaves_a_serving_gateway_alone(tmp_path: Path) -> None: + lay = Layout(tmp_path) + done, _, calls = _ensure_for_real(lay, _fake_gateway(lay, "already")) + assert done.returncode == 0 and calls == ["status"] + + +def test_ensure_waits_until_the_gateway_serves(tmp_path: Path) -> None: + """Holding the pid lock is not serving: the wait ends only once a backend socket is bound.""" + lay = Layout(tmp_path) + ctl = _fake_gateway(lay, "slow") + done, elapsed, calls = _ensure_for_real(lay, ctl) + assert done.returncode == 0 and calls[:2] == ["status", "serve"] + assert elapsed >= 0.6 and calls.count("status") >= 3 + + +@pytest.mark.parametrize("mode", ["dies", "never"]) +def test_ensure_stops_waiting_for_a_gateway_that_is_gone(tmp_path: Path, mode: str) -> None: + """No backend (the gateway exits) or no start at all must not stall every container command for 5 s.""" + lay = Layout(tmp_path) + done, elapsed, calls = _ensure_for_real(lay, _fake_gateway(lay, mode)) + assert done.returncode == 0 and "serve" in calls + assert elapsed < 3 + + +def test_a_settings_fifo_refuses_without_blocking(tmp_path: Path) -> None: + """jq must never open a FIFO sandboxed code planted where a settings file would be.""" + lay = Layout(tmp_path) + sub = lay.base / "other" + (sub / ".claude").mkdir(parents=True) + os.mkfifo(sub / ".claude" / "settings.json") + done = ensure(lay, "docker ps", cwd=sub) + assert done.stdout == "" and "not a readable regular file" in done.stderr diff --git a/tools/spec-loop/specs/container-gateway.md b/tools/spec-loop/specs/container-gateway.md index af24489a0..0de7f2d5d 100644 --- a/tools/spec-loop/specs/container-gateway.md +++ b/tools/spec-loop/specs/container-gateway.md @@ -369,10 +369,12 @@ CLI flags with environment-variable equivalents, no config file: outside the tree — each one is logged at start so it shows up in a `setup verify` run), `--idle-timeout`, `--log-level`, `--pid-file`. -Reference settings. The `hooks` half is committed and -project-agnostic; the `env` half is per-machine and belongs in the -gitignored `.claude/settings.local.json`, because it needs absolute -socket paths: +Reference settings. The `hooks` half goes in the user settings by the +script's absolute path (per machine, so `~/.claude/settings.local.json` +when `~/.claude/settings.json` is synced; a `~` there is expanded from +`$HOME`, which the session environment can redirect); the `env` half is +per-project and belongs in the gitignored `.claude/settings.local.json`, +because it needs absolute socket paths: ```jsonc // .claude/settings.local.json — per machine @@ -383,18 +385,15 @@ socket paths: } } -// committed, project-agnostic +// ~/.claude/settings.local.json — per machine, absolute path { "hooks": { "SessionStart": [{ "hooks": [{ "type": "command", - "command": "~/.claude/scripts/container-gateway-hook.sh start" }] }], + "command": "/Users//.claude/scripts/container-gateway-hook.sh start" }] }], "SessionEnd": [{ "hooks": [{ "type": "command", - "command": "~/.claude/scripts/container-gateway-hook.sh stop" }] }], - "PreToolUse": [{ "matcher": "Bash", "hooks": [ - { "type": "command", "if": "Bash(docker *)", "timeout": 30, - "command": "~/.claude/scripts/container-gateway-hook.sh ensure" }, - { "type": "command", "if": "Bash(podman *)", "timeout": 30, - "command": "~/.claude/scripts/container-gateway-hook.sh ensure" }] }] + "command": "/Users//.claude/scripts/container-gateway-hook.sh stop" }] }], + "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "timeout": 30, + "command": "/Users//.claude/scripts/container-gateway-hook.sh ensure" }] }] } } ```