diff --git a/plugins/disk-hygiene/.claude-plugin/plugin.json b/plugins/disk-hygiene/.claude-plugin/plugin.json index 79241c891..c9a54dcdf 100644 --- a/plugins/disk-hygiene/.claude-plugin/plugin.json +++ b/plugins/disk-hygiene/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "disk-hygiene", - "version": "0.23.11", + "version": "0.23.12", "description": "Context-aware disk hygiene for arbitrary directory trees: inventories orphaned and temporary artifacts, classifies evidence into review tiers, and offers exact-path cleanup only after a fresh safety preview and explicit per-tier approval. The target is read-only by default; OS-managed paths, links and mount points, VCS-tracked content without the complete checkout evidence bundle, changed entries, and live-handle uncertainty fail closed.", "author": { "name": "Melodic Software", diff --git a/plugins/disk-hygiene/CHANGELOG.md b/plugins/disk-hygiene/CHANGELOG.md index f3f1f6047..b96edd1ab 100644 --- a/plugins/disk-hygiene/CHANGELOG.md +++ b/plugins/disk-hygiene/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `disk-hygiene` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.23.12] + +### Changed + +- **The `Stop` guard-launch monitor no longer starts Python in a session that launched no guard.** The engine-gate rows are `if`-gated on the engine's file name, so most sessions never run `destructive_guard.py` at all, yet the monitor started a whole interpreter every turn to discover that from the transcript. `Stop` rows accept neither `matcher` nor `if`, so the gate lives in `hooks/run-python-hook.sh`, which grows three optional leading flags: `--marker-root `, `--launch-marker ` (write `//.launched` before exec'ing Python) and `--skip-unless-marker ` (exit 0 without exec'ing when a candidate marker directory exists and none of them holds that file). Process creations per `Stop` on Windows, measured with a job-object census at n=5: 5 before, 3 with no marker, and still 5 with one present, against a 1-creation harness floor; wall clock 271 ms to 120 ms on the skipped path. The engine-gate row pays nothing for the marker after the first launch in a plugin data root, which spends one `mkdir`. +- Marker semantics: the marker is per session and is never removed, so a data root accumulates one empty file per session that launched a guard, and no retention sweep collects them; a session whose guard rows never fired is skipped by design; the marker is written before the interpreter is resolved, so a guard that launches and dies, the failure the monitor exists to report, still leaves it. With a marker present the monitor's `systemMessage` and its `guard-decisions` record are byte-identical to before. A skipped turn emits no telemetry envelope at all, where it previously emitted an `ok` one. +- The `Stop` gate fails open on the half it can detect: it skips only when at least one candidate marker directory exists and holds no marker for the session, so a launch whose `mkdir` failed for both roots leaves nothing and every later `Stop` runs the monitor as it did before the flags. The cost is that the skip is inert in a plugin data root where no guard has ever launched, until the first launch spends its `mkdir`: 5 creations with no candidate directory, 3 with an empty one, 5 with a marker present. The residual it cannot detect: a candidate directory that exists while the marker file itself could not be written (a full disk, a permission denial on the file alone) still silences the monitor for that session, because that file is the only channel between the launch row and the `Stop` row. + ## [0.23.11] ### Changed diff --git a/plugins/disk-hygiene/hooks/hooks.json b/plugins/disk-hygiene/hooks/hooks.json index 2c606707f..a51996d63 100644 --- a/plugins/disk-hygiene/hooks/hooks.json +++ b/plugins/disk-hygiene/hooks/hooks.json @@ -7,7 +7,7 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/run-python-hook.sh \"${CLAUDE_PLUGIN_ROOT}\"/skills/clean/scripts/destructive_guard.py --mode engine-gate --plugin-root \"${CLAUDE_PLUGIN_ROOT}\" --authorized-data-root \"${CLAUDE_PLUGIN_DATA}\"", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/run-python-hook.sh --marker-root \"${CLAUDE_PLUGIN_DATA}\" --launch-marker guard-launch-monitor \"${CLAUDE_PLUGIN_ROOT}\"/skills/clean/scripts/destructive_guard.py --mode engine-gate --plugin-root \"${CLAUDE_PLUGIN_ROOT}\" --authorized-data-root \"${CLAUDE_PLUGIN_DATA}\"", "if": "Bash(*hygiene.py*)", "shell": "bash", "timeout": 60, @@ -20,7 +20,7 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/run-python-hook.sh \"${CLAUDE_PLUGIN_ROOT}\"/skills/clean/scripts/destructive_guard.py --mode engine-gate --plugin-root \"${CLAUDE_PLUGIN_ROOT}\" --authorized-data-root \"${CLAUDE_PLUGIN_DATA}\"", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/run-python-hook.sh --marker-root \"${CLAUDE_PLUGIN_DATA}\" --launch-marker guard-launch-monitor \"${CLAUDE_PLUGIN_ROOT}\"/skills/clean/scripts/destructive_guard.py --mode engine-gate --plugin-root \"${CLAUDE_PLUGIN_ROOT}\" --authorized-data-root \"${CLAUDE_PLUGIN_DATA}\"", "if": "PowerShell(*hygiene.py*)", "shell": "bash", "timeout": 60, @@ -28,7 +28,7 @@ }, { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/run-python-hook.sh \"${CLAUDE_PLUGIN_ROOT}\"/skills/clean/scripts/destructive_guard.py --mode engine-gate --plugin-root \"${CLAUDE_PLUGIN_ROOT}\" --authorized-data-root \"${CLAUDE_PLUGIN_DATA}\"", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/run-python-hook.sh --marker-root \"${CLAUDE_PLUGIN_DATA}\" --launch-marker guard-launch-monitor \"${CLAUDE_PLUGIN_ROOT}\"/skills/clean/scripts/destructive_guard.py --mode engine-gate --plugin-root \"${CLAUDE_PLUGIN_ROOT}\" --authorized-data-root \"${CLAUDE_PLUGIN_DATA}\"", "if": "PowerShell(*python*$*)", "shell": "bash", "timeout": 60, @@ -36,7 +36,7 @@ }, { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/run-python-hook.sh \"${CLAUDE_PLUGIN_ROOT}\"/skills/clean/scripts/destructive_guard.py --mode engine-gate --plugin-root \"${CLAUDE_PLUGIN_ROOT}\" --authorized-data-root \"${CLAUDE_PLUGIN_DATA}\"", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/run-python-hook.sh --marker-root \"${CLAUDE_PLUGIN_DATA}\" --launch-marker guard-launch-monitor \"${CLAUDE_PLUGIN_ROOT}\"/skills/clean/scripts/destructive_guard.py --mode engine-gate --plugin-root \"${CLAUDE_PLUGIN_ROOT}\" --authorized-data-root \"${CLAUDE_PLUGIN_DATA}\"", "if": "PowerShell(*& $*)", "shell": "bash", "timeout": 60, @@ -50,7 +50,7 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/run-python-hook.sh \"${CLAUDE_PLUGIN_ROOT}\"/skills/clean/scripts/guard_launch_monitor.py --data-root \"${CLAUDE_PLUGIN_DATA}\"", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/run-python-hook.sh --marker-root \"${CLAUDE_PLUGIN_DATA}\" --skip-unless-marker guard-launch-monitor \"${CLAUDE_PLUGIN_ROOT}\"/skills/clean/scripts/guard_launch_monitor.py --data-root \"${CLAUDE_PLUGIN_DATA}\"", "shell": "bash", "timeout": 20, "statusMessage": "Checking for silent destructive-guard failures..." diff --git a/plugins/disk-hygiene/hooks/run-python-hook.sh b/plugins/disk-hygiene/hooks/run-python-hook.sh index ceea8e718..6b6ae6743 100755 --- a/plugins/disk-hygiene/hooks/run-python-hook.sh +++ b/plugins/disk-hygiene/hooks/run-python-hook.sh @@ -81,6 +81,93 @@ except OSError: raise SystemExit(0 if sys.version_info >= floor else 1) ' +# --- optional per-session launch marker ------------------------------------ +# +# Three OPTIONAL leading flags, consumed here and never forwarded to Python: +# +# --marker-root root the marker tree lives under, spelled +# `"${CLAUDE_PLUGIN_DATA}"` by every caller; +# --launch-marker write `//.launched` +# BEFORE exec'ing the target; +# --skip-unless-marker exit 0 without exec'ing anything when at +# least one candidate directory exists and +# none of them holds that file. +# +# Together they let a `Stop` hook cost nothing in a session where the hook it +# watches never ran. `guard_launch_monitor.py` reports failures of the +# `PreToolUse` guard, and the guard rows are `if`-gated on the engine's file +# name, so most sessions never launch the guard at all — yet the monitor still +# started a whole Python on every turn to discover that. `Stop` rows accept +# neither `matcher` nor `if`, so the only place that gate can live is here. +# +# The root is passed EXPLICITLY rather than read from `CLAUDE_PLUGIN_DATA` in +# the environment: a hook subprocess can inherit that variable naming a +# DIFFERENT plugin's data directory, and a writer and a reader that disagree +# about the root skip silently, which is the missed-detection failure this +# monitor exists to prevent. An empty value, or an unsubstituted literal +# `${CLAUDE_PLUGIN_DATA}`, is treated as absent (the placeholder idiom +# `guard_launch_monitor.py` already uses) and the tmp fallback carries the +# marker alone. +# +# Failure modes, stated rather than implied: +# * a marker is per session and is never removed. Under the tmp fallback the +# OS clears it; under `--marker-root` it is one empty file per session that +# launched a guard, and neither the clean engine nor `lib/guard_decision_log.py` +# runs a retention sweep over the plugin data root that could collect them. +# * a session whose guard rows never fired is skipped BY DESIGN: there is no +# guard invocation for the monitor to have found a failure of. +# * a hook that failed before this script ran leaves no marker and is not this +# monitor's to detect — the detector reads the transcript for the guard's +# own command string, which such a failure still records. +# * the marker is written before the interpreter is even resolved, so the +# failure the monitor DOES exist to catch — the guard launching and dying — +# still leaves the marker that keeps the monitor running. +# * the session id is recovered with a regex over the raw payload rather than +# a JSON parse, because a parse is the Python this gate exists to avoid. +# The match is anchored to the payload's opening key, which nothing further +# in the payload can reach; only a payload that does not open with +# `session_id` falls back to an unanchored match, where a NESTED +# `session_id` key could win instead and key the marker wrongly. Both rows +# parse identically, so that corner is a missed detection, never a false +# alarm. +# * a misparse fails SAFE: no session id means write nothing and skip +# nothing, which is this launcher's behavior before these flags existed. +# * a launch that could create NEITHER candidate directory leaves no marker +# at all, and a Stop that read only the marker file would then silence the +# monitor for the whole session: a silent failure in the one detector that +# exists to catch silent failures. So the skip is gated on a candidate +# DIRECTORY existing. With none, the Stop row falls through and runs the +# monitor, which is the behavior before these flags existed. The cost of +# that fail-open is that the skip is inert in a plugin data root where no +# guard has ever launched: every Stop pays the old price until the first +# guard launch spends its one `mkdir`. +# * RESIDUAL, and undetectable across processes: a candidate directory that +# exists while the marker FILE could not be written (a full disk, a +# permission denial on the file alone) still degrades to silence for that +# session. The file that failed is the only channel between the launch row +# and the Stop row, so the Stop row cannot tell that case apart from a +# session whose guard rows never fired. +MARKER_ROOT="" +LAUNCH_MARKER_SUBDIR="" +SKIP_MARKER_SUBDIR="" +while (($#)); do + case "$1" in + --marker-root) + MARKER_ROOT="${2:-}" + shift 2 || break + ;; + --launch-marker) + LAUNCH_MARKER_SUBDIR="${2:-}" + shift 2 || break + ;; + --skip-unless-marker) + SKIP_MARKER_SUBDIR="${2:-}" + shift 2 || break + ;; + *) break ;; + esac +done + SCRIPT="${1:-}" shift || true @@ -90,6 +177,86 @@ case "$SCRIPT" in *) ;; esac +# shellcheck disable=SC2016 # the literal placeholder, deliberately unexpanded +_DATA_ROOT_PLACEHOLDER='${CLAUDE_PLUGIN_DATA}' +_MARKER_PATHS=() +_PAYLOAD="" +_SESSION_ID="" +_BUFFERED_STDIN=0 + +# Buffer the whole hook payload into a variable and recover its session id. +# `read` is a builtin, so buffering costs no process; the payload is handed to +# Python on a here-string at `exec` time, leaving its stdin unchanged. +_read_payload() { + _BUFFERED_STDIN=1 + IFS= read -r -d '' _PAYLOAD || true + local field='"session_id"[[:space:]]*:[[:space:]]*"([^"\\]*)"' + # Anchored first. The hooks reference shows `session_id` as the payload's + # opening key for every event but documents no ordering guarantee, and the + # payload nests objects of its own (`tool_input`) that could carry the same + # key name. An anchored match cannot be reached by either; the unanchored + # fallback runs only for a payload that did NOT open with the field, and keeps + # a reordered payload from turning this gate into a silent no-op. + local anchored="^[[:space:]]*[{][[:space:]]*$field" + if [[ "$_PAYLOAD" =~ $anchored ]] || [[ "$_PAYLOAD" =~ $field ]]; then + _SESSION_ID="${BASH_REMATCH[1]}" + fi +} + +# Fill `_MARKER_PATHS` with the candidates for one subdir, most-preferred +# first, mirroring `guard_launch_monitor.py`'s own `_marker_path_candidates`: +# the data root when one resolved, then a tmp fallback whose directory name is +# built from the subdir, so a `.launched` marker sits beside the `.warned` one +# the monitor writes. Only bash reads and writes `.launched`, so the tmp +# directory need not be the one Python's `tempfile.gettempdir()` picks; what +# matters is that both flags resolve it identically, which they do. +_marker_candidates() { + local subdir="$1" session="$2" + local safe="${session//[^a-zA-Z0-9_-]/_}" + _MARKER_PATHS=() + [[ -n "$safe" ]] || return 1 + if [[ -n "$MARKER_ROOT" && "$MARKER_ROOT" != "$_DATA_ROOT_PLACEHOLDER" ]]; then + _MARKER_PATHS+=("$MARKER_ROOT/$subdir/$safe.launched") + fi + _MARKER_PATHS+=("${TMPDIR:-/tmp}/disk-hygiene-$subdir/$safe.launched") +} + +if [[ -n "$SKIP_MARKER_SUBDIR" ]]; then + _read_payload + if [[ -n "$_SESSION_ID" ]] && _marker_candidates "$SKIP_MARKER_SUBDIR" "$_SESSION_ID"; then + _marker_found=0 + # Skipping requires POSITIVE evidence that the launch side got as far as a + # marker tree. No candidate directory at all is the shape a launch whose + # `mkdir` failed for every root leaves behind, and silencing the monitor on + # it would be a silent failure in the detector that exists to catch silent + # failures. Both tests are builtins, so the skip path still spawns nothing. + _marker_dir_seen=0 + for _marker_path in "${_MARKER_PATHS[@]}"; do + [[ -d "${_marker_path%/*}" ]] && _marker_dir_seen=1 + if [[ -f "$_marker_path" ]]; then + _marker_found=1 + break + fi + done + if ((_marker_dir_seen && !_marker_found)); then + exit 0 + fi + fi +fi + +if [[ -n "$LAUNCH_MARKER_SUBDIR" ]]; then + ((_BUFFERED_STDIN)) || _read_payload + if [[ -n "$_SESSION_ID" ]] && _marker_candidates "$LAUNCH_MARKER_SUBDIR" "$_SESSION_ID"; then + for _marker_path in "${_MARKER_PATHS[@]}"; do + _marker_dir="${_marker_path%/*}" + # `mkdir` is the one spawn on this path and `[[ -d ]]` is a builtin, so it + # is paid once per root rather than once per launch. + [[ -d "$_marker_dir" ]] || mkdir -p "$_marker_dir" 2>/dev/null || continue + : >"$_marker_path" 2>/dev/null && break + done + fi +fi + # Portable WindowsApps path-component check (case-insensitive). _under_windowsapps() { [[ "${1,,}" == *windowsapps* ]] @@ -315,4 +482,13 @@ if [[ -z "$PYTHON" ]]; then exit 0 fi +# A buffered payload is replayed on a here-string, which bash serves from a pipe +# or a temp file without creating a process. Python reads the same bytes it +# would have read from the inherited stdin, plus the newline `<<<` appends — +# both consumers here (`json.load(sys.stdin)` in the guard, `sys.stdin.read()` +# then `json.loads` in the monitor) ignore trailing whitespace. +if ((_BUFFERED_STDIN)); then + exec "$PYTHON" "$SCRIPT" "$@" <<<"$_PAYLOAD" +fi + exec "$PYTHON" "$SCRIPT" "$@" diff --git a/plugins/disk-hygiene/hooks/run-python-hook.test.sh b/plugins/disk-hygiene/hooks/run-python-hook.test.sh index b9d0c7715..3add1588d 100755 --- a/plugins/disk-hygiene/hooks/run-python-hook.test.sh +++ b/plugins/disk-hygiene/hooks/run-python-hook.test.sh @@ -381,7 +381,10 @@ done FAKE_BIN="$(mktemp -d)" # Replaces (does not chain onto) the earlier EXIT trap, so it cleans up both. trap 'rm -rf "$FAKE_BIN" "$PROBE_DIR"' EXIT -for stub in python3 python; do +# `py` belongs in the stub set with the other two: the launcher's third branch +# resolves through the Windows `py` launcher, and a host that has one resolves a +# real interpreter here, runs the monitor, and sees no systemMessage at all. +for stub in python3 python py; do printf '#!/usr/bin/env bash\nexit 127\n' >"$FAKE_BIN/$stub" chmod +x "$FAKE_BIN/$stub" done @@ -458,4 +461,181 @@ else "2" "$census_execs" fi +# --- the per-session launch marker gates the Stop monitor before python --- +# +# `Stop` rows accept neither `matcher` nor `if`, so the only place a "did the +# guard run at all this session" gate can live is the launcher. The contract has +# five halves: a launch records the session (even when the launched python then +# dies, which is the very failure the monitor exists to report), a session +# unrecorded in a marker tree that EXISTS reaches no python at all, a recorded +# one runs exactly as before, a payload the launcher cannot key on falls back to +# running python, and a marker tree that does not exist at all falls back the +# same way rather than silencing the monitor. +MARKER_CASE="$PROBE_DIR/marker-case" +MARKER_ROOT_DIR="$MARKER_CASE/data" +MARKER_HOME="$MARKER_CASE/home" +MARKER_SEEN="$MARKER_CASE/target-stdin" +MARKER_DIR="$MARKER_ROOT_DIR/guard-launch-monitor" +mkdir -p "$MARKER_ROOT_DIR" "$MARKER_HOME" "$MARKER_CASE/tmp" +MARKER_TARGET="$FIXTURE_ROOT/skills/clean/scripts/marker_target.py" +{ + printf 'import pathlib, sys\n' + # Byte-for-byte, through the binary buffer: the payload reaches python over a + # here-string now, and a decoded round trip would hide a newline difference. + printf 'pathlib.Path(sys.argv[1]).write_bytes(sys.stdin.buffer.read())\n' + printf 'pathlib.Path(sys.argv[1] + ".argv").write_text("\\n".join(sys.argv[3:]))\n' + printf 'raise SystemExit(int(sys.argv[2]))\n' +} >"$MARKER_TARGET" + +MARKER_PAYLOAD="" +# Echo the launcher's exit code; `$MARKER_SEEN` exists afterwards only if the +# target actually started, and holds the bytes python read. +marker_launch() { + local flag="$1" subdir="$2" target_rc="$3" rc=0 + rm -f "$MARKER_SEEN" "$MARKER_SEEN.argv" + printf '%s' "$MARKER_PAYLOAD" | + HOME="$MARKER_HOME" TMPDIR="$MARKER_CASE/tmp" \ + bash "$FIXTURE_ROOT/hooks/run-python-hook.sh" \ + --marker-root "$MARKER_ROOT_DIR" "$flag" "$subdir" \ + "$MARKER_TARGET" "$MARKER_SEEN" "$target_rc" >/dev/null 2>&1 || rc=$? + printf '%s' "$rc" +} + +marker_state() { + [[ -f "$MARKER_DIR/$1.launched" ]] && printf 'present' || printf 'absent' +} + +target_state() { + [[ -e "$MARKER_SEEN" ]] && printf 'ran' || printf 'skipped' +} + +MARKER_PAYLOAD='{"session_id":"sess-1","hook_event_name":"PreToolUse"}' +rm -rf "$MARKER_DIR" +marker_rc="$(marker_launch --launch-marker guard-launch-monitor 0)" +assert_eq "a guarded launch records the session" "present" "$(marker_state sess-1)" +assert_eq "a guarded launch still runs its target" "ran" "$(target_state)" +assert_eq "a guarded launch still reports its target's exit code" "0" "$marker_rc" +assert_eq "the marker flags never reach python's argv" "" "$(cat "$MARKER_SEEN.argv")" + +# The monitor exists to catch a guard that launched and died, so a non-zero exit +# must leave the marker that keeps the monitor running for the rest of the turn. +rm -rf "$MARKER_DIR" +marker_rc="$(marker_launch --launch-marker guard-launch-monitor 3)" +assert_eq "a launch whose python exits non-zero still records the session" \ + "present" "$(marker_state sess-1)" +assert_eq "a launch whose python exits non-zero propagates that code" "3" "$marker_rc" + +# An EXISTING marker tree that holds no marker for this session is the shape a +# launch-free session leaves, and is the only shape that may skip. +MARKER_PAYLOAD='{"session_id":"sess-1","hook_event_name":"Stop"}' +rm -rf "$MARKER_DIR" +mkdir -p "$MARKER_DIR" +marker_rc="$(marker_launch --skip-unless-marker guard-launch-monitor 0)" +assert_eq "an unrecorded session exits 0" "0" "$marker_rc" +assert_eq "an unrecorded session starts no python" "skipped" "$(target_state)" + +mkdir -p "$MARKER_DIR" +: >"$MARKER_DIR/sess-1.launched" +marker_rc="$(marker_launch --skip-unless-marker guard-launch-monitor 0)" +assert_eq "a recorded session runs python" "ran" "$(target_state)" +assert_eq "a recorded session exits 0" "0" "$marker_rc" + +# Byte identity across the buffer-and-replay: python receives what it would have +# read from the inherited stdin, plus the newline `<<<` appends. Both consumers +# (`json.load(sys.stdin)`, `sys.stdin.read()` then `json.loads`) ignore it. +MARKER_EXPECTED="$MARKER_CASE/expected" +printf '%s\n' "$MARKER_PAYLOAD" >"$MARKER_EXPECTED" +assert_eq "the buffered payload reaches python unchanged" "same" \ + "$(cmp -s "$MARKER_EXPECTED" "$MARKER_SEEN" && printf 'same' || printf 'differs')" + +# A payload the launcher cannot key on must behave exactly as it did before +# these flags existed: record nothing, skip nothing, run python. +MARKER_PAYLOAD='{"hook_event_name":"Stop","cwd":"/tmp"}' +rm -rf "$MARKER_DIR" +marker_rc="$(marker_launch --skip-unless-marker guard-launch-monitor 0)" +assert_eq "a payload with no session id still runs python" "ran" "$(target_state)" +assert_eq "a payload with no session id exits 0" "0" "$marker_rc" +marker_launch --launch-marker guard-launch-monitor 0 >/dev/null +assert_eq "a payload with no session id records nothing" "absent" \ + "$([[ -d "$MARKER_DIR" ]] && printf 'present' || printf 'absent')" + +# A payload whose opening key is something else still keys correctly, through +# the unanchored fallback: the docs show `session_id` first for every event but +# guarantee no ordering, and a reordered payload must degrade to running python, +# not to keying on nothing. +MARKER_PAYLOAD='{"hook_event_name":"Stop","session_id":"sess-2"}' +rm -rf "$MARKER_DIR" +mkdir -p "$MARKER_DIR" +marker_launch --skip-unless-marker guard-launch-monitor 0 >/dev/null +assert_eq "a reordered payload still keys on its session id" "skipped" "$(target_state)" +mkdir -p "$MARKER_DIR" +: >"$MARKER_DIR/sess-2.launched" +marker_launch --skip-unless-marker guard-launch-monitor 0 >/dev/null +assert_eq "a reordered payload finds its own marker" "ran" "$(target_state)" + +# --- a launch that can write no marker at all must not silence the monitor --- +# +# Both candidates fail on the launch side here, which leaves the Stop row +# nothing to find. Reading only the marker FILE would then skip every Stop for +# the rest of the session: a silent failure in the one detector that exists to +# report silent failures. The Stop row therefore skips only when a candidate +# DIRECTORY exists, and otherwise runs the monitor as it did before the flags. +# +# "Unwritable" is staged structurally rather than through permission bits, which +# MSYS cannot set against Windows ACLs: `mkdir -p` refuses a parent that is a +# regular FILE on every host, and refuses a target that already exists as one. +# The marker root sits under a file; `TMPDIR` itself stays a real directory +# (bash may place a here-string there) with a file occupying the name the tmp +# fallback wants. +BLOCKED_CASE="$PROBE_DIR/blocked-case" +BLOCKED_TMP="$BLOCKED_CASE/tmp" +mkdir -p "$BLOCKED_CASE" "$BLOCKED_TMP" +BLOCKED_FILE="$BLOCKED_CASE/not-a-directory" +: >"$BLOCKED_FILE" +: >"$BLOCKED_TMP/disk-hygiene-guard-launch-monitor" +BLOCKED_ROOT="$BLOCKED_FILE/data" + +blocked_launch() { + local flag="$1" subdir="$2" rc=0 + rm -f "$MARKER_SEEN" "$MARKER_SEEN.argv" + printf '%s' "$MARKER_PAYLOAD" | + HOME="$MARKER_HOME" TMPDIR="$BLOCKED_TMP" \ + bash "$FIXTURE_ROOT/hooks/run-python-hook.sh" \ + --marker-root "$BLOCKED_ROOT" "$flag" "$subdir" \ + "$MARKER_TARGET" "$MARKER_SEEN" 0 >/dev/null 2>&1 || rc=$? + printf '%s' "$rc" +} + +MARKER_PAYLOAD='{"session_id":"sess-3","hook_event_name":"PreToolUse"}' +blocked_rc="$(blocked_launch --launch-marker guard-launch-monitor)" +assert_eq "a launch that can write no marker still runs its target" \ + "ran" "$(target_state)" +assert_eq "a launch that can write no marker reports its target's code" \ + "0" "$blocked_rc" +assert_eq "a failed marker write creates no candidate directory" "absent" \ + "$([[ -d "$BLOCKED_ROOT" || -d "$BLOCKED_TMP/disk-hygiene-guard-launch-monitor" ]] && + printf 'present' || printf 'absent')" +assert_eq "and it leaves the blocking file alone" "file" \ + "$([[ -f "$BLOCKED_FILE" ]] && printf 'file' || printf 'gone')" + +MARKER_PAYLOAD='{"session_id":"sess-3","hook_event_name":"Stop"}' +blocked_rc="$(blocked_launch --skip-unless-marker guard-launch-monitor)" +assert_eq "a Stop with no candidate marker directory runs the monitor" \ + "ran" "$(target_state)" +assert_eq "a Stop with no candidate marker directory exits 0" "0" "$blocked_rc" + +# --- both wirings carry the flag the other one depends on --- +guard_rows="$(jq '[.hooks.PreToolUse[].hooks[] | + select(.command | contains("destructive_guard.py"))] | length' "$HOOKS_JSON")" +guard_marked="$(jq '[.hooks.PreToolUse[].hooks[] | + select(.command | contains("destructive_guard.py")) | + select(.command | contains("--launch-marker guard-launch-monitor"))] | length' \ + "$HOOKS_JSON")" +assert_eq "every engine-gate row records the session it launched in" \ + "$guard_rows" "$guard_marked" +assert_eq "the Stop row skips a session that launched no guard" "1" \ + "$(jq '[.hooks.Stop[].hooks[] | + select(.command | contains("--skip-unless-marker guard-launch-monitor"))] | length' \ + "$HOOKS_JSON")" + pass "all run-python-hook contract checks"