Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 26 additions & 5 deletions tools/agent-isolation/gpg-touch-overlay.sh
Original file line number Diff line number Diff line change
Expand Up @@ -303,20 +303,39 @@ _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:-}
if [[ -n $session ]]; then
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
Expand Down Expand Up @@ -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 ---
Expand Down
24 changes: 24 additions & 0 deletions tools/agent-isolation/tests/test_gpg_touch_overlay.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down