diff --git a/docs/setup/secure-agent-setup.md b/docs/setup/secure-agent-setup.md index b47eb49d..6648ae00 100644 --- a/docs/setup/secure-agent-setup.md +++ b/docs/setup/secure-agent-setup.md @@ -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 diff --git a/tools/agent-isolation/README.md b/tools/agent-isolation/README.md index 26a0895a..beabb56f 100644 --- a/tools/agent-isolation/README.md +++ b/tools/agent-isolation/README.md @@ -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 `` alone there falls through to user scope and paints a green `[sandbox]` on a session that has none. Layout-agnostic: worktrunk's `./` siblings, `.claude/worktrees/`, 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). | diff --git a/tools/agent-isolation/gpg-touch-overlay-window-macos.py b/tools/agent-isolation/gpg-touch-overlay-window-macos.py index cdffffce..d8bb6f40 100644 --- a/tools/agent-isolation/gpg-touch-overlay-window-macos.py +++ b/tools/agent-isolation/gpg-touch-overlay-window-macos.py @@ -48,6 +48,7 @@ import ctypes import ctypes.util +import os import signal import sys import tkinter as tk @@ -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 @@ -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): @@ -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) diff --git a/tools/agent-isolation/gpg-touch-overlay-window.py b/tools/agent-isolation/gpg-touch-overlay-window.py index be6bc20f..1113e324 100755 --- a/tools/agent-isolation/gpg-touch-overlay-window.py +++ b/tools/agent-isolation/gpg-touch-overlay-window.py @@ -31,6 +31,7 @@ to go through, so trapping the screen would buy nothing. """ +import os import signal import sys @@ -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 @@ -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 @@ -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) diff --git a/tools/agent-isolation/gpg-touch-overlay.sh b/tools/agent-isolation/gpg-touch-overlay.sh index 30c4cf05..a3fcce5d 100755 --- a/tools/agent-isolation/gpg-touch-overlay.sh +++ b/tools/agent-isolation/gpg-touch-overlay.sh @@ -169,6 +169,21 @@ fi # the question "is this mine to kill?" always has an answer. readonly OWNERS_DIR="$RUNTIME_DIR/owners" +# What the window says the touch is *for*: one file per owner, keyed by +# the same id as its registration, holding the working directory on the +# first line and the command on the second. +# +# A separate directory rather than a suffix inside `owners/`, because +# `_sweep_owners` reads every entry there as a registration — a +# `s-abc.ctx` sitting beside `s-abc` would be parsed as one, fail, and be +# swept away on the next arm. +# +# Keying by owner id is what makes the ride-along case work: an arm that +# finds its session already watched writes the file anyway and returns, +# and the running watcher — which holds this path, not the text — shows +# the newer command the next time it raises the window. +readonly CONTEXT_DIR="$RUNTIME_DIR/context" + # The window is the one thing that must stay single across owners, so it # is leased rather than owned: an atomic directory create, which is the # one primitive both macOS and Linux have without flock(1). @@ -336,6 +351,53 @@ _kill_watcher() { kill -- -"$1" "$1" 2>/dev/null || true } +# ----------------------------------------------------------- context --- + +#: Longest command kept on disk. The window truncates again for its own +#: width; this only stops a multi-kilobyte heredoc — the shape of an +#: agent's `git commit -F -` — from being written out on every arm. +readonly CONTEXT_COMMAND_MAX=400 + +# One line, no control characters. A command reaches here straight from +# a hook payload or a terminal argv, and both routinely carry newlines +# (a heredoc, a quoted commit message); a second line in a two-line file +# would be read back as neither the directory nor the command. +_flatten() { + printf '%s' "$1" | + tr '\n\r\t\v\f\0' ' ' | + sed -e 's/ */ /g' -e 's/^ //' -e 's/ $//' | + cut -c "1-$CONTEXT_COMMAND_MAX" +} + +# Passwords in a URL are the one secret that routinely rides in an argv +# this script matches: `git push https://user:token@host/repo` is a +# documented git spelling, and the overlay puts what it is given across +# the whole screen — the moment somebody is most likely to be sharing it. +# Nothing else is scrubbed; this is not a general redactor, and a command +# that carries a secret anywhere else still shows it. +_scrub() { + printf '%s' "$1" | sed -E 's#(://[^/@:[:space:]]+):[^/@[:space:]]+@#\1:***@#g' +} + +# Record what this owner's touch is for. Best-effort throughout: a +# window with no context is the old behaviour, and never a reason to +# fail the commit the hook is standing in front of. +_write_context() { + local id=$1 cwd=$2 command=$3 tmp + mkdir -p "$CONTEXT_DIR" 2>/dev/null || return 0 + tmp="$CONTEXT_DIR/.$id.$$" + { + printf '%s\n' "$(_flatten "$cwd")" + printf '%s\n' "$(_scrub "$(_flatten "$command")")" + } >"$tmp" 2>/dev/null && + mv -f "$tmp" "$CONTEXT_DIR/$id" 2>/dev/null + return 0 +} + +_forget_context() { + rm -f "$CONTEXT_DIR/$1" 2>/dev/null || true +} + # A context whose owner is gone left its watcher behind, and a watcher # nobody will disarm holds its window until MAX_WAIT. This is what turns # a crashed session from a ten-minute stuck overlay into nothing at all. @@ -346,11 +408,13 @@ _sweep_owners() { id=${reg##*/} if ! _read_registration "$id"; then rm -f "$reg" 2>/dev/null + _forget_context "$id" continue fi if ! kill -0 "$_owner_pid" 2>/dev/null; then _kill_watcher "$_watcher_pid" rm -f "$reg" 2>/dev/null + _forget_context "$id" fi done } @@ -365,6 +429,9 @@ _cleanup_if_idle() { done rm -f "$DISMISSED_MARKER" 2>/dev/null [[ -n ${WINDOW_LOCK:-} ]] && rm -rf "$WINDOW_LOCK" 2>/dev/null + # No owner left means no context belongs to anybody, including the + # temporary files of a writer that died mid-write. + [[ -n ${CONTEXT_DIR:-} ]] && rm -rf "$CONTEXT_DIR" 2>/dev/null return 0 } @@ -405,11 +472,16 @@ arm() { _gui_available || return 0 fi - local payload command_text session + local payload command_text session cwd payload="$(cat)" command_text="$(printf '%s' "$payload" | jq -r '.tool_input.command // empty' 2>/dev/null)" [[ -n $command_text ]] || return 0 session="$(printf '%s' "$payload" | jq -r '.session_id // empty' 2>/dev/null)" + # Where the blocked command runs, for the window to name. The + # harness sends it; a payload without one leaves the line off rather + # than substituting this hook's own directory, which is the + # harness's and not the command's. + cwd="$(printf '%s' "$payload" | jq -r '.cwd // empty' 2>/dev/null)" printf '%s' "$command_text" | grep -Eq "(^|[;&|(]|[[:space:]])(git([[:space:]]+-[A-Za-z-]+([[:space:]]+[^[:space:]]+)?)*[[:space:]]+($KEY_SUBCOMMANDS)|($KEY_COMMANDS))([[:space:];&|)]|$)" || @@ -430,6 +502,11 @@ arm() { # command in the same session rides along with it. local id id="$(_owner_id "$session")" + # Written before that early return, not after it. The rider is the + # command actually about to block, so it is the one the window + # should name; the watcher already holds this path and reads it + # afresh each time it raises the window. + _write_context "$id" "$cwd" "$command_text" if _read_registration "$id" && kill -0 "$_watcher_pid" 2>/dev/null; then return 0 fi @@ -451,6 +528,7 @@ arm() { # if that goes, the disarm is never coming, and the watcher should # not wait out MAX_WAIT to find that out. MAGPIE_GPG_TOUCH_PARENT=$PPID \ + MAGPIE_GPG_TOUCH_CONTEXT="$CONTEXT_DIR/$id" \ "${SESSION_LAUNCHER[@]}" "$SELF" _watch >>"$log" 2>&1 & _register "$id" "$PPID" "$!" } @@ -474,6 +552,10 @@ disarm() { _kill_watcher "$_watcher_pid" rm -f "$OWNERS_DIR/$id" 2>/dev/null fi + # Unconditionally: an arm that wrote the context and then found the + # session already watched left a file behind with no registration of + # its own to hang it off. + _forget_context "$id" # Somebody else's crashed session is nobody's to wait for. _sweep_owners _cleanup_if_idle @@ -564,11 +646,17 @@ wrap() { : >"$log" fi _set_session_launcher + # What the window will name. `$PWD` is the right directory here and + # not in `arm`: this wrapper *is* the command's process, sitting in + # the shell's own directory, whereas the hook runs beside the + # command rather than in it. + _write_context "$id" "$PWD" "$program${*:+ $*}" # The watcher is told whose it is, and leaves on its own once this # wrapper is gone -- however that happened. The kill in the trap # below is the fast path; the parent check is the one that cannot be # raced or skipped. MAGPIE_GPG_TOUCH_WRAPPED_SIGNER=$signer MAGPIE_GPG_TOUCH_PARENT=$$ \ + MAGPIE_GPG_TOUCH_CONTEXT="$CONTEXT_DIR/$id" \ "${SESSION_LAUNCHER[@]}" "$SELF" _watch >>"$log" 2>&1 & own=$! _register "$id" "$$" "$own" @@ -582,7 +670,7 @@ wrap() { # window it spawned, the pid itself for a watcher too young to have # called setsid (a signature that returns in milliseconds ends # before it has). - trap 'kill -- -'"$own"' '"$own"' 2>/dev/null; rm -f "'"$OWNERS_DIR/$id"'" 2>/dev/null; _cleanup_if_idle' EXIT + trap 'kill -- -'"$own"' '"$own"' 2>/dev/null; rm -f "'"$OWNERS_DIR/$id"'" "'"$CONTEXT_DIR/$id"'" 2>/dev/null; _cleanup_if_idle' EXIT "$real" "$@" return $? @@ -635,7 +723,16 @@ show_overlay() { # draw two windows for the same touch. Losing it is the normal case # and means somebody else's window is already up. _lease_acquire || return 0 - "$SELF" _overlay & + # Read here rather than at arm time: the file is what carries a + # second command in the same session, and this is the last moment + # before the window is drawn. A missing or unreadable file leaves + # both empty and the window drops the two lines. + local cwd="" command="" + if [[ -n ${MAGPIE_GPG_TOUCH_CONTEXT:-} && -r ${MAGPIE_GPG_TOUCH_CONTEXT:-} ]]; then + { read -r cwd; read -r command; } <"$MAGPIE_GPG_TOUCH_CONTEXT" || true + fi + MAGPIE_GPG_TOUCH_CWD="$cwd" MAGPIE_GPG_TOUCH_COMMAND="$command" \ + "$SELF" _overlay & overlay_pid=$! raise_overlay & } @@ -771,10 +868,13 @@ _overlay() { # no tokens or keys land in a world-readable log. { printf 'overlay env: ' - env | grep -E '^(PATH|HOME|USER|SHELL|TERM|LANG|TMPDIR|DISPLAY|WAYLAND_DISPLAY|SSH_AUTH_SOCK|XPC_SERVICE_NAME|__CFBundleIdentifier|CLAUDE[A-Z_]*)=' | sort | tr '\n' ' ' + env | grep -E '^(PATH|HOME|USER|SHELL|TERM|LANG|TMPDIR|DISPLAY|WAYLAND_DISPLAY|SSH_AUTH_SOCK|XPC_SERVICE_NAME|__CFBundleIdentifier|MAGPIE_GPG_TOUCH_(CWD|COMMAND)|CLAUDE[A-Z_]*)=' | sort | tr '\n' ' ' printf '\n' } >>"$out" 2>&1 fi + # The two toolkit windows read MAGPIE_GPG_TOUCH_CWD / _COMMAND from + # the environment they are exec'd with; only zenity, which takes one + # string, has to have them spliced into its text here. if [[ $PLATFORM == Darwin ]]; then py="$(_tk_python)" || return 0 exec "$py" "$OVERLAY_WINDOW_MACOS" >>"$out" 2>&1 @@ -782,7 +882,17 @@ _overlay() { if py="$(_gi_python)"; then exec "$py" "$OVERLAY_WINDOW" >>"$out" 2>&1 fi - exec zenity --warning --title="$TITLE" --width=560 --text="$BODY" >>"$out" 2>&1 + local body="$BODY" + if [[ -n ${MAGPIE_GPG_TOUCH_COMMAND:-} ]]; then + # Pango markup, as the rest of BODY is, so the command has to be + # escaped: a `&&` chain or a `2>&1` is otherwise an unterminated + # entity and zenity renders the raw markup instead of the text. + body+=$'\n\n'"$(printf '%s' "$MAGPIE_GPG_TOUCH_COMMAND" | + sed -e 's/&/\&/g' -e 's//\>/g')" + [[ -n ${MAGPIE_GPG_TOUCH_CWD:-} ]] && + body+=$'\n'"in ${MAGPIE_GPG_TOUCH_CWD/#$HOME/\~}" + fi + exec zenity --warning --title="$TITLE" --width=560 --text="$body" >>"$out" 2>&1 } # A PreToolUse hook's exit status is a verdict on the command about to @@ -825,6 +935,10 @@ case "${1:-}" in # Test seam: report where state would be kept, so the fallback can be # asserted against the script's own value rather than a copy of it. _runtime_dir) printf '%s\n' "$RUNTIME_DIR"; exit 0 ;; + # Test seams: the two transformations every command goes through + # before it can be put on screen, exercised without a display. + _flatten) _flatten "${2:-}"; printf '\n'; exit 0 ;; + _scrub) _scrub "${2:-}"; printf '\n'; exit 0 ;; _signing_in_flight) signing_in_flight ;; _agent_sockets) agent_sockets ;; _agent_socket_rows) agent_socket_rows "$(printf '%s\n' "${@:2}")" ;; diff --git a/tools/agent-isolation/tests/test_gpg_touch_overlay.py b/tools/agent-isolation/tests/test_gpg_touch_overlay.py index 181a41f7..b55ca45f 100644 --- a/tools/agent-isolation/tests/test_gpg_touch_overlay.py +++ b/tools/agent-isolation/tests/test_gpg_touch_overlay.py @@ -25,6 +25,7 @@ from __future__ import annotations +import ast import json import os import shutil @@ -965,3 +966,217 @@ def test_wrap_registers_itself_and_leaves_other_owners_alone(tmp_path: Path) -> finally: other.kill() other.wait() + + +# ---------------------------------------------------------------- context --- +# +# What the window says the touch is *for*. The window scripts themselves +# are display code this package does not import (see the pyproject note); +# what is tested here is the shell half — what gets recorded, when it is +# refreshed, and that it is gone once nothing is signing. + + +def _context_file(runtime_root: Path, session: str) -> Path: + return runtime_root / "magpie-gpg-touch" / "context" / f"s-{session}" + + +def _context(runtime_root: Path, session: str) -> tuple[str, str]: + """The recorded (cwd, command) for a session.""" + cwd, command = _context_file(runtime_root, session).read_text().split("\n")[:2] + return cwd, command + + +def _arm_session_in( + runtime_root: Path, session: str, cwd: str, command: str = "git commit -m x" +): + result = subprocess.run( + ["bash", str(SCRIPT), "arm"], + input=json.dumps( + {"session_id": session, "cwd": cwd, "tool_input": {"command": command}} + ), + capture_output=True, + text=True, + env=_hook_env(runtime_root), + ) + assert result.returncode == 0, result.stderr + return result + + +def _transform(mode: str, text: str) -> str: + result = subprocess.run( + ["bash", str(SCRIPT), mode, text], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + return result.stdout.rstrip("\n") + + +def test_arm_records_the_directory_and_command(tmp_path: Path) -> None: + _arm_session_in(tmp_path, "ctx", "/srv/checkout", "git commit -m hello") + try: + assert _context(tmp_path, "ctx") == ("/srv/checkout", "git commit -m hello") + finally: + _disarm_session(tmp_path, "ctx") + + +def test_a_payload_without_a_cwd_still_records_the_command(tmp_path: Path) -> None: + """A harness that sends no cwd loses the line, not the window. + + The hook's own directory is not a stand-in: it is the harness's, + which is not where the blocked command runs. + """ + _arm_session(tmp_path, "nocwd", command="git push origin main") + try: + assert _context(tmp_path, "nocwd") == ("", "git push origin main") + finally: + _disarm_session(tmp_path, "nocwd") + + +def test_a_rider_refreshes_the_context_under_the_same_watcher(tmp_path: Path) -> None: + """The second command in a session is the one the window should name. + + It arms into a watcher that already exists and returns early, so the + context has to be written before that return — and read by the + watcher from the file rather than held in its environment. + """ + _arm_session_in(tmp_path, "ride", "/first", "git commit -m one") + first_watcher = _read_registration(tmp_path, "ride")[1] + try: + _arm_session_in(tmp_path, "ride", "/second", "git push origin main") + assert _read_registration(tmp_path, "ride")[1] == first_watcher + assert _context(tmp_path, "ride") == ("/second", "git push origin main") + finally: + _disarm_session(tmp_path, "ride") + + +def test_disarm_forgets_the_context(tmp_path: Path) -> None: + _arm_session_in(tmp_path, "gone", "/srv/checkout") + _disarm_session(tmp_path, "gone") + assert not _context_file(tmp_path, "gone").exists() + + +def test_the_context_of_a_dead_owner_is_swept(tmp_path: Path) -> None: + """A crashed session leaves no stale command behind for the next window.""" + dead = subprocess.Popen(["sh", "-c", "exit 0"]) + dead.wait() + owners = _owners_dir(tmp_path) + owners.mkdir(parents=True) + (owners / "s-crashed").write_text(f"{dead.pid} {dead.pid}\n") + context = _context_file(tmp_path, "crashed") + context.parent.mkdir(parents=True, exist_ok=True) + context.write_text("/gone\ngit commit -m stale\n") + + _arm_session_in(tmp_path, "live", "/srv/checkout") + try: + assert not context.exists() + finally: + _disarm_session(tmp_path, "live") + + +def test_wrap_records_its_own_directory_and_command(tmp_path: Path) -> None: + """The wrapper is the command's own process, so `$PWD` is the answer.""" + workdir = tmp_path / "work" + workdir.mkdir() + runtime = tmp_path / "run" + result = subprocess.run( + ["bash", str(SCRIPT), "wrap", "sh", "-c", "exit 0"], + capture_output=True, + text=True, + cwd=workdir, + env={**_wrap_env(runtime), "MAGPIE_GPG_TOUCH_ASSUME_GUI": "1"}, + ) + assert result.returncode == 0, result.stderr + # The wrapper cleans up on exit, so the file is read back from the + # wrapped program rather than after the fact. + probe = subprocess.run( + [ + "bash", + str(SCRIPT), + "wrap", + "sh", + "-c", + 'cat "${MAGPIE_GPG_TOUCH_REGISTRATION%/owners/*}/context/${MAGPIE_GPG_TOUCH_REGISTRATION##*/}"', + ], + capture_output=True, + text=True, + cwd=workdir, + env={**_wrap_env(runtime), "MAGPIE_GPG_TOUCH_ASSUME_GUI": "1"}, + ) + assert probe.returncode == 0, probe.stderr + recorded_cwd, recorded_command = probe.stdout.split("\n")[:2] + assert Path(recorded_cwd).resolve() == workdir.resolve() + assert recorded_command.startswith("sh -c ") + + +@pytest.mark.parametrize( + "raw,flattened", + [ + ("git commit -F -\nbody line\n", "git commit -F - body line"), + ("git commit\t-m\tx", "git commit -m x"), + ("git commit -m x", "git commit -m x"), + (" git commit ", "git commit"), + ], +) +def test_a_command_is_recorded_as_one_line(raw: str, flattened: str) -> None: + """A two-line file cannot hold a command that carries newlines itself. + + An agent's `git commit -F -` heredoc is the everyday case. + """ + assert _transform("_flatten", raw) == flattened + + +def test_a_long_command_is_capped_before_it_is_written() -> None: + assert len(_transform("_flatten", "git commit -m " + "x" * 5000)) == 400 + + +@pytest.mark.parametrize( + "raw,scrubbed", + [ + ( + "git push https://jarek:ghp_secret@github.com/apache/magpie main", + "git push https://jarek:***@github.com/apache/magpie main", + ), + ( + "git clone ssh://user:pw@host:22/repo.git", + "git clone ssh://user:***@host:22/repo.git", + ), + # No password, nothing to do — and `ssh git@host` has no scheme + # for the pattern to anchor on in the first place. + ( + "git push https://github.com/apache/magpie main", + "git push https://github.com/apache/magpie main", + ), + ("ssh git@github.com", "ssh git@github.com"), + ], +) +def test_a_password_in_a_url_is_not_put_on_screen(raw: str, scrubbed: str) -> None: + """The overlay covers the whole screen at the moment most likely to be shared.""" + assert _transform("_scrub", raw) == scrubbed + + +def test_the_two_windows_share_one_copy_of_the_context_helpers() -> None: + """The GTK and Aqua windows are twins by design — these parts must not drift. + + They are different toolkits and duplicate their drawing on purpose, + but `elide` and `context` are toolkit-independent and are meant to be + the same text in both files. Neither is imported by this package (see + the pyproject note), so nothing else would notice them diverging. + """ + windows = [ + SCRIPT.parent / "gpg-touch-overlay-window.py", + SCRIPT.parent / "gpg-touch-overlay-window-macos.py", + ] + wanted = ("elide", "context") + helpers = [] + for window in windows: + source = window.read_text() + tree = ast.parse(source) + found = { + node.name: ast.get_source_segment(source, node) + for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name in wanted + } + assert set(found) == set(wanted), f"{window.name} is missing {wanted}" + helpers.append([found[name] for name in wanted]) + assert helpers[0] == helpers[1], "the two windows' context helpers have drifted" diff --git a/tools/spec-loop/specs/agent-isolation-sandbox.md b/tools/spec-loop/specs/agent-isolation-sandbox.md index 8c5835f8..463122f0 100644 --- a/tools/spec-loop/specs/agent-isolation-sandbox.md +++ b/tools/spec-loop/specs/agent-isolation-sandbox.md @@ -80,7 +80,16 @@ existing sandbox grants can widen the baseline. See `docs/adapters/gemini.md`. the wrapper only runs the program and the hook's watcher shows the window, and across contexts the window is leased by atomic directory create, so only one watcher draws it and a lease left by a watcher - that died is reclaimed. That registry, the lease, and the watcher's + that died is reclaimed. The window names the blocked command and the + directory it runs in, so a touch is never given to the wrong one of + two waiting contexts: `arm` records them from the hook payload and + `wrap` from its own `$PWD` and argv, into a per-owner file under + `context/` keyed like the registration. The watcher holds the path + and re-reads it each time it raises the window, which is what lets a + second command arming into an already-watched session replace the + text without a second watcher. A password in a URL is masked before + it is written; nothing else is, and the window is full-screen. That + registry, the context files, the lease, and the watcher's pid and log files live in `$XDG_RUNTIME_DIR/magpie-gpg-touch`, else `${XDG_CACHE_HOME:-$HOME/.cache}/magpie-gpg-touch` on a platform that sets no `XDG_RUNTIME_DIR`. The fallback has to be per-user rather than