diff --git a/tools/agent-isolation/gpg-touch-overlay.sh b/tools/agent-isolation/gpg-touch-overlay.sh index a3fcce5d..823b3a66 100755 --- a/tools/agent-isolation/gpg-touch-overlay.sh +++ b/tools/agent-isolation/gpg-touch-overlay.sh @@ -303,12 +303,31 @@ _set_session_launcher() { # ---------------------------------------------------------- ownership --- +# The harness process that ran this hook. Claude Code on Linux runs a +# hook command through `sh -c`, so the hook's own parent is that shell, +# which exits the moment the hook returns: a watcher told to outlive it +# ends on its first poll, before the key has blocked. Shells between +# the hook and the harness are skipped; where the harness starts the +# hook directly, this is just $PPID. +_find_harness_pid() { + local pid=$PPID ppid comm + while read -r ppid comm < <(ps -o ppid= -o comm= -p "$pid" 2>/dev/null); do + case ${comm##*/} in + sh|bash|dash|zsh|-sh|-bash|-zsh) ;; + *) break ;; + esac + [[ $ppid -gt 1 ]] || break + pid=$ppid + done + printf '%s\n' "$pid" +} + # The key a signing context is known by, stable from its arm to its # disarm and distinct from every other context's. An agent session is # identified by the session id the harness puts in both hook payloads; # a wrapped git by the wrapper's own pid. The fallback covers a harness -# that sends no session id: the hook's parent is the harness process, -# which is the same for that session's arm and its disarm. +# that sends no session id: the harness process is the same for that +# session's arm and its disarm. _owner_id() { local session=${1:-} [[ -z $session ]] && session=${CLAUDE_SESSION_ID:-} @@ -316,7 +335,7 @@ _owner_id() { printf 's-%s\n' "${session//[^A-Za-z0-9_-]/_}" return 0 fi - printf 'h-%s\n' "$PPID" + printf 'h-%s\n' "$(_find_harness_pid)" } # A registration records two pids: the owner, whose death means the @@ -527,10 +546,12 @@ arm() { # spawned, so the process worth watching for is the harness itself: # 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 \ + local harness + harness="$(_find_harness_pid)" + MAGPIE_GPG_TOUCH_PARENT=$harness \ MAGPIE_GPG_TOUCH_CONTEXT="$CONTEXT_DIR/$id" \ "${SESSION_LAUNCHER[@]}" "$SELF" _watch >>"$log" 2>&1 & - _register "$id" "$PPID" "$!" + _register "$id" "$harness" "$!" } # ------------------------------------------------------------- disarm --- diff --git a/tools/agent-isolation/tests/test_gpg_touch_overlay.py b/tools/agent-isolation/tests/test_gpg_touch_overlay.py index a8b0d19e..1bacecef 100644 --- a/tools/agent-isolation/tests/test_gpg_touch_overlay.py +++ b/tools/agent-isolation/tests/test_gpg_touch_overlay.py @@ -866,6 +866,30 @@ def test_rearming_the_same_session_keeps_one_watcher(tmp_path: Path) -> None: _disarm_session(tmp_path, "aaa") +def test_watcher_outlives_the_shell_the_hook_ran_in(tmp_path: Path) -> None: + """The owner is the harness, not the ``sh -c`` it runs the hook through. + + Claude Code on Linux wraps a hook command in a shell that exits as + soon as the hook returns; a watcher that took that shell for its + owner ended on its first poll, before the key had blocked. + """ + result = subprocess.run( + ["sh", "-c", f"bash '{SCRIPT}' arm"], + input=json.dumps({"session_id": "wrapped", "tool_input": {"command": "git commit -m x"}}), + capture_output=True, + text=True, + env=_hook_env(tmp_path), + ) + assert result.returncode == 0, result.stderr + try: + owner, watcher = _read_registration(tmp_path, "wrapped") + assert owner == os.getpid() + time.sleep(1) + assert _alive(watcher) + finally: + _disarm_session(tmp_path, "wrapped") + + def test_a_dead_owners_watcher_is_swept(tmp_path: Path) -> None: """A session that died without disarming leaves nothing behind.