From 4cdfa3c7018d5fb860528ec559ff256d0a190826 Mon Sep 17 00:00:00 2001 From: Lior Finkelshtein Date: Sat, 27 Jun 2026 18:33:33 +0300 Subject: [PATCH 1/4] =?UTF-8?q?feat(agent):=20per-deployment=20sensor=20?= =?UTF-8?q?=E2=80=94=20run=20FIFO=20+=20atime=20baits=20together=20(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/thumper_agent.sh | 75 ++++++++++++++++++++++++++----- tests/test_agent_mixed.py | 92 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 12 deletions(-) create mode 100644 tests/test_agent_mixed.py diff --git a/agent/thumper_agent.sh b/agent/thumper_agent.sh index 7b6a18b..c2bca14 100755 --- a/agent/thumper_agent.sh +++ b/agent/thumper_agent.sh @@ -64,6 +64,26 @@ probe_fifo_mode() { if mkfifo "$_probe" 2>/dev/null; then rm -f "$_probe"; FIFO_MODE=1; fi unset _probe } +# effective_sensor : the deployment's OWN sensor when the server sent one +# (dual-plant pairs), else the global default. Lets one agent run a FIFO bait +# and an atime bait side by side. +effective_sensor() { + eval "_es=\${dep_sensor_$1:-}" + if [ -n "$_es" ]; then printf '%s' "$_es"; return 0; fi + if [ "$SENSOR" = atime ]; then printf 'atime' + elif [ "$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') @@ -244,7 +264,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" @@ -252,6 +272,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") @@ -542,19 +563,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 @@ -562,10 +590,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 @@ -636,9 +664,32 @@ watch_fifo() { # supervisor: one serve_fifo per bait, wait on them watch_atime } +# 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 & @@ -724,7 +775,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. report_plant "$vid" failed elif [ -e "$p" ]; then diff --git a/tests/test_agent_mixed.py b/tests/test_agent_mixed.py new file mode 100644 index 0000000..82788c8 --- /dev/null +++ b/tests/test_agent_mixed.py @@ -0,0 +1,92 @@ +"""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, subprocess, threading, os, stat, 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) From ad0f5323ac559c898e97fa3e2f55dc5af47891f8 Mon Sep 17 00:00:00 2001 From: Lior Finkelshtein Date: Sat, 27 Jun 2026 23:16:16 +0300 Subject: [PATCH 2/4] 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) --- 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 c2bca14..c817d52 100755 --- a/agent/thumper_agent.sh +++ b/agent/thumper_agent.sh @@ -558,7 +558,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 33359a667f289fae1e5fc3948f81a145ee17ff0d Mon Sep 17 00:00:00 2001 From: Lior Finkelshtein Date: Sun, 28 Jun 2026 15:34:05 +0300 Subject: [PATCH 3/4] =?UTF-8?q?fix(agent):=20address=20Roee's=20#164=20rev?= =?UTF-8?q?iew=20=E2=80=94=20explicit=20--sensor=20wins=20over=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 --- agent/thumper_agent.sh | 15 +++++++++------ tests/test_agent_mixed.py | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/agent/thumper_agent.sh b/agent/thumper_agent.sh index 54bf33a..4b2532f 100755 --- a/agent/thumper_agent.sh +++ b/agent/thumper_agent.sh @@ -68,14 +68,17 @@ probe_fifo_mode() { # AUTO policy: default to FIFO on macOS only (Linux default [ "$(platform)" = "darwin" ] || return 0 mkfifo_works && FIFO_MODE=1 } -# effective_sensor : the deployment's OWN sensor when the server sent one -# (dual-plant pairs), else the global default. Lets one agent run a FIFO bait -# and an atime bait side by side. +# 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:-}" - if [ -n "$_es" ]; then printf '%s' "$_es"; return 0; fi - if [ "$SENSOR" = atime ]; then printf 'atime' - elif [ "$FIFO_MODE" = 1 ]; then printf 'fifo' + [ -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 } diff --git a/tests/test_agent_mixed.py b/tests/test_agent_mixed.py index 82788c8..5a8d268 100644 --- a/tests/test_agent_mixed.py +++ b/tests/test_agent_mixed.py @@ -90,3 +90,24 @@ def test_mixed_sensors_plant_and_fire_together(server, tmp_path): 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) From de82348b7c74487b8d216a75c92fbb7289359b0e Mon Sep 17 00:00:00 2001 From: Lior Finkelshtein Date: Tue, 30 Jun 2026 14:39:02 +0300 Subject: [PATCH 4/4] 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 --- tests/test_agent_mixed.py | 153 +++++++++++++++++++++++++++++--------- 1 file changed, 118 insertions(+), 35 deletions(-) diff --git a/tests/test_agent_mixed.py b/tests/test_agent_mixed.py index 5a8d268..431977d 100644 --- a/tests/test_agent_mixed.py +++ b/tests/test_agent_mixed.py @@ -5,12 +5,20 @@ 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, 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="pair includes a FIFO bait (macOS)") +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" @@ -18,25 +26,49 @@ class Stub(http.server.BaseHTTPRequestHandler): - callbacks = [] # (callback_path, body) - deployments = [] # list of (id, path, sensor) - def log_message(self, *a): pass + 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()) + 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") + 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] + 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) + if self.path.startswith("/content/"): + return self._t(BAIT_BODY) return self._t("") @@ -54,34 +86,62 @@ def _spawn(server, tmp_path): 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) + [ + "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 + 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 _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")] + 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)), \ + 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" + ) + 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) @@ -89,7 +149,8 @@ def test_mixed_sensors_plant_and_fire_together(server, tmp_path): 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) + agent.terminate() + agent.wait(timeout=5) def test_explicit_sensor_overrides_server_per_deployment(server, tmp_path): @@ -97,17 +158,39 @@ def test_explicit_sensor_overrides_server_per_deployment(server, tmp_path): # 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... + 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) + [ + "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), \ + 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) + agent.terminate() + agent.wait(timeout=5)