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
28 changes: 28 additions & 0 deletions docs/setup/secure-agent-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -2153,6 +2153,34 @@ that never prompts.
pinentry does for the PIN: the desktop dims and a pulsing contact ring
says which key is waiting. It closes itself the moment the touch lands.

The window also names **what** the touch is for — the command that is
blocked and the directory it runs in:

```text
Touch your security key
Your security key is waiting for a touch

git commit -m 'fix the parser'
in ~/code/magpie
```

That is the difference between a prompt you can answer and one you have
to go and investigate. An agent session and a terminal can both be
waiting on the same key, several worktrees of the same repository look
alike from the outside, and a touch given to the wrong one is not
recoverable — the key fires its OTP slot into whatever has focus. The
two lines come from the hook payload of the command about to run
(`arm`), or from the wrapper's own `$PWD` and argv when git calls it
directly (`wrap`); a second command arming into a session that is
already watched replaces them, so the window always names the command
actually blocked rather than the one that started the watcher.

A password embedded in a URL — `git push https://user:token@host/repo`
— is masked to `user:***@` before it is recorded. Nothing else is
scrubbed: this is a full-screen window raised at an unpredictable
moment, so treat what it shows as visible to anyone who can see the
screen.

### Install (user-scope)

```sh
Expand Down
4 changes: 2 additions & 2 deletions tools/agent-isolation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ per runtime — see [`docs/adapters/add-a-harness.md`](../../docs/adapters/add-a
| [`sandbox-bypass-warn.sh`](sandbox-bypass-warn.sh) | Claude Code `PreToolUse` hook (Bash matcher). Prints a bold-red banner to stderr whenever the model invokes the Bash tool with `dangerouslyDisableSandbox: true`. Belt-and-braces visibility for the sandbox-bypass permission prompt. Recommended user-scope (`~/.claude/settings.json`) so it fires across every session on the host. |
| [`sandbox-error-hint.sh`](sandbox-error-hint.sh) | Claude Code `PostToolUse` hook (Bash matcher). Scans the tool's stdout + stderr for the four known sandbox-shaped error signatures (SSH agent / Yubikey unreachable, loopback port-bind blocked, docker / podman socket denied, temp files blocked under `/tmp`) and prints a `[sandbox-hint]` line pointing at the matching entry in [`docs/setup/sandbox-troubleshooting.md`](../../docs/setup/sandbox-troubleshooting.md). Fail-open: any unexpected JSON shape exits silent. Recommended user-scope so the hint fires across every session. Complements `setup-isolated-setup-doctor` (the structured probe) by surfacing the catalog reference at the moment of failure, without the user having to remember the catalog exists. |
| [`sandbox-status-line.sh`](sandbox-status-line.sh) | Claude Code `statusLine` helper, and the only one Magpie ships. **Claude Code only** — the one script in this directory that is not harness-agnostic: it is wired through Claude Code's `statusLine` setting, is fed Claude Code's statusLine payload on stdin, and reads Claude Code's `sandbox.enabled` schema. No other harness the framework supports has a status-line hook of that shape; Codex, Gemini, OpenCode and Kiro carry their sandbox posture in their own config and surface it, where they surface it at all, through their own UI. A harness that grows one gets its own helper — see [`docs/adapters/add-a-harness.md`](../../docs/adapters/add-a-harness.md). Leads with the sandbox state — green `[sandbox]`, yellow `[sandbox-auto]` when `autoAllowBashIfSandboxed` widens the blast radius, bold-red `[NO SANDBOX]` — read from `sandbox.enabled` in the active settings, project `settings.local.json` first, then project `settings.json`, then user-scope, mirroring Claude Code's own precedence, so in-session `/sandbox` toggles are reflected. Inside a linked git worktree the walk **leads with the main checkout** — that is where Claude Code scopes the project and where `/sandbox` persists the toggle, so it is the only file that can describe the session; reading `<cwd>` alone there falls through to user scope and paints a green `[sandbox]` on a session that has none. Layout-agnostic: worktrunk's `<repo>.<branch>/` siblings, `.claude/worktrees/<name>`, and plain `git worktree add` all resolve the same way, and a bare repo's worktrees — which have no main checkout at all — fall through to user scope rather than reading the bare directory's parent. Then the context that tells sessions apart: hash-coloured folder (stable per repo and worktree), git branch + dirty + ahead/behind (local only, no network), the branch's PR number and title (cached, gated by `gh`, silent without it), and the model. Every segment degrades to silence when its input is missing. Install user-scope. |
| [`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. `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. 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. 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.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. `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` 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. 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). |
| [`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). |
Expand Down
66 changes: 63 additions & 3 deletions tools/agent-isolation/gpg-touch-overlay-window-macos.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@

import ctypes
import ctypes.util
import os
import signal
import sys
import tkinter as tk
Expand All @@ -56,6 +57,12 @@
SUBTITLE = "Your security key is waiting for a touch"
HINT = "The git command stays blocked until you touch the key · Esc to dismiss"

# How much of the context the window will show. The watcher has already
# capped what it writes; these are display widths, chosen so a line stays
# on one row at the font sizes below rather than wrapping the layout.
COMMAND_MAX = 96
CWD_MAX = 72

BG = (0, 0, 0)
BG_HEX = "#000000"
ACCENT = (245, 194, 41) # amber, the colour these keys blink
Expand All @@ -72,6 +79,48 @@
STOP_POLL_MS = 60

FONT = "Helvetica Neue"
MONO = "Menlo"


def elide(text, limit, keep="head"):
"""Cut *text* to *limit*, marking where it was cut.

Which end survives is not the same question for the two lines. A
command is identified by how it starts — ``git commit``, ``git
push`` — so the head is kept; a path is identified by where it ends,
since the leading components are the ones every checkout on the
machine shares.
"""
if len(text) <= limit:
return text
if keep == "tail":
return "…" + text[-(limit - 1):]
return text[: limit - 1] + "…"


def context():
"""The command this touch is blocking and the directory it runs in.

Both come from the watcher, which read them from the file the arming
hook wrote. Either being absent is normal and not an error: a
wrapped signature outside an agent session has no hook payload
behind it, and an older watcher passes nothing at all. The window
simply drops the lines it has no text for.

Control characters are stripped again here even though the watcher
already flattened them. This process is handed its text through the
environment, and a window that renders whatever is in a variable is
worth one defensive pass.
"""
raw_command = os.environ.get("MAGPIE_GPG_TOUCH_COMMAND", "")
raw_cwd = os.environ.get("MAGPIE_GPG_TOUCH_CWD", "")
command, cwd = (
" ".join(value.split()) for value in (raw_command, raw_cwd)
)
home = os.path.expanduser("~")
if home and (cwd == home or cwd.startswith(home + os.sep)):
cwd = "~" + cwd[len(home):]
return elide(command, COMMAND_MAX), elide(cwd, CWD_MAX, keep="tail")


def blend(fg, bg, alpha):
Expand Down Expand Up @@ -226,11 +275,22 @@ def build_window():
frame.place(relx=0.5, rely=0.5, anchor="center")

Pulse(frame).pack()
for text, font, colour, pad in (
command, cwd = context()
# The context sits between the subtitle and the hint, and only when
# there is any: the lines are what distinguishes two windows raised
# minutes apart, and an empty row where a command should be reads as
# a window that failed to load one.
lines = [
(TITLE, (FONT, 64, "bold"), "#ffffff", (14, 0)),
(SUBTITLE, (FONT, 28), "#dfe4ec", (18, 0)),
(HINT, (FONT, 16), "#8d96a4", (34, 0)),
):
]
if command:
lines.append((command, (MONO, 20), "#f5c229", (26, 0)))
if cwd:
lines.append((f"in {cwd}", (MONO, 16), "#8d96a4", (6, 0)))
lines.append((HINT, (FONT, 16), "#8d96a4", (34, 0)))

for text, font, colour, pad in lines:
tk.Label(
frame, text=text, font=font, fg=colour, bg=BG_HEX
).pack(pady=pad)
Expand Down
70 changes: 67 additions & 3 deletions tools/agent-isolation/gpg-touch-overlay-window.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
to go through, so trapping the screen would buy nothing.
"""

import os
import signal
import sys

Expand All @@ -44,6 +45,12 @@
SUBTITLE = "Your security key is waiting for a touch"
HINT = "The git command stays blocked until you touch the key · Esc to dismiss"

# How much of the context the window will show. The watcher has already
# capped what it writes; these are display widths, chosen so a line stays
# on one row at the font sizes below rather than wrapping the layout.
COMMAND_MAX = 96
CWD_MAX = 72

DIM = (0.0, 0.0, 0.0, 0.76)
ACCENT = (0.96, 0.76, 0.16) # amber, the colour these keys blink

Expand All @@ -58,10 +65,53 @@
.touch-title { color: #ffffff; font-size: 84px; font-weight: 800;
letter-spacing: 1px; }
.touch-subtitle { color: #dfe4ec; font-size: 36px; }
.touch-command { color: #f5c229; font-size: 26px; font-family: monospace; }
.touch-cwd { color: #8d96a4; font-size: 20px; font-family: monospace; }
.touch-hint { color: #8d96a4; font-size: 22px; letter-spacing: 1px; }
"""


def elide(text, limit, keep="head"):
"""Cut *text* to *limit*, marking where it was cut.

Which end survives is not the same question for the two lines. A
command is identified by how it starts — ``git commit``, ``git
push`` — so the head is kept; a path is identified by where it ends,
since the leading components are the ones every checkout on the
machine shares.
"""
if len(text) <= limit:
return text
if keep == "tail":
return "…" + text[-(limit - 1):]
return text[: limit - 1] + "…"


def context():
"""The command this touch is blocking and the directory it runs in.

Both come from the watcher, which read them from the file the arming
hook wrote. Either being absent is normal and not an error: a
wrapped signature outside an agent session has no hook payload
behind it, and an older watcher passes nothing at all. The window
simply drops the lines it has no text for.

Control characters are stripped again here even though the watcher
already flattened them. This process is handed its text through the
environment, and a window that renders whatever is in a variable is
worth one defensive pass.
"""
raw_command = os.environ.get("MAGPIE_GPG_TOUCH_COMMAND", "")
raw_cwd = os.environ.get("MAGPIE_GPG_TOUCH_CWD", "")
command, cwd = (
" ".join(value.split()) for value in (raw_command, raw_cwd)
)
home = os.path.expanduser("~")
if home and (cwd == home or cwd.startswith(home + os.sep)):
cwd = "~" + cwd[len(home):]
return elide(command, COMMAND_MAX), elide(cwd, CWD_MAX, keep="tail")


TAU = 6.283185307179586


Expand Down Expand Up @@ -135,11 +185,25 @@ def build_content():
box.set_valign(Gtk.Align.CENTER)

box.pack_start(Pulse(), False, False, 0)
for text, css_class, pad in (
command, cwd = context()
# The context sits between the subtitle and the hint, and only when
# there is any: the lines are what distinguishes two windows raised
# minutes apart, and an empty row where a command should be reads as
# a window that failed to load one.
lines = [
(TITLE, "touch-title", 14),
(SUBTITLE, "touch-subtitle", 0),
(HINT, "touch-hint", 34),
):
]
if command:
lines.append((command, "touch-command", 22))
if cwd:
lines.append((f"in {cwd}", "touch-cwd", 0))
lines.append((HINT, "touch-hint", 34))

for text, css_class, pad in lines:
# `label=` and never `set_markup`: the command is arbitrary text
# that routinely contains `&&`, `<` and `>`, and Pango would
# take those for markup and refuse to render the line.
label = Gtk.Label(label=text)
label.get_style_context().add_class(css_class)
box.pack_start(label, False, False, pad)
Expand Down
Loading