diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 86c684f7c..aedb0fcd8 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -58,7 +58,7 @@ When changing any primary turn-end hook, validate the real harness behavior in a ## Primary pre-arm (PreToolUse) seatbelt -Every verified primary harness also has a wired PreToolUse-equivalent hook that denies a watcher-arm anti-pattern (shell `&`, truncating pipe, bundling, broad `pkill -f fm-watch`) before it runs. +Every verified primary harness also has a wired PreToolUse-equivalent hook that denies supervision-arm anti-patterns (shell `&`, truncating pipe, bundling, broad `pkill -f fm-watch`) before they run. `claude` and `codex` block directly through PreToolUse hooks; `grok` blocks the same way but requires every `$VAR` reference in its hook `command` string to carry an inline `:-default` or it fails to launch the hook entirely. `opencode` and `pi` block by throwing from `tool.execute.before` / returning `{block: true}` from `tool_call`. The exact hook files, commands, output-shaping quirks (Claude Code only honors the deny when stdout is empty), and validation transcripts are owned by `docs/arm-pretool-check.md`. diff --git a/AGENTS.md b/AGENTS.md index c291135c0..091c379d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,8 @@ config/backend runtime session-provider backend override for new tasks; LOCAL, config/cmux-socket-password optional cmux control-socket password; LOCAL, gitignored; read fresh on every cmux CLI call and passed through without ever overriding an operator's own ambient CMUX_SOCKET_PASSWORD when absent (docs/cmux-backend.md "Setup") config/wedge-alarm optional away-mode wedge-alarm active-alert directives; LOCAL, gitignored; absent means auto (macOS Notification Center when available); see docs/wedge-alarm.md config/x-mode.env generated X-mode watcher cadence; LOCAL, gitignored; source before arming watcher when present +config/telegram.env optional direct Telegram receive credentials; LOCAL, gitignored; when present, the locked session-start digest emits the `bin/fm-tg-recv-arm.sh` tracked-background arm step +config/fm-tg-recv.sh optional local direct Telegram receiver implementation; LOCAL, gitignored; `bin/fm-tg-recv-arm.sh` owns only the tracked arm/attach wrapper config/bridge-vessel Bridge inbox vessel name; LOCAL, gitignored; used only when FM_BRIDGE_VESSEL is unset, and absent (with FM_BRIDGE_VESSEL also unset) disables Bridge inbox scans (docs/configuration.md "Bridge inbox check (FM_BRIDGE_*)") data/ personal fleet records; LOCAL, gitignored as a whole backlog.md task queue, dependencies, history @@ -139,9 +141,9 @@ A lock-refused session must not spawn, steer, merge, drain the wake queue, repai A file that does not exist prints an explicit `ABSENT` marker, never confused with an empty-but-present file: absence is meaningful (`captain.md` absent means use the firstmate repo's built-in defaults, `projects.md` absent means rebuild it from the clones under `projects/`, etc.). 5. **Fleet-state digest** - the compact backlog listing owned by `bin/fm-session-start.sh`; every `state/.meta`; a bounded tail of each task's `state/.status` (labeled as wake-EVENT history, not current state, with the full log path printed for a deeper read); the `state/.afk` flag; and one cheap alive/dead read of each task's recorded backend endpoint. That liveness line is a fast presence check only, not a full state read - when you need a crew's actual current state (a run-step, not just "is the pane there"), read it with `bin/fm-crew-state.sh ` as before; the digest deliberately skips that deeper, slower read for every task so it stays fast and bounded. -6. **Supervision operating instructions and next step** - after the wake queue and before context, the digest emits exactly one operating block for the detected primary harness. +6. **Supervision operating instructions and next step** - after the wake queue and before context, the digest emits exactly one operating block for the detected primary harness plus the optional direct Telegram receiver arm step when `config/telegram.env` exists. The closing reminder points back to that emitted block and preserves only the lock, afk, X-mode, and read-once reminders. - The script itself never starts supervision; the emitted harness protocol owns the exact wait or wake mechanism. + The script itself never starts long-lived polls; the emitted harness protocol and `bin/fm-tg-recv-arm.sh` own the exact tracked-background wait or wake mechanisms. Bootstrap detects first, asks for consent, and installs only after the captain approves in the current session. Do not dispatch until the required tools are present and GitHub authentication is good. @@ -322,6 +324,7 @@ Fleet supervision is an always-loaded operational contract; `docs/architecture.m Whenever work is under way, keep exactly one live supervision cycle using the emitted protocol for this primary harness. X mode may require that same live cycle with no fleet work. Do not substitute another harness's wait shape, use shell `&`, or create a second cycle when a healthy one already exists. +When the session-start digest reports direct Telegram receive as active, keep `bin/fm-tg-recv-arm.sh` armed as its own separate tracked background task; it starts or attaches to the receiver for this home. After every actionable wake, resume the emitted protocol as the final action before ending the turn. No turn ends blind while work is under way, including turns described as holding or waiting. diff --git a/bin/fm-arm-command-policy.mjs b/bin/fm-arm-command-policy.mjs index 846965fa9..bbf43e8bf 100755 --- a/bin/fm-arm-command-policy.mjs +++ b/bin/fm-arm-command-policy.mjs @@ -9,7 +9,7 @@ // The tokenizer and command-position analysis (Lexer, splitProgram, // commandPosition) are exported so the sibling cd-guard policy // (bin/fm-cd-command-policy.mjs) reuses the same proven parser instead of -// duplicating shell lexing; see docs/cd-guard.md. The watcher-arm decision +// duplicating shell lexing; see docs/cd-guard.md. The supervision-arm decision // procedure below stays private to this file. The CLI entry point at the bottom // runs only when this module is invoked directly, never on import. @@ -44,7 +44,7 @@ function parseArguments(argv) { } function rawMentionsProtected(command) { - return /(?:^|[/\s'"`(])fm-watch(?:-(?:arm|checkpoint))?\.sh\b/.test(normalizeLineContinuations(command)); + return /(?:^|[/\s'"`(])(?:fm-watch(?:-(?:arm|checkpoint))?|fm-tg-recv-arm)\.sh\b/.test(normalizeLineContinuations(command)); } function rawMentionsBroadKill(command) { @@ -597,6 +597,7 @@ export function commandPosition(tokens) { const PROTECTED_SCRIPTS = [ { relative: "bin/fm-watch-arm.sh", kind: "arm" }, + { relative: "bin/fm-tg-recv-arm.sh", kind: "arm" }, { relative: "bin/fm-watch-checkpoint.sh", kind: "checkpoint" }, { relative: "bin/fm-watch.sh", kind: "watch" }, ]; @@ -611,7 +612,7 @@ function protectedIdentity(value, root) { function hasUnclassifiableProtectedExpansion(word, root) { if (!word?.unquotedExpansion || protectedIdentity(word.value, root)) return false; - return /(?:^|\/)fm-watch/.test(word.value); + return /(?:^|\/)(?:fm-watch|fm-tg-recv-arm)/.test(word.value); } function shellInvocation(position) { diff --git a/bin/fm-arm-pretool-check.sh b/bin/fm-arm-pretool-check.sh index 6ac8941b9..57c815178 100755 --- a/bin/fm-arm-pretool-check.sh +++ b/bin/fm-arm-pretool-check.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Stable PreToolUse transport for the watcher-arm command policy. +# Stable PreToolUse transport for the supervision-arm command policy. # # A firstmate primary must arm the watcher or run a Codex checkpoint as a # standalone verified harness call. @@ -104,23 +104,23 @@ fi [ -n "$CMD" ] || exit 0 # Strict-superset prefilter (transport only; owns zero classification semantics). -# Every protected watcher execution and every broad watcher kill resolves to the -# fm-watch byte sequence AFTER the classifier's byte normalization, so a command -# that cannot contain fm-watch even after that normalization can never be a -# deniable watcher command and is fast-allowed without the Node policy owner. +# Every protected arm/watch execution and every broad watcher kill resolves to a +# protected byte sequence AFTER the classifier's byte normalization, so a command +# that cannot contain one even after that normalization can never be a deniable +# command and is fast-allowed without the Node policy owner. # We mirror the classifier's cheapest byte transforms here (drop line- # continuation and escape backslashes, quotes, and newlines) so obfuscated -# protected paths such as fm-watc\h-arm.sh or fm-"watch"-arm.sh still -# delegate. Stripping only these non-alphanumeric bytes can never destroy an -# existing fm-watch run. +# protected paths such as fm-watc\h-arm.sh, fm-"watch"-arm.sh, or +# fm-tg-recv-"arm".sh still delegate. Stripping only these non-alphanumeric +# bytes can never destroy an existing protected run. # # The fast path may allow ONLY when BOTH hold: (a) the stripped/normalized text -# lacks the fm-watch watcher substring, AND (b) the raw command carries no +# lacks every protected substring, AND (b) the raw command carries no # quoting-decoder marker - a $ immediately followed by a single quote (ANSI-C # $'...') or a double quote (bash locale $"..."), both of which the classifier -# decodes and can therefore reconstruct fm-watch from bytes this cheap byte -# strip cannot. This marker set is COUPLED to the classifier's decoder set in -# bin/fm-arm-command-policy.mjs: adding any new quote/expansion form the +# decodes and can therefore reconstruct protected paths from bytes this cheap +# byte strip cannot. This marker set is COUPLED to the classifier's decoder set +# in bin/fm-arm-command-policy.mjs: adding any new quote/expansion form the # classifier decodes REQUIRES extending this marker set in the same change, or # the prefilter stops being a strict superset. Otherwise the command always # delegates to the classifier - the single owner of every decision. Any deeper @@ -137,6 +137,7 @@ case "$CMD" in *) case "$PREFILTER" in *fm-watch*) ;; + *fm-tg-recv-arm*) ;; *) exit 0 ;; esac ;; diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index cdeb03f04..55d0d36cb 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -40,9 +40,10 @@ # every state/*.meta, a bounded state/*.status tail, # state/.afk, and a cheap per-task endpoint-liveness read: # read-only, always runs. -# 6. closing reminder - prints the context-specific watcher next step; this -# script points back to the emitted harness supervision -# block and deliberately never arms the watcher itself. +# 6. closing reminder - prints the context-specific watcher and optional +# Telegram receiver next steps; this script points back +# to the emitted harness supervision block and deliberately +# never runs long-lived polls itself. # # On a Pi primary, the supervision-block step also checks whether Pi's two # tracked primary extensions are loaded and prints a PI_WATCH_EXTENSION @@ -299,7 +300,22 @@ else fi fi -# --- 4. supervision operating instructions ---------------------------------- +# --- 4. direct Telegram receiver --------------------------------------------- +TELEGRAM_PRESENT=0 +[ -f "$CONFIG/telegram.env" ] && TELEGRAM_PRESENT=1 + +subsection "TELEGRAM RECEIVER" +if [ "$TELEGRAM_PRESENT" -eq 0 ]; then + printf '%s\n' 'inactive (config/telegram.env absent)' +elif [ "$READ_ONLY" -eq 1 ]; then + printf '%s\n' 'skipped (read-only session) - the session holding the lock owns Telegram receiver arming.' +elif [ ! -x "$CONFIG/fm-tg-recv.sh" ]; then + printf '%s\n' 'TELEGRAM_RECEIVER: config/telegram.env exists but config/fm-tg-recv.sh is missing or not executable; direct Telegram receive is not armed' +else + printf '%s\n' "TELEGRAM_RECEIVER: active - run bin/fm-tg-recv-arm.sh as its own tracked background task, never shell &; it starts or attaches to this home's receiver" +fi + +# --- 5. supervision operating instructions ---------------------------------- AFK_PRESENT=0 [ -e "$STATE/.afk" ] && AFK_PRESENT=1 X_MODE_PRESENT=0 @@ -324,7 +340,7 @@ fi --afk "$AFK_PRESENT" \ --x-mode "$X_MODE_PRESENT" -# --- 4. context digest ----------------------------------------------------- +# --- 6. context digest ----------------------------------------------------- section "CONTEXT" print_file_or_absent "$DATA/projects.md" "data/projects.md" print_file_or_absent "$DATA/secondmates.md" "data/secondmates.md" @@ -332,7 +348,7 @@ print_file_or_absent "$DATA/captain.md" "data/captain.md" print_file_or_absent "$DATA/captain-shared.md" "data/captain-shared.md (shared, main-authoritative, read-only in secondmate homes)" print_file_or_absent "$DATA/learnings.md" "data/learnings.md" -# --- 5. fleet-state digest --------------------------------------------- +# --- 7. fleet-state digest --------------------------------------------- section "FLEET STATE" print_backlog_compact "$DATA/backlog.md" "data/backlog.md" @@ -386,7 +402,7 @@ else printf 'absent\n' fi -# --- 6. closing reminder ----------------------------------------------- +# --- 8. closing reminder ----------------------------------------------- section "NEXT STEP" if [ "$READ_ONLY" -eq 1 ]; then cat <<'EOF' @@ -406,13 +422,15 @@ elif [ -f "$CONFIG/x-mode.env" ]; then cat <&2 +} + +case "${1:-}" in + '') ;; + -h|--help) usage; exit 0 ;; + *) usage; exit 2 ;; +esac + +if [ ! -f "$ENV_FILE" ]; then + printf 'telegram receiver: inactive (config/telegram.env absent)\n' + exit 0 +fi + +if [ ! -x "$RECV" ]; then + printf 'telegram receiver: FAILED - config/fm-tg-recv.sh missing or not executable\n' + exit 1 +fi + +TG_HEALTHY_PID= +tg_receiver_lock_matches_pid() { + local pid=$1 lock_home lock_path lock_identity current_identity + lock_home=$(cat "$RECV_LOCK/fm-home" 2>/dev/null || true) + lock_path=$(cat "$RECV_LOCK/receiver-path" 2>/dev/null || true) + lock_identity=$(cat "$RECV_LOCK/pid-identity" 2>/dev/null || true) + [ "$lock_home" = "$FM_HOME" ] || return 1 + [ "$lock_path" = "$RECV" ] || return 1 + [ -n "$lock_identity" ] || return 1 + current_identity=$(fm_pid_identity "$pid") || return 1 + [ "$current_identity" = "$lock_identity" ] +} + +healthy_receiver() { + local pid + TG_HEALTHY_PID= + pid=$(cat "$RECV_LOCK/pid" 2>/dev/null || true) + fm_pid_alive "$pid" || return 1 + tg_receiver_lock_matches_pid "$pid" || return 1 + TG_HEALTHY_PID=$pid + return 0 +} + +clear_dead_recorded_receiver_lock() { + local lock_home lock_path pid + lock_home=$(cat "$RECV_LOCK/fm-home" 2>/dev/null || true) + lock_path=$(cat "$RECV_LOCK/receiver-path" 2>/dev/null || true) + pid=$(cat "$RECV_LOCK/pid" 2>/dev/null || true) + [ "$lock_home" = "$FM_HOME" ] || return 0 + [ "$lock_path" = "$RECV" ] || return 0 + fm_pid_alive "$pid" && return 0 + relay_recorded_receiver_output_once + fm_lock_remove_path "$RECV_LOCK" || true +} + +relay_output_file_once() { + local output_path=$1 relay_path + [ -n "$output_path" ] || return 0 + [ -e "$output_path" ] || return 0 + relay_path="$output_path.relay.$$" + if mv "$output_path" "$relay_path" 2>/dev/null; then + [ -s "$relay_path" ] && cat "$relay_path" + rm -f "$relay_path" 2>/dev/null || true + fi +} + +relay_recorded_receiver_output_once() { + local output_path + output_path=$(cat "$RECV_LOCK/output-path" 2>/dev/null || true) + relay_output_file_once "$output_path" +} + +attach_and_wait() { + while :; do + if healthy_receiver; then + sleep "$ATTACH_POLL" + continue + fi + relay_recorded_receiver_output_once + clear_dead_recorded_receiver_lock + exit 0 + done +} + +attach_if_receiver_becomes_healthy() { + local deadline now + deadline=$(($(date +%s) + ATTACH_CONFIRM_TIMEOUT)) + while [ -e "$RECV_LOCK" ] || [ -L "$RECV_LOCK" ]; do + if healthy_receiver; then + printf 'telegram receiver: attached pid=%s\n' "$TG_HEALTHY_PID" + attach_and_wait + fi + now=$(date +%s) + [ "$now" -lt "$deadline" ] || return 1 + sleep "$ATTACH_POLL" + done + return 1 +} + +if healthy_receiver; then + printf 'telegram receiver: attached pid=%s\n' "$TG_HEALTHY_PID" + attach_and_wait +fi + +clear_dead_recorded_receiver_lock + +ownerdir= +if ! fm_lock_try_acquire "$RECV_LOCK"; then + attach_if_receiver_becomes_healthy + if ! fm_lock_try_acquire "$RECV_LOCK"; then + printf 'telegram receiver: FAILED - receiver lock is held but no live matching receiver was confirmed\n' + exit 1 + fi +fi +ownerdir=$FM_LOCK_OWNER_DIR + +child= +child_out= +release_lock_if_owned() { + [ -n "$ownerdir" ] || return 0 + if fm_lock_points_to_owner "$RECV_LOCK" "$ownerdir"; then + fm_lock_remove_path "$RECV_LOCK" 2>/dev/null || true + fi +} + +record_child_lock_metadata_if_possible() { + local current_identity + [ -n "$ownerdir" ] || return 0 + [ -n "$child" ] || return 0 + fm_lock_points_to_owner "$RECV_LOCK" "$ownerdir" || return 0 + current_identity=$(fm_pid_identity "$child" 2>/dev/null) || return 0 + { + printf '%s\n' "$child" > "$ownerdir/pid" + printf '%s\n' "$FM_HOME" > "$ownerdir/fm-home" + printf '%s\n' "$current_identity" > "$ownerdir/pid-identity" + printf '%s\n' "$RECV" > "$ownerdir/receiver-path" + [ -n "$child_out" ] && printf '%s\n' "$child_out" > "$ownerdir/output-path" + } 2>/dev/null || true +} + +child_still_running() { + local stat + fm_pid_alive "$child" || return 1 + stat=$(ps -p "$child" -o stat= 2>/dev/null | sed 's/^[[:space:]]*//') || return 1 + case "$stat" in + Z*) return 1 ;; + esac + return 0 +} + +terminate_child_bounded() { + local i=0 + [ -n "$child" ] || return 0 + if child_still_running; then + kill -TERM "$child" 2>/dev/null || true + fi + while child_still_running && [ "$i" -lt "$TERM_WAIT_CYCLES" ]; do + sleep "$TERM_WAIT_POLL" + i=$((i + 1)) + done + if child_still_running; then + return 1 + fi + wait "$child" 2>/dev/null || true + return 0 +} + +cleanup() { + record_child_lock_metadata_if_possible + if terminate_child_bounded; then + release_lock_if_owned + [ -n "$child_out" ] && relay_output_file_once "$child_out" + [ -n "$child_out" ] && rm -f "$child_out" 2>/dev/null || true + return + fi +} +trap 'cleanup; exit 129' HUP +trap 'cleanup; exit 143' TERM INT + +child_out=$(mktemp "$STATE/.tg-recv-output.XXXXXX") || { + cleanup + printf 'telegram receiver: FAILED - could not create output capture\n' + exit 1 +} + +FM_HOME="$FM_HOME" FM_CONFIG_OVERRIDE="$CONFIG" FM_STATE_OVERRIDE="$STATE" "$RECV" >"$child_out" & +child=$! +identity=$(fm_pid_identity "$child" 2>/dev/null || true) +if [ -z "$identity" ]; then + if [ -s "$child_out" ] || ! fm_pid_alive "$child"; then + wait "$child" + rc=$? + [ -s "$child_out" ] && cat "$child_out" + release_lock_if_owned + rm -f "$child_out" 2>/dev/null || true + trap - HUP TERM INT + exit "$rc" + fi + cleanup + printf 'telegram receiver: FAILED - could not identify receiver process\n' + exit 1 +fi + +{ + printf '%s\n' "$child" > "$ownerdir/pid" + printf '%s\n' "$FM_HOME" > "$ownerdir/fm-home" + printf '%s\n' "$identity" > "$ownerdir/pid-identity" + printf '%s\n' "$RECV" > "$ownerdir/receiver-path" + printf '%s\n' "$child_out" > "$ownerdir/output-path" +} 2>/dev/null || { + cleanup + printf 'telegram receiver: FAILED - could not record receiver lock metadata\n' + exit 1 +} + +printf 'telegram receiver: started pid=%s\n' "$child" +wait "$child" +rc=$? +relay_output_file_once "$child_out" +release_lock_if_owned +rm -f "$child_out" 2>/dev/null || true +trap - HUP TERM INT +exit "$rc" diff --git a/bin/fm-wake-lib.sh b/bin/fm-wake-lib.sh index c68327ba9..90ab7340b 100755 --- a/bin/fm-wake-lib.sh +++ b/bin/fm-wake-lib.sh @@ -89,6 +89,8 @@ fm_lock_clean_known_files() { "$lockdir/fm-home" \ "$lockdir/pid-identity" \ "$lockdir/watcher-path" \ + "$lockdir/receiver-path" \ + "$lockdir/output-path" \ 2>/dev/null || true } diff --git a/docs/architecture.md b/docs/architecture.md index ebe463d37..d11e46d83 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,6 +52,8 @@ That block owns the live wait shape for the running primary harness: Claude and `bin/fm-watch-arm.sh` remains the verified arm wrapper for protocols that call it; it forks the watcher as a tracked child, verifies it is genuinely alive with a fresh liveness beacon, and prints exactly one honest status line (`started` / `attached` / restart-only `healthy` / `FAILED`, the last exiting non-zero). On `attached` it stays live until that existing cycle ends so background-notify harnesses do not get an empty false wake from a healthy no-op exit. Its `--restart` mode signals only the watcher recorded in the current home's `state/.watch.lock`, so restarting one home cannot kill sibling secondmate watchers. +Optional direct Telegram receive is armed separately from watcher supervision when `config/telegram.env` and an executable `config/fm-tg-recv.sh` exist. +`bin/fm-tg-recv-arm.sh` starts or attaches to one home-scoped receiver through `state/.tg-recv.lock`, while the local receiver script owns the Telegram protocol and emitted `CAPTAIN-TELEGRAM` line. A pull-based guard (`bin/fm-guard.sh`) warns through supervision tool output if the primary checkout is tangled, or if tasks are in flight and that watcher stops running or queued wakes are waiting to be drained. The drain script calls that guard after emptying the queue, which avoids repeating the queued-wakes warning for records it just consumed while still warning on stale watcher liveness. It leads with a prominent bordered tangle banner, while `bin/fm-guard.sh` owns the stale-watcher banner/reminder policy so repeated guarded commands stay noisy without reprinting the full watcher-down banner in the same episode. diff --git a/docs/arm-pretool-check.md b/docs/arm-pretool-check.md index 14fdf714a..fc40b7d8d 100644 --- a/docs/arm-pretool-check.md +++ b/docs/arm-pretool-check.md @@ -1,6 +1,6 @@ -# Watcher arm PreToolUse seatbelt +# Supervision arm PreToolUse seatbelt -This document is the authoritative human-readable contract for the watcher arm PreToolUse seatbelt. +This document is the authoritative human-readable contract for the supervision arm PreToolUse seatbelt. `bin/fm-arm-command-policy.mjs` is the single semantic owner. `bin/fm-arm-pretool-check.sh` is only the stable harness transport and output renderer. The tracked harness adapters forward command text without classifying it. @@ -8,12 +8,12 @@ The tracked harness adapters forward command text without classifying it. ## Purpose and boundary -A firstmate primary must arm `bin/fm-watch-arm.sh` or run `bin/fm-watch-checkpoint.sh` through an observable harness call. -A shell background operator, pipeline, redirection, wrapper, or unrelated command list can hide failure or let the watcher child die with the tool call. +A firstmate primary must arm `bin/fm-watch-arm.sh`, run `bin/fm-watch-checkpoint.sh`, or arm `bin/fm-tg-recv-arm.sh` through an observable harness call. +A shell background operator, pipeline, redirection, wrapper, or unrelated command list can hide failure or let the supervised child die with the tool call. The seatbelt rejects those command shapes before execution. This policy is not a post-arm liveness guarantee. -`bin/fm-guard.sh`, `bin/fm-turnend-guard.sh`, the watcher lock, and the watcher beacon still prove whether supervision is healthy after an allowed call. +`bin/fm-guard.sh`, `bin/fm-turnend-guard.sh`, the watcher lock, the watcher beacon, and the Telegram receiver lock still prove whether supervision is healthy after an allowed call. The classifier never executes, sources, evaluates, or expands any part of the submitted command. It tokenizes the bytes and classifies lexical execution positions only. @@ -32,19 +32,19 @@ The wrapper discovers the code root from its own location. The active firstmate home is `${FM_HOME:-}`. It passes both roots and the exact command string to the Node policy owner. -The wrapper fast-allows a command without invoking the Node policy owner only when the command cannot contain the `fm-watch` byte sequence even after the classifier's decoders run. +The wrapper fast-allows a command without invoking the Node policy owner only when the command cannot contain any protected byte sequence even after the classifier's decoders run. The fast path may allow only when both of these hold: -1. The stripped text lacks the `fm-watch` watcher substring, after mirroring the classifier's cheapest byte normalizations - dropping line-continuation and escape backslashes, quotes, and newlines. +1. The stripped text lacks the `fm-watch` and `fm-tg-recv-arm` protected substrings, after mirroring the classifier's cheapest byte normalizations - dropping line-continuation and escape backslashes, quotes, and newlines. 2. The raw command carries no quoting-decoder marker: a `$` immediately followed by a single quote (ANSI-C `$'...'`) or a double quote (bash locale `$"..."`). -Any `fm-watch` match or any quoting-decoder marker delegates to the classifier. -Normalizing first keeps this a strict superset: a protected watcher path obfuscated as `fm-watc\h-arm.sh` or `fm-"watch"-arm.sh` still delegates, and stripping only those non-alphanumeric bytes can never destroy an existing `fm-watch` run. -The quoting-decoder marker closes the case the byte strip cannot: `bin/fm-$'\x77'atch-arm.sh` and `bin/fm-$"watch"-arm.sh` both resolve to `bin/fm-watch-arm.sh` only after the classifier decodes the encoded character, so a cheap byte strip would otherwise lose the `fm-watch` bytes and fast-allow them. +Any protected substring match or any quoting-decoder marker delegates to the classifier. +Normalizing first keeps this a strict superset: a protected path obfuscated as `fm-watc\h-arm.sh`, `fm-"watch"-arm.sh`, or `fm-tg-recv-"arm".sh` still delegates, and stripping only those non-alphanumeric bytes can never destroy an existing protected run. +The quoting-decoder marker closes the case the byte strip cannot: `bin/fm-$'\x77'atch-arm.sh`, `bin/fm-$"watch"-arm.sh`, and equivalent encoded `fm-tg-recv-arm` forms resolve to protected scripts only after the classifier decodes the encoded character, so a cheap byte strip would otherwise lose the protected bytes and fast-allow them. This marker set is coupled to the classifier's decoder set in `bin/fm-arm-command-policy.mjs`: adding any new quote or expansion form the classifier decodes requires extending this marker set in the same change, or the prefilter stops being a strict superset. -The prefilter owns no semantic exception: it can only ever fast-allow a command that is definitely not a watcher command, so it never flips a classification and the classifier remains the single owner of every decision. +The prefilter owns no semantic exception: it can only ever fast-allow a command that is definitely not protected, so it never flips a classification and the classifier remains the single owner of every decision. -The seatbelt's threat model is agent mistakes: no one accidentally writes an ANSI-C- or locale-obfuscated watcher path, and deliberate obfuscation is the post-arm liveness guard's territory. +The seatbelt's threat model is agent mistakes: no one accidentally writes an ANSI-C- or locale-obfuscated protected path, and deliberate obfuscation is the post-arm liveness guard's territory. The marker guard closes the static gap anyway because it is cheap and provable per encoding class. Tripwire: if a third strict-superset gap is ever found after this marker generalization, that falsifies the "provable per encoding class" claim and the decision flips to Option B - drop the prefilter and always invoke the classifier. Deeper decode-required obfuscation beyond the coupled marker set stays the classifier's and the post-arm liveness guards' responsibility. @@ -58,12 +58,13 @@ Malformed or unsupported shell syntax that contains a protected command is a sem The tokenizer recognizes cooked words with quote provenance, comments, heredoc bodies, shell list operators, pipelines, redirections, command and process substitutions, parenthesized subshells, brace groups, and literal nested execution payloads. Quoted text, comments, heredoc bodies, and later argument words are data positions unless a recognized execution sink recursively executes them. -A command word in executed position is a protected execution when its normalized path suffix matches one of the protected watcher scripts: +A command word in executed position is a protected execution when its normalized path suffix matches one of the protected scripts: ```text bin/fm-watch-arm.sh (arm; blessed entry point) bin/fm-watch-checkpoint.sh (checkpoint; blessed entry point) bin/fm-watch.sh (watch; protected but never blessed) +bin/fm-tg-recv-arm.sh (arm; blessed entry point) ``` The relative form, the ``-anchored absolute form, and any word ending in `/bin/