From 631acb488de11f3b7bb16289c6a9bac296f428c0 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Mon, 21 Sep 2026 13:20:36 +0200 Subject: [PATCH] fix(agent-isolation): raise the touch overlay for key users other than git MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hook armed only on `git `, so anything reaching the security key without git in front of it — `ssh`, `scp`, `sftp`, `rsync -e ssh`, `svn+ssh`, a direct `gpg --detach-sign` — blocked for its touch with nothing on screen. Add those under the same whole-command shape rule. Arming stays deliberately broad: the watcher shows no window until something has actually blocked on the key for longer than the grace, so a false positive costs one short-lived background process and nothing visible. `wrap` reaches the same commands through an optional shim directory on PATH. Git can be told which program to call; a bare `ssh` cannot, so only PATH can put the wrapper in front of it. The script now dispatches on its own basename for a key command's name, not only for `gpg-touch-wrap-`. That surfaced a latent bug in the lookup for the real program. `command -v -a` is not valid bash — `-a` belongs to `type` — so the loop never saw a candidate and every call fell through to exec'ing the bare name, relying on PATH to resolve it. Harmless while the script was never on PATH; with a shim it re-execs itself indefinitely, leaving a hung terminal, no window, and a load average that climbs until someone goes looking. Use `type -aP`, and exit 127 rather than falling back to the name. That same self-skip is what keeps a wrapped git from chaining into a shim, so one connection still gets one wrapper and one window. Generated-by: Claude Opus 5 --- docs/mode-economics.md | 8 +- docs/setup/secure-agent-setup.md | 37 +++++ .../skills/isolated-setup-install/SKILL.md | 17 ++ .../skills/isolated-setup-update/SKILL.md | 4 +- .../skills/isolated-setup-verify/SKILL.md | 14 ++ tools/agent-isolation/gpg-touch-overlay.sh | 46 +++++- .../tests/test_gpg_touch_overlay.py | 152 ++++++++++++++++++ .../specs/agent-isolation-sandbox.md | 18 ++- 8 files changed, 286 insertions(+), 10 deletions(-) diff --git a/docs/mode-economics.md b/docs/mode-economics.md index b37c90ba..0c2f982c 100644 --- a/docs/mode-economics.md +++ b/docs/mode-economics.md @@ -92,7 +92,7 @@ special-token spellings counted as ordinary text. Coverage: **75 of 75 local `skills/*/SKILL.md` files**. External `source.md` redirects and harness symlinks are excluded. -Measurement manifest SHA-256: `ff0639052281a5f8336b83b048cd57b403f3f3b5dad5f255093600a74ef5cd18`. +Measurement manifest SHA-256: `026fe6f701d8b3c1050c01f3a7bc192b8996ae0f5f7702290cab4c5c052806d8`. | Skill file | Measured tokens | Source SHA-256 (first 16 characters) | |---|---:|---| @@ -160,9 +160,9 @@ Measurement manifest SHA-256: `ff0639052281a5f8336b83b048cd57b403f3f3b5dad5f2550 | [security-tracker-stats-dashboard](../skills/security-tracker-stats-dashboard/SKILL.md) | 4,897 | `b52154deb8557ba4` | | [setup](../skills/setup/SKILL.md) | 8,724 | `82788542bb240309` | | [setup-isolated-setup-doctor](../skills/setup-isolated-setup-doctor/SKILL.md) | 7,950 | `3fa5d728fa080ed0` | -| [setup-isolated-setup-install](../skills/setup-isolated-setup-install/SKILL.md) | 10,915 | `e806b0f1d0be8f03` | -| [setup-isolated-setup-update](../skills/setup-isolated-setup-update/SKILL.md) | 5,036 | `a2317a80efccf5c8` | -| [setup-isolated-setup-verify](../skills/setup-isolated-setup-verify/SKILL.md) | 8,149 | `4dfa606de85da6ff` | +| [setup-isolated-setup-install](../skills/setup-isolated-setup-install/SKILL.md) | 11,197 | `c046c45d2e176827` | +| [setup-isolated-setup-update](../skills/setup-isolated-setup-update/SKILL.md) | 5,069 | `dd56cf376cd14f55` | +| [setup-isolated-setup-verify](../skills/setup-isolated-setup-verify/SKILL.md) | 8,409 | `3ba7f351ebfa32bd` | | [setup-override-upstream](../skills/setup-override-upstream/SKILL.md) | 4,012 | `fb583feb56b7f77c` | | [setup-privacy-llm](../skills/setup-privacy-llm/SKILL.md) | 2,145 | `0e27b542a1656846` | | [setup-shared-config-sync](../skills/setup-shared-config-sync/SKILL.md) | 4,357 | `d1dfcd7cdeb5f5a6` | diff --git a/docs/setup/secure-agent-setup.md b/docs/setup/secure-agent-setup.md index a940ab02..f696a664 100644 --- a/docs/setup/secure-agent-setup.md +++ b/docs/setup/secure-agent-setup.md @@ -48,6 +48,7 @@ - [Install (user-scope)](#install-user-scope-3) - [Verify](#verify-3) - [From your own terminal — git's program config](#from-your-own-terminal--gits-program-config) + - [Beyond git — ssh, scp, sftp and rsync you type yourself](#beyond-git--ssh-scp-sftp-and-rsync-you-type-yourself) - [Trade-offs](#trade-offs-2) - [Container gateway](#container-gateway) - [Why install it](#why-install-it-1) @@ -2266,6 +2267,7 @@ What this covers, and what it costs: - Verify-only calls pass straight through: `git log --show-signature` runs `ssh-keygen -Y verify` once per commit, and none of those gets a watcher or a display probe. - The toolkit probe runs once per signature or ssh connection, about a third of a second. - One signature, one window. Inside an agent session (Claude Code marks its Bash with `CLAUDECODE=1`) the wrapper only runs the program: the hook armed a watcher outside the sandbox before the command started, and that one shows the window for the agent's commits. Outside a session the wrapper starts a watcher of its own. Every signing context — each agent session, each wrapped git — owns the watcher it started and can tear down no other, so two sessions signing at once, or a terminal commit beside one, cannot blind each other. What they share is the window, leased by whichever watcher reaches it first, so one touch still draws exactly one. So the hook stays necessary for the agent's own git commands, and the wrapper never doubles it. +The shim directory below does not double it either: the lookup that finds the real program skips every `PATH` entry resolving back to this script, so a wrapped git reaches `/usr/bin/ssh` and not the shim. **Under the sandbox this needs one more grant.** Global git config is read by the git the agent runs too, and the sandbox denies reads under `~/.claude/` wholesale — so without it every sandboxed `git commit` fails at once with `fatal: cannot exec '…/gpg-touch-wrap-ssh-keygen': Operation not permitted`, before the key is asked for anything. Allow the two wrapper files, and nothing wider: @@ -2289,6 +2291,41 @@ The install skill proposes it with the other two grants; the failure mode and it To undo it: `git config --global --unset gpg.ssh.program` and `git config --global --unset core.sshCommand`. +### Beyond git — ssh, scp, sftp and rsync you type yourself + +Git can be told which program to call. +A bare `ssh` cannot: nothing sits between the word you type and `/usr/bin/ssh` except `PATH`. +So an `ssh host`, an `scp`, an `sftp` or an `rsync -e ssh` run straight from a terminal asks the key for its authentication touch with nothing on screen — the same silence `core.sshCommand` removed for git. + +A shim directory early on `PATH` closes that gap. +It holds symlinks named for the programs themselves, all pointing at the same script: + +```sh +mkdir -p ~/.claude/scripts/shims +for p in ssh scp sftp rsync; do + ln -sfn ~/.claude/scripts/gpg-touch-overlay.sh ~/.claude/scripts/shims/"$p" +done +``` + +Then put that directory ahead of the real ones, in the rc your shells read: + +```sh +export PATH="$HOME/.claude/scripts/shims:$PATH" +``` + +The script answers to a key command's own name the way it already answers to `gpg-touch-wrap-`, and finds the real program behind itself: the lookup walks every `PATH` match and skips the one that resolves back to the script. +Only the commands that can reach the key dispatch this way, so a symlink named anything else is refused rather than silently exec'd. +When the lookup finds nothing but the script, it exits 127 and says so — it never falls back to the bare name, which on a `PATH` holding the shim would re-exec the script forever, with the terminal hung and nothing on screen to say why. + +What it costs, and what it does not double: + +- Nothing extra under the sandbox. The shims resolve to `gpg-touch-overlay.sh`, which the grant above already allows, and the sandbox checks the resolved path. +- No second window when git is wrapped too, per the self-skip described above: one connection, one wrapper. +- Nothing at all inside an agent session — `CLAUDECODE=1` makes the wrapper stand aside, because the hook armed a watcher for the whole command before it started. +- A wider reach than a shell alias, deliberately. A `PATH` entry is seen by scripts and Makefiles, which is where an unattended `rsync` would otherwise block with no window. It is seen by everything else you run as well, which is the trade. + +To undo it: drop the `PATH` line and delete the directory. + ### Trade-offs - **Placement is X11's to give.** On Linux the overlay places and stacks diff --git a/plugins/magpie-setup/skills/isolated-setup-install/SKILL.md b/plugins/magpie-setup/skills/isolated-setup-install/SKILL.md index 2fc972c5..f28e4725 100644 --- a/plugins/magpie-setup/skills/isolated-setup-install/SKILL.md +++ b/plugins/magpie-setup/skills/isolated-setup-install/SKILL.md @@ -684,6 +684,23 @@ show the current value and let the operator decide; do not overwrite it. Why a symlink, what it covers, what it costs: [docs/setup/secure-agent-setup.md → From your own terminal](../../../../docs/setup/secure-agent-setup.md#from-your-own-terminal--gits-program-config). +**Optional, beyond git.** `ssh`, `scp`, `sftp` and `rsync` typed +straight into a terminal reach the key with no program setting to +point anywhere — only `PATH` sits in front of them. Offer a shim +directory: `mkdir -p ~/.claude/scripts/shims` and, for each of +`ssh scp sftp rsync`, `ln -sfn ~/.claude/scripts/gpg-touch-overlay.sh +~/.claude/scripts/shims/`. The script dispatches on its own +basename for those names and skips any `PATH` entry resolving back to +itself, so a shim finds the real program and a wrapped git does not +chain into a shim. Create the directory and the links, then **print +the `PATH` line and let the operator add it themselves** — a shell rc +is never edited for them: +`export PATH="$HOME/.claude/scripts/shims:$PATH"`. No sandbox grant +is needed beyond K.4's: the links resolve to the script already +allowed there. Skip the offer when the operator has no touch-required +key. Rationale and the undo: +[docs/setup/secure-agent-setup.md → Beyond git](../../../../docs/setup/secure-agent-setup.md#beyond-git--ssh-scp-sftp-and-rsync-you-type-yourself). + **K.4 — Sandbox grants.** Three, all surfaced as one settings diff: gpg-agent's ssh socket (`gpgconf --list-dirs agent-ssh-socket`, absolute path) under `sandbox.network.allowUnixSockets`, so a sandboxed diff --git a/plugins/magpie-setup/skills/isolated-setup-update/SKILL.md b/plugins/magpie-setup/skills/isolated-setup-update/SKILL.md index b3afccb5..4c899ce4 100644 --- a/plugins/magpie-setup/skills/isolated-setup-update/SKILL.md +++ b/plugins/magpie-setup/skills/isolated-setup-update/SKILL.md @@ -186,7 +186,9 @@ Walk each: overlay where that is installed (the `gpg-touch-wrap-*` entry beside them is a symlink to the script, not a copy — nothing to diff, but report it missing when git's `gpg.ssh.program` / - `gpg.program` or `core.sshCommand` names it and it is gone), + `gpg.program` or `core.sshCommand` names it and it is gone; the + same for any link under `~/.claude/scripts/shims/`, which is a + symlink whose target is compared, not its contents), `~/.claude/scripts/container-gateway-hook.sh` for the container gateway's `SessionStart` / `SessionEnd` hook (diff against `tools/agent-isolation/container-gateway-hook.sh`), and the diff --git a/plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md b/plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md index efd96eb6..8aacdef8 100644 --- a/plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md +++ b/plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md @@ -410,6 +410,20 @@ Walk each in order: `~/.claude/`: [`docs/setup/sandbox-troubleshooting.md` → Signed commit fails with "cannot exec" of the touch-overlay wrapper](../../../../docs/setup/sandbox-troubleshooting.md#signed-commit-fails-with-cannot-exec-of-the-touch-overlay-wrapper). + **10e — the ssh shim directory, if the user wants one.** `ssh`, + `scp`, `sftp` and `rsync` typed into a terminal have no program + setting to point anywhere, so only a `PATH` shim puts the wrapper + in front of them. Absent entirely is **n/a**, not a gap — it is + opt-in. When `~/.claude/scripts/shims/` exists, every link in it + must resolve to `~/.claude/scripts/gpg-touch-overlay.sh` + (`readlink -f`) and be named for a command the script dispatches + on — a link named anything else never wraps and is ⚠ with the + name shown. The directory must also appear in `PATH` ahead of + `/usr/bin`: compare `command -v ssh` against the shim path, and + report ⚠ with both paths when the real binary wins, since the + links are then inert. Rationale and the `PATH` line: + [`docs/setup/secure-agent-setup.md` → Beyond git](../../../../docs/setup/secure-agent-setup.md#beyond-git--ssh-scp-sftp-and-rsync-you-type-yourself). + 11. **`gh` runs outside the sandbox.** `sandbox.excludedCommands` must contain `"gh *"` in the project `.claude/settings.json` or the user-scope `~/.claude/settings.json`. On macOS a sandboxed diff --git a/tools/agent-isolation/gpg-touch-overlay.sh b/tools/agent-isolation/gpg-touch-overlay.sh index a176779a..30c4cf05 100755 --- a/tools/agent-isolation/gpg-touch-overlay.sh +++ b/tools/agent-isolation/gpg-touch-overlay.sh @@ -238,6 +238,17 @@ agent_socket_rows() { # sign just as much as `git commit -m`. readonly KEY_SUBCOMMANDS='commit|tag|merge|rebase|revert|cherry-pick|am|push|pull|fetch|clone|ls-remote|remote|submodule' +# The same reach, without git in front of it. An ssh transport asks the +# key for its authentication touch before anything moves, and gpg asks +# for the signing touch straight through the OpenPGP card, so a bare +# `scp`, `rsync -e ssh` or `gpg --detach-sign` blocks exactly as a +# `git push` does — and, until these were listed, blocked with no +# window. The word has to end where a shell word ends, which is what +# keeps `ssh-agent` (starts one, never asks the key) and `sshuttle` out +# while leaving `ssh-keygen -Y sign` and `ssh-add -l` in. Longer names +# lead the alternation so the match cannot stop short at `ssh`. +readonly KEY_COMMANDS='ssh-keygen|ssh-add|ssh|scp|sftp|sshfs|rsync|svn|gpg2|gpg' + # A screen to draw on, and something to draw the window with. # # On macOS both questions collapse into one: a logged-in user always has @@ -401,7 +412,7 @@ arm() { session="$(printf '%s' "$payload" | jq -r '.session_id // empty' 2>/dev/null)" printf '%s' "$command_text" | - grep -Eq "(^|[;&|(]|[[:space:]])git([[:space:]]+-[A-Za-z-]+([[:space:]]+[^[:space:]]+)?)*[[:space:]]+($KEY_SUBCOMMANDS)([[:space:];&|)]|$)" || + grep -Eq "(^|[;&|(]|[[:space:]])(git([[:space:]]+-[A-Za-z-]+([[:space:]]+[^[:space:]]+)?)*[[:space:]]+($KEY_SUBCOMMANDS)|($KEY_COMMANDS))([[:space:];&|)]|$)" || return 0 # Test seam: report the decision instead of spawning a watcher, so @@ -495,12 +506,24 @@ wrap() { local program=$1; shift local real="" candidate # The real program: first match on PATH that is not this script under - # another name. + # another name. `type -aP` and not `command -v -a`: bash's `command` + # has no -a, so that spelling only ever printed a usage error, left + # every candidate unseen and fell through to the name-only fallback + # below -- harmless while this script was never on PATH, fatal once + # a shim puts it there. while IFS= read -r candidate; do [[ "$(readlink -f "$candidate" 2>/dev/null)" == "$SELF" ]] && continue real=$candidate; break - done < <(command -v -a "$program" 2>/dev/null || true) - [[ -n $real ]] || real=$program + done < <(type -aP "$program" 2>/dev/null || true) + # No fallback to the bare name. A shim is on PATH *as* that name, so + # exec'ing it re-enters this script forever: a hung terminal, no + # window, no message, and a load average that climbs until someone + # goes looking. 127 is what a missing program exits with anyway. + if [[ -z $real ]]; then + printf '%s: no %s found on PATH behind this wrapper\n' \ + "${0##*/}" "$program" >&2 + exit 127 + fi # Which invocations reach the key. A signing program is also git's # verifier (`git log --show-signature` runs `ssh-keygen -Y verify` @@ -775,6 +798,21 @@ if [[ $_name == gpg-touch-wrap-?* ]]; then exit $? fi +# The same dispatch under the program's own name, which is what a bare +# `ssh` typed in a terminal needs: git can be told to call a wrapper +# (`gpg.ssh.program`, `core.sshCommand`), a person cannot, so the only +# thing that can put the wrapper in front of them is PATH. A shim +# directory early on PATH holds symlinks named `ssh`, `scp`, `sftp`, +# `rsync`; `wrap` skips any PATH candidate that resolves back to this +# script, so the shim finds the real program behind itself. Restricted +# to the key commands on purpose -- a symlink named anything else is a +# mistake, and exec'ing what is behind it would hide that mistake for +# as long as the link lived. +if [[ $_name =~ ^(${KEY_COMMANDS})$ ]]; then + wrap "$_name" "$@" + exit $? +fi + case "${1:-}" in arm) arm; exit 0 ;; wrap) shift; wrap "$@"; exit $? ;; diff --git a/tools/agent-isolation/tests/test_gpg_touch_overlay.py b/tools/agent-isolation/tests/test_gpg_touch_overlay.py index db884d5e..181a41f7 100644 --- a/tools/agent-isolation/tests/test_gpg_touch_overlay.py +++ b/tools/agent-isolation/tests/test_gpg_touch_overlay.py @@ -93,12 +93,50 @@ def test_arms_for_commands_that_can_reach_the_key(command: str) -> None: "ls -la", "echo 'committing to the plan'", "grep -r commit .", + # A path component, not a command: the character before the name + # is not one that can end a shell word. + "cat ~/.ssh/config", + "ls -la ~/.ssh", + # Longer names that merely start with one of the key commands. + "sshuttle --dns -r host 0/0", + # Starting an agent is not a request to the key. + "ssh-agent -s", ], ) def test_stays_quiet_for_commands_that_cannot_sign(command: str) -> None: assert _arm(command) == "" +@pytest.mark.parametrize( + "command", + [ + # ssh transports reach the key for the authentication touch + # without git ever being involved. + "ssh git@github.com", + "ssh -T git@github.com", + "scp report.txt host:/tmp/", + "sftp host", + "rsync -avz -e ssh src/ host:/dst", + "sshfs host:/remote /mnt", + "svn commit -m 'release'", + # Signing and decryption straight through the OpenPGP card. + "gpg --detach-sign --armor apache-magpie-0.2.0.tar.gz", + "gpg2 --clearsign message.txt", + "gpg --decrypt secrets.asc", + # The ssh-format signer and the agent query, invoked directly. + "ssh-keygen -Y sign -f ~/.ssh/id_rsa_yubikey.pub -n file x", + "ssh-add -l", + # The same shell-word rule the git matcher follows. + "cd /tmp && scp file host:/path", + "ssh host; echo done", + "(ssh host)", + "git fetch && ssh host", + ], +) +def test_arms_for_non_git_commands_that_can_reach_the_key(command: str) -> None: + assert _arm(command) == "arm" + + def test_agent_sockets_include_ssh_auth_sock() -> None: """The system ssh-agent counts too, not only gpg-agent's socket.""" result = subprocess.run( @@ -486,6 +524,120 @@ def test_wrap_symlink_name_selects_the_program(tmp_path: Path) -> None: assert _no_registrations(tmp_path) +def _shim(tmp_path: Path, name: str) -> tuple[Path, dict[str, str]]: + """A shim directory ahead of a directory holding the real program. + + This is the PATH layout the native wrapper installs into: a symlink + named for the program itself, early on PATH, with the real binary + behind it. + """ + shims = tmp_path / "shims" + shims.mkdir() + (shims / name).symlink_to(SCRIPT) + real_dir = tmp_path / "bin" + real_dir.mkdir() + real = real_dir / name + real.write_text(f'#!/bin/sh\necho "real-{name} $*"\n') + real.chmod(0o755) + env = { + **_wrap_env(tmp_path), + "PATH": f"{shims}:{real_dir}:/usr/bin:/bin", + # The hook owns the watcher inside an agent session; this test is + # about which program the shim resolves to, not about watchers. + "CLAUDECODE": "1", + } + return shims / name, env + + +@pytest.mark.parametrize("name", ["ssh", "scp", "sftp", "rsync"]) +def test_a_shim_named_for_a_key_command_runs_the_real_program( + tmp_path: Path, name: str +) -> None: + """A bare `ssh` typed in a terminal has to reach `wrap`. + + `gpg.ssh.program` can name `gpg-touch-wrap-`, but nothing + names the ssh a person types — only PATH does. So the script also + answers to a key command's own name, and must find the real program + behind its own shim rather than itself. + """ + shim, env = _shim(tmp_path, name) + result = subprocess.run( + [str(shim), "-T", "host"], + capture_output=True, + text=True, + env=env, + timeout=30, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == f"real-{name} -T host" + + +def test_wrap_fails_rather_than_exec_the_name_it_could_not_resolve( + tmp_path: Path, +) -> None: + """Falling back to the bare name is what recursion is made of. + + A shim puts this script on PATH under the real program's name, so + `exec "$program"` finds the shim again and the process re-execs + itself forever with the terminal hung and nothing on screen. When + no real program can be found, say so and stop. + """ + result = subprocess.run( + ["bash", str(SCRIPT), "wrap", "magpie-no-such-program", "--version"], + capture_output=True, + text=True, + env=_wrap_env(tmp_path), + timeout=30, + ) + assert result.returncode == 127 + assert "magpie-no-such-program" in result.stderr + + +def test_a_wrapper_does_not_chain_into_a_shim(tmp_path: Path) -> None: + """One connection, one wrapper, one window. + + `core.sshCommand` already names `wrap ssh`, and a shim directory + puts another wrapper on PATH under the name `ssh`. If `wrap` + resolved to the shim, git would get two nested wrapping contexts + for a single connection -- two watchers, and a second window the + moment the lease changed hands. The self-skip in the lookup is what + keeps that from happening, so the real program has to run exactly + once. + """ + shim, env = _shim(tmp_path, "ssh") + result = subprocess.run( + ["bash", str(SCRIPT), "wrap", "ssh", "-T", "host"], + capture_output=True, + text=True, + env=env, + timeout=30, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "real-ssh -T host" + assert result.stdout.count("real-ssh") == 1, "the real program ran more than once" + assert shim.exists() + + +def test_a_shim_named_for_an_unrelated_command_is_refused(tmp_path: Path) -> None: + """Only the commands that can reach the key dispatch by name. + + A symlink named for anything else is a mistake, and exec'ing the + thing behind it would hide that mistake for as long as the link + lived. + """ + shim, env = _shim(tmp_path, "curl") + result = subprocess.run( + [str(shim), "--version"], + capture_output=True, + text=True, + env=env, + timeout=30, + ) + assert result.returncode == 2 + assert "real-curl" not in result.stdout + assert "expected arm|disarm|wrap" in result.stderr + + def test_wrap_stands_aside_inside_an_agent_session(tmp_path: Path) -> None: """One signature, one window: in an agent session the hook owns it. diff --git a/tools/spec-loop/specs/agent-isolation-sandbox.md b/tools/spec-loop/specs/agent-isolation-sandbox.md index 42ac9919..47ad68d5 100644 --- a/tools/spec-loop/specs/agent-isolation-sandbox.md +++ b/tools/spec-loop/specs/agent-isolation-sandbox.md @@ -51,7 +51,23 @@ existing sandbox grants can widen the baseline. See `docs/adapters/gemini.md`. command (`gpg.ssh.program` / `gpg.program` through an argument-free `gpg-touch-wrap-` symlink, `core.sshCommand … wrap ssh`) for the commits and pushes the operator makes by hand — no git hook type - sits at the right moment for those. Each is a signing context that + sits at the right moment for those. The hook arms on any command that + can reach the key, which is broader than signing: every git + subcommand that signs or opens an ssh remote, and the key consumers + git never sees — `ssh`, `scp`, `sftp`, `rsync`, `gpg` and the ssh + signer invoked directly. Arming is deliberately over-broad because + the *window* is what a false positive would cost, and the watcher + shows none until something has actually blocked on the key for + longer than the grace. `wrap` reaches those same non-git consumers + through an optional shim directory on `PATH`: the script dispatches + on its own basename for a key command's name, not only for + `gpg-touch-wrap-`, and resolves the real program by walking + every `PATH` match and skipping the one that resolves back to + itself. That self-skip is load-bearing twice over — it is why a + wrapped git does not chain into a shim, and why the lookup must fail + with 127 rather than fall back to the bare name, which on a `PATH` + holding the shim re-execs the script indefinitely. Each is a signing + context that owns its own watcher, registered under `owners/` and keyed by the harness session id the hooks carry or by the wrapper's pid, so a context can only ever tear down the watcher it started; a context