From 9519e1cd730308d7dd7bf4380dec7e0395c88f82 Mon Sep 17 00:00:00 2001 From: Lior Finkelshtein Date: Wed, 24 Jun 2026 12:18:05 +0300 Subject: [PATCH 01/23] feat(agent): plant bait as a FIFO with cached content (#100) Adds FIFO_MODE probe (probe_fifo_mode), BAITCACHE dir, cache_path helper, and a FIFO branch in plant() that creates a named pipe and caches content for the upcoming serve loop. Regular-file path remains as FIFO_MODE=0 fallback. Co-Authored-By: Claude Opus 4.8 (1M context) --- agent/thumper_agent.sh | 34 +++++++++++++++++++++++++ tests/test_agent_fifo.py | 55 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 tests/test_agent_fifo.py diff --git a/agent/thumper_agent.sh b/agent/thumper_agent.sh index 52b2f43..c012de6 100755 --- a/agent/thumper_agent.sh +++ b/agent/thumper_agent.sh @@ -48,6 +48,18 @@ REPLANT_MAX=3 # max re-plant attempts per deployment before giving up (verify # enroll storm. RESYNC_COOLDOWN=30 LAST_RESYNC=0 +# FIFO sensor: bait is a named pipe the agent serves. Probed once against the +# state dir's filesystem; if mkfifo is unavailable there we fall back to the +# (fixed) atime poll. Bait content is cached to BAITCACHE so the per-bait +# serving loop can re-serve it on every read. +FIFO_MODE=0 +BAITCACHE="" +probe_fifo_mode() { + command -v mkfifo >/dev/null 2>&1 || { FIFO_MODE=0; return; } + _probe="$(dirname "$STATE_FILE")/.fifoprobe.$$" + if mkfifo "$_probe" 2>/dev/null; then rm -f "$_probe"; FIFO_MODE=1; else FIFO_MODE=0; fi +} +cache_path() { printf '%s/%s' "$BAITCACHE" "$1"; } # cache_path TAB=$(printf '\t') log() { printf '[thumper %s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; } @@ -302,6 +314,25 @@ plant() { # plant return 1 fi + if [ "$FIFO_MODE" = 1 ]; then + mkdir -p "$BAITCACHE" + cf=$(cache_path "$id") + if ! curl -fsS "$url" -H "Authorization: Bearer $AGENT_TOKEN" -o "$cf"; then + rm -f "$cf"; err "failed to fetch bait for $id"; report_plant "$id" failed; return 1 + fi + chmod 600 "$cf" 2>/dev/null || true + [ -p "$path" ] && rm -f "$path" # replace our own stale FIFO on re-plant + if ! mkfifo "$path" 2>/dev/null; then + err "mkfifo failed at $path - skipping $id"; report_plant "$id" failed; return 1 + fi + record_planted "$path" + chmod 600 "$path" 2>/dev/null || true + [ -n "$TARGET_USER" ] && chown "$TARGET_USER" "$path" 2>/dev/null || true + report_plant "$id" planted + log "planted (fifo) $id -> $path" + return 0 + fi + if ! curl -fsS "$url" -H "Authorization: Bearer $AGENT_TOKEN" -o "$path"; then rm -f "$path" # remove the partial/empty file curl may have left err "failed to fetch bait for $id" @@ -632,6 +663,9 @@ verify_planted() { run() { STATE_FILE=${STATE_FILE:-$DEFAULT_STATE} MANIFEST_FILE="$(dirname "$STATE_FILE")/planted.list" + BAITCACHE="$(dirname "$STATE_FILE")/bait" + probe_fifo_mode + [ "$FIFO_MODE" = 1 ] && log "sensor: FIFO bait" || log "sensor: atime poll (mkfifo unavailable)" MAIN_PID=$$ # so the backgrounded heartbeat loop can signal us to self-destruct # Enforce one-agent-per-install before any work; a duplicate exits here (the # EXIT trap below is NOT yet set, so it can't disturb the live holder's lock). diff --git a/tests/test_agent_fifo.py b/tests/test_agent_fifo.py new file mode 100644 index 0000000..614960e --- /dev/null +++ b/tests/test_agent_fifo.py @@ -0,0 +1,55 @@ +"""FIFO bait sensor (#100): bait is planted as a named pipe; a read of it +unblocks the agent's write and fires a callback. Driven against a stub server, +like test_agent_live_sync.py.""" +import http.server, socket, subprocess, threading, time, os, stat +from pathlib import Path +import pytest + +AGENT = Path(__file__).resolve().parents[1] / "agent" / "thumper_agent.sh" +BAIT_BODY = "AKIA-BAIT\nsecret=shhh\n" + +class Stub(http.server.BaseHTTPRequestHandler): + callbacks = [] # POSTed callback bodies + bait_path = "" # absolute path the agent should plant + def log_message(self, *a): pass + def _t(self, body=""): + self.send_response(200); self.send_header("Content-Type","text/plain") + self.end_headers(); self.wfile.write(body.encode()) + def do_POST(self): + n=int(self.headers.get("Content-Length",0)); body=self.rfile.read(n).decode() + if self.path == "/api/enroll": return self._t("agent_token=tok-1\nendpoint_id=ep_1\n") + if self.path.startswith("/cb/"): Stub.callbacks.append(body); return self._t("ok") + if self.path.endswith("/state"): return self._t("ok") + return self._t("ok") + def do_GET(self): + if self.path == "/api/agent/deployments": + base=f"http://127.0.0.1:{self.server.server_port}" + rec="\t".join(["dep_1", Stub.bait_path, "sekret", f"{base}/content/dep_1", f"{base}/cb/dep_1"]) + return self._t(rec+"\n") + if self.path.startswith("/content/"): return self._t(BAIT_BODY) + return self._t("") + +@pytest.fixture +def server(): + httpd = http.server.HTTPServer(("127.0.0.1",0), Stub) + threading.Thread(target=httpd.serve_forever, daemon=True).start() + Stub.callbacks = [] + yield httpd + httpd.shutdown() + +def _run(server, tmp_path, *extra, timeout=15): + port = server.server_port + state = tmp_path / "agent.json" + return subprocess.run( + ["sh", str(AGENT), "run", "--server", f"http://127.0.0.1:{port}", + "--enroll-token", "e", "--tripwire", "tw_1", "--state-file", str(state), + "--heartbeat", "0", *extra], + capture_output=True, text=True, timeout=timeout) + +def test_plant_creates_a_fifo_and_caches_content(server, tmp_path): + bait = tmp_path / "bait_aws" + Stub.bait_path = str(bait) + _run(server, tmp_path, "--once") + assert bait.exists() and stat.S_ISFIFO(bait.stat().st_mode), "bait is not a FIFO" + cache = tmp_path / "bait" / "dep_1" + assert cache.read_text() == BAIT_BODY, "bait content not cached" From 0390be14d489da664e1abde1d81326e0ee7ac552 Mon Sep 17 00:00:00 2001 From: Lior Finkelshtein Date: Wed, 24 Jun 2026 14:51:50 +0300 Subject: [PATCH 02/23] fix(agent): unset probe temp, clean cache on mkfifo failure, tidy test imports (#100) - probe_fifo_mode(): add `unset _probe` to prevent the temp var from persisting as a global in POSIX sh - plant() FIFO branch: rm -f "$cf" before returning on mkfifo failure so no stale cache file is left behind - tests/test_agent_fifo.py: drop unused `socket` and `time` imports Co-Authored-By: Claude Opus 4.8 (1M context) --- agent/thumper_agent.sh | 3 ++- tests/test_agent_fifo.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/agent/thumper_agent.sh b/agent/thumper_agent.sh index c012de6..35ccb44 100755 --- a/agent/thumper_agent.sh +++ b/agent/thumper_agent.sh @@ -58,6 +58,7 @@ probe_fifo_mode() { command -v mkfifo >/dev/null 2>&1 || { FIFO_MODE=0; return; } _probe="$(dirname "$STATE_FILE")/.fifoprobe.$$" if mkfifo "$_probe" 2>/dev/null; then rm -f "$_probe"; FIFO_MODE=1; else FIFO_MODE=0; fi + unset _probe } cache_path() { printf '%s/%s' "$BAITCACHE" "$1"; } # cache_path TAB=$(printf '\t') @@ -323,7 +324,7 @@ plant() { # plant chmod 600 "$cf" 2>/dev/null || true [ -p "$path" ] && rm -f "$path" # replace our own stale FIFO on re-plant if ! mkfifo "$path" 2>/dev/null; then - err "mkfifo failed at $path - skipping $id"; report_plant "$id" failed; return 1 + rm -f "$cf"; err "mkfifo failed at $path - skipping $id"; report_plant "$id" failed; return 1 fi record_planted "$path" chmod 600 "$path" 2>/dev/null || true diff --git a/tests/test_agent_fifo.py b/tests/test_agent_fifo.py index 614960e..aa0951b 100644 --- a/tests/test_agent_fifo.py +++ b/tests/test_agent_fifo.py @@ -1,7 +1,7 @@ """FIFO bait sensor (#100): bait is planted as a named pipe; a read of it unblocks the agent's write and fires a callback. Driven against a stub server, like test_agent_live_sync.py.""" -import http.server, socket, subprocess, threading, time, os, stat +import http.server, subprocess, threading, os, stat from pathlib import Path import pytest From 9a051832eb56b434bb9388e9da584bcc928a4fb0 Mon Sep 17 00:00:00 2001 From: Lior Finkelshtein Date: Wed, 24 Jun 2026 14:55:52 +0300 Subject: [PATCH 03/23] feat(agent): FIFO serving loop + supervisor; reads fire callbacks (#100) Add attribute() stub, serve_fifo() (held-fd read-detection loop), watch_fifo() supervisor, and update start_watcher() to prefer watch_fifo when FIFO_MODE=1. A reader opening the bait FIFO unblocks exec 3>"$fifo", serves cached content, then fires a callback with event_type=open after debounce check. Co-Authored-By: Claude Opus 4.8 (1M context) --- agent/thumper_agent.sh | 37 ++++++++++++++++++++++++++++++------- tests/test_agent_fifo.py | 25 ++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/agent/thumper_agent.sh b/agent/thumper_agent.sh index 35ccb44..002b445 100755 --- a/agent/thumper_agent.sh +++ b/agent/thumper_agent.sh @@ -556,14 +556,37 @@ plant_all() { # plant every current deployment; sets `planted` done } +# attribute : best-effort set of globals process/pid/os_user. Real +# implementation lands in the attribution task; default is "unknown". +attribute() { pid=""; process=""; os_user=""; return 0; } + +serve_fifo() { # serve_fifo - serve one bait FIFO forever; a read = a hit + eval "fifo=\$dep_path_$1 id=\$dep_id_$1" + cf=$(cache_path "$id") + trap '' PIPE # a reader closing early must not kill us + while [ -p "$fifo" ]; do + exec 3>"$fifo" || break # open(O_WRONLY) BLOCKS until a reader opens + attribute "$fifo" # reader is parked in read(); grab it before we write + cat "$cf" >&3 2>/dev/null || true # serve bait into the held fd (ignore EPIPE) + exec 3>&- # close -> reader gets EOF + now=$(date +%s); eval "last=\${dep_last_$1:-0}" + if [ $((now - last)) -ge "$DEBOUNCE_SECS" ]; then + eval "dep_last_$1=\$now" + is_noise "$process" || fire "$1" open "$process" "$pid" "$os_user" "$fifo" + fi + done +} + +watch_fifo() { # supervisor: one serve_fifo per bait, wait on them + log "watching $DEP_COUNT bait file(s) via FIFO" + i=1 + while [ "$i" -le "$DEP_COUNT" ]; do serve_fifo "$i" & i=$((i + 1)); done + wait +} + start_watcher() { # launch the right sensor in the background; set WATCH_PID - # fs_usage needs root, but watch_fs_usage runs it under `sudo -n` when we are - # not root - so a non-root Mac with passwordless sudo still gets the real - # sensor. Probe that capability instead of gating on `id -u = 0`, which would - # silently downgrade such hosts to the lossy atime poll. - if [ "$(platform)" = "darwin" ] && command -v fs_usage >/dev/null 2>&1 \ - && { [ "$(id -u)" = "0" ] || sudo -n true >/dev/null 2>&1; }; then - watch_fs_usage & + if [ "$FIFO_MODE" = 1 ]; then + watch_fifo & else watch_atime & fi diff --git a/tests/test_agent_fifo.py b/tests/test_agent_fifo.py index aa0951b..0a77beb 100644 --- a/tests/test_agent_fifo.py +++ b/tests/test_agent_fifo.py @@ -1,7 +1,7 @@ """FIFO bait sensor (#100): bait is planted as a named pipe; a read of it unblocks the agent's write and fires a callback. Driven against a stub server, like test_agent_live_sync.py.""" -import http.server, subprocess, threading, os, stat +import http.server, subprocess, threading, os, stat, time from pathlib import Path import pytest @@ -53,3 +53,26 @@ def test_plant_creates_a_fifo_and_caches_content(server, tmp_path): assert bait.exists() and stat.S_ISFIFO(bait.stat().st_mode), "bait is not a FIFO" cache = tmp_path / "bait" / "dep_1" assert cache.read_text() == BAIT_BODY, "bait content not cached" + +def _wait(pred, timeout=12): + end=time.time()+timeout + while time.time() Date: Wed, 24 Jun 2026 15:01:18 +0300 Subject: [PATCH 04/23] feat(agent): attribute FIFO reader via lsof inode scan (#100) Replace stub attribute() with real implementation that full-scans lsof -nP, matches the FIFO by inode (BSD stat -f %i / GNU stat -c %i), filters for read-mode fd (field 4 ending in 'r') not owned by our serve subshell, then resolves pid -> process and os_user best-effort. Adds TDD test test_callback_includes_reader_pid_and_user (RED -> GREEN). Co-Authored-By: Claude Opus 4.8 (1M context) --- agent/thumper_agent.sh | 19 ++++++++++++++++--- tests/test_agent_fifo.py | 20 ++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/agent/thumper_agent.sh b/agent/thumper_agent.sh index 002b445..ff4b413 100755 --- a/agent/thumper_agent.sh +++ b/agent/thumper_agent.sh @@ -556,9 +556,22 @@ plant_all() { # plant every current deployment; sets `planted` done } -# attribute : best-effort set of globals process/pid/os_user. Real -# implementation lands in the attribution task; default is "unknown". -attribute() { pid=""; process=""; os_user=""; return 0; } +# attribute : best-effort set globals pid/process/os_user to the reader's. +# lsof does NOT report FIFO openers on macOS; full-scan and match by inode. +attribute() { # attribute ; best-effort set globals pid/process/os_user + pid=""; process=""; os_user="" + command -v lsof >/dev/null 2>&1 || return 0 + ino=$(stat -f %i "$1" 2>/dev/null || stat -c %i "$1" 2>/dev/null) || return 0 + [ -n "$ino" ] || return 0 + # Full scan: pick the process whose fd on THIS inode is open for READ (4r) and + # is not our serve subshell ($$). Inode matched as a standalone field so a + # blank DEVICE column can't shift parsing. + pid=$(lsof -nP 2>/dev/null | awk -v ino="$ino" -v me="$$" ' + index($0,"FIFO") && $2!=me && $4 ~ /r$/ && $0 ~ ("(^|[ \t])" ino "([ \t]|$)") { print $2; exit }') + [ -n "$pid" ] || { pid=""; return 0; } + process=$(ps -o comm= -p "$pid" 2>/dev/null | sed 's#.*/##' | tr -d ' ') + os_user=$(user_of_pid "$pid") +} serve_fifo() { # serve_fifo - serve one bait FIFO forever; a read = a hit eval "fifo=\$dep_path_$1 id=\$dep_id_$1" diff --git a/tests/test_agent_fifo.py b/tests/test_agent_fifo.py index 0a77beb..ad3993a 100644 --- a/tests/test_agent_fifo.py +++ b/tests/test_agent_fifo.py @@ -61,6 +61,26 @@ def _wait(pred, timeout=12): time.sleep(0.2) return False +def test_callback_includes_reader_pid_and_user(server, tmp_path): + bait = tmp_path / "bait_aws"; Stub.bait_path = str(bait) + p = subprocess.Popen( + ["sh", str(AGENT), "run", "--server", f"http://127.0.0.1:{server.server_port}", + "--enroll-token","e","--tripwire","tw_1","--state-file",str(tmp_path/"agent.json"), + "--heartbeat","0","--sync-interval","0"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + try: + assert _wait(lambda: bait.exists() and stat.S_ISFIFO(bait.stat().st_mode)) + time.sleep(0.5) + Path(bait).read_text() + assert _wait(lambda: Stub.callbacks) + body = Stub.callbacks[-1] + assert "pid=" in body and "os_user=" in body + pid_line = [l for l in body.splitlines() if l.startswith("pid=")][0] + user_line = [l for l in body.splitlines() if l.startswith("os_user=")][0] + assert pid_line != "pid=" and user_line != "os_user=", f"reader not attributed: {body!r}" + finally: + p.terminate(); p.wait(timeout=5) + def test_reading_the_fifo_fires_a_callback(server, tmp_path): bait = tmp_path / "bait_aws"; Stub.bait_path = str(bait) p = subprocess.Popen( From 7430a8a7b1472000e945dd16d11f37e4f937b68b Mon Sep 17 00:00:00 2001 From: Lior Finkelshtein Date: Wed, 24 Jun 2026 15:06:09 +0300 Subject: [PATCH 05/23] feat(agent): remove FIFOs on exit + sweep stale pipes on startup (#100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add remove_fifos(): reads MANIFEST_FILE and rm -f's any path that is a FIFO - Call remove_fifos at startup (FIFO_MODE=1) to clear orphaned pipes from a prior hard-kill - Wire remove_fifos into EXIT/INT/TERM traps so a clean exit leaves no writer-less FIFOs - stop_watcher is NOT modified (runs on every reconcile; must not remove kept baits) - TDD: test_clean_exit_removes_fifos and test_startup_sweeps_a_stale_fifo RED→GREEN Co-Authored-By: Claude Opus 4.8 (1M context) --- agent/thumper_agent.sh | 13 +++++++++++-- tests/test_agent_fifo.py | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/agent/thumper_agent.sh b/agent/thumper_agent.sh index ff4b413..99423df 100755 --- a/agent/thumper_agent.sh +++ b/agent/thumper_agent.sh @@ -616,6 +616,14 @@ stop_watcher() { # kill the watcher AND its fs_usage/grep children WATCH_PID="" } +remove_fifos() { # remove every manifest path that is a FIFO (clean exit / startup sweep) + [ -f "${MANIFEST_FILE:-}" ] || return 0 + while IFS= read -r p; do + [ -n "$p" ] && [ -p "$p" ] && rm -f "$p" && log "removed fifo bait $p" + done < "$MANIFEST_FILE" + return 0 +} + # reconcile : dep_* already hold the NEW set (post re-pull). reconcile() { _old=$1 @@ -703,6 +711,7 @@ run() { BAITCACHE="$(dirname "$STATE_FILE")/bait" probe_fifo_mode [ "$FIFO_MODE" = 1 ] && log "sensor: FIFO bait" || log "sensor: atime poll (mkfifo unavailable)" + [ "$FIFO_MODE" = 1 ] && remove_fifos # clear orphaned pipes from a prior hard-kill before re-planting MAIN_PID=$$ # so the backgrounded heartbeat loop can signal us to self-destruct # Enforce one-agent-per-install before any work; a duplicate exits here (the # EXIT trap below is NOT yet set, so it can't disturb the live holder's lock). @@ -744,8 +753,8 @@ run() { # release the singleton lock. Combined into one trap (replacing the release- # only trap set after acquire_singleton) so none clobbers the others. cleanup_heartbeat() { [ -n "$HEARTBEAT_PID" ] && kill "$HEARTBEAT_PID" 2>/dev/null; } - trap 'stop_watcher; cleanup_heartbeat; release_singleton; exit 0' INT TERM - trap 'stop_watcher; cleanup_heartbeat; release_singleton' EXIT + trap 'stop_watcher; remove_fifos; cleanup_heartbeat; release_singleton; exit 0' INT TERM + trap 'stop_watcher; remove_fifos; cleanup_heartbeat; release_singleton' EXIT # Remote kill: the heartbeat loop raises USR1 when the server flags us. trap 'self_destruct' USR1 diff --git a/tests/test_agent_fifo.py b/tests/test_agent_fifo.py index ad3993a..dc50e3c 100644 --- a/tests/test_agent_fifo.py +++ b/tests/test_agent_fifo.py @@ -96,3 +96,24 @@ def test_reading_the_fifo_fires_a_callback(server, tmp_path): assert _wait(lambda: any("event_type=open" in c for c in Stub.callbacks)), "no callback fired" finally: p.terminate(); p.wait(timeout=5) + +def test_clean_exit_removes_fifos(server, tmp_path): + bait = tmp_path / "bait_aws"; Stub.bait_path = str(bait) + p = subprocess.Popen( + ["sh", str(AGENT), "run", "--server", f"http://127.0.0.1:{server.server_port}", + "--enroll-token","e","--tripwire","tw_1","--state-file",str(tmp_path/"agent.json"), + "--heartbeat","0","--sync-interval","0"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + assert _wait(lambda: bait.exists()) + p.terminate(); p.wait(timeout=5) + assert not bait.exists(), "FIFO left behind after clean exit" + +def test_startup_sweeps_a_stale_fifo(server, tmp_path): + # Simulate a prior hard-killed run: a manifest naming a leftover FIFO. + bait = tmp_path / "bait_aws"; Stub.bait_path = str(bait) + os.mkfifo(bait) + (tmp_path / "planted.list").write_text(str(bait) + "\n") + _run(server, tmp_path, "--once") + # After --once the agent re-plants; the stale pipe must have been swept and + # re-created (still a FIFO) rather than colliding on mkfifo EEXIST. + assert bait.exists() and stat.S_ISFIFO(bait.stat().st_mode) From cab073fdebc5af4cb4e06cde3e9c13f47e9945b7 Mon Sep 17 00:00:00 2001 From: Lior Finkelshtein Date: Wed, 24 Jun 2026 15:10:02 +0300 Subject: [PATCH 06/23] test(agent): make startup-sweep test gate the feature via orphan FIFO (#100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the prior test body which used the current-deployment bait path (so plant()'s own EEXIST guard would remove it, bypassing the sweep) with an orphan FIFO path that is in the manifest but NOT in the current deployment set. Only the startup sweep can remove an orphan; without it the test fails. RED/GREEN verified: commented-out sweep → FAIL, restored → PASS. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_agent_fifo.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/test_agent_fifo.py b/tests/test_agent_fifo.py index dc50e3c..63aafca 100644 --- a/tests/test_agent_fifo.py +++ b/tests/test_agent_fifo.py @@ -109,11 +109,13 @@ def test_clean_exit_removes_fifos(server, tmp_path): assert not bait.exists(), "FIFO left behind after clean exit" def test_startup_sweeps_a_stale_fifo(server, tmp_path): - # Simulate a prior hard-killed run: a manifest naming a leftover FIFO. - bait = tmp_path / "bait_aws"; Stub.bait_path = str(bait) - os.mkfifo(bait) - (tmp_path / "planted.list").write_text(str(bait) + "\n") + # An orphan FIFO from a prior run: listed in the manifest but NOT a current + # deployment, so ONLY the startup sweep (not plant's own EEXIST handling) can + # remove it. Without the sweep it persists and blocks readers forever. + bait = tmp_path / "bait_aws"; Stub.bait_path = str(bait) # the current deployment + orphan = tmp_path / "orphan_fifo" # NOT a current deployment + os.mkfifo(orphan) + (tmp_path / "planted.list").write_text(f"{orphan}\n") # manifest from a prior run _run(server, tmp_path, "--once") - # After --once the agent re-plants; the stale pipe must have been swept and - # re-created (still a FIFO) rather than colliding on mkfifo EEXIST. - assert bait.exists() and stat.S_ISFIFO(bait.stat().st_mode) + assert not orphan.exists(), "orphan FIFO from manifest was not swept on startup" + assert bait.exists() and stat.S_ISFIFO(bait.stat().st_mode), "current bait not planted" From 145a9dd80fea060c79e716118526d64c2b1c5823 Mon Sep 17 00:00:00 2001 From: Lior Finkelshtein Date: Wed, 24 Jun 2026 15:59:22 +0300 Subject: [PATCH 07/23] feat(agent): remove fs_usage, fix atime fallback (#28), FIFO-aware verify (#100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes watch_fs_usage; reverses the atime stat probe order so Linux reads real access time (stat -c %X first; stat -f %a was statfs free-blocks on Linux). verify_planted treats a regular file where a FIFO should be as tampering. Updates architecture.md and the legacy plant/state tests to expect FIFO bait. Known follow-up (next commit): verify-pass re-plant must restart the FIFO watcher so a re-planted pipe is served — otherwise it blocks readers. Co-Authored-By: Claude Opus 4.8 (1M context) --- agent/thumper_agent.sh | 48 ++++++-------------------------- docs/architecture.md | 11 +++++--- tests/test_agent_fifo.py | 9 ++++++ tests/test_agent_plant.py | 9 +++--- tests/test_agent_state_report.py | 3 +- 5 files changed, 31 insertions(+), 49 deletions(-) diff --git a/agent/thumper_agent.sh b/agent/thumper_agent.sh index 99423df..104faa7 100755 --- a/agent/thumper_agent.sh +++ b/agent/thumper_agent.sh @@ -39,7 +39,7 @@ set -eu DEFAULT_STATE="$HOME/.thumper/agent.json" READ_OPS="open read RdData pread readlink mmap" # macOS background daemons that legitimately touch files (indexing/backup/security). -NOISE_PROCS="fs_usage sh bash thumper_agent curl mds mds_stores mdworker mdworker_shared mdbulkimport mdflagwriter mdsync fseventsd backupd tccd syspolicyd XProtect XprotectService quicklookd Spotlight mdiagnosticd" +NOISE_PROCS="sh bash thumper_agent curl mds mds_stores mdworker mdworker_shared mdbulkimport mdflagwriter mdsync fseventsd backupd tccd syspolicyd XProtect XprotectService quicklookd Spotlight mdiagnosticd" DEBOUNCE_SECS=3 REPLANT_MAX=3 # max re-plant attempts per deployment before giving up (verify pass) # After a callback is rejected with 401 (server no longer knows this deployment - @@ -322,7 +322,7 @@ plant() { # plant rm -f "$cf"; err "failed to fetch bait for $id"; report_plant "$id" failed; return 1 fi chmod 600 "$cf" 2>/dev/null || true - [ -p "$path" ] && rm -f "$path" # replace our own stale FIFO on re-plant + { [ -p "$path" ] || [ -f "$path" ]; } && rm -f "$path" # replace our own stale bait on re-plant if ! mkfifo "$path" 2>/dev/null; then rm -f "$cf"; err "mkfifo failed at $path - skipping $id"; report_plant "$id" failed; return 1 fi @@ -472,48 +472,12 @@ dep_index_for_line() { # echo the deployment index whose path appears in the li is_read_op() { for op in $READ_OPS; do [ "$op" = "$1" ] && return 0; done; return 1; } is_noise() { for n in $NOISE_PROCS; do [ "$n" = "$1" ] && return 0; done; return 1; } -watch_fs_usage() { - # Build a grep filter of just our bait paths so fs_usage's firehose is trimmed - # at the source - the shell loop only ever sees lines about our files. - set -- - i=1 - while [ "$i" -le "$DEP_COUNT" ]; do - eval "p=\$dep_path_$i" - set -- "$@" -e "$p" - i=$((i + 1)) - done - - cmd="fs_usage -w -f filesys" - [ "$(id -u)" = "0" ] || cmd="sudo -n $cmd" - command -v fs_usage >/dev/null 2>&1 || return 1 - - log "watching $DEP_COUNT bait file(s) via fs_usage" - # shellcheck disable=SC2086 - $cmd 2>/dev/null | grep --line-buffered -F "$@" | while read -r line; do - op=$(printf '%s' "$line" | awk '{print $2}') - is_read_op "$op" || continue - idx=$(dep_index_for_line "$line") || continue - last_field=$(printf '%s' "$line" | awk '{print $NF}') - process=$(printf '%s' "$last_field" | sed 's/\.[0-9][0-9]*$//') - pid=$(printf '%s' "$last_field" | sed -n 's/.*\.\([0-9][0-9]*\)$/\1/p') - is_noise "$process" && continue - now=$(date +%s) - eval "last=\$dep_last_$idx" - [ $((now - last)) -lt "$DEBOUNCE_SECS" ] && continue - eval "dep_last_$idx=\$now" - eval "watched=\$dep_path_$idx" - os_user=""; [ -n "$pid" ] && os_user=$(user_of_pid "$pid") - fire "$idx" "$op" "$process" "$pid" "$os_user" "$watched" - done - return 0 -} - watch_atime() { log "fs_usage unavailable - atime poll every ${POLL}s (best-effort; may miss reads, no process/user)" i=1 while [ "$i" -le "$DEP_COUNT" ]; do eval "p=\$dep_path_$i" - eval "atime_$i=\$(stat -f %a \"\$p\" 2>/dev/null || stat -c %X \"\$p\" 2>/dev/null || echo 0)" + eval "atime_$i=\$(stat -c %X \"\$p\" 2>/dev/null || stat -f %a \"\$p\" 2>/dev/null || echo 0)" i=$((i + 1)) done while true; do @@ -521,7 +485,7 @@ watch_atime() { i=1 while [ "$i" -le "$DEP_COUNT" ]; do eval "p=\$dep_path_$i prev=\$atime_$i" - cur=$(stat -f %a "$p" 2>/dev/null || stat -c %X "$p" 2>/dev/null || echo 0) + cur=$(stat -c %X "$p" 2>/dev/null || stat -f %a "$p" 2>/dev/null || echo 0) if [ "$cur" != "0" ] && [ "$cur" -gt "$prev" ] 2>/dev/null; then eval "atime_$i=\$cur" fire "$i" "atime-change" "" "" "" "$p" @@ -679,6 +643,9 @@ verify_planted() { # and never re-plant through it (curl -o would write the target); report # failed so the lost coverage is visible. report_plant "$vid" failed + elif [ "$FIFO_MODE" = 1 ] && [ -e "$p" ] && ! [ -p "$p" ]; then + # A regular file where our FIFO should be = tampering/replacement. + report_plant "$vid" failed elif [ -e "$p" ]; then # Bait is on disk → re-assert planted every cycle. Recovers a deployment # whose initial report was lost (e.g. a network blip during report_plant) @@ -709,6 +676,7 @@ run() { STATE_FILE=${STATE_FILE:-$DEFAULT_STATE} MANIFEST_FILE="$(dirname "$STATE_FILE")/planted.list" BAITCACHE="$(dirname "$STATE_FILE")/bait" + mkdir -p "$(dirname "$STATE_FILE")" probe_fifo_mode [ "$FIFO_MODE" = 1 ] && log "sensor: FIFO bait" || log "sensor: atime poll (mkfifo unavailable)" [ "$FIFO_MODE" = 1 ] && remove_fifos # clear orphaned pipes from a prior hard-kill before re-planting diff --git a/docs/architecture.md b/docs/architecture.md index b6522bc..08d7f52 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -66,10 +66,13 @@ language runtime. Delivered by a deploy plugin or manual copy. The agent checks for path conflicts, self-enrolls with the server, pulls its unique deployments (each with its own bait content and HMAC secret), plants the -files, and continuously monitors them for read access. On macOS it uses -`fs_usage` for real-time process-level detection; elsewhere it falls back to -`st_atime` polling. When a read is detected, the agent sends an HMAC-signed -callback to the server with enriched context (process, pid, user, path). +files, and continuously monitors them for read access. Each bait is planted as a +named pipe (FIFO); the agent holds the write end, so any process that opens the +bait to read it unblocks the agent and is detected in real time, unprivileged, +on macOS and Linux. The reading process/user is attributed best-effort via +`lsof`. When `mkfifo` is unavailable, the agent falls back to `st_atime` +polling. When a read is detected, the agent sends an HMAC-signed callback to +the server with enriched context (process, pid, user, path). #### Singleton lock diff --git a/tests/test_agent_fifo.py b/tests/test_agent_fifo.py index 63aafca..37df9e6 100644 --- a/tests/test_agent_fifo.py +++ b/tests/test_agent_fifo.py @@ -119,3 +119,12 @@ def test_startup_sweeps_a_stale_fifo(server, tmp_path): _run(server, tmp_path, "--once") assert not orphan.exists(), "orphan FIFO from manifest was not swept on startup" assert bait.exists() and stat.S_ISFIFO(bait.stat().st_mode), "current bait not planted" + + +def test_atime_stat_order_prefers_portable_access_time(): + # #28: `stat -f %a` on Linux is statfs (free blocks), so the portable + # `stat -c %X` must be tried FIRST. Assert the script's order. + src = AGENT.read_text() + assert 'stat -c %X "$p" 2>/dev/null || stat -f %a' in src, \ + "watch_atime must try `stat -c %X` (atime) before `stat -f %a`" + assert "watch_fs_usage" not in src, "fs_usage sensor must be removed" diff --git a/tests/test_agent_plant.py b/tests/test_agent_plant.py index be1eef7..75736bb 100644 --- a/tests/test_agent_plant.py +++ b/tests/test_agent_plant.py @@ -10,6 +10,7 @@ """ import http.server +import stat import subprocess import threading from pathlib import Path @@ -133,8 +134,8 @@ def test_plants_all_when_no_conflicts(agent): assert result.returncode == 0 assert "/api/enroll" in _StubHandler.seen, "did not enroll on a clean install" - assert Path(a).read_text() == BAIT_BODY - assert Path(b).read_text() == BAIT_BODY + assert stat.S_ISFIFO(Path(a).stat().st_mode), "bait not planted as FIFO" + assert stat.S_ISFIFO(Path(b).stat().st_mode), "bait not planted as FIFO" def test_refreshes_its_own_bait(agent): @@ -145,7 +146,7 @@ def test_refreshes_its_own_bait(agent): assert run().returncode == 0 Path(path).write_text("stale-bait") # simulate server-side rotation assert run().returncode == 0 - assert Path(path).read_text() == BAIT_BODY + assert stat.S_ISFIFO(Path(path).stat().st_mode), "bait not planted as FIFO" def test_force_overwrites_occupied_path(agent): @@ -157,4 +158,4 @@ def test_force_overwrites_occupied_path(agent): result = run("--force") assert result.returncode == 0 - assert Path(path).read_text() == BAIT_BODY + assert stat.S_ISFIFO(Path(path).stat().st_mode), "bait not planted as FIFO" diff --git a/tests/test_agent_state_report.py b/tests/test_agent_state_report.py index fae61d4..f1707d4 100644 --- a/tests/test_agent_state_report.py +++ b/tests/test_agent_state_report.py @@ -2,6 +2,7 @@ leftover. Drives the real agent against a stub that can fail one content fetch and records state reports.""" import http.server +import stat import subprocess import threading from pathlib import Path @@ -89,7 +90,7 @@ def test_reports_planted_for_good_and_failed_for_bad(agent): assert _Stub.states.get("dp_good") == "planted" assert _Stub.states.get("dp_bad") == "failed" - assert Path(good).read_text() == BAIT, "dp_good file not written despite planted state" + assert stat.S_ISFIFO(Path(good).stat().st_mode), "dp_good not planted as FIFO" def test_force_curl_failure_leaves_no_leftover(agent): From c9dc855c587a8894ebad1584a7ea7ce1ef5de7d2 Mon Sep 17 00:00:00 2001 From: Lior Finkelshtein Date: Wed, 24 Jun 2026 16:39:02 +0300 Subject: [PATCH 08/23] fix(agent): restart FIFO watcher after verify re-plants a bait (#100) When verify_planted successfully re-plants a missing FIFO bait, nothing previously restarted the watcher, so the new pipe had no serve_fifo loop writing to it. Any opener then blocked forever on open() and no callback ever fired. Fix: introduce a REPLANTED flag, set it on a successful re-plant inside verify_planted, and restart the watcher (stop_watcher + start_watcher) at the end of each sync cycle when FIFO_MODE=1 and something was re-planted. REPLANTED is cleared before each call so it only reflects the current cycle. Also fix a pre-existing deadlock in tests/test_agent_plant.py::test_refreshes_its_own_bait: in FIFO mode the planted path is a named pipe, so Path.write_text() blocks forever (open O_WRONLY on a pipe with no reader). Fix: unlink the FIFO before writing a regular file to simulate stale bait. Co-Authored-By: Claude Opus 4.8 (1M context) --- agent/thumper_agent.sh | 8 ++++++++ tests/test_agent_plant.py | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/agent/thumper_agent.sh b/agent/thumper_agent.sh index 104faa7..c3199e4 100755 --- a/agent/thumper_agent.sh +++ b/agent/thumper_agent.sh @@ -54,6 +54,7 @@ LAST_RESYNC=0 # serving loop can re-serve it on every read. FIFO_MODE=0 BAITCACHE="" +REPLANTED=0 probe_fifo_mode() { command -v mkfifo >/dev/null 2>&1 || { FIFO_MODE=0; return; } _probe="$(dirname "$STATE_FILE")/.fifoprobe.$$" @@ -660,6 +661,7 @@ verify_planted() { if [ "$a" -lt "$REPLANT_MAX" ]; then if plant "$i"; then log "re-planted missing bait $vid" + REPLANTED=1 else eval "heal_$vid=$((a + 1))" log "re-plant failed for $vid ($((a + 1))/$REPLANT_MAX)" @@ -754,7 +756,13 @@ run() { reconcile "$_old" start_watcher fi + REPLANTED=0 verify_planted # every cycle, even when the set did not change + if [ "$FIFO_MODE" = 1 ] && [ "$REPLANTED" = 1 ]; then + log "re-planted bait - restarting FIFO watcher to serve it" + stop_watcher + start_watcher + fi done } diff --git a/tests/test_agent_plant.py b/tests/test_agent_plant.py index 75736bb..5599288 100644 --- a/tests/test_agent_plant.py +++ b/tests/test_agent_plant.py @@ -144,6 +144,10 @@ def test_refreshes_its_own_bait(agent): (path,) = configure("bait_target") assert run().returncode == 0 + # In FIFO mode the planted path is a named pipe; open() on a FIFO blocks + # until a writer appears, so write_text() would deadlock. Remove the pipe + # first, then place a regular file to simulate stale/rotated bait. + Path(path).unlink() Path(path).write_text("stale-bait") # simulate server-side rotation assert run().returncode == 0 assert stat.S_ISFIFO(Path(path).stat().st_mode), "bait not planted as FIFO" From 648b8f5760a8af6ad04a3ef794165f3cb566d24f Mon Sep 17 00:00:00 2001 From: Lior Finkelshtein Date: Wed, 24 Jun 2026 17:23:13 +0300 Subject: [PATCH 09/23] test(agent): two agents on one host both detect via FIFO (#94/#95) (#100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds test_two_agents_one_host_both_detect: starts two concurrent agents each with their own per-bait FIFO, reads both pipes, asserts >= 2 event_type=open callbacks reach the stub server — proving no single-consumer collision (the original kdebug/fs_usage shared-sensor bug) is possible with per-bait FIFOs. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_agent_fifo.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_agent_fifo.py b/tests/test_agent_fifo.py index 37df9e6..3d5d5ab 100644 --- a/tests/test_agent_fifo.py +++ b/tests/test_agent_fifo.py @@ -121,6 +121,29 @@ def test_startup_sweeps_a_stale_fifo(server, tmp_path): assert bait.exists() and stat.S_ISFIFO(bait.stat().st_mode), "current bait not planted" +def test_two_agents_one_host_both_detect(server, tmp_path): + procs=[]; baits=[] + for i in (1,2): + d = tmp_path / f"inst{i}"; d.mkdir() + bait = d / "bait_aws"; baits.append(bait) + # each install gets its own bait path (distinct deployment per server stub run) + Stub.bait_path = str(bait) + procs.append(subprocess.Popen( + ["sh", str(AGENT), "run", "--server", f"http://127.0.0.1:{server.server_port}", + "--enroll-token","e","--tripwire",f"tw_{i}","--state-file",str(d/"agent.json"), + "--heartbeat","0","--sync-interval","0"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)) + assert _wait(lambda: bait.exists() and stat.S_ISFIFO(bait.stat().st_mode)) + try: + time.sleep(0.5) + for b in baits: Path(b).read_text() + assert _wait(lambda: sum("event_type=open" in c for c in Stub.callbacks) >= 2), \ + "both agents should detect — no single-consumer collision" + finally: + for p in procs: p.terminate() + for p in procs: p.wait(timeout=5) + + def test_atime_stat_order_prefers_portable_access_time(): # #28: `stat -f %a` on Linux is statfs (free blocks), so the portable # `stat -c %X` must be tried FIRST. Assert the script's order. From 226f37f3b3f4b5411d23bcf4debcefc176a6d3fb Mon Sep 17 00:00:00 2001 From: Lior Finkelshtein Date: Wed, 24 Jun 2026 17:44:09 +0300 Subject: [PATCH 10/23] fix(agent): sweep stale FIFOs only after acquiring singleton lock (#100) C1: move remove_fifos() to after acquire_singleton() so a duplicate invocation (MDM re-push with the same --state-file) exits at the mutex rather than deleting the live agent's shared bait FIFOs. Also: - m1: delete dead READ_OPS constant and is_read_op() (only removed watch_fs_usage used them) - m2: chmod 700 $BAITCACHE after mkdir -p in plant() to prevent deployment-ID enumeration via directory listing - m3: rm -rf $BAITCACHE in self_destruct before rmdir so fake creds are not left on the endpoint after decommission - m5: replace \t in attribute() awk inode regex with [[:space:]] for POSIX portability - m6: split test_atime_stat_order_prefers_portable_access_time into two distinct site-pinning assertions plus a wrong-order guard - C1 regression test: test_duplicate_install_does_not_sweep_live_agents_fifo verified RED before fix, GREEN after Co-Authored-By: Claude Opus 4.8 (1M context) --- agent/thumper_agent.sh | 52 +++++++++++++++++--------------- tests/test_agent_fifo.py | 65 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 27 deletions(-) diff --git a/agent/thumper_agent.sh b/agent/thumper_agent.sh index c3199e4..c10b19d 100755 --- a/agent/thumper_agent.sh +++ b/agent/thumper_agent.sh @@ -16,28 +16,28 @@ # 4. WATCH - detect reads and POST an HMAC-signed, enriched callback per # deployment. A read is the signal. # -# Root is NOT needed to plant a user-space bait (~/.aws, ~/.config, ~/.ssh) or for -# the attacker to read it - Shai-Hulud runs as the dev user, who owns the file. -# Root is only needed to (a) plant in a system path like /etc/ssh, or (b) run the -# macOS fs_usage sensor below. +# Root is NOT needed to plant a user-space bait (~/.aws, ~/.config, ~/.ssh) or to +# detect reads — the agent runs as the dev user who owns the file. Root is only +# needed to plant bait in a system path like /etc/ssh. # # Read detection: -# • macOS : `fs_usage`, pre-filtered with grep to ONLY our bait paths before -# anything else touches it (so we don't process the whole firehose). -# Yields the offending process + (looked-up) user. Needs root. -# • else : st_atime poll fallback. NOTE: best-effort only - many systems -# (notably macOS with relatime-style behavior) update atime lazily or -# not at all, so this can miss reads. fs_usage is the real sensor. +# • Primary : FIFO named-pipe bait (unprivileged). The agent serves bait content +# to any opener; `open(O_WRONLY)` blocks until a reader connects, so +# every read is a guaranteed, synchronous event. Works on macOS and +# Linux without elevated privileges. +# • Fallback : st_atime poll (when mkfifo is unavailable on the state-dir fs). +# Best-effort only — many systems update atime lazily or not at all, +# so this can miss reads and yields no process/user attribution. # -# Example (the shape an MDM/SSH deploy pushes; run as root for fs_usage): -# sudo sh thumper_agent.sh run \ +# Example (the shape an MDM/SSH deploy pushes): +# sh thumper_agent.sh run \ # --server http://localhost:8000 --enroll-token dev-enroll-token \ # --tripwire tw_ab12cd34 +# # (sudo only if planting in a system path like /etc/ssh) set -eu DEFAULT_STATE="$HOME/.thumper/agent.json" -READ_OPS="open read RdData pread readlink mmap" # macOS background daemons that legitimately touch files (indexing/backup/security). NOISE_PROCS="sh bash thumper_agent curl mds mds_stores mdworker mdworker_shared mdbulkimport mdflagwriter mdsync fseventsd backupd tccd syspolicyd XProtect XprotectService quicklookd Spotlight mdiagnosticd" DEBOUNCE_SECS=3 @@ -164,7 +164,7 @@ gen_machine_id() { platform() { uname -s | tr 'A-Z' 'a-z'; } # darwin | linux -# ── target user / path expansion (when running as root for fs_usage) ────────── +# ── target user / path expansion (when running as root for system-path planting) ── # Resolve the real desktop/dev user so bait lands in THEIR home and is owned by # them (the threat reads as that user), not /var/root. TARGET_USER="" @@ -318,6 +318,7 @@ plant() { # plant if [ "$FIFO_MODE" = 1 ]; then mkdir -p "$BAITCACHE" + chmod 700 "$BAITCACHE" 2>/dev/null || true cf=$(cache_path "$id") if ! curl -fsS "$url" -H "Authorization: Bearer $AGENT_TOKEN" -o "$cf"; then rm -f "$cf"; err "failed to fetch bait for $id"; report_plant "$id" failed; return 1 @@ -393,8 +394,8 @@ _fire() { if [ "$FIRE_RETRIED" = "0" ] && resync; then FIRE_RETRIED=1 # NOTE: recovers a rotated deployment id/secret for an EXISTING - # tripwire+path. If the path itself changed, the fs_usage grep - # filter won't see future reads until the watcher restarts. + # tripwire+path. After resync, dep_index_for_line re-matches the + # path against the refreshed deployment set. new_idx=$(dep_index_for_line "$accessed_path") || { err "callback REJECTED - path not deployed after re-enroll ($summary)"; return 0; } _fire "$new_idx" "$event_type" "$process" "$pid" "$os_user" "$accessed_path" @@ -454,6 +455,7 @@ self_destruct() { # Remove only the files we created, then rmdir. No `rm -rf` on a derived path: # rmdir is non-recursive and refuses a non-empty dir, so a misconfigured # --state-file can never wipe '/', $HOME, or anything we didn't plant here. + rm -rf "$BAITCACHE" 2>/dev/null || true # fake creds must not linger after decommission rm -f "$STATE_FILE" "$MANIFEST_FILE" "$dir/agent.log" "$dir/thumper_agent.sh" rmdir "$dir" 2>/dev/null || log "left $dir in place (not empty)" log "agent removed" @@ -470,11 +472,10 @@ dep_index_for_line() { # echo the deployment index whose path appears in the li return 1 } -is_read_op() { for op in $READ_OPS; do [ "$op" = "$1" ] && return 0; done; return 1; } is_noise() { for n in $NOISE_PROCS; do [ "$n" = "$1" ] && return 0; done; return 1; } watch_atime() { - log "fs_usage unavailable - atime poll every ${POLL}s (best-effort; may miss reads, no process/user)" + log "mkfifo unavailable - atime poll every ${POLL}s (best-effort; may miss reads, no process/user)" i=1 while [ "$i" -le "$DEP_COUNT" ]; do eval "p=\$dep_path_$i" @@ -532,7 +533,7 @@ attribute() { # attribute ; best-effort set globals pid/process/os_user # is not our serve subshell ($$). Inode matched as a standalone field so a # blank DEVICE column can't shift parsing. pid=$(lsof -nP 2>/dev/null | awk -v ino="$ino" -v me="$$" ' - index($0,"FIFO") && $2!=me && $4 ~ /r$/ && $0 ~ ("(^|[ \t])" ino "([ \t]|$)") { print $2; exit }') + index($0,"FIFO") && $2!=me && $4 ~ /r$/ && $0 ~ ("(^|[[:space:]])" ino "([[:space:]]|$)") { print $2; exit }') [ -n "$pid" ] || { pid=""; return 0; } process=$(ps -o comm= -p "$pid" 2>/dev/null | sed 's#.*/##' | tr -d ' ') os_user=$(user_of_pid "$pid") @@ -571,11 +572,11 @@ start_watcher() { # launch the right sensor in the background; set WATCH_PID WATCH_PID=$! } -stop_watcher() { # kill the watcher AND its fs_usage/grep children +stop_watcher() { # kill the watcher AND its serve_fifo children [ -n "${WATCH_PID:-}" ] || return 0 - # Reap children FIRST. Killing the subshell first makes the kernel reparent - # fs_usage/grep to PID 1, after which `pkill -P "$WATCH_PID"` matches nothing - # and leaks a root fs_usage on every reconcile. + # Reap children FIRST. Killing the parent subshell first makes the kernel + # reparent serve_fifo children to PID 1, after which `pkill -P "$WATCH_PID"` + # matches nothing and leaks serving loops on every reconcile. pkill -P "$WATCH_PID" 2>/dev/null || true kill "$WATCH_PID" 2>/dev/null || true WATCH_PID="" @@ -681,13 +682,16 @@ run() { mkdir -p "$(dirname "$STATE_FILE")" probe_fifo_mode [ "$FIFO_MODE" = 1 ] && log "sensor: FIFO bait" || log "sensor: atime poll (mkfifo unavailable)" - [ "$FIFO_MODE" = 1 ] && remove_fifos # clear orphaned pipes from a prior hard-kill before re-planting MAIN_PID=$$ # so the backgrounded heartbeat loop can signal us to self-destruct # Enforce one-agent-per-install before any work; a duplicate exits here (the # EXIT trap below is NOT yet set, so it can't disturb the live holder's lock). acquire_singleton trap 'release_singleton; exit 0' INT TERM trap 'release_singleton' EXIT + # Only the lock holder sweeps stale FIFOs from a prior hard-kill; a duplicate + # invocation exits at acquire_singleton above and must never touch the live + # agent's shared manifest/FIFOs (MDM re-push safety). + [ "$FIFO_MODE" = 1 ] && remove_fifos resolve_target_user # Abort BEFORE enrolling if any bait path is occupied, so a refused install diff --git a/tests/test_agent_fifo.py b/tests/test_agent_fifo.py index 3d5d5ab..dd77c0a 100644 --- a/tests/test_agent_fifo.py +++ b/tests/test_agent_fifo.py @@ -144,10 +144,69 @@ def test_two_agents_one_host_both_detect(server, tmp_path): for p in procs: p.wait(timeout=5) +def test_duplicate_install_does_not_sweep_live_agents_fifo(server, tmp_path): + """C1 regression: a second invocation with the SAME --state-file must exit at + the singleton lock WITHOUT deleting the live agent's bait FIFO. The startup + sweep (remove_fifos) must only run AFTER acquire_singleton succeeds, so the + duplicate invocation — which loses the mutex race and exits early — never + touches the first agent's manifest or FIFOs.""" + bait = tmp_path / "bait_aws" + Stub.bait_path = str(bait) + state = tmp_path / "agent.json" + + # Start agent A — the live agent. + p_a = subprocess.Popen( + ["sh", str(AGENT), "run", + "--server", f"http://127.0.0.1:{server.server_port}", + "--enroll-token", "e", "--tripwire", "tw_1", + "--state-file", str(state), + "--heartbeat", "0", "--sync-interval", "0"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + try: + # Wait for agent A to plant its FIFO. + assert _wait(lambda: bait.exists() and stat.S_ISFIFO(bait.stat().st_mode)), \ + "agent A did not plant a FIFO in time" + + # Start agent B with the SAME state-file — it must detect the singleton and exit. + result_b = subprocess.run( + ["sh", str(AGENT), "run", + "--server", f"http://127.0.0.1:{server.server_port}", + "--enroll-token", "e", "--tripwire", "tw_1", + "--state-file", str(state), + "--heartbeat", "0", "--sync-interval", "0"], + capture_output=True, text=True, timeout=15) + + # B should have exited cleanly (returncode 0, logged "already running"). + assert result_b.returncode == 0, f"agent B exited with rc={result_b.returncode}" + + # CRITICAL: agent A's FIFO must still exist after B exits. + assert bait.exists() and stat.S_ISFIFO(bait.stat().st_mode), \ + "agent B's startup sweep deleted agent A's live bait FIFO!" + + # Confirm the FIFO is still readable — agent A is still serving it. + content = Path(bait).read_text() + assert content == BAIT_BODY, \ + f"bait FIFO no longer serves the expected content: {content!r}" + finally: + p_a.terminate() + p_a.wait(timeout=5) + import subprocess as _sp + _sp.run(["pkill", "-f", "thumper_agent.sh run --server http://127.0.0.1"], + capture_output=True) + + def test_atime_stat_order_prefers_portable_access_time(): # #28: `stat -f %a` on Linux is statfs (free blocks), so the portable - # `stat -c %X` must be tried FIRST. Assert the script's order. + # `stat -c %X` must be tried FIRST. Assert BOTH call sites use the right order + # so that a future refactor of one cannot silently pass by matching the other. src = AGENT.read_text() - assert 'stat -c %X "$p" 2>/dev/null || stat -f %a' in src, \ - "watch_atime must try `stat -c %X` (atime) before `stat -f %a`" + # Site 1: initial atime capture at the top of watch_atime + assert 'stat -c %X "$p" 2>/dev/null || stat -f %a "$p" 2>/dev/null || echo 0)' in src, \ + "watch_atime initialisation must try `stat -c %X` before `stat -f %a`" + # Site 2: per-poll atime refresh inside the watch loop + assert 'stat -c %X "$p" 2>/dev/null || stat -f %a "$p" 2>/dev/null || echo 0' in src, \ + "watch_atime poll loop must try `stat -c %X` before `stat -f %a`" + # The reversed (wrong) order must not appear anywhere in the source + assert 'stat -f %a "$p" 2>/dev/null || stat -c %X' not in src, \ + "source must not contain the wrong stat order (stat -f %a before stat -c %X)" assert "watch_fs_usage" not in src, "fs_usage sensor must be removed" From 4148d5f73c61ecdc3f6ad1b13de6a8037ffbf321 Mon Sep 17 00:00:00 2001 From: Lior Finkelshtein Date: Wed, 24 Jun 2026 17:44:18 +0300 Subject: [PATCH 11/23] docs(agent): replace stale fs_usage references with FIFO sensor (#100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I1: rewrite header comment to document FIFO named-pipe as primary (unprivileged) sensor and atime poll as mkfifo-unavailable fallback; drop "run as root for fs_usage" from example. I2: update routes.py install script docstring and inline comment, and deploy.py _install_command comment: root is only needed to plant in system paths (e.g. /etc/ssh), NOT for read detection. I3: change watch_atime log line from "fs_usage unavailable" to "mkfifo unavailable". I4: fix three stale inline comments — target-user expansion header ("for fs_usage" → "for system-path planting"), stop_watcher docstring ("fs_usage/grep children" → "serve_fifo children"), and _fire NOTE ("fs_usage grep filter" → "dep_index_for_line re-matches the path"). Co-Authored-By: Claude Opus 4.8 (1M context) --- server/thumper/api/routes.py | 9 +++++---- server/thumper/services/deploy.py | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/server/thumper/api/routes.py b/server/thumper/api/routes.py index 6f4d868..42dd5a4 100644 --- a/server/thumper/api/routes.py +++ b/server/thumper/api/routes.py @@ -491,8 +491,9 @@ def serve_agent(): @router.get("/install.sh", response_class=PlainTextResponse) def install_script(tripwire: list[str] = Query(default=[]), token: str = Query(default="")): """A self-bootstrapping installer. The tripwire's deploy command pipes this - into `sudo sh`: it downloads the Bash agent and starts it watching as root - (so fs_usage works). Distribute it via MDM/SSH or paste it on the endpoint. + into `sh` (or `sudo sh` when planting in a system path like /etc/ssh): it + downloads the Bash agent and starts it watching. Distribute via MDM/SSH or + paste it on the endpoint. The script embeds ENROLL_TOKEN, so it is gated behind INSTALL_TOKEN - only the server-generated deploy command (which carries the token) can fetch it. @@ -511,8 +512,8 @@ def install_script(tripwire: list[str] = Query(default=[]), token: str = Query(d mkdir -p "$DIR" curl -fsSL "$SERVER/api/agent/thumper_agent.sh" -o "$DIR/thumper_agent.sh" chmod +x "$DIR/thumper_agent.sh" -# Start watching in the background. Runs as root (for fs_usage); the agent plants -# bait in the real user's home and chowns it to that user. +# Start watching in the background. Root is only needed to plant bait in system +# paths like /etc/ssh; the agent plants in the real user's home and chowns it. nohup sh "$DIR/thumper_agent.sh" run \\ --server "$SERVER" --enroll-token "$ENROLL_TOKEN" {tw_args} \\ --heartbeat 60 --state-file "$DIR/agent.json" >"$DIR/agent.log" 2>&1 & diff --git a/server/thumper/services/deploy.py b/server/thumper/services/deploy.py index 0279fc3..5a741d4 100644 --- a/server/thumper/services/deploy.py +++ b/server/thumper/services/deploy.py @@ -14,7 +14,8 @@ def _install_command(tripwire_ids: list[str]) -> str: # Self-bootstrapping: downloads the agent from the server and starts it # watching. Works pasted on an endpoint or pushed via MDM/SSH. We download # THEN `sudo sh ` (not `curl | sudo sh`) so sudo keeps the terminal for - # its password prompt. sudo because macOS fs_usage read-detection needs root. + # its password prompt. sudo is only needed when planting in system paths like + # /etc/ssh — read detection (FIFO sensor) is unprivileged. # The install token gates /api/install.sh (it embeds the enroll token), so we # pass it here - this command is generated server-side, never exposed publicly. # One `tripwire=` param per tripwire; the agent enrolls for the whole set and From b28239454ba3dd9f1dbe566ce831c83099bb062a Mon Sep 17 00:00:00 2001 From: Lior Finkelshtein Date: Thu, 25 Jun 2026 14:52:21 +0300 Subject: [PATCH 12/23] agent: fix bare `return` in probe_fifo_mode aborting the agent on Linux probe_fifo_mode short-circuits with `[ "$(platform)" = "darwin" ] || return` on non-macOS. A bare `return` yields the exit status of the preceding test, which is 1 on Linux - and since run() calls probe_fifo_mode bare under `set -e`, the whole agent aborted immediately (exit 1, no output). This broke every agent test on the Linux CI runner while passing on macOS (where the darwin test is true). Return 0 explicitly; same for the mkfifo-missing branch. Verified in a python:3.12-slim (dash) container: full suite 250 passed, 9 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) --- agent/thumper_agent.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/agent/thumper_agent.sh b/agent/thumper_agent.sh index e8eec49..365e372 100755 --- a/agent/thumper_agent.sh +++ b/agent/thumper_agent.sh @@ -58,8 +58,8 @@ BAITCACHE="" REPLANTED=0 probe_fifo_mode() { FIFO_MODE=0 - [ "$(platform)" = "darwin" ] || return # FIFO sensor is macOS-only; Linux uses inotify - command -v mkfifo >/dev/null 2>&1 || return + [ "$(platform)" = "darwin" ] || return 0 # FIFO sensor is macOS-only; Linux uses inotify + command -v mkfifo >/dev/null 2>&1 || return 0 _probe="$(dirname "$STATE_FILE")/.fifoprobe.$$" if mkfifo "$_probe" 2>/dev/null; then rm -f "$_probe"; FIFO_MODE=1; fi unset _probe From 6267d45c9ea959c9c755668da84fb0df59c11b85 Mon Sep 17 00:00:00 2001 From: Lior Finkelshtein Date: Thu, 25 Jun 2026 15:40:03 +0300 Subject: [PATCH 13/23] ci: run the test matrix on macOS too so the FIFO sensor is covered The FIFO read sensor is macOS-only (#100) and its tests are skipif!=Darwin, so an ubuntu-only CI never exercises them. Add macos-latest to the matrix (Linux still covers inotify/atime). This would have caught the Linux probe_fifo_mode regression that shipped because the FIFO/Linux paths had no matrixed coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2373082..ecb5f8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,14 @@ on: jobs: test: - runs-on: ubuntu-latest + # macOS is included so the FIFO read sensor (macOS-only; #100) actually runs + # in CI - the FIFO tests are skipif!=Darwin, so ubuntu alone never exercises + # them. Linux covers inotify/atime; macOS covers the FIFO path. + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v7 From 0de43c83f71a83b5543746537b4e9845f9ea934e Mon Sep 17 00:00:00 2001 From: Lior Finkelshtein Date: Sat, 27 Jun 2026 00:13:56 +0300 Subject: [PATCH 14/23] feat(agent): re-armable atime sensor + `--sensor` selector (#28, #100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the atime regular-file primary detection layer from the validated layered design (#100): a normal regular-file bait whose atime is armed to the past, so a read bumps it; the agent fires AND re-arms, making every subsequent read detectable too (the old watch_atime fired at most once under APFS relatime). Detection-only — no pid — but honors all sensor constraints (regular file, no kdebug, no mount, no privilege), so it covers the FIFO sensor's blind spots (statSync-guarded / mmap / scan-only readers) and the "normal file" requirement the FIFO can't. - `--sensor auto|fifo|atime` (default auto). `atime` forces a regular-file bait + the atime watcher on any platform (incl. Linux), making the layer selectable and testable. - Centralize the atime read in read_atime() (GNU %X before BSD %a — the #28 fix, now DRY across the one call site) and arm/re-arm via arm_atime(). - Tests: deterministic re-arm cycle driven by os.utime() (relatime-policy independent), cross-platform. Update the #28 stat-order guard for the refactor to a single helper. 40 agent tests pass; shellcheck -S error clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018DARDAxeg4NM8FKoyGMQZy --- agent/thumper_agent.sh | 41 +++++++++++++---- tests/test_agent_atime.py | 94 +++++++++++++++++++++++++++++++++++++++ tests/test_agent_fifo.py | 18 ++++---- 3 files changed, 134 insertions(+), 19 deletions(-) create mode 100644 tests/test_agent_atime.py diff --git a/agent/thumper_agent.sh b/agent/thumper_agent.sh index 365e372..7b6a18b 100755 --- a/agent/thumper_agent.sh +++ b/agent/thumper_agent.sh @@ -531,12 +531,24 @@ watch_inotify() { watch_atime } +# atime sensor helpers. Detection-only (no process/user) but works on a NORMAL +# regular-file bait under all constraints (no kdebug, no mount, no privilege), +# so it's the primary layer that covers the FIFO sensor's blind spots +# (statSync-guarded / mmap / scan-only readers). See #28, #100. +ATIME_ARM_STAMP=200001010000 # `touch -t` stamp: 2000-01-01 00:00 - atime far in the past +arm_atime() { # arm_atime : set atime to the past so the next read bumps it (relatime/APFS) + touch -a -t "$ATIME_ARM_STAMP" "$1" 2>/dev/null || true +} +read_atime() { # read_atime : portable access-time epoch (GNU %X first, then BSD %a - never %a on Linux, that's free blocks: #28) + stat -c %X "$1" 2>/dev/null || stat -f %a "$1" 2>/dev/null || echo 0 +} watch_atime() { - log "mkfifo unavailable - atime poll every ${POLL}s (best-effort; may miss reads, no process/user)" + log "atime poll every ${POLL}s on regular-file bait (re-armable; detection only - no process/user)" i=1 while [ "$i" -le "$DEP_COUNT" ]; do eval "p=\$dep_path_$i" - eval "atime_$i=\$(stat -c %X \"\$p\" 2>/dev/null || stat -f %a \"\$p\" 2>/dev/null || echo 0)" + arm_atime "$p" # arm so relatime bumps atime on a read + eval "atime_$i=\$(read_atime \"\$p\")" i=$((i + 1)) done while true; do @@ -544,10 +556,11 @@ watch_atime() { i=1 while [ "$i" -le "$DEP_COUNT" ]; do eval "p=\$dep_path_$i prev=\$atime_$i" - cur=$(stat -c %X "$p" 2>/dev/null || stat -f %a "$p" 2>/dev/null || echo 0) + cur=$(read_atime "$p") if [ "$cur" != "0" ] && [ "$cur" -gt "$prev" ] 2>/dev/null; then - eval "atime_$i=\$cur" fire "$i" "atime-change" "" "" "" "$p" + arm_atime "$p" # RE-ARM so the NEXT read is detectable too + eval "atime_$i=\$(read_atime \"\$p\")" fi i=$((i + 1)) done @@ -625,7 +638,9 @@ watch_fifo() { # supervisor: one serve_fifo per bait, wait on them start_watcher() { # launch the right sensor in the background; set WATCH_PID rm -f "${WATCH_STOP_FLAG:-}" 2>/dev/null || true # this start is not a stop - if [ "$FIFO_MODE" = 1 ]; then + if [ "$SENSOR" = atime ]; then + watch_atime & # forced atime sensor (any platform) + elif [ "$FIFO_MODE" = 1 ]; then watch_fifo & elif [ "$(platform)" = "linux" ] && command -v inotifywait >/dev/null 2>&1; then watch_inotify & @@ -745,8 +760,12 @@ run() { BAITCACHE="$(dirname "$STATE_FILE")/bait" WATCH_STOP_FLAG="$(dirname "$STATE_FILE")/watcher.stopping" mkdir -p "$(dirname "$STATE_FILE")" - probe_fifo_mode - [ "$FIFO_MODE" = 1 ] && log "sensor: FIFO bait (macOS)" + if [ "$SENSOR" = atime ]; then + FIFO_MODE=0; log "sensor: atime poll (regular-file bait, re-armable)" + else + probe_fifo_mode + [ "$FIFO_MODE" = 1 ] && log "sensor: FIFO bait (macOS)" + fi MAIN_PID=$$ # so the backgrounded heartbeat loop can signal us to self-destruct # Enforce one-agent-per-install before any work; a duplicate exits here (the # EXIT trap below is NOT yet set, so it can't disturb the live holder's lock). @@ -841,7 +860,9 @@ usage() { usage: thumper_agent.sh run --server URL --enroll-token TOKEN [options] --tripwire ID tripwire to apply (repeatable) --state-file PATH state file (default: $DEFAULT_STATE) - --poll SECONDS atime fallback poll interval (default: 5) + --poll SECONDS atime poll interval (default: 5) + --sensor MODE read sensor: auto|fifo|atime (default: auto). atime plants a + regular-file bait + re-armable atime tripwire (no pid) --heartbeat SECONDS heartbeat interval; 0 to disable (default: 60) --sync-interval SECS re-pull deployments + reconcile every SECS (default: 300, 0 disables) --once enroll + plant, then exit @@ -851,7 +872,7 @@ EOF exit 2 } -SERVER=""; ENROLL_TOKEN=""; TRIPWIRES=""; STATE_FILE=""; POLL=5; HEARTBEAT=60; SYNC_INTERVAL=300; ONCE=0; SIMULATE=0; FORCE=0 +SERVER=""; ENROLL_TOKEN=""; TRIPWIRES=""; STATE_FILE=""; POLL=5; HEARTBEAT=60; SYNC_INTERVAL=300; ONCE=0; SIMULATE=0; FORCE=0; SENSOR=auto [ "${1:-}" = "run" ] || usage shift @@ -864,6 +885,7 @@ while [ $# -gt 0 ]; do --poll) POLL=$2; shift 2 ;; --heartbeat) HEARTBEAT=$2; shift 2 ;; --sync-interval) SYNC_INTERVAL=$2; shift 2 ;; + --sensor) SENSOR=$2; shift 2 ;; --once) ONCE=1; shift ;; --simulate) SIMULATE=1; shift ;; --force) FORCE=1; shift ;; @@ -871,6 +893,7 @@ while [ $# -gt 0 ]; do esac done [ -n "$SERVER" ] && [ -n "$ENROLL_TOKEN" ] || usage +case "$SENSOR" in auto|fifo|atime) ;; *) err "invalid --sensor: $SENSOR (want auto|fifo|atime)"; usage ;; esac for tool in curl openssl; do command -v "$tool" >/dev/null 2>&1 || { err "$tool is required"; exit 1; } diff --git a/tests/test_agent_atime.py b/tests/test_agent_atime.py new file mode 100644 index 0000000..64fb58c --- /dev/null +++ b/tests/test_agent_atime.py @@ -0,0 +1,94 @@ +"""Re-armable atime sensor (#28 / #100 layered design): `--sensor atime` plants +the bait as a NORMAL regular file whose atime is armed to the past; a read bumps +atime, the agent fires AND re-arms so the NEXT read is detectable too. + +Reads are simulated by bumping atime via os.utime(), so the test is deterministic +regardless of the filesystem's relatime policy. Cross-platform (macOS + Linux): +atime is the primary regular-file detection layer on both.""" +import http.server, subprocess, threading, os, time +from pathlib import Path +import pytest + +AGENT = Path(__file__).resolve().parents[1] / "agent" / "thumper_agent.sh" +BAIT_BODY = "AKIA-BAIT\nsecret=shhh\n" +ARMED_MAX = 1_000_000_000 # armed atime (~year 2000, ~9.46e8) is well below "now" (~1.7e9) + + +class Stub(http.server.BaseHTTPRequestHandler): + callbacks = [] + bait_path = "" + def log_message(self, *a): pass + def _t(self, body=""): + self.send_response(200); self.send_header("Content-Type", "text/plain") + self.end_headers(); self.wfile.write(body.encode()) + def do_POST(self): + n = int(self.headers.get("Content-Length", 0)); body = self.rfile.read(n).decode() + if self.path == "/api/enroll": return self._t("agent_token=tok-1\nendpoint_id=ep_1\n") + if self.path.startswith("/cb/"): Stub.callbacks.append(body); return self._t("ok") + if self.path.endswith("/state"): return self._t("ok") + return self._t("ok") + def do_GET(self): + if self.path == "/api/agent/deployments": + base = f"http://127.0.0.1:{self.server.server_port}" + rec = "\t".join(["dep_1", Stub.bait_path, "sekret", f"{base}/content/dep_1", f"{base}/cb/dep_1"]) + return self._t(rec + "\n") + if self.path.startswith("/content/"): return self._t(BAIT_BODY) + return self._t("") + + +@pytest.fixture +def server(): + httpd = http.server.HTTPServer(("127.0.0.1", 0), Stub) + threading.Thread(target=httpd.serve_forever, daemon=True).start() + Stub.callbacks = [] + yield httpd + httpd.shutdown() + + +def _spawn(server, tmp_path, *extra): + port = server.server_port + state = tmp_path / "agent.json" + return subprocess.Popen( + ["sh", str(AGENT), "run", "--server", f"http://127.0.0.1:{port}", + "--enroll-token", "e", "--tripwire", "tw_1", "--state-file", str(state), + "--sensor", "atime", "--poll", "1", "--heartbeat", "0", "--sync-interval", "0", *extra], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + + +def _wait(cond, t=12.0): + end = time.time() + t + while time.time() < end: + if cond(): return True + time.sleep(0.05) + return False + + +def _atime(p): return os.stat(p).st_atime +def _atime_hits(): return sum("atime" in c for c in Stub.callbacks) + + +def test_atime_sensor_plants_a_regular_file_and_arms_it(server, tmp_path): + bait = tmp_path / "credentials"; Stub.bait_path = str(bait) + p = _spawn(server, tmp_path) + try: + assert _wait(lambda: bait.exists() and bait.is_file()), "bait was not planted as a regular file" + assert _wait(lambda: _atime(bait) < ARMED_MAX), "atime sensor did not arm the bait to the past" + finally: + p.terminate(); p.wait(timeout=5) + + +def test_atime_sensor_is_rearmable(server, tmp_path): + bait = tmp_path / "credentials"; Stub.bait_path = str(bait) + p = _spawn(server, tmp_path) + try: + assert _wait(lambda: bait.exists() and _atime(bait) < ARMED_MAX), "bait not planted+armed" + # simulate read #1: bump atime forward (what a real read does under relatime) + os.utime(bait, (time.time(), os.stat(bait).st_mtime)) + assert _wait(lambda: _atime_hits() >= 1), "read #1 was not detected" + # the agent MUST re-arm so the next read is detectable + assert _wait(lambda: _atime(bait) < ARMED_MAX), "bait was not re-armed after detection" + # simulate read #2 + os.utime(bait, (time.time(), os.stat(bait).st_mtime)) + assert _wait(lambda: _atime_hits() >= 2), "read #2 not detected (re-arm is broken)" + finally: + p.terminate(); p.wait(timeout=5) diff --git a/tests/test_agent_fifo.py b/tests/test_agent_fifo.py index 444a278..d5c5062 100644 --- a/tests/test_agent_fifo.py +++ b/tests/test_agent_fifo.py @@ -200,16 +200,14 @@ def test_duplicate_install_does_not_sweep_live_agents_fifo(server, tmp_path): def test_atime_stat_order_prefers_portable_access_time(): # #28: `stat -f %a` on Linux is statfs (free blocks), so the portable - # `stat -c %X` must be tried FIRST. Assert BOTH call sites use the right order - # so that a future refactor of one cannot silently pass by matching the other. + # `stat -c %X` must be tried FIRST. The atime read is now centralized in the + # read_atime() helper (one site, not two inline copies); assert that helper + # uses the right order and the reversed (wrong) order appears nowhere. src = AGENT.read_text() - # Site 1: initial atime capture at the top of watch_atime - assert 'stat -c %X "$p" 2>/dev/null || stat -f %a "$p" 2>/dev/null || echo 0)' in src, \ - "watch_atime initialisation must try `stat -c %X` before `stat -f %a`" - # Site 2: per-poll atime refresh inside the watch loop - assert 'stat -c %X "$p" 2>/dev/null || stat -f %a "$p" 2>/dev/null || echo 0' in src, \ - "watch_atime poll loop must try `stat -c %X` before `stat -f %a`" + assert 'stat -c %X "$1" 2>/dev/null || stat -f %a "$1" 2>/dev/null || echo 0' in src, \ + "read_atime() must try `stat -c %X` before `stat -f %a`" # The reversed (wrong) order must not appear anywhere in the source - assert 'stat -f %a "$p" 2>/dev/null || stat -c %X' not in src, \ - "source must not contain the wrong stat order (stat -f %a before stat -c %X)" + for badvar in ('"$1"', '"$p"'): + assert f'stat -f %a {badvar} 2>/dev/null || stat -c %X' not in src, \ + "source must not contain the wrong stat order (stat -f %a before stat -c %X)" assert "watch_fs_usage" not in src, "fs_usage sensor must be removed" From 1e029eabea7f06fe0ea21b71d06e551f59c37c3a Mon Sep 17 00:00:00 2001 From: Lior Finkelshtein Date: Sat, 27 Jun 2026 23:14:23 +0300 Subject: [PATCH 15/23] agent: arm_atime must not create the bait file (fixes re-plant cap on Linux) arm_atime used `touch -a` which creates the file if missing. watch_atime arms every dep path, so a failed-plant dep (no bait on disk) got an empty file created at its path - verify_planted then saw it as planted and never re-planted it. Broke test_replant_is_bounded on Linux (atime sensor); macOS uses FIFO so it passed there. Add `-c` (--no-create) so arming only touches existing bait. Verified in a Debian/dash container: full agent suite 32 passed, 8 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) --- agent/thumper_agent.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/agent/thumper_agent.sh b/agent/thumper_agent.sh index 7b6a18b..5cce5ec 100755 --- a/agent/thumper_agent.sh +++ b/agent/thumper_agent.sh @@ -537,7 +537,10 @@ watch_inotify() { # (statSync-guarded / mmap / scan-only readers). See #28, #100. ATIME_ARM_STAMP=200001010000 # `touch -t` stamp: 2000-01-01 00:00 - atime far in the past arm_atime() { # arm_atime : set atime to the past so the next read bumps it (relatime/APFS) - touch -a -t "$ATIME_ARM_STAMP" "$1" 2>/dev/null || true + # -c: never CREATE the file. Arming a missing bait would otherwise leave an + # empty file behind, making verify_planted think a failed-plant dep is planted + # and silently skip re-planting it (#28/#100). + touch -a -c -t "$ATIME_ARM_STAMP" "$1" 2>/dev/null || true } read_atime() { # read_atime : portable access-time epoch (GNU %X first, then BSD %a - never %a on Linux, that's free blocks: #28) stat -c %X "$1" 2>/dev/null || stat -f %a "$1" 2>/dev/null || echo 0 From baa79c702bcdf2e241d71f233f354d412001c833 Mon Sep 17 00:00:00 2001 From: Lior Finkelshtein Date: Sun, 28 Jun 2026 15:31:28 +0300 Subject: [PATCH 16/23] =?UTF-8?q?fix(agent):=20address=20Roee's=20#160=20r?= =?UTF-8?q?eview=20=E2=80=94=20FIFO/atime=20mode=20safety?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F1: sweep stale FIFOs at startup regardless of sensor mode, and have plant() rm a leftover FIFO before `curl -o` into it. A FIFO→atime restart after a hard-kill no longer hangs writing into a no-reader pipe. - F2 (+ #164 F1): restart the watcher on ANY re-plant, not just FIFO_MODE=1, so a re-planted atime bait is re-armed (no ghost alert from the stale year-2000 baseline) and a re-planted FIFO is re-served on Linux too. - F3: `--sensor fifo` now FORCES a pipe wherever mkfifo works (incl. Linux/CI) via a platform-agnostic mkfifo_works() probe, instead of silently falling through to auto-detection; errors out loudly if mkfifo is unavailable. New regression tests: atime mode doesn't hang on a leftover FIFO; --sensor fifo forces a named pipe. 42 agent tests pass; shellcheck -S error clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018DARDAxeg4NM8FKoyGMQZy --- agent/thumper_agent.sh | 50 ++++++++++++++++++++++++++------------- tests/test_agent_atime.py | 31 +++++++++++++++++++++++- 2 files changed, 64 insertions(+), 17 deletions(-) diff --git a/agent/thumper_agent.sh b/agent/thumper_agent.sh index 839f01e..f61d068 100755 --- a/agent/thumper_agent.sh +++ b/agent/thumper_agent.sh @@ -57,13 +57,16 @@ LAST_RESYNC=0 FIFO_MODE=0 BAITCACHE="" REPLANTED=0 -probe_fifo_mode() { - FIFO_MODE=0 - [ "$(platform)" = "darwin" ] || return 0 # FIFO sensor is macOS-only; Linux uses inotify - command -v mkfifo >/dev/null 2>&1 || return 0 +mkfifo_works() { # 0 if mkfifo actually works in the state dir (any platform: FIFO works on Linux/CI too) + command -v mkfifo >/dev/null 2>&1 || return 1 _probe="$(dirname "$STATE_FILE")/.fifoprobe.$$" - if mkfifo "$_probe" 2>/dev/null; then rm -f "$_probe"; FIFO_MODE=1; fi - unset _probe + if mkfifo "$_probe" 2>/dev/null; then rm -f "$_probe"; unset _probe; return 0; fi + unset _probe; return 1 +} +probe_fifo_mode() { # AUTO policy: default to FIFO on macOS only (Linux defaults to inotify) + FIFO_MODE=0 + [ "$(platform)" = "darwin" ] || return 0 + mkfifo_works && FIFO_MODE=1 } cache_path() { printf '%s/%s' "$BAITCACHE" "$1"; } # cache_path TAB=$(printf '\t') @@ -351,6 +354,11 @@ plant() { # plant return 0 fi + # Planting a REGULAR-file bait: if a leftover FIFO sits at this path (e.g. a + # prior FIFO run, swept-miss), remove it first - `curl -o` into a no-reader + # FIFO blocks forever (Roee #160 F1). Only our own bait reaches here (the + # overwrite guard above already refused a path we didn't plant). + [ -p "$path" ] && rm -f "$path" if ! curl -fsS "$url" -H "Authorization: Bearer $AGENT_TOKEN" -o "$path"; then rm -f "$path" # remove the partial/empty file curl may have left err "failed to fetch bait for $id" @@ -764,12 +772,14 @@ run() { BAITCACHE="$(dirname "$STATE_FILE")/bait" WATCH_STOP_FLAG="$(dirname "$STATE_FILE")/watcher.stopping" mkdir -p "$(dirname "$STATE_FILE")" - if [ "$SENSOR" = atime ]; then - FIFO_MODE=0; log "sensor: atime poll (regular-file bait, re-armable)" - else - probe_fifo_mode - [ "$FIFO_MODE" = 1 ] && log "sensor: FIFO bait (macOS)" - fi + case "$SENSOR" in + atime) FIFO_MODE=0; log "sensor: atime poll (regular-file bait, re-armable)" ;; + fifo) # operator forced FIFO: honor it on ANY platform (mkfifo works on Linux/CI), or fail loudly + if mkfifo_works; then FIFO_MODE=1; log "sensor: FIFO bait (forced)" + else err "--sensor fifo requested but mkfifo is unavailable here"; exit 1; fi ;; + *) probe_fifo_mode + [ "$FIFO_MODE" = 1 ] && log "sensor: FIFO bait (macOS)" ;; + esac MAIN_PID=$$ # so the backgrounded heartbeat loop can signal us to self-destruct # Enforce one-agent-per-install before any work; a duplicate exits here (the # EXIT trap below is NOT yet set, so it can't disturb the live holder's lock). @@ -778,8 +788,11 @@ run() { trap 'release_singleton' EXIT # Only the lock holder sweeps stale FIFOs from a prior hard-kill; a duplicate # invocation exits at acquire_singleton above and must never touch the live - # agent's shared manifest/FIFOs (MDM re-push safety). - [ "$FIFO_MODE" = 1 ] && remove_fifos + # agent's shared manifest/FIFOs (MDM re-push safety). Sweep regardless of the + # CURRENT sensor: a prior FIFO run's leftover pipes must be cleared even when + # this run is atime mode, else plant() would curl into a no-reader FIFO and + # hang forever (only manifest paths that ARE FIFOs are removed, so it's safe). + remove_fifos resolve_target_user # Abort BEFORE enrolling if any bait path is occupied, so a refused install @@ -850,8 +863,13 @@ run() { fi REPLANTED=0 verify_planted # every cycle, even when the set did not change - if [ "$FIFO_MODE" = 1 ] && [ "$REPLANTED" = 1 ]; then - log "re-planted bait - restarting FIFO watcher to serve it" + if [ "$REPLANTED" = 1 ]; then + # A re-plant gives the bait a new inode/timestamp, which every sensor's + # per-bait state depends on: a FIFO needs re-serving, an atime bait + # needs re-arming (else its stale year-2000 baseline fires a ghost + # alert), an inotify watch needs re-pointing at the new inode. Restart + # regardless of platform/mode (FIFO_MODE is 0 on Linux even for FIFOs). + log "re-planted bait - restarting watcher to re-arm/re-serve it" stop_watcher start_watcher fi diff --git a/tests/test_agent_atime.py b/tests/test_agent_atime.py index 64fb58c..ba76db1 100644 --- a/tests/test_agent_atime.py +++ b/tests/test_agent_atime.py @@ -5,7 +5,7 @@ Reads are simulated by bumping atime via os.utime(), so the test is deterministic regardless of the filesystem's relatime policy. Cross-platform (macOS + Linux): atime is the primary regular-file detection layer on both.""" -import http.server, subprocess, threading, os, time +import http.server, subprocess, threading, os, stat, time from pathlib import Path import pytest @@ -92,3 +92,32 @@ def test_atime_sensor_is_rearmable(server, tmp_path): assert _wait(lambda: _atime_hits() >= 2), "read #2 not detected (re-arm is broken)" finally: p.terminate(); p.wait(timeout=5) + + +def test_atime_mode_does_not_hang_on_a_leftover_fifo(server, tmp_path): + # Roee #160 F1: a leftover FIFO from a prior FIFO run (at the bait path and in + # the manifest) must be SWEPT in atime mode too - else plant() `curl -o`s into + # a no-reader pipe and the agent hangs at startup. Without the fix this run + # blocks and the timeout fires. + bait = tmp_path / "credentials"; Stub.bait_path = str(bait) + os.mkfifo(bait) # leftover pipe from a prior FIFO run + (tmp_path / "planted.list").write_text(f"{bait}\n") # recorded as ours + r = subprocess.run( + ["sh", str(AGENT), "run", "--server", f"http://127.0.0.1:{server.server_port}", + "--enroll-token", "e", "--tripwire", "tw_1", "--state-file", str(tmp_path / "agent.json"), + "--sensor", "atime", "--heartbeat", "0", "--once"], + capture_output=True, text=True, timeout=15) # TimeoutExpired == the hang regressed + assert bait.exists() and stat.S_ISREG(bait.stat().st_mode) and not stat.S_ISFIFO(bait.stat().st_mode), \ + "leftover FIFO was not replaced by a regular-file bait" + + +def test_sensor_fifo_forces_a_named_pipe_on_any_platform(server, tmp_path): + # Roee #160 F3: --sensor fifo must FORCE a pipe wherever mkfifo works (incl. + # Linux/CI), not fall through to auto-detection (which picks inotify on Linux). + bait = tmp_path / "credentials"; Stub.bait_path = str(bait) + subprocess.run( + ["sh", str(AGENT), "run", "--server", f"http://127.0.0.1:{server.server_port}", + "--enroll-token", "e", "--tripwire", "tw_1", "--state-file", str(tmp_path / "agent.json"), + "--sensor", "fifo", "--heartbeat", "0", "--once"], + capture_output=True, text=True, timeout=15) + assert bait.exists() and stat.S_ISFIFO(bait.stat().st_mode), "--sensor fifo did not force a named pipe" From c870d8129a4bfda129df5b6e82516c692a3f3309 Mon Sep 17 00:00:00 2001 From: Lior Finkelshtein Date: Sun, 28 Jun 2026 16:27:35 +0300 Subject: [PATCH 17/23] =?UTF-8?q?fix(agent):=20address=20Roee's=20#123=20r?= =?UTF-8?q?eview=20=E2=80=94=20FIFO=20sensor=20resilience?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F1: verify_planted now RECOVERS a FIFO replaced by a regular file (plant() rm's the impostor + re-creates the pipe, REPLANTED restarts the watcher), instead of reporting "failed" forever and going permanently blind. Replacing the bait was a stronger attack than deleting it (which already self-heals). - F2: --simulate sweeps its FIFOs before exiting (the cleanup traps are armed later), so it never leaves a no-reader pipe that blocks real open()s forever. - F3: watch_fifo re-serves on unexpected exit instead of "degrading to atime" - atime can't detect FIFO reads (open() blocks on the writerless pipe, atime never moves), so the old fallback looked healthy while detecting zero. New tests: tampered FIFO is recovered; --simulate leaves no FIFO. 40 agent tests pass; shellcheck -S error clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018DARDAxeg4NM8FKoyGMQZy --- agent/thumper_agent.sh | 42 ++++++++++++++++++++++++++++++++-------- tests/test_agent_fifo.py | 28 +++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/agent/thumper_agent.sh b/agent/thumper_agent.sh index bda42d1..8bcdda3 100755 --- a/agent/thumper_agent.sh +++ b/agent/thumper_agent.sh @@ -614,14 +614,21 @@ serve_fifo() { # serve_fifo - serve one bait FIFO forever; a read = a hit done } -watch_fifo() { # supervisor: one serve_fifo per bait, wait on them - log "watching $DEP_COUNT bait file(s) via FIFO" - i=1 - while [ "$i" -le "$DEP_COUNT" ]; do serve_fifo "$i" & i=$((i + 1)); done - wait - [ -e "${WATCH_STOP_FLAG:-/nonexistent}" ] && return 0 - err "FIFO watcher exited unexpectedly - degrading to atime poll" - watch_atime +watch_fifo() { # supervisor: one serve_fifo per bait, wait on them, re-serve on death + while :; do + log "watching $DEP_COUNT bait file(s) via FIFO" + i=1 + while [ "$i" -le "$DEP_COUNT" ]; do serve_fifo "$i" & i=$((i + 1)); done + wait + [ -e "${WATCH_STOP_FLAG:-/nonexistent}" ] && return 0 + # Do NOT fall back to atime: the baits are still pipes, so a reader's + # open() blocks on the now-writerless FIFO, atime never moves, and nothing + # ever fires - "degraded to atime" looks healthy but detects zero (Roee + # #123 F3). Re-serve instead; this self-heals once verify_planted + # re-creates any deleted/tampered FIFO. + err "FIFO serving exited unexpectedly - re-serving in 1s" + sleep 1 + done } start_watcher() { # launch the right sensor in the background; set WATCH_PID @@ -712,7 +719,22 @@ verify_planted() { report_plant "$vid" failed elif [ "$FIFO_MODE" = 1 ] && [ -e "$p" ] && ! [ -p "$p" ]; then # A regular file where our FIFO should be = tampering/replacement. + # Recover like the "missing" branch below: plant() removes the impostor + # (our own path) and re-creates the FIFO, then REPLANTED restarts the + # watcher to serve it. A bare report-failed would leave the sensor + # permanently blind - while a mere *deletion* self-heals, so a + # *replacement* must recover too, not be the stronger attack (Roee #123 F1). report_plant "$vid" failed + eval "a=\${heal_$vid:-0}" + if [ "$a" -lt "$REPLANT_MAX" ]; then + if plant "$i"; then + log "recovered tampered FIFO bait $vid" + REPLANTED=1 + else + eval "heal_$vid=$((a + 1))" + log "FIFO recovery failed for $vid ($((a + 1))/$REPLANT_MAX)" + fi + fi elif [ -e "$p" ]; then # Bait is on disk → re-assert planted every cycle. Recovers a deployment # whose initial report was lost (e.g. a network blip during report_plant) @@ -778,6 +800,10 @@ run() { fire "$i" open simulated "$$" "${USER:-$(id -un)}" "" i=$((i + 1)) done + # --simulate exits before the cleanup traps are armed, so sweep any FIFO + # bait now: a leftover no-reader pipe blocks every real open() forever + # (e.g. a process reading ~/.aws/credentials) - Roee #123 F2. + [ "$FIFO_MODE" = 1 ] && remove_fifos return 0 fi [ "$ONCE" = "1" ] && return 0 diff --git a/tests/test_agent_fifo.py b/tests/test_agent_fifo.py index 444a278..a63c38a 100644 --- a/tests/test_agent_fifo.py +++ b/tests/test_agent_fifo.py @@ -198,6 +198,34 @@ def test_duplicate_install_does_not_sweep_live_agents_fifo(server, tmp_path): capture_output=True) +def test_tampered_fifo_is_recovered(server, tmp_path): + # Roee #123 F1: replacing the FIFO bait with a regular file must RECOVER (rm the + # impostor + re-create the FIFO), not just report failed forever and go blind. + # Needs live-sync (--sync-interval) so verify_planted runs. + bait = tmp_path / "bait_aws"; Stub.bait_path = str(bait) + p = subprocess.Popen( + ["sh", str(AGENT), "run", "--server", f"http://127.0.0.1:{server.server_port}", + "--enroll-token", "e", "--tripwire", "tw_1", "--state-file", str(tmp_path / "agent.json"), + "--heartbeat", "0", "--sync-interval", "1"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + try: + assert _wait(lambda: bait.exists() and stat.S_ISFIFO(bait.stat().st_mode)), "no FIFO planted" + os.remove(bait); bait.write_text("attacker regular file") # tamper: replace pipe with a file + assert _wait(lambda: bait.exists() and stat.S_ISFIFO(bait.stat().st_mode), timeout=15), \ + "tampered FIFO was not recovered - sensor left permanently blind" + finally: + p.terminate(); p.wait(timeout=5) + + +def test_simulate_does_not_leave_a_no_reader_fifo(server, tmp_path): + # Roee #123 F2: --simulate plants, fires test callbacks, exits - it must sweep + # its FIFOs, else a leftover no-reader pipe blocks every real open() forever. + bait = tmp_path / "bait_aws"; Stub.bait_path = str(bait) + _run(server, tmp_path, "--simulate") + assert not (bait.exists() and stat.S_ISFIFO(bait.stat().st_mode)), "--simulate left a no-reader FIFO" + assert any("simulated" in c for c in Stub.callbacks), "simulate callback did not fire" + + def test_atime_stat_order_prefers_portable_access_time(): # #28: `stat -f %a` on Linux is statfs (free blocks), so the portable # `stat -c %X` must be tried FIRST. Assert BOTH call sites use the right order From 1c4e30ea8db0b4daf7aa4d6fe5764b87009c9f35 Mon Sep 17 00:00:00 2001 From: Lior Finkelshtein Date: Sun, 28 Jun 2026 17:34:19 +0300 Subject: [PATCH 18/23] test(agent): de-flake test_atime_sensor_is_rearmable The test bumped atime for "read #2" in a race with the agent's re-arm. The agent re-arms in two steps (touch atime->past, then re-read the baseline); a bump landing in that ms-wide window is captured as the new baseline and missed, so the second read intermittently went undetected on loaded CI (same commit passed on the PR run, failed on the push run). Production is unaffected - DEBOUNCE_SECS coalesces reads that close together. Wait one poll cycle for the re-arm to settle before the next read. 12/12 stable locally. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018DARDAxeg4NM8FKoyGMQZy --- tests/test_agent_atime.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_agent_atime.py b/tests/test_agent_atime.py index ba76db1..25df6e5 100644 --- a/tests/test_agent_atime.py +++ b/tests/test_agent_atime.py @@ -87,6 +87,11 @@ def test_atime_sensor_is_rearmable(server, tmp_path): assert _wait(lambda: _atime_hits() >= 1), "read #1 was not detected" # the agent MUST re-arm so the next read is detectable assert _wait(lambda: _atime(bait) < ARMED_MAX), "bait was not re-armed after detection" + # Let the re-arm fully settle before the next read. The agent re-arms in two + # steps (touch atime->past, then re-read the baseline); bumping atime in that + # ms-wide window would be captured AS the new baseline and missed. A real + # reader doesn't race the re-arm, so the test shouldn't either (one poll cycle). + time.sleep(2) # simulate read #2 os.utime(bait, (time.time(), os.stat(bait).st_mtime)) assert _wait(lambda: _atime_hits() >= 2), "read #2 not detected (re-arm is broken)" From 5298a1fd0f0f6427a1a6cbfec506de6441ec2978 Mon Sep 17 00:00:00 2001 From: Lior Finkelshtein Date: Tue, 30 Jun 2026 14:33:28 +0300 Subject: [PATCH 19/23] chore: merge main + ruff-clean test_agent_fifo.py (#177) Merge current main (brings #177's ruff config + auto-merges #178's interval validation cleanly). test_agent_fifo.py is a #123-introduced file in the old compact style, so #177 never touched it; ruff-format it (E701/E702), rename the ambiguous loop var l->ln (E741), split the import line (E401). Tree-wide ruff clean; agent suite green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018DARDAxeg4NM8FKoyGMQZy --- tests/test_agent_fifo.py | 415 +++++++++++++++++++++++++++++---------- 1 file changed, 315 insertions(+), 100 deletions(-) diff --git a/tests/test_agent_fifo.py b/tests/test_agent_fifo.py index a63c38a..547108f 100644 --- a/tests/test_agent_fifo.py +++ b/tests/test_agent_fifo.py @@ -1,53 +1,103 @@ """FIFO bait sensor (#100): bait is planted as a named pipe; a read of it unblocks the agent's write and fires a callback. Driven against a stub server, like test_agent_live_sync.py.""" -import http.server, subprocess, threading, os, stat, time + +import http.server +import subprocess +import threading +import os +import stat +import time import platform as _platform from pathlib import Path import pytest -pytestmark = pytest.mark.skipif(_platform.system() != "Darwin", reason="FIFO sensor is macOS-only; Linux uses inotify") +pytestmark = pytest.mark.skipif( + _platform.system() != "Darwin", + reason="FIFO sensor is macOS-only; Linux uses inotify", +) AGENT = Path(__file__).resolve().parents[1] / "agent" / "thumper_agent.sh" BAIT_BODY = "AKIA-BAIT\nsecret=shhh\n" + class Stub(http.server.BaseHTTPRequestHandler): - callbacks = [] # POSTed callback bodies - bait_path = "" # absolute path the agent should plant - def log_message(self, *a): pass + callbacks = [] # POSTed callback bodies + bait_path = "" # absolute path the agent should plant + + def log_message(self, *a): + pass + def _t(self, body=""): - self.send_response(200); self.send_header("Content-Type","text/plain") - self.end_headers(); self.wfile.write(body.encode()) + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.end_headers() + self.wfile.write(body.encode()) + def do_POST(self): - n=int(self.headers.get("Content-Length",0)); body=self.rfile.read(n).decode() - if self.path == "/api/enroll": return self._t("agent_token=tok-1\nendpoint_id=ep_1\n") - if self.path.startswith("/cb/"): Stub.callbacks.append(body); return self._t("ok") - if self.path.endswith("/state"): return self._t("ok") + n = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(n).decode() + if self.path == "/api/enroll": + return self._t("agent_token=tok-1\nendpoint_id=ep_1\n") + if self.path.startswith("/cb/"): + Stub.callbacks.append(body) + return self._t("ok") + if self.path.endswith("/state"): + return self._t("ok") return self._t("ok") + def do_GET(self): if self.path == "/api/agent/deployments": - base=f"http://127.0.0.1:{self.server.server_port}" - rec="\t".join(["dep_1", Stub.bait_path, "sekret", f"{base}/content/dep_1", f"{base}/cb/dep_1"]) - return self._t(rec+"\n") - if self.path.startswith("/content/"): return self._t(BAIT_BODY) + base = f"http://127.0.0.1:{self.server.server_port}" + rec = "\t".join( + [ + "dep_1", + Stub.bait_path, + "sekret", + f"{base}/content/dep_1", + f"{base}/cb/dep_1", + ] + ) + return self._t(rec + "\n") + if self.path.startswith("/content/"): + return self._t(BAIT_BODY) return self._t("") + @pytest.fixture def server(): - httpd = http.server.HTTPServer(("127.0.0.1",0), Stub) + httpd = http.server.HTTPServer(("127.0.0.1", 0), Stub) threading.Thread(target=httpd.serve_forever, daemon=True).start() Stub.callbacks = [] yield httpd httpd.shutdown() + def _run(server, tmp_path, *extra, timeout=15): port = server.server_port state = tmp_path / "agent.json" return subprocess.run( - ["sh", str(AGENT), "run", "--server", f"http://127.0.0.1:{port}", - "--enroll-token", "e", "--tripwire", "tw_1", "--state-file", str(state), - "--heartbeat", "0", *extra], - capture_output=True, text=True, timeout=timeout) + [ + "sh", + str(AGENT), + "run", + "--server", + f"http://127.0.0.1:{port}", + "--enroll-token", + "e", + "--tripwire", + "tw_1", + "--state-file", + str(state), + "--heartbeat", + "0", + *extra, + ], + capture_output=True, + text=True, + timeout=timeout, + ) + def test_plant_creates_a_fifo_and_caches_content(server, tmp_path): bait = tmp_path / "bait_aws" @@ -57,20 +107,41 @@ def test_plant_creates_a_fifo_and_caches_content(server, tmp_path): cache = tmp_path / "bait" / "dep_1" assert cache.read_text() == BAIT_BODY, "bait content not cached" + def _wait(pred, timeout=12): - end=time.time()+timeout - while time.time()= 2), \ - "both agents should detect — no single-consumer collision" + for b in baits: + Path(b).read_text() + assert _wait( + lambda: sum("event_type=open" in c for c in Stub.callbacks) >= 2 + ), "both agents should detect — no single-consumer collision" finally: - for p in procs: p.terminate() - for p in procs: p.wait(timeout=5) + for p in procs: + p.terminate() + for p in procs: + p.wait(timeout=5) def test_duplicate_install_does_not_sweep_live_agents_fifo(server, tmp_path): @@ -159,71 +307,135 @@ def test_duplicate_install_does_not_sweep_live_agents_fifo(server, tmp_path): # Start agent A — the live agent. p_a = subprocess.Popen( - ["sh", str(AGENT), "run", - "--server", f"http://127.0.0.1:{server.server_port}", - "--enroll-token", "e", "--tripwire", "tw_1", - "--state-file", str(state), - "--heartbeat", "0", "--sync-interval", "0"], - stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + [ + "sh", + str(AGENT), + "run", + "--server", + f"http://127.0.0.1:{server.server_port}", + "--enroll-token", + "e", + "--tripwire", + "tw_1", + "--state-file", + str(state), + "--heartbeat", + "0", + "--sync-interval", + "0", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) try: # Wait for agent A to plant its FIFO. - assert _wait(lambda: bait.exists() and stat.S_ISFIFO(bait.stat().st_mode)), \ + assert _wait(lambda: bait.exists() and stat.S_ISFIFO(bait.stat().st_mode)), ( "agent A did not plant a FIFO in time" + ) # Start agent B with the SAME state-file — it must detect the singleton and exit. result_b = subprocess.run( - ["sh", str(AGENT), "run", - "--server", f"http://127.0.0.1:{server.server_port}", - "--enroll-token", "e", "--tripwire", "tw_1", - "--state-file", str(state), - "--heartbeat", "0", "--sync-interval", "0"], - capture_output=True, text=True, timeout=15) + [ + "sh", + str(AGENT), + "run", + "--server", + f"http://127.0.0.1:{server.server_port}", + "--enroll-token", + "e", + "--tripwire", + "tw_1", + "--state-file", + str(state), + "--heartbeat", + "0", + "--sync-interval", + "0", + ], + capture_output=True, + text=True, + timeout=15, + ) # B should have exited cleanly (returncode 0, logged "already running"). assert result_b.returncode == 0, f"agent B exited with rc={result_b.returncode}" # CRITICAL: agent A's FIFO must still exist after B exits. - assert bait.exists() and stat.S_ISFIFO(bait.stat().st_mode), \ + assert bait.exists() and stat.S_ISFIFO(bait.stat().st_mode), ( "agent B's startup sweep deleted agent A's live bait FIFO!" + ) # Confirm the FIFO is still readable — agent A is still serving it. content = Path(bait).read_text() - assert content == BAIT_BODY, \ + assert content == BAIT_BODY, ( f"bait FIFO no longer serves the expected content: {content!r}" + ) finally: p_a.terminate() p_a.wait(timeout=5) import subprocess as _sp - _sp.run(["pkill", "-f", "thumper_agent.sh run --server http://127.0.0.1"], - capture_output=True) + + _sp.run( + ["pkill", "-f", "thumper_agent.sh run --server http://127.0.0.1"], + capture_output=True, + ) def test_tampered_fifo_is_recovered(server, tmp_path): # Roee #123 F1: replacing the FIFO bait with a regular file must RECOVER (rm the # impostor + re-create the FIFO), not just report failed forever and go blind. # Needs live-sync (--sync-interval) so verify_planted runs. - bait = tmp_path / "bait_aws"; Stub.bait_path = str(bait) + bait = tmp_path / "bait_aws" + Stub.bait_path = str(bait) p = subprocess.Popen( - ["sh", str(AGENT), "run", "--server", f"http://127.0.0.1:{server.server_port}", - "--enroll-token", "e", "--tripwire", "tw_1", "--state-file", str(tmp_path / "agent.json"), - "--heartbeat", "0", "--sync-interval", "1"], - stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + [ + "sh", + str(AGENT), + "run", + "--server", + f"http://127.0.0.1:{server.server_port}", + "--enroll-token", + "e", + "--tripwire", + "tw_1", + "--state-file", + str(tmp_path / "agent.json"), + "--heartbeat", + "0", + "--sync-interval", + "1", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) try: - assert _wait(lambda: bait.exists() and stat.S_ISFIFO(bait.stat().st_mode)), "no FIFO planted" - os.remove(bait); bait.write_text("attacker regular file") # tamper: replace pipe with a file - assert _wait(lambda: bait.exists() and stat.S_ISFIFO(bait.stat().st_mode), timeout=15), \ - "tampered FIFO was not recovered - sensor left permanently blind" + assert _wait(lambda: bait.exists() and stat.S_ISFIFO(bait.stat().st_mode)), ( + "no FIFO planted" + ) + os.remove(bait) + bait.write_text("attacker regular file") # tamper: replace pipe with a file + assert _wait( + lambda: bait.exists() and stat.S_ISFIFO(bait.stat().st_mode), timeout=15 + ), "tampered FIFO was not recovered - sensor left permanently blind" finally: - p.terminate(); p.wait(timeout=5) + p.terminate() + p.wait(timeout=5) def test_simulate_does_not_leave_a_no_reader_fifo(server, tmp_path): # Roee #123 F2: --simulate plants, fires test callbacks, exits - it must sweep # its FIFOs, else a leftover no-reader pipe blocks every real open() forever. - bait = tmp_path / "bait_aws"; Stub.bait_path = str(bait) + bait = tmp_path / "bait_aws" + Stub.bait_path = str(bait) _run(server, tmp_path, "--simulate") - assert not (bait.exists() and stat.S_ISFIFO(bait.stat().st_mode)), "--simulate left a no-reader FIFO" - assert any("simulated" in c for c in Stub.callbacks), "simulate callback did not fire" + assert not (bait.exists() and stat.S_ISFIFO(bait.stat().st_mode)), ( + "--simulate left a no-reader FIFO" + ) + assert any("simulated" in c for c in Stub.callbacks), ( + "simulate callback did not fire" + ) def test_atime_stat_order_prefers_portable_access_time(): @@ -232,12 +444,15 @@ def test_atime_stat_order_prefers_portable_access_time(): # so that a future refactor of one cannot silently pass by matching the other. src = AGENT.read_text() # Site 1: initial atime capture at the top of watch_atime - assert 'stat -c %X "$p" 2>/dev/null || stat -f %a "$p" 2>/dev/null || echo 0)' in src, \ - "watch_atime initialisation must try `stat -c %X` before `stat -f %a`" + assert ( + 'stat -c %X "$p" 2>/dev/null || stat -f %a "$p" 2>/dev/null || echo 0)' in src + ), "watch_atime initialisation must try `stat -c %X` before `stat -f %a`" # Site 2: per-poll atime refresh inside the watch loop - assert 'stat -c %X "$p" 2>/dev/null || stat -f %a "$p" 2>/dev/null || echo 0' in src, \ - "watch_atime poll loop must try `stat -c %X` before `stat -f %a`" + assert ( + 'stat -c %X "$p" 2>/dev/null || stat -f %a "$p" 2>/dev/null || echo 0' in src + ), "watch_atime poll loop must try `stat -c %X` before `stat -f %a`" # The reversed (wrong) order must not appear anywhere in the source - assert 'stat -f %a "$p" 2>/dev/null || stat -c %X' not in src, \ + assert 'stat -f %a "$p" 2>/dev/null || stat -c %X' not in src, ( "source must not contain the wrong stat order (stat -f %a before stat -c %X)" + ) assert "watch_fs_usage" not in src, "fs_usage sensor must be removed" From 4e58e60f38fea0659440476ead6c32425e445a89 Mon Sep 17 00:00:00 2001 From: Lior Finkelshtein Date: Wed, 1 Jul 2026 13:51:39 +0300 Subject: [PATCH 20/23] fix(agent): close Roee's #123 re-review residuals (F2, F3) F3: watch_fifo is now a poll-supervisor that checks each writer's liveness (kill -0) and restarts a SINGLE dead writer whose FIFO still exists. The bare `wait` only recovered when ALL writers died - a lone writer death left that bait writerless and silently blind (verify only checks FIFO existence, not writer liveness) until the next full re-plant restart. F2: --simulate now also removes the cached fake-credential content it planted, not just the FIFOs (test-only mode shouldn't leave either behind). New tests: --simulate leaves no cache; watch_fifo uses per-writer restart (not bare-wait). 44 agent tests pass; shellcheck + ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018DARDAxeg4NM8FKoyGMQZy --- agent/thumper_agent.sh | 40 ++++++++++++++++++++++++++-------------- tests/test_agent_fifo.py | 21 +++++++++++++++++++++ 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/agent/thumper_agent.sh b/agent/thumper_agent.sh index b7e2fab..405ff89 100755 --- a/agent/thumper_agent.sh +++ b/agent/thumper_agent.sh @@ -620,19 +620,29 @@ serve_fifo() { # serve_fifo - serve one bait FIFO forever; a read = a hit done } -watch_fifo() { # supervisor: one serve_fifo per bait, wait on them, re-serve on death +watch_fifo() { # supervisor: keep one serve_fifo alive per bait; restart any that dies + log "watching $DEP_COUNT bait file(s) via FIFO" + i=1 + while [ "$i" -le "$DEP_COUNT" ]; do serve_fifo "$i" & eval "sf_pid_$i=\$!"; i=$((i + 1)); done while :; do - log "watching $DEP_COUNT bait file(s) via FIFO" - i=1 - while [ "$i" -le "$DEP_COUNT" ]; do serve_fifo "$i" & i=$((i + 1)); done - wait [ -e "${WATCH_STOP_FLAG:-/nonexistent}" ] && return 0 - # Do NOT fall back to atime: the baits are still pipes, so a reader's - # open() blocks on the now-writerless FIFO, atime never moves, and nothing - # ever fires - "degraded to atime" looks healthy but detects zero (Roee - # #123 F3). Re-serve instead; this self-heals once verify_planted - # re-creates any deleted/tampered FIFO. - err "FIFO serving exited unexpectedly - re-serving in 1s" + i=1 + while [ "$i" -le "$DEP_COUNT" ]; do + eval "_sp=\$sf_pid_$i _sf=\$dep_path_$i" + # Restart a writer that died while its FIFO still exists. Without this + # that ONE bait has no writer, any reader's open() blocks forever, and + # nothing fires - and verify_planted can't catch it because it only + # checks FIFO existence, not writer liveness (Roee #123 F3 residual). + # A bare `wait` can't do per-writer recovery: it blocks until ALL + # writers exit, so a single death among live siblings went unrecovered + # until the next full re-plant restart. And we must NOT fall back to + # atime: atime polling on a writerless pipe is silently blind. + if [ -p "$_sf" ] && ! kill -0 "$_sp" 2>/dev/null; then + serve_fifo "$i" & eval "sf_pid_$i=\$!" + log "restarted dead FIFO writer for bait $i" + fi + i=$((i + 1)) + done sleep 1 done } @@ -806,10 +816,12 @@ run() { fire "$i" open simulated "$$" "${USER:-$(id -un)}" "" i=$((i + 1)) done - # --simulate exits before the cleanup traps are armed, so sweep any FIFO - # bait now: a leftover no-reader pipe blocks every real open() forever - # (e.g. a process reading ~/.aws/credentials) - Roee #123 F2. + # --simulate exits before the cleanup traps are armed, so clean up now: + # sweep any FIFO bait (a leftover no-reader pipe blocks every real open() + # forever) AND remove the cached fake-credential content it planted - + # test-only mode shouldn't leave either behind (Roee #123 F2). [ "$FIFO_MODE" = 1 ] && remove_fifos + [ -n "${BAITCACHE:-}" ] && [ -d "$BAITCACHE" ] && rm -rf "$BAITCACHE" return 0 fi [ "$ONCE" = "1" ] && return 0 diff --git a/tests/test_agent_fifo.py b/tests/test_agent_fifo.py index 547108f..06bc608 100644 --- a/tests/test_agent_fifo.py +++ b/tests/test_agent_fifo.py @@ -438,6 +438,27 @@ def test_simulate_does_not_leave_a_no_reader_fifo(server, tmp_path): ) +def test_simulate_does_not_leave_cached_credential_files(server, tmp_path): + # Roee #123 F2 residual: --simulate must also remove the cached fake-credential + # content it planted during planting, not just the FIFOs. + bait = tmp_path / "bait_aws" + Stub.bait_path = str(bait) + _run(server, tmp_path, "--simulate") + cache = tmp_path / "bait" + assert not cache.exists(), "--simulate left cached credential content behind" + + +def test_fifo_supervisor_restarts_individual_dead_writers(): + # Roee #123 F3 residual: watch_fifo must poll each writer's liveness (kill -0) + # and restart a SINGLE dead one whose FIFO still exists - a bare `wait` blocks + # until ALL writers exit, leaving one dead bait silently blind until the next + # full re-plant restart. + src = AGENT.read_text() + assert "kill -0" in src, "watch_fifo must check per-writer liveness" + assert "restarted dead FIFO writer" in src, "watch_fifo must restart a dead writer" + assert "re-serving in 1s" not in src, "the bare-wait/re-serve-all recovery must be gone" + + def test_atime_stat_order_prefers_portable_access_time(): # #28: `stat -f %a` on Linux is statfs (free blocks), so the portable # `stat -c %X` must be tried FIRST. Assert BOTH call sites use the right order From ae7a64735d3d6446f851c9da396aa8f75ba66cdb Mon Sep 17 00:00:00 2001 From: Lior Finkelshtein Date: Wed, 1 Jul 2026 15:04:49 +0300 Subject: [PATCH 21/23] CI: stable aggregate `test` check over the OS matrix Branch protection requires a status check named `test`, but the matrix job emitted per-leg names (`test (ubuntu-latest)` / `test (macos-latest)`), so the required check never appeared and approved PRs on this branch were BLOCKED forever. Rename the matrix job to `test-matrix` and add a lightweight aggregate `test` job that fails unless every leg passes. The required check name is now stable and independent of the matrix, and main-based PRs (single `test` job) keep satisfying the same required check. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018DARDAxeg4NM8FKoyGMQZy --- .github/workflows/ci.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97cbc59..abfc305 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: - name: Lint run: ruff check . - test: + test-matrix: # macOS is included so the FIFO read sensor (macOS-only; #100) actually runs # in CI - the FIFO tests are skipif!=Darwin, so ubuntu alone never exercises # them. Linux covers inotify/atime; macOS covers the FIFO path. @@ -41,3 +41,19 @@ jobs: - name: Run tests run: pytest -v + + # Aggregate gate with a stable name. Branch protection requires the check + # "test"; the OS matrix above emits per-leg names ("test-matrix (ubuntu-latest)" + # etc.), so this single job is what the ruleset keys on. It stays green only + # when every matrix leg passes, and the name is invariant to matrix changes. + test: + needs: [test-matrix] + if: ${{ always() }} + runs-on: ubuntu-latest + steps: + - name: Require all matrix legs to pass + run: | + if [ "${{ needs.test-matrix.result }}" != "success" ]; then + echo "one or more test-matrix legs failed: ${{ needs.test-matrix.result }}" + exit 1 + fi From b4402d1d5def0f46b9fe8e173d2353d71710c648 Mon Sep 17 00:00:00 2001 From: Lior Finkelshtein Date: Wed, 1 Jul 2026 15:15:48 +0300 Subject: [PATCH 22/23] agent: record FIFO bait before mkfifo to close a clean-exit cleanup race plant() created the FIFO with mkfifo and only recorded it in the manifest on the next line. A clean-exit signal (INT/TERM) delivered in that gap ran the teardown trap's remove_fifos against a manifest that did not yet list the path, leaving the FIFO behind - a slow-runner race that surfaced as a flaky macOS CI failure in test_clean_exit_removes_fifos (passes locally, fails under CI load). Record before mkfifo (record_planted is idempotent) and forget on mkfifo failure so the manifest never lists a phantom path. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018DARDAxeg4NM8FKoyGMQZy --- agent/thumper_agent.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/agent/thumper_agent.sh b/agent/thumper_agent.sh index b098dc5..8cd2b9b 100755 --- a/agent/thumper_agent.sh +++ b/agent/thumper_agent.sh @@ -350,10 +350,15 @@ plant() { # plant fi chmod 600 "$cf" 2>/dev/null || true { [ -p "$path" ] || [ -f "$path" ]; } && rm -f "$path" # replace our own stale bait on re-plant + # Record BEFORE mkfifo, not after: a clean-exit signal (INT/TERM) landing + # in the gap between creating the FIFO and recording it would otherwise + # leave the FIFO behind, because the teardown trap's remove_fifos only + # removes paths listed in the manifest. record_planted is idempotent; + # undo it if mkfifo fails so the manifest never lists a phantom path. + record_planted "$path" if ! mkfifo "$path" 2>/dev/null; then - rm -f "$cf"; err "mkfifo failed at $path - skipping $id"; report_plant "$id" failed; return 1 + forget_planted "$path"; rm -f "$cf"; err "mkfifo failed at $path - skipping $id"; report_plant "$id" failed; return 1 fi - record_planted "$path" chmod 600 "$path" 2>/dev/null || true [ -n "$TARGET_USER" ] && chown "$TARGET_USER" "$path" 2>/dev/null || true report_plant "$id" planted From 584b9d074d428a25b8ad54c1f822b523583f57f1 Mon Sep 17 00:00:00 2001 From: Lior Finkelshtein <141519591+LiorFink00@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:36:52 +0300 Subject: [PATCH 23/23] Per-deployment sensor: run FIFO + atime baits together (#100, dual-plant 1/3) (#164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agent): per-deployment sensor — run FIFO + atime baits together (#100) Increment 1 of dual-plant: each deployment record carries an optional 6th `sensor` field (fifo|atime|inotify). The agent now plants and watches EACH bait under its own sensor, so a FIFO bait (canonical, definitive pid) and an atime bait (companion, normal-file detection) run side by side from one agent — the foundation for always deploying the pair. Backward-compatible: an absent field falls back to today's global behavior (`effective_sensor` → per-deployment value, else the auto-probe/--sensor default), so this merges safely before the server sends pairs. The existing homogeneous FIFO/atime/inotify paths are byte-for-byte unchanged; the new `watch_mixed` dispatcher only engages when the server sends explicit sensors. - Parse the `sensor` field -> `dep_sensor_$i`; `effective_sensor`, `has_explicit_sensors` helpers. - `plant()` keys FIFO-vs-regular on the deployment's sensor, not global FIFO_MODE. - Refactor `watch_atime` -> `atime_poll ""` so a subset can be polled; `watch_atime()` keeps polling all (homogeneous + degradation fallback). - `watch_mixed`: FIFO baits served individually, the rest atime-polled as a group. - verify's FIFO-tamper check keys on the deployment's sensor too. New test: a fifo+atime pair from one agent — both plant as the right type and both fire. Full suite: 261 passed, 1 skipped; shellcheck -S error clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018DARDAxeg4NM8FKoyGMQZy * agent: arm_atime must not create the bait file (fixes re-plant cap on Linux) Same fix as the atime-rearm branch: `touch -a` creates the file if missing, so arming a failed-plant dep's path left an empty file behind and verify_planted stopped re-planting it. Broke test_replant_is_bounded on Linux. Add `-c`. Verified in a Debian/dash container: agent suite 32 passed, 9 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(agent): address Roee's #164 review — explicit --sensor wins over server #164 F2: an operator's explicit `--sensor fifo|atime` is an intentional override and must win over the server's per-deployment `sensor` field. effective_sensor precedence is now: explicit --sensor -> per-deployment -> platform default, so `--sensor atime` reliably opts a host out of FIFOs even when the server sends sensor=fifo. (#164 F1 — re-planted FIFO unserved on Linux — is fixed by the mode-independent watcher restart propagated from the #160 fixes.) New test: --sensor atime plants a regular file even when the server asks for fifo. 44 agent tests pass; shellcheck -S error clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018DARDAxeg4NM8FKoyGMQZy * chore: reconcile #164 with main + ruff-clean test_agent_mixed.py (#177) Clean merge of the reconciled #160 (main/#178/#177 already resolved below). ruff-format + fix test_agent_mixed.py. Tree-wide ruff clean; 50 agent tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018DARDAxeg4NM8FKoyGMQZy --------- Co-authored-by: Claude Opus 4.8 (1M context) --- agent/thumper_agent.sh | 78 ++++++++++++--- tests/test_agent_mixed.py | 196 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 262 insertions(+), 12 deletions(-) create mode 100644 tests/test_agent_mixed.py diff --git a/agent/thumper_agent.sh b/agent/thumper_agent.sh index de72809..26873e8 100755 --- a/agent/thumper_agent.sh +++ b/agent/thumper_agent.sh @@ -68,6 +68,29 @@ probe_fifo_mode() { # AUTO policy: default to FIFO on macOS only (Linux default [ "$(platform)" = "darwin" ] || return 0 mkfifo_works && FIFO_MODE=1 } +# effective_sensor : which sensor governs THIS bait. Precedence: +# 1. an explicit operator --sensor (fifo|atime) - an intentional override that +# must win over the server (#164 F2): the operator opted out of/into FIFOs; +# 2. the deployment's OWN sensor when the server sent one (dual-plant pairs); +# 3. the platform default. +# Lets one agent run a FIFO bait and an atime bait side by side. +effective_sensor() { + case "$SENSOR" in fifo|atime) printf '%s' "$SENSOR"; return 0 ;; esac + eval "_es=\${dep_sensor_$1:-}" + [ -n "$_es" ] && { printf '%s' "$_es"; return 0; } + if [ "$FIFO_MODE" = 1 ]; then printf 'fifo' + elif [ "$(platform)" = linux ] && command -v inotifywait >/dev/null 2>&1; then printf 'inotify' + else printf 'atime'; fi +} +has_explicit_sensors() { # 0 if any deployment carries its own sensor (server is sending pairs) + _i=1 + while [ "$_i" -le "$DEP_COUNT" ]; do + eval "_s=\${dep_sensor_$_i:-}" + [ -n "$_s" ] && return 0 + _i=$((_i + 1)) + done + return 1 +} cache_path() { printf '%s/%s' "$BAITCACHE" "$1"; } # cache_path TAB=$(printf '\t') @@ -258,7 +281,7 @@ pull_deployments() { oldifs=$IFS IFS="$TAB" # `printf | while` would subshell the counters away; feed via a here-doc. - while IFS="$TAB" read -r id path secret content_url callback_url; do + while IFS="$TAB" read -r id path secret content_url callback_url sensor; do [ -n "$id" ] || continue DEP_COUNT=$((DEP_COUNT + 1)) eval "dep_id_$DEP_COUNT=\$id" @@ -266,6 +289,7 @@ pull_deployments() { eval "dep_secret_$DEP_COUNT=\$secret" eval "dep_content_$DEP_COUNT=\$content_url" eval "dep_callback_$DEP_COUNT=\$callback_url" + eval "dep_sensor_$DEP_COUNT=\${sensor:-}" # per-deployment sensor (fifo|atime|inotify); empty = use global default eval "dep_last_$DEP_COUNT=0" done < return 1 fi - if [ "$FIFO_MODE" = 1 ]; then + if [ "$(effective_sensor "$1")" = fifo ]; then mkdir -p "$BAITCACHE" chmod 700 "$BAITCACHE" 2>/dev/null || true cf=$(cache_path "$id") @@ -570,19 +594,26 @@ arm_atime() { # arm_atime : set atime to the past so the next read bumps read_atime() { # read_atime : portable access-time epoch (GNU %X first, then BSD %a - never %a on Linux, that's free blocks: #28) stat -c %X "$1" 2>/dev/null || stat -f %a "$1" 2>/dev/null || echo 0 } -watch_atime() { - log "atime poll every ${POLL}s on regular-file bait (re-armable; detection only - no process/user)" - i=1 - while [ "$i" -le "$DEP_COUNT" ]; do +all_indices() { # "1 2 ... DEP_COUNT" - every deployment + _ai=""; _i=1 + while [ "$_i" -le "$DEP_COUNT" ]; do _ai="$_ai $_i"; _i=$((_i + 1)); done + printf '%s' "$_ai" +} +# atime_poll "": arm + re-armable-poll only the given deployments. +# The index list lets the mixed watcher poll just the atime baits while FIFO +# baits are served separately; watch_atime() polls all (homogeneous + fallback). +atime_poll() { + log "atime poll every ${POLL}s on regular-file bait(s) (re-armable; detection only - no process/user)" + # shellcheck disable=SC2086 # $1 is a space-separated index list; splitting is intended + for i in $1; do eval "p=\$dep_path_$i" arm_atime "$p" # arm so relatime bumps atime on a read eval "atime_$i=\$(read_atime \"\$p\")" - i=$((i + 1)) done while true; do sleep "$POLL" - i=1 - while [ "$i" -le "$DEP_COUNT" ]; do + # shellcheck disable=SC2086 + for i in $1; do eval "p=\$dep_path_$i prev=\$atime_$i" cur=$(read_atime "$p") if [ "$cur" != "0" ] && [ "$cur" -gt "$prev" ] 2>/dev/null; then @@ -590,10 +621,10 @@ watch_atime() { arm_atime "$p" # RE-ARM so the NEXT read is detectable too eval "atime_$i=\$(read_atime \"\$p\")" fi - i=$((i + 1)) done done } +watch_atime() { atime_poll "$(all_indices)"; } # poll every bait (homogeneous atime mode + degradation fallback) # ── live sync (re-pull + reconcile) ─────────────────────────────────────────── # A running agent re-pulls its deployment set every --sync-interval and applies @@ -681,9 +712,32 @@ watch_fifo() { # supervisor: keep one serve_fifo alive per bait; restart any th done } +# Dual-plant: each deployment runs under its OWN sensor. FIFO baits (canonical, +# definitive pid) are served individually; atime/inotify baits (companion, +# detection) are atime-polled as a group. Used whenever the server sends pairs. +watch_mixed() { + log "watching $DEP_COUNT bait(s) with per-deployment sensors" + _atidx=""; i=1 + while [ "$i" -le "$DEP_COUNT" ]; do + if [ "$(effective_sensor "$i")" = fifo ]; then + serve_fifo "$i" & + else + _atidx="$_atidx $i" # atime/inotify/unknown -> atime poll (detection) + fi + i=$((i + 1)) + done + [ -n "$_atidx" ] && atime_poll "$_atidx" & + wait + [ -e "${WATCH_STOP_FLAG:-/nonexistent}" ] && return 0 + err "mixed watcher exited unexpectedly - degrading to atime poll" + atime_poll "$(all_indices)" +} + start_watcher() { # launch the right sensor in the background; set WATCH_PID rm -f "${WATCH_STOP_FLAG:-}" 2>/dev/null || true # this start is not a stop - if [ "$SENSOR" = atime ]; then + if has_explicit_sensors; then + watch_mixed & # per-deployment sensors (dual-plant pairs) + elif [ "$SENSOR" = atime ]; then watch_atime & # forced atime sensor (any platform) elif [ "$FIFO_MODE" = 1 ]; then watch_fifo & @@ -769,7 +823,7 @@ verify_planted() { # and never re-plant through it (curl -o would write the target); report # failed so the lost coverage is visible. report_plant "$vid" failed - elif [ "$FIFO_MODE" = 1 ] && [ -e "$p" ] && ! [ -p "$p" ]; then + elif [ "$(effective_sensor "$i")" = fifo ] && [ -e "$p" ] && ! [ -p "$p" ]; then # A regular file where our FIFO should be = tampering/replacement. # Recover like the "missing" branch below: plant() removes the impostor # (our own path) and re-creates the FIFO, then REPLANTED restarts the diff --git a/tests/test_agent_mixed.py b/tests/test_agent_mixed.py new file mode 100644 index 0000000..431977d --- /dev/null +++ b/tests/test_agent_mixed.py @@ -0,0 +1,196 @@ +"""Per-deployment sensor (#100 dual-plant, increment 1): each deployment record +carries a 6th `sensor` field (fifo|atime). The agent plants and watches EACH +bait per its own sensor, so a FIFO bait (canonical, definitive pid) and an atime +bait (companion, normal-file detection) run side by side from one agent. + +macOS-gated: the pair includes a FIFO bait. The atime 'read' is simulated by +os.utime() (deterministic); the FIFO read is a real open().""" + +import http.server +import subprocess +import threading +import os +import stat +import time +import platform as _platform +from pathlib import Path +import pytest + +pytestmark = pytest.mark.skipif( + _platform.system() != "Darwin", reason="pair includes a FIFO bait (macOS)" +) + +AGENT = Path(__file__).resolve().parents[1] / "agent" / "thumper_agent.sh" +BAIT_BODY = "AKIA-BAIT\nsecret=shhh\n" +ARMED_MAX = 1_000_000_000 + + +class Stub(http.server.BaseHTTPRequestHandler): + callbacks = [] # (callback_path, body) + deployments = [] # list of (id, path, sensor) + + def log_message(self, *a): + pass + + def _t(self, body=""): + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.end_headers() + self.wfile.write(body.encode()) + + def do_POST(self): + n = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(n).decode() + if self.path == "/api/enroll": + return self._t("agent_token=tok-1\nendpoint_id=ep_1\n") + if self.path.startswith("/cb/"): + Stub.callbacks.append((self.path, body)) + return self._t("ok") + if self.path.endswith("/state"): + return self._t("ok") + return self._t("ok") + + def do_GET(self): + if self.path == "/api/agent/deployments": + base = f"http://127.0.0.1:{self.server.server_port}" + lines = [ + "\t".join( + [ + did, + path, + "sekret", + f"{base}/content/{did}", + f"{base}/cb/{did}", + sensor, + ] + ) + for did, path, sensor in Stub.deployments + ] + return self._t("\n".join(lines) + "\n") + if self.path.startswith("/content/"): + return self._t(BAIT_BODY) + return self._t("") + + +@pytest.fixture +def server(): + httpd = http.server.HTTPServer(("127.0.0.1", 0), Stub) + threading.Thread(target=httpd.serve_forever, daemon=True).start() + Stub.callbacks = [] + yield httpd + httpd.shutdown() + + +def _spawn(server, tmp_path): + port = server.server_port + state = tmp_path / "agent.json" + # NO --sensor: the per-deployment field must govern, overriding the auto-probe. + return subprocess.Popen( + [ + "sh", + str(AGENT), + "run", + "--server", + f"http://127.0.0.1:{port}", + "--enroll-token", + "e", + "--tripwire", + "tw_1", + "--state-file", + str(state), + "--poll", + "1", + "--heartbeat", + "0", + "--sync-interval", + "0", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + +def _wait(cond, t=15.0): + end = time.time() + t + while time.time() < end: + if cond(): + return True + time.sleep(0.05) + return False + + +def _fired(cb_id): + return any(cb_id in p for p, _ in Stub.callbacks) + + +def test_mixed_sensors_plant_and_fire_together(server, tmp_path): + fifo = tmp_path / "credentials" # canonical -> FIFO (pid) + atin = tmp_path / "config" # companion -> regular file (atime detect) + Stub.deployments = [ + ("dep_fifo", str(fifo), "fifo"), + ("dep_atime", str(atin), "atime"), + ] + agent = _spawn(server, tmp_path) + try: + # planted per its OWN sensor, not the global auto-probe + assert _wait(lambda: fifo.exists() and stat.S_ISFIFO(fifo.stat().st_mode)), ( + "fifo-sensor bait was not planted as a named pipe" + ) + assert _wait( + lambda: ( + atin.exists() and atin.is_file() and atin.stat().st_atime < ARMED_MAX + ) + ), "atime-sensor bait was not planted as an armed regular file" + # read the FIFO bait (blocks until the agent serves it) -> fires with pid + threading.Thread(target=lambda: open(fifo).read(), daemon=True).start() + # 'read' the atime bait -> fires (detection) + os.utime(atin, (time.time(), os.stat(atin).st_mtime)) + assert _wait(lambda: _fired("/cb/dep_fifo")), "FIFO bait did not fire" + assert _wait(lambda: _fired("/cb/dep_atime")), "atime bait did not fire" + finally: + agent.terminate() + agent.wait(timeout=5) + + +def test_explicit_sensor_overrides_server_per_deployment(server, tmp_path): + # Roee #164 F2: an operator's explicit --sensor atime is an intentional opt-out + # of FIFOs; it MUST win over the server's sensor=fifo, so the bait is planted as + # a regular file, never a named pipe. + bait = tmp_path / "credentials" + Stub.deployments = [("dep_1", str(bait), "fifo")] # server asks for FIFO... + port = server.server_port + agent = subprocess.Popen( + [ + "sh", + str(AGENT), + "run", + "--server", + f"http://127.0.0.1:{port}", + "--enroll-token", + "e", + "--tripwire", + "tw_1", + "--state-file", + str(tmp_path / "agent.json"), + "--sensor", + "atime", # ...operator overrides to atime + "--poll", + "1", + "--heartbeat", + "0", + "--sync-interval", + "0", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + assert _wait(lambda: bait.exists()), "bait was never planted" + assert bait.is_file() and not stat.S_ISFIFO(bait.stat().st_mode), ( + "--sensor atime did not override server sensor=fifo (a pipe was planted)" + ) + finally: + agent.terminate() + agent.wait(timeout=5)