You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@magpie.apache.org by po...@apache.org on 2026/09/14 17:18:27 UTC

(magpie) branch main updated: feat(agent-isolation): show a window while gpg waits for a key touch (#1224)

This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/magpie.git


The following commit(s) were added to refs/heads/main by this push:
     new b94274db feat(agent-isolation): show a window while gpg waits for a key touch (#1224)
b94274db is described below

commit b94274db71cb9f8911611b2bd0044d1ad8fef271
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Mon Sep 14 23:48:21 2026 +0630

    feat(agent-isolation): show a window while gpg waits for a key touch (#1224)
    
    A signing key on a hardware token can carry a touch policy on its
    signature slot, and gpg surfaces the PIN and the touch very differently:
    the PIN gets a pinentry window, the touch gets nothing at all. The commit
    just blocks, indistinguishable from a hung command, until somebody
    happens to touch the key or gpg reports "signing failed: Timeout".
    
    The hardware-key rule added in #1219 has the agent probe gpg-agent before
    committing, but keyinfo reports the PIN cache and not the touch policy —
    a key with a warm PIN and an expired touch reports cached=1 and still
    blocks, with no prompt of any kind. The probe cannot see that case, so
    this covers it from the other side, at the moment gpg is actually
    waiting, and the rule now says which half covers what.
    
    Generated-by: Claude Opus 5
    Claude-Session: https://claude.ai/code/session_015J6P12zaYGD4r1DZiAkG7c
---
 AGENTS.md                                          |   8 +
 docs/setup/secure-agent-setup.md                   | 112 +++++++++-
 tools/agent-isolation/gpg-touch-overlay-window.py  | 220 +++++++++++++++++++
 tools/agent-isolation/gpg-touch-overlay.sh         | 234 +++++++++++++++++++++
 .../tests/test_gpg_touch_overlay.py                | 112 ++++++++++
 5 files changed, 685 insertions(+), 1 deletion(-)

diff --git a/AGENTS.md b/AGENTS.md
index 8c4465ed..b4c3e8be 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -571,6 +571,14 @@ to a home-dir path and update the tool to read from there.
 
   `type=T` is a token-backed key; `type=D` is an on-disk key, which
   prompts for a passphrase rather than a touch but blocks the same way.
+
+  `cached` reports the **PIN** cache, which is not the same thing as the
+  key's *touch policy*. A token whose signature slot is set to `on` or
+  `cached` (`ykman openpgp info`) needs a physical touch per signature,
+  and gpg raises no prompt at all for it — so `cached=1` still blocks,
+  silently, once the touch has expired. The probe cannot see that;
+  `tools/agent-isolation/gpg-touch-overlay.sh` covers it from the other
+  side by putting a window on screen while gpg waits.
   On `cached=-`, surface a dialogue naming the key and telling the
   operator to expect the prompt, or hand them the `git commit` line to run
   in their own terminal — do not run it and hope. On `cached=1`, commit
diff --git a/docs/setup/secure-agent-setup.md b/docs/setup/secure-agent-setup.md
index 41cfd262..ed124226 100644
--- a/docs/setup/secure-agent-setup.md
+++ b/docs/setup/secure-agent-setup.md
@@ -41,6 +41,10 @@
     - [Trade-offs](#trade-offs-1)
   - [Sandbox-state status line](#sandbox-state-status-line)
   - [Waiting-for-input terminal tint](#waiting-for-input-terminal-tint)
+  - [Hardware-key touch overlay](#hardware-key-touch-overlay)
+    - [Install (user-scope)](#install-user-scope-3)
+    - [Verify](#verify-3)
+    - [Trade-offs](#trade-offs-2)
   - [Syncing user-scope config across machines](#syncing-user-scope-config-across-machines)
     - [What to track, what not to track](#what-to-track-what-not-to-track)
     - [Layout](#layout)
@@ -204,7 +208,8 @@ npm install -g --no-save @anthropic-ai/claude-code@latest
 #    under `PreToolUse`, `PostToolUse`, and `statusLine`.
 #    Sections: "Sandbox-bypass visibility hook",
 #    "Sandbox-error hint hook", and "Sandbox-state status line"
-#    below.
+#    below. Add `gpg-touch-overlay.sh` too if your signing key
+#    asks for a touch — section "Hardware-key touch overlay".
 
 # 5. Verify the install actually denies what it claims to —
 #    section "Verification" below has both a three-line Bash
@@ -1746,6 +1751,111 @@ shell's pty via `$PPID` and writes there.)
   and [Sandbox-bypass visibility hook](#sandbox-bypass-visibility-hook)
   for the security-relevant signals.
 
+## Hardware-key touch overlay
+
+**Linux/X11.** A signing key on a YubiKey, Nitrokey, or any OpenPGP
+card can carry a *touch policy* on its signature slot. With
+`on` or `cached`, the key will not sign until somebody physically
+touches it.
+
+gpg surfaces the PIN and the touch very differently. The PIN gets a
+pinentry window. The touch gets **nothing at all** — gpg simply blocks.
+From the outside there is no way to tell a key waiting to be touched
+from a hung command, so the commit sits until somebody happens to touch
+the key or gpg gives up with `gpg: signing failed: Timeout`, leaving no
+commit behind.
+
+Check whether this applies to you:
+
+```bash
+ykman openpgp info | grep -A2 'Touch policies'
+#   Signature key:      Cached      <- needs a touch; this hook helps
+#   Signature key:      Off         <- never waits for a touch; skip this
+```
+
+This is the operator-facing half of the hardware-key rule in
+[`AGENTS.md`](../../AGENTS.md) → *Commit and PR conventions*. That rule
+has the agent probe gpg-agent's cache and warn **before** committing.
+The probe reads `keyinfo`, which reports the **PIN** cache only — a key
+whose PIN is warm but whose touch has expired reports `cached=1` and
+still blocks, with no prompt of any kind. The two fit together: the
+probe catches the prompt you would not see, and this catches the touch
+that never prompts.
+
+`gpg-touch-overlay.sh` puts a window on screen for the touch, the way
+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.
+
+### Install (user-scope)
+
+```sh
+mkdir -p ~/.claude/scripts
+cp tools/agent-isolation/gpg-touch-overlay.sh \
+   tools/agent-isolation/gpg-touch-overlay-window.py \
+   ~/.claude/scripts/
+chmod +x ~/.claude/scripts/gpg-touch-overlay*.sh ~/.claude/scripts/gpg-touch-overlay-window.py
+```
+
+Both files go in the same directory — the shell script finds the window
+next to itself. Then wire the two modes into `~/.claude/settings.json`,
+in the `Bash` matcher groups you already have:
+
+```json
+{
+  "hooks": {
+    "PreToolUse": [
+      { "matcher": "Bash",
+        "hooks": [
+          { "type": "command", "command": "~/.claude/scripts/gpg-touch-overlay.sh arm" }
+        ] }
+    ],
+    "PostToolUse": [
+      { "matcher": "Bash",
+        "hooks": [
+          { "type": "command", "command": "~/.claude/scripts/gpg-touch-overlay.sh disarm" }
+        ] }
+    ]
+  }
+}
+```
+
+Needs `python3` with PyGObject for the dimmed overlay; where that is
+missing it falls back to a `zenity` dialog. `pgrep`, from `procps`, is
+the only other requirement.
+
+### Verify
+
+With the PIN already cached — so the next signature needs the touch and
+nothing else — start a signature and leave it waiting:
+
+```sh
+echo probe | gpg --status-fd=2 -bsau "$(git config --get user.signingkey)" >/dev/null &
+sleep 3 && pgrep -x zenity >/dev/null || pgrep -f gpg-touch-overlay-window >/dev/null \
+  && echo "overlay is up"
+pkill -x gpg
+```
+
+The window should appear about a second and a half in, and disappear
+when the gpg process ends. `MAGPIE_GPG_TOUCH_DEBUG=1` makes the watcher
+log to `$XDG_RUNTIME_DIR/magpie-gpg-touch/watcher.log`.
+
+### Trade-offs
+
+- **X11 only.** Placement and stacking use EWMH hints. Under Wayland the
+  overlay still draws, but the compositor decides where it lands.
+- **Nothing is shown while pinentry is up.** Two dialogs competing for
+  focus would make the PIN impossible to type, so the overlay waits for
+  pinentry to go away.
+- **Nothing is shown for a fast signature.** A still-warm touch signs in
+  well under a second; the overlay only appears once gpg has blocked
+  longer than that, so ordinary commits stay silent.
+- **The window is dismissible.** Esc or a click closes it. The key still
+  has to be touched for the commit to go through, so trapping the screen
+  would buy nothing.
+- **It watches the whole host, not just the agent.** The watcher keys off
+  any signing `gpg` process, so a commit you make yourself in another
+  terminal raises the window too.
+
 ## Syncing user-scope config across machines
 
 The user-scope pieces of the secure setup —
diff --git a/tools/agent-isolation/gpg-touch-overlay-window.py b/tools/agent-isolation/gpg-touch-overlay-window.py
new file mode 100755
index 00000000..c9397641
--- /dev/null
+++ b/tools/agent-isolation/gpg-touch-overlay-window.py
@@ -0,0 +1,220 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Full-screen dimmed "touch your key" overlay, shown while gpg blocks.
+
+Spawned by gpg-touch-overlay.sh; it draws nothing on its own schedule and
+exits when killed, so the shell script stays the only thing that decides
+when a touch is actually being waited for.
+
+Shape of the thing: one translucent window per monitor, so the whole
+desktop dims the way the pinentry PIN prompt dims it, with the prompt
+drawn large enough to read from across the room. The contact ring pulses
+because a still image reads as "an error happened", while motion reads as
+"something is waiting for you".
+
+Closes on Esc or a click — the key must still be touched for the commit
+to go through, so trapping the screen would buy nothing.
+"""
+
+import signal
+import sys
+
+import cairo
+import gi
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import GLib, Gdk, Gtk  # noqa: E402
+
+TITLE = "Touch your security key"
+SUBTITLE = "gpg is waiting for a touch to sign"
+HINT = "The commit stays blocked until you touch the key   ·   Esc to dismiss"
+
+DIM = (0.0, 0.0, 0.0, 0.76)
+ACCENT = (0.96, 0.76, 0.16)  # amber, the colour these keys blink
+
+# The canvas is deliberately wider than the widest ripple: a ripple that
+# reaches the widget edge gets clipped into a lopsided smudge.
+CANVAS_PX = 420
+RIPPLES = 3
+PULSE_MS = 33
+PULSE_PERIOD = 1.9  # seconds, one ripple's whole travel
+
+CSS = b"""
+.touch-title    { color: #ffffff; font-size: 84px; font-weight: 800;
+                  letter-spacing: 1px; }
+.touch-subtitle { color: #dfe4ec; font-size: 36px; }
+.touch-hint     { color: #8d96a4; font-size: 22px; letter-spacing: 1px; }
+"""
+
+
+TAU = 6.283185307179586
+
+
+class Pulse(Gtk.DrawingArea):
+    """A contact dot sending out ripples — the "touch here" affordance.
+
+    Several ripples at staggered phases, rather than one: a single ring
+    spends most of its cycle invisible, which reads as a stalled image
+    instead of something actively waiting.
+    """
+
+    def __init__(self):
+        super().__init__()
+        self.phase = 0.0
+        self.set_size_request(CANVAS_PX, CANVAS_PX)
+        self.connect("draw", self.on_draw)
+        GLib.timeout_add(PULSE_MS, self.tick)
+
+    def tick(self):
+        self.phase = (self.phase + PULSE_MS / 1000.0 / PULSE_PERIOD) % 1.0
+        self.queue_draw()
+        return True
+
+    def on_draw(self, _widget, cr):
+        w, h = self.get_allocated_width(), self.get_allocated_height()
+        cx, cy = w / 2.0, h / 2.0
+        base = min(w, h) / 2.0
+        r, g, b = ACCENT
+
+        # Repaint the dim under the animation. Only this widget is damaged
+        # per frame, so the window's own dim pass does not run again and the
+        # previous ripples would otherwise pile up as a smudge.
+        cr.set_operator(cairo.OPERATOR_SOURCE)
+        cr.set_source_rgba(*DIM)
+        cr.paint()
+        cr.set_operator(cairo.OPERATOR_OVER)
+
+        dot = base * 0.24
+        ring = base * 0.42
+
+        for i in range(RIPPLES):
+            travel = (self.phase + i / RIPPLES) % 1.0
+            radius = ring + (base * 0.92 - ring) * travel
+            # Fade in off the ring, then out, so nothing pops into view.
+            alpha = 0.45 * min(1.0, travel * 6.0) * (1.0 - travel) ** 1.5
+            cr.set_source_rgba(r, g, b, alpha)
+            cr.set_line_width(base * 0.055)
+            cr.arc(cx, cy, radius, 0, TAU)
+            cr.stroke()
+
+        glow = cairo.RadialGradient(cx, cy, dot * 0.4, cx, cy, ring * 1.5)
+        glow.add_color_stop_rgba(0.0, r, g, b, 0.38)
+        glow.add_color_stop_rgba(1.0, r, g, b, 0.0)
+        cr.set_source(glow)
+        cr.arc(cx, cy, ring * 1.5, 0, TAU)
+        cr.fill()
+
+        cr.set_source_rgba(r, g, b, 0.9)
+        cr.set_line_width(base * 0.075)
+        cr.arc(cx, cy, ring, 0, TAU)
+        cr.stroke()
+
+        cr.set_source_rgba(1.0, 0.88, 0.45, 1.0)
+        cr.arc(cx, cy, dot, 0, TAU)
+        cr.fill()
+
+
+def build_content():
+    box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18)
+    box.set_halign(Gtk.Align.CENTER)
+    box.set_valign(Gtk.Align.CENTER)
+
+    box.pack_start(Pulse(), False, False, 0)
+    for text, css_class, pad in (
+        (TITLE, "touch-title", 14),
+        (SUBTITLE, "touch-subtitle", 0),
+        (HINT, "touch-hint", 34),
+    ):
+        label = Gtk.Label(label=text)
+        label.get_style_context().add_class(css_class)
+        box.pack_start(label, False, False, pad)
+    return box
+
+
+def make_window(geometry):
+    win = Gtk.Window(type=Gtk.WindowType.TOPLEVEL)
+    win.set_title(TITLE)
+    win.set_decorated(False)
+    win.set_skip_taskbar_hint(True)
+    win.set_skip_pager_hint(True)
+    win.set_keep_above(True)
+    win.set_type_hint(Gdk.WindowTypeHint.SPLASHSCREEN)
+
+    # An RGBA visual is what makes the dim translucent rather than a solid
+    # black sheet. Without a compositor the window still works — it just
+    # blacks the screen out instead of dimming it.
+    visual = win.get_screen().get_rgba_visual()
+    if visual is not None:
+        win.set_visual(visual)
+    win.set_app_paintable(True)
+    win.connect("draw", on_draw_dim)
+
+    win.move(geometry.x, geometry.y)
+    win.resize(geometry.width, geometry.height)
+
+    win.add(build_content())
+    win.connect("destroy", Gtk.main_quit)
+    win.connect("button-press-event", lambda *_: Gtk.main_quit())
+    win.connect(
+        "key-press-event",
+        lambda _w, ev: Gtk.main_quit() if ev.keyval == Gdk.KEY_Escape else None,
+    )
+    win.add_events(Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.KEY_PRESS_MASK)
+    return win
+
+
+def on_draw_dim(_widget, cr):
+    cr.set_operator(cairo.OPERATOR_SOURCE)  # replace the desktop, don't blend
+    cr.set_source_rgba(*DIM)
+    cr.paint()
+    cr.set_operator(cairo.OPERATOR_OVER)  # children draw normally on top
+    return False
+
+
+def main():
+    # Killed by the watcher the moment the touch lands, so the default
+    # SIGTERM disposition is the normal way this process ends.
+    signal.signal(signal.SIGTERM, lambda *_: Gtk.main_quit())
+    signal.signal(signal.SIGINT, lambda *_: Gtk.main_quit())
+
+    provider = Gtk.CssProvider()
+    provider.load_from_data(CSS)
+    Gtk.StyleContext.add_provider_for_screen(
+        Gdk.Screen.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
+    )
+
+    display = Gdk.Display.get_default()
+    if display is None:
+        return 1
+
+    windows = [
+        make_window(display.get_monitor(i).get_geometry())
+        for i in range(display.get_n_monitors())
+    ]
+    for win in windows:
+        win.show_all()
+        win.get_window().raise_()
+    if windows:
+        windows[0].present()
+
+    Gtk.main()
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/tools/agent-isolation/gpg-touch-overlay.sh b/tools/agent-isolation/gpg-touch-overlay.sh
new file mode 100755
index 00000000..f2a37a1c
--- /dev/null
+++ b/tools/agent-isolation/gpg-touch-overlay.sh
@@ -0,0 +1,234 @@
+#!/usr/bin/env bash
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# gpg-touch-overlay.sh — Claude Code PreToolUse/PostToolUse hook (Bash matcher).
+#
+# Shows a window while gpg blocks waiting for a touch on a hardware
+# signing key (YubiKey, Nitrokey, any OpenPGP card).
+#
+# A key whose signature slot has a touch policy of `on` or `cached`
+# (`ykman openpgp info`) needs a physical touch for every signature. gpg
+# puts a pinentry window on screen for the *PIN* and nothing at all for
+# the *touch* — it simply blocks, and `git commit` sits there until the
+# key is touched or gpg gives up with "signing failed: Timeout". There is
+# nothing to distinguish it from a hung command.
+#
+# This is the other half of the hardware-key rule in AGENTS.md
+# ("Commit and PR conventions"). That rule has the agent probe the PIN
+# cache and warn *before* committing; gpg-agent's `keyinfo` reports the
+# PIN cache only, so a key with a warm PIN and a cold touch still blocks
+# with no prompt at all. This covers that case from the other side, at
+# the moment gpg is actually waiting.
+#
+#   arm     PreToolUse  — a git command that might sign is about to run;
+#                         start a watcher that shows the window if and
+#                         when signing actually blocks.
+#   disarm  PostToolUse — the command is done; tear the watcher down.
+#
+# The watcher, not the hook, decides whether anything is shown:
+#
+#   * `pgrep -x gpg` — a signing gpg is in flight. Matching on the exact
+#     process name matters: `pgrep -f` would match the hook's own command
+#     line and report a signature that is not happening.
+#   * `pgrep -x 'pinentry.*'` — pinentry is up, so the PIN is being asked
+#     for. The window stays hidden then; two dialogs competing for focus
+#     would make the PIN impossible to type.
+#   * SHOW_DELAY — a signature with a still-cached touch finishes in well
+#     under a second. Nothing is shown until gpg has blocked for longer
+#     than that, so ordinary commits stay silent.
+#
+# So arming is cheap and deliberately over-broad: it costs one background
+# process that exits on its own when no signature materialises.
+#
+# Dismissing the window with its button is honoured — it is a prompt, not
+# a trap, and gpg keeps waiting either way. It is not re-shown for the
+# same signature.
+
+set -uo pipefail
+
+SELF="$(readlink -f "${BASH_SOURCE[0]}")"
+readonly SELF
+readonly OVERLAY_WINDOW="${SELF%/*}/gpg-touch-overlay-window.py"
+
+readonly TITLE="Touch your security key"
+readonly BODY="<b>gpg is waiting for a touch to sign.</b>
+
+Touch the key's contact now — the commit is blocked until you do.
+This window closes by itself once the touch registers."
+
+readonly SHOW_DELAY=8     # polls a signature must block before showing (0.2s each)
+readonly APPEAR_GRACE=45  # seconds to wait for gpg to appear at all
+readonly MAX_WAIT=600     # seconds the watcher may live, whatever happens
+readonly POLL=0.2
+
+RUNTIME_DIR="${XDG_RUNTIME_DIR:-/tmp}/magpie-gpg-touch"
+readonly WATCHER_PID_FILE="$RUNTIME_DIR/watcher.pid"
+
+# A signing gpg is running. Exact-name match only — see the header.
+signing_in_flight() { pgrep -x 'gpg|gpg2' >/dev/null 2>&1; }
+
+# pinentry is asking for the PIN and owns the screen.
+pinentry_up() { pgrep -x 'pinentry.*' >/dev/null 2>&1; }
+
+# ---------------------------------------------------------------- arm ---
+
+# Subcommands that can produce a signature under this config. Broad on
+# purpose: a false positive costs one short-lived watcher, a false
+# negative costs a silent block with no window.
+readonly SIGNING_SUBCOMMANDS='commit|tag|merge|rebase|revert|cherry-pick|am|push'
+
+arm() {
+    if [[ -z ${MAGPIE_GPG_TOUCH_DRY_RUN:-} ]]; then
+        [[ -n ${DISPLAY:-}${WAYLAND_DISPLAY:-} ]] || return 0
+        command -v zenity >/dev/null 2>&1 || python3 -c 'import gi' >/dev/null 2>&1 || return 0
+    fi
+
+    local payload command_text
+    payload="$(cat)"
+    command_text="$(printf '%s' "$payload" | jq -r '.tool_input.command // empty' 2>/dev/null)"
+    [[ -n $command_text ]] || return 0
+
+    printf '%s' "$command_text" |
+        grep -Eq "(^|[;&|(]|[[:space:]])git([[:space:]]+-[A-Za-z-]+([[:space:]]+[^[:space:]]+)?)*[[:space:]]+($SIGNING_SUBCOMMANDS)([[:space:]]|$)" ||
+        return 0
+
+    # Test seam: report the decision instead of spawning a watcher, so
+    # the command matcher can be exercised without an X display.
+    if [[ -n ${MAGPIE_GPG_TOUCH_DRY_RUN:-} ]]; then
+        printf 'arm\n'
+        return 0
+    fi
+
+    mkdir -p "$RUNTIME_DIR" 2>/dev/null || return 0
+
+    # One watcher covers whatever is in flight; a live one needs no second.
+    local old
+    old="$(cat "$WATCHER_PID_FILE" 2>/dev/null || true)"
+    if [[ -n ${old:-} ]] && kill -0 "$old" 2>/dev/null; then
+        return 0
+    fi
+
+    # setsid: the watcher leads its own process group, so disarm can take
+    # down the window it spawned with a single group kill.
+    local log=/dev/null
+    [[ -n ${MAGPIE_GPG_TOUCH_DEBUG:-} ]] && log="$RUNTIME_DIR/watcher.log"
+    setsid "$SELF" _watch >"$log" 2>&1 &
+    printf '%s\n' "$!" >"$WATCHER_PID_FILE"
+}
+
+# ------------------------------------------------------------- disarm ---
+
+disarm() {
+    local pid
+    pid="$(cat "$WATCHER_PID_FILE" 2>/dev/null || true)"
+    [[ -n ${pid:-} ]] || return 0
+    kill -- -"$pid" 2>/dev/null || true
+    rm -f "$WATCHER_PID_FILE"
+}
+
+# ------------------------------------------------------------ watcher ---
+
+overlay_pid=""
+overlay_dismissed=0
+
+# Raise the window and keep it above the others. Backgrounded: it polls
+# for the window to map, which must not hold up the watcher's own loop.
+raise_overlay() {
+    local i
+    for (( i = 0; i < 15; i++ )); do
+        if wmctrl -l 2>/dev/null | grep -Fq -- "$TITLE"; then
+            wmctrl -F -r "$TITLE" -b add,above 2>/dev/null || true
+            wmctrl -F -a "$TITLE" 2>/dev/null || true
+            return 0
+        fi
+        sleep 0.2
+    done
+}
+
+show_overlay() {
+    (( overlay_dismissed )) && return 0
+    if [[ -n $overlay_pid ]]; then
+        # Still up: nothing to do. Gone without us killing it: the button
+        # was pressed, so respect that and stop re-showing.
+        if kill -0 "$overlay_pid" 2>/dev/null; then
+            return 0
+        fi
+        overlay_pid=""
+        overlay_dismissed=1
+        return 0
+    fi
+    "$SELF" _overlay &
+    overlay_pid=$!
+    raise_overlay &
+}
+
+hide_overlay() {
+    [[ -n $overlay_pid ]] || return 0
+    kill "$overlay_pid" 2>/dev/null || true
+    overlay_pid=""
+}
+
+_watch() {
+    trap 'hide_overlay' EXIT INT TERM
+
+    local i blocked=0
+
+    # Nothing signs instantly; git may run hooks first. Wait for gpg to
+    # show up, and stop caring if it never does.
+    for (( i = 0; i < APPEAR_GRACE * 5; i++ )); do
+        signing_in_flight && break
+        sleep "$POLL"
+    done
+    signing_in_flight || return 0
+
+    for (( i = 0; i < MAX_WAIT * 5; i++ )); do
+        signing_in_flight || break
+        if pinentry_up; then
+            blocked=0
+            hide_overlay
+        else
+            blocked=$(( blocked + 1 ))
+            (( blocked >= SHOW_DELAY )) && show_overlay
+        fi
+        sleep "$POLL"
+    done
+}
+
+# The window is exec'd so this pid *is* the window: one kill closes it,
+# with no orphaned child left drawing on the screen.
+#
+# The GTK overlay dims the whole desktop the way the pinentry PIN prompt
+# does. zenity is the fallback for a machine without PyGObject — a small
+# dialog, but better than a silent block.
+_overlay() {
+    if python3 -c 'import gi' >/dev/null 2>&1; then
+        exec python3 "$OVERLAY_WINDOW" >/dev/null 2>&1
+    fi
+    exec zenity --warning --title="$TITLE" --width=560 --text="$BODY" >/dev/null 2>&1
+}
+
+case "${1:-}" in
+    arm)      arm ;;
+    disarm)   disarm ;;
+    _watch)   _watch ;;
+    _overlay) _overlay ;;
+    *)
+        printf '%s: expected arm|disarm, got "%s"\n' "${0##*/}" "${1:-}" >&2
+        exit 2
+        ;;
+esac
+exit 0
diff --git a/tools/agent-isolation/tests/test_gpg_touch_overlay.py b/tools/agent-isolation/tests/test_gpg_touch_overlay.py
new file mode 100644
index 00000000..9b49d6a7
--- /dev/null
+++ b/tools/agent-isolation/tests/test_gpg_touch_overlay.py
@@ -0,0 +1,112 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Tests for gpg-touch-overlay.sh.
+
+The hook reads a JSON tool-use payload on stdin and decides whether the
+command about to run could produce a signature. ``MAGPIE_GPG_TOUCH_DRY_RUN``
+makes ``arm`` print its decision instead of spawning a watcher, so the
+matcher is exercisable with no X display and nothing left running.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+from pathlib import Path
+
+import pytest
+
+SCRIPT = Path(__file__).parent.parent / "gpg-touch-overlay.sh"
+
+
+def _arm(command: str) -> str:
+    env = {**os.environ, "MAGPIE_GPG_TOUCH_DRY_RUN": "1"}
+    result = subprocess.run(
+        ["bash", str(SCRIPT), "arm"],
+        input=json.dumps({"tool_input": {"command": command}}),
+        capture_output=True,
+        text=True,
+        env=env,
+    )
+    assert result.returncode == 0, result.stderr
+    return result.stdout.strip()
+
+
+@pytest.mark.parametrize(
+    "command",
+    [
+        "git commit -m 'x'",
+        "git commit --amend --no-edit",
+        "git -C /some/repo commit -m 'x'",
+        "cd /tmp/repo && git commit -m 'x'",
+        "git tag -s v1.0.0 -m 'release'",
+        "git rebase --continue",
+        "git cherry-pick abc1234",
+        "git merge --no-ff feature",
+    ],
+)
+def test_arms_for_commands_that_can_sign(command: str) -> None:
+    assert _arm(command) == "arm"
+
+
+@pytest.mark.parametrize(
+    "command",
+    [
+        "git status --short",
+        "git log --oneline -5",
+        "git diff HEAD",
+        "ls -la",
+        "echo 'committing to the plan'",
+        "grep -r commit .",
+    ],
+)
+def test_stays_quiet_for_commands_that_cannot_sign(command: str) -> None:
+    assert _arm(command) == ""
+
+
+def test_payload_without_a_command_is_ignored() -> None:
+    result = subprocess.run(
+        ["bash", str(SCRIPT), "arm"],
+        input=json.dumps({"tool_input": {}}),
+        capture_output=True,
+        text=True,
+        env={**os.environ, "MAGPIE_GPG_TOUCH_DRY_RUN": "1"},
+    )
+    assert result.returncode == 0
+    assert result.stdout == ""
+
+
+def test_disarm_is_silent_when_nothing_is_armed(tmp_path: Path) -> None:
+    result = subprocess.run(
+        ["bash", str(SCRIPT), "disarm"],
+        capture_output=True,
+        text=True,
+        env={**os.environ, "XDG_RUNTIME_DIR": str(tmp_path)},
+    )
+    assert result.returncode == 0
+    assert result.stdout == ""
+    assert result.stderr == ""
+
+
+def test_unknown_mode_is_rejected() -> None:
+    result = subprocess.run(
+        ["bash", str(SCRIPT), "wibble"], capture_output=True, text=True
+    )
+    assert result.returncode == 2
+    assert "arm|disarm" in result.stderr