From 3635e8232a289183934e6934fc8ac210505835de Mon Sep 17 00:00:00 2001 From: Tombar Date: Tue, 2 Jun 2026 10:12:22 -0300 Subject: [PATCH 01/30] test(docs): scaffold bats doc-validation harness Co-Authored-By: Claude Sonnet 4.6 --- Makefile | 6 ++++++ test/docs/helpers.bash | 8 ++++++++ test/docs/smoke.bats | 7 +++++++ 3 files changed, 21 insertions(+) create mode 100644 Makefile create mode 100644 test/docs/helpers.bash create mode 100644 test/docs/smoke.bats diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d26a73d --- /dev/null +++ b/Makefile @@ -0,0 +1,6 @@ +# Doc validation harness. +.PHONY: validate-docs validate-docs-live +validate-docs: ## headless doc validation (CI runs this) + bats test/docs/*.bats +validate-docs-live: ## GUI-launch checks (local only; needs WezTerm/iTerm) + bats test/docs/live-gui/*.bats diff --git a/test/docs/helpers.bash b/test/docs/helpers.bash new file mode 100644 index 0000000..ebe40f5 --- /dev/null +++ b/test/docs/helpers.bash @@ -0,0 +1,8 @@ +# shellcheck shell=bash +# Shared helpers for the doc-validation bats suite. + +# Repo root (this file lives at test/docs/helpers.bash). +DOCS_REPO_ROOT="${DOCS_REPO_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" + +# Snapshot the real HOME before any test sandboxes it (needed for python user-site imports). +ORIG_HOME="${ORIG_HOME:-$HOME}" diff --git a/test/docs/smoke.bats b/test/docs/smoke.bats new file mode 100644 index 0000000..61e066f --- /dev/null +++ b/test/docs/smoke.bats @@ -0,0 +1,7 @@ +load helpers + +@test "harness loads and repo root resolves" { + [ -n "$DOCS_REPO_ROOT" ] + [ -f "$DOCS_REPO_ROOT/docs/toggle/tmux.md" ] + [ -f "$DOCS_REPO_ROOT/examples/claude-statusline.sh" ] +} From baa5ed73b983bb1aad24304e51bae8b1ebbbd800 Mon Sep 17 00:00:00 2001 From: Tombar Date: Tue, 2 Jun 2026 10:17:48 -0300 Subject: [PATCH 02/30] test(docs): markdown fenced-block extractor with heading anchors Co-Authored-By: Claude Opus 4.8 (1M context) --- test/docs/extract.bats | 23 +++++++++++++++++++++++ test/docs/lib/extract.sh | 20 ++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 test/docs/extract.bats create mode 100755 test/docs/lib/extract.sh diff --git a/test/docs/extract.bats b/test/docs/extract.bats new file mode 100644 index 0000000..cfbd8ad --- /dev/null +++ b/test/docs/extract.bats @@ -0,0 +1,23 @@ +load helpers + +EX() { bash "$DOCS_REPO_ROOT/test/docs/lib/extract.sh" "$@"; } + +@test "extract.sh returns the first bash block of tmux.md (the toggle script)" { + run EX "$DOCS_REPO_ROOT/docs/toggle/tmux.md" bash 1 "The toggle helper" + [ "$status" -eq 0 ] + [[ "$output" == *"#!/usr/bin/env bash"* ]] + [[ "$output" == *'printf '"'"'%s'"'"' "$next" > "$file"'* ]] + [[ "$output" != *"status indicator"* ]] +} + +@test "extract.sh anchors to a heading so ordinals are stable" { + run EX "$DOCS_REPO_ROOT/docs/toggle/tmux.md" bash 1 "Status indicator" + [ "$status" -eq 0 ] + [[ "$output" == *"πŸ”“ sudo"* ]] + [[ "$output" == *"πŸ”’ read"* ]] +} + +@test "extract.sh without anchor counts globally" { + run EX "$DOCS_REPO_ROOT/docs/claude-statusline.md" json 1 + [[ "$output" == *'"statusLine"'* ]] +} diff --git a/test/docs/lib/extract.sh b/test/docs/lib/extract.sh new file mode 100755 index 0000000..86f1175 --- /dev/null +++ b/test/docs/lib/extract.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# extract.sh [heading_substr] +# Print the Nth (1-based) ``` fenced block. If heading_substr is given, +# only blocks appearing after the most recent heading line containing that +# substring are counted (so edits elsewhere in the doc don't shift the ordinal). +set -euo pipefail +file="${1:?usage: extract.sh [heading_substr]}" +lang="${2:?lang}" +want="${3:?n}" +anchor="${4:-}" + +awk -v lang="$lang" -v want="$want" -v anchor="$anchor" ' + BEGIN { armed = (anchor == "") ? 1 : 0; count = 0; grab = 0 } + anchor != "" && /^#/ && index($0, anchor) > 0 { armed = 1 } + armed && $0 ~ ("^[[:space:]]*```" lang "[[:space:]]*$") { + if (grab == 0) { count++; if (count == want) { grab = 1; next } } + } + grab && $0 ~ "^[[:space:]]*```[[:space:]]*$" { exit } + grab { print } +' "$file" From d8445eb3c33cc0ddaa7c362d4c87c7bd4390c1b4 Mon Sep 17 00:00:00 2001 From: Tombar Date: Tue, 2 Jun 2026 10:25:02 -0300 Subject: [PATCH 03/30] test(docs): harden extractor (literal fence match, count reset, status assert) Co-Authored-By: Claude Sonnet 4.6 --- test/docs/extract.bats | 1 + test/docs/lib/extract.sh | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/test/docs/extract.bats b/test/docs/extract.bats index cfbd8ad..77aa4ea 100644 --- a/test/docs/extract.bats +++ b/test/docs/extract.bats @@ -19,5 +19,6 @@ EX() { bash "$DOCS_REPO_ROOT/test/docs/lib/extract.sh" "$@"; } @test "extract.sh without anchor counts globally" { run EX "$DOCS_REPO_ROOT/docs/claude-statusline.md" json 1 + [ "$status" -eq 0 ] [[ "$output" == *'"statusLine"'* ]] } diff --git a/test/docs/lib/extract.sh b/test/docs/lib/extract.sh index 86f1175..858f9fb 100755 --- a/test/docs/lib/extract.sh +++ b/test/docs/lib/extract.sh @@ -3,6 +3,8 @@ # Print the Nth (1-based) ``` fenced block. If heading_substr is given, # only blocks appearing after the most recent heading line containing that # substring are counted (so edits elsewhere in the doc don't shift the ordinal). +# Fence detection trims surrounding whitespace (handles fences indented inside +# markdown lists); grabbed content is printed verbatim. set -euo pipefail file="${1:?usage: extract.sh [heading_substr]}" lang="${2:?lang}" @@ -10,11 +12,16 @@ want="${3:?n}" anchor="${4:-}" awk -v lang="$lang" -v want="$want" -v anchor="$anchor" ' - BEGIN { armed = (anchor == "") ? 1 : 0; count = 0; grab = 0 } - anchor != "" && /^#/ && index($0, anchor) > 0 { armed = 1 } - armed && $0 ~ ("^[[:space:]]*```" lang "[[:space:]]*$") { - if (grab == 0) { count++; if (count == want) { grab = 1; next } } + BEGIN { armed = (anchor == "") ? 1 : 0; count = 0; grab = 0; open = "```" lang } + { + t = $0 + sub(/^[[:space:]]+/, "", t) + sub(/[[:space:]]+$/, "", t) } - grab && $0 ~ "^[[:space:]]*```[[:space:]]*$" { exit } + # A matching heading (re)arms and resets the counter: ordinals are relative to + # the MOST RECENT matching heading. + anchor != "" && /^#/ && index($0, anchor) > 0 { armed = 1; count = 0; grab = 0 } + armed && t == open { if (grab == 0) { count++; if (count == want) { grab = 1; next } } } + grab && t == "```" { exit } grab { print } ' "$file" From 24062e26293135b166dc4a9daff93235dc7e550e Mon Sep 17 00:00:00 2001 From: Tombar Date: Tue, 2 Jun 2026 10:27:17 -0300 Subject: [PATCH 04/30] test(docs): sandbox HOME, tool guards, mode-file helpers Co-Authored-By: Claude Sonnet 4.6 --- test/docs/helpers.bash | 28 ++++++++++++++++++++++++++++ test/docs/helpers.bats | 26 ++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 test/docs/helpers.bats diff --git a/test/docs/helpers.bash b/test/docs/helpers.bash index ebe40f5..8e136c8 100644 --- a/test/docs/helpers.bash +++ b/test/docs/helpers.bash @@ -6,3 +6,31 @@ DOCS_REPO_ROOT="${DOCS_REPO_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && # Snapshot the real HOME before any test sandboxes it (needed for python user-site imports). ORIG_HOME="${ORIG_HOME:-$HOME}" + +# Resolve interpreters once (names vary across distros/brew). +LUA_BIN="$(command -v lua || command -v lua5.4 || command -v luajit || true)" +PYTHON_BIN="$(command -v python3 || command -v python || true)" + +EXTRACT_SH="$(dirname "${BASH_SOURCE[0]}")/lib/extract.sh" +STUB_DIR="$(dirname "${BASH_SOURCE[0]}")/stubs" + +extract_block() { bash "$EXTRACT_SH" "$@"; } + +setup_sandbox() { + TEST_HOME="$(mktemp -d)" + export HOME="$TEST_HOME" + mkdir -p "$HOME/.claude/pane-mode" "$HOME/.config/failsafe" + unset WEZTERM_PANE TMUX_PANE ITERM_SESSION_ID KITTY_WINDOW_ID CLAUDE_SESSION_ID FAILSAFE_MODE +} + +teardown_sandbox() { + [ -n "${TEST_HOME:-}" ] && rm -rf "$TEST_HOME" + return 0 +} + +# Skip (not fail) when a required tool is missing β€” CI installs everything, so a +# skip in CI is itself a signal. +need() { command -v "$1" >/dev/null 2>&1 || skip "$1 not installed"; } + +write_mode_file() { printf '%s' "$2" > "$HOME/.claude/pane-mode/$1"; } +read_mode_file() { tr -d '\n' < "$HOME/.claude/pane-mode/$1"; } diff --git a/test/docs/helpers.bats b/test/docs/helpers.bats new file mode 100644 index 0000000..9b491ac --- /dev/null +++ b/test/docs/helpers.bats @@ -0,0 +1,26 @@ +load helpers + +setup() { setup_sandbox; } +teardown() { teardown_sandbox; } + +@test "setup_sandbox isolates HOME and clears pane vars" { + [ "$HOME" != "$ORIG_HOME" ] + [ -d "$HOME/.claude/pane-mode" ] + [ -z "${WEZTERM_PANE:-}" ] && [ -z "${TMUX_PANE:-}" ] +} + +@test "mode-file helpers round-trip" { + write_mode_file "%5" "read & write" + [ "$(read_mode_file "%5")" = "read & write" ] +} + +@test "need skips when a tool is absent" { + need definitely-not-a-real-binary-xyz + false # unreachable: skip above aborts the test +} + +@test "lua resolver finds an interpreter or skips" { + if [ -z "$LUA_BIN" ]; then skip "no lua interpreter"; fi + run "$LUA_BIN" -e 'print("ok")' + [ "$output" = "ok" ] +} From 5b8678c364e786fc761cf6bec86c9bbb233e1eeb Mon Sep 17 00:00:00 2001 From: Tombar Date: Tue, 2 Jun 2026 10:32:56 -0300 Subject: [PATCH 05/30] test(docs): harden sandbox teardown guard and mode-file CR/LF trim --- test/docs/helpers.bash | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/docs/helpers.bash b/test/docs/helpers.bash index 8e136c8..0eaea7d 100644 --- a/test/docs/helpers.bash +++ b/test/docs/helpers.bash @@ -17,14 +17,19 @@ STUB_DIR="$(dirname "${BASH_SOURCE[0]}")/stubs" extract_block() { bash "$EXTRACT_SH" "$@"; } setup_sandbox() { - TEST_HOME="$(mktemp -d)" + TEST_HOME="$(mktemp -d "${TMPDIR:-/tmp}/failsafe-doctest.XXXXXX")" export HOME="$TEST_HOME" mkdir -p "$HOME/.claude/pane-mode" "$HOME/.config/failsafe" unset WEZTERM_PANE TMUX_PANE ITERM_SESSION_ID KITTY_WINDOW_ID CLAUDE_SESSION_ID FAILSAFE_MODE } +# Only ever removes dirs created by setup_sandbox (our own template prefix), so a +# stray or externally-set TEST_HOME can never be rm -rf'd. teardown_sandbox() { - [ -n "${TEST_HOME:-}" ] && rm -rf "$TEST_HOME" + case "${TEST_HOME:-}" in + */failsafe-doctest.*) rm -rf "$TEST_HOME" ;; + *) : ;; + esac return 0 } @@ -33,4 +38,4 @@ teardown_sandbox() { need() { command -v "$1" >/dev/null 2>&1 || skip "$1 not installed"; } write_mode_file() { printf '%s' "$2" > "$HOME/.claude/pane-mode/$1"; } -read_mode_file() { tr -d '\n' < "$HOME/.claude/pane-mode/$1"; } +read_mode_file() { tr -d '\r\n' < "$HOME/.claude/pane-mode/$1"; } From ddf108a0410928bbeb5e217df57f0b550fc8d889 Mon Sep 17 00:00:00 2001 From: Tombar Date: Tue, 2 Jun 2026 10:35:12 -0300 Subject: [PATCH 06/30] test(docs): cross-cutting chain/canonical/alias claims Co-Authored-By: Claude Sonnet 4.6 --- test/docs/crosscutting.bats | 50 +++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 test/docs/crosscutting.bats diff --git a/test/docs/crosscutting.bats b/test/docs/crosscutting.bats new file mode 100644 index 0000000..7995987 --- /dev/null +++ b/test/docs/crosscutting.bats @@ -0,0 +1,50 @@ +load helpers + +setup() { setup_sandbox; need failsafe; } +teardown() { teardown_sandbox; } + +# tmux.md states the chain order WEZTERM_PANE -> TMUX_PANE -> ITERM_SESSION_ID. +@test "chain order: WEZTERM_PANE wins over TMUX_PANE (black-box)" { + write_mode_file "%w" "read & write" + write_mode_file "%t" "read" + WEZTERM_PANE="%w" TMUX_PANE="%t" run failsafe mode get + [ "$status" -eq 0 ] + [[ "$output" == "read & write"* ]] + [[ "$output" == *"/pane-mode/%w"* ]] +} + +# All four docs: "missing file = read (safe default)". +@test "missing mode file resolves to read" { + WEZTERM_PANE="%none" run failsafe mode get + [[ "$output" == read* ]] +} + +# All four docs: the canonical value is what the bundled Rego policies match. +@test "every rego mode comparison is exactly input.mode == \"read\"" { + run grep -rhoE 'input\.mode == "[^"]*"' "$DOCS_REPO_ROOT"/internal/embed/policies/*.rego + [ "$status" -eq 0 ] + while IFS= read -r line; do + [ "$line" = 'input.mode == "read"' ] + done <<< "$output" +} + +# Docs claim rw/ro aliases normalize to the canonical bytes written to the file. +@test "mode set aliases write canonical bytes" { + for a in rw w "read & write"; do + write_mode_file "%a" "read" + WEZTERM_PANE="%a" failsafe mode set "$a" + [ "$(read_mode_file "%a")" = "read & write" ] + done + for a in ro r read; do + WEZTERM_PANE="%a" failsafe mode set "$a" + [ "$(read_mode_file "%a")" = "read" ] + done +} + +# claude-statusline.sh relies on `failsafe mode get | cut -f1` β€” assert the value +# is the first tab-delimited field. +@test "mode get output is tab-delimited (value in field 1)" { + write_mode_file "%w" "read & write" + WEZTERM_PANE="%w" run bash -c 'failsafe mode get | cut -f1' + [ "$output" = "read & write" ] +} From e2faad32cea8e58c678ca81e659a6d89cbb06585 Mon Sep 17 00:00:00 2001 From: Tombar Date: Tue, 2 Jun 2026 10:39:39 -0300 Subject: [PATCH 07/30] test(docs): reset state per ro-alias so each alias is actively verified --- test/docs/crosscutting.bats | 1 + 1 file changed, 1 insertion(+) diff --git a/test/docs/crosscutting.bats b/test/docs/crosscutting.bats index 7995987..542ce6b 100644 --- a/test/docs/crosscutting.bats +++ b/test/docs/crosscutting.bats @@ -36,6 +36,7 @@ teardown() { teardown_sandbox; } [ "$(read_mode_file "%a")" = "read & write" ] done for a in ro r read; do + write_mode_file "%a" "read & write" WEZTERM_PANE="%a" failsafe mode set "$a" [ "$(read_mode_file "%a")" = "read" ] done From e74dc23e04d593f8151018233a78a7124ba9f73c Mon Sep 17 00:00:00 2001 From: Tombar Date: Tue, 2 Jun 2026 10:41:28 -0300 Subject: [PATCH 08/30] test(docs): claude-statusline.sh contract + graceful degrade Co-Authored-By: Claude Opus 4.8 (1M context) --- test/docs/helpers.bash | 12 ++++++++++ test/docs/statusline.bats | 49 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 test/docs/statusline.bats diff --git a/test/docs/helpers.bash b/test/docs/helpers.bash index 0eaea7d..56588df 100644 --- a/test/docs/helpers.bash +++ b/test/docs/helpers.bash @@ -39,3 +39,15 @@ need() { command -v "$1" >/dev/null 2>&1 || skip "$1 not installed"; } write_mode_file() { printf '%s' "$2" > "$HOME/.claude/pane-mode/$1"; } read_mode_file() { tr -d '\r\n' < "$HOME/.claude/pane-mode/$1"; } + +# Build a PATH dir that contains everything claude-statusline.sh needs EXCEPT jq, +# to exercise the documented graceful-degrade branch. +make_nojq_path() { + local bin="$TEST_HOME/nojq-bin"; mkdir -p "$bin" + local t + for t in env bash sh cat cut sed failsafe; do + local p; p="$(command -v "$t" || true)" + [ -n "$p" ] && ln -sf "$p" "$bin/$t" + done + printf '%s' "$bin" +} diff --git a/test/docs/statusline.bats b/test/docs/statusline.bats new file mode 100644 index 0000000..4fb7cda --- /dev/null +++ b/test/docs/statusline.bats @@ -0,0 +1,49 @@ +load helpers + +SL="$DOCS_REPO_ROOT/examples/claude-statusline.sh" +JSON='{"cwd":"%CWD%","model":{"display_name":"Opus"}}' + +setup() { setup_sandbox; need failsafe; export CLAUDE_SESSION_ID="sess1"; } +teardown() { teardown_sandbox; } + +run_sl() { # $1 = json; pipes it into the real script + printf '%s' "$1" | "$SL" +} + +@test "statusline script exists and is bash" { + [ -f "$SL" ] + head -1 "$SL" | grep -q 'bash' +} + +@test "read mode renders the lock + read" { + write_mode_file "sess1" "read" + run run_sl "${JSON/\%CWD\%//tmp/x}" + [[ "$output" == "failsafe πŸ”’ read"* ]] +} + +@test "read & write mode renders the open lock + write" { + write_mode_file "sess1" "read & write" + run run_sl "${JSON/\%CWD\%//tmp/x}" + [[ "$output" == "failsafe πŸ”“ write"* ]] +} + +@test "with jq, cwd is tilde-substituted and model appended" { + need jq + write_mode_file "sess1" "read" + run run_sl "${JSON/\%CWD\%/$HOME/code/infra}" + [[ "$output" == *"~/code/infra"* ]] + [[ "$output" == *"Opus"* ]] +} + +@test "without jq it still prints the guard mode (graceful degrade)" { + write_mode_file "sess1" "read & write" + local p; p="$(make_nojq_path)" + run env PATH="$p" "$SL" <<< "${JSON/\%CWD\%//tmp/x}" + [[ "$output" == "failsafe πŸ”“ write"* ]] +} + +@test "output is a single line" { + write_mode_file "sess1" "read" + run run_sl "${JSON/\%CWD\%//tmp/x}" + [ "${#lines[@]}" -eq 1 ] +} From f60e512579d5f0093678b50c3c42c72e36bd159a Mon Sep 17 00:00:00 2001 From: Tombar Date: Tue, 2 Jun 2026 10:44:28 -0300 Subject: [PATCH 09/30] test(docs): assert jq-degrade branch skips enrichment (no cwd) --- test/docs/statusline.bats | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/docs/statusline.bats b/test/docs/statusline.bats index 4fb7cda..a2eed0e 100644 --- a/test/docs/statusline.bats +++ b/test/docs/statusline.bats @@ -40,6 +40,9 @@ run_sl() { # $1 = json; pipes it into the real script local p; p="$(make_nojq_path)" run env PATH="$p" "$SL" <<< "${JSON/\%CWD\%//tmp/x}" [[ "$output" == "failsafe πŸ”“ write"* ]] + # Prove the jq-enrichment branch was actually skipped (no cwd appended), not + # merely that the guard label survived. + [[ "$output" != *"/tmp/x"* ]] } @test "output is a single line" { From 685daab13315d3995ef1885538b0b8e3f8866055 Mon Sep 17 00:00:00 2001 From: Tombar Date: Tue, 2 Jun 2026 10:47:52 -0300 Subject: [PATCH 10/30] test(docs): live headless tmux toggle/status/keybinding validation Co-Authored-By: Claude Opus 4.8 (1M context) --- test/docs/tmux.bats | 66 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 test/docs/tmux.bats diff --git a/test/docs/tmux.bats b/test/docs/tmux.bats new file mode 100644 index 0000000..e8dc491 --- /dev/null +++ b/test/docs/tmux.bats @@ -0,0 +1,66 @@ +load helpers + +TMUX_DOC="$DOCS_REPO_ROOT/docs/toggle/tmux.md" +SOCK="failsafe-doctest" + +setup() { + setup_sandbox; need failsafe; need tmux + TOGGLE="$TEST_HOME/tmux-toggle.sh" + extract_block "$TMUX_DOC" bash 1 "The toggle helper" > "$TOGGLE" + chmod +x "$TOGGLE" +} +teardown() { + tmux -L "$SOCK" kill-server 2>/dev/null || true + teardown_sandbox +} + +@test "toggle script flips read <-> read & write" { + # The toggle script ends with `tmux display-message`, which requires a tmux + # server β€” run it via run-shell so it has a proper tmux context (just as it + # would be invoked from a real key binding). + tmux -L "$SOCK" new-session -d -s s -x 80 -y 24 + tmux -L "$SOCK" run-shell "$TOGGLE '%5'" + [ "$(read_mode_file "%5")" = "read & write" ] + tmux -L "$SOCK" run-shell "$TOGGLE '%5'" + [ "$(read_mode_file "%5")" = "read" ] +} + +@test "#{pane_id} equals \$TMUX_PANE inside a real session" { + local out="$TEST_HOME/pane.txt" + tmux -L "$SOCK" new-session -d -s s -x 80 -y 24 + tmux -L "$SOCK" send-keys -t s "printf '%s' \"\$TMUX_PANE\" > '$out'" Enter + for _ in $(seq 1 20); do [ -s "$out" ] && break; sleep 0.1; done + local pid; pid="$(tmux -L "$SOCK" display-message -p -t s '#{pane_id}')" + [ "$(cat "$out")" = "$pid" ] +} + +# Headless reality: send-keys injects into the pane app and bypasses tmux's root +# key table, so a `bind -n` can't be fired from a script. We instead prove the +# binding is REGISTERED to the toggle script, and (above) that the script flips +# the file β€” together that validates the documented keybinding. +@test "C-M-t is bound to the toggle script with #{pane_id}" { + local conf="$TEST_HOME/tmux.conf" + printf "bind -n C-M-t run-shell \"%s '#{pane_id}'\"\n" "$TOGGLE" > "$conf" + tmux -L "$SOCK" -f "$conf" new-session -d -s s + run tmux -L "$SOCK" list-keys -T root + [[ "$output" == *"C-M-t"* ]] + [[ "$output" == *"$TOGGLE"* ]] + [[ "$output" == *'#{pane_id}'* ]] +} + +@test "status script colors writable amber / read green" { + local STAT="$TEST_HOME/tmux-status.sh" + extract_block "$TMUX_DOC" bash 1 "Status indicator" > "$STAT"; chmod +x "$STAT" + write_mode_file "%5" "read & write" + run "$STAT" "%5" + [[ "$output" == *"πŸ”“ sudo"* ]]; [[ "$output" == *"fg=yellow"* ]] + write_mode_file "%5" "read" + run "$STAT" "%5" + [[ "$output" == *"πŸ”’ read"* ]]; [[ "$output" == *"fg=green"* ]] +} + +@test "no-script alternative toggles only the target pane" { + WEZTERM_PANE= ITERM_SESSION_ID= TMUX_PANE="%5" failsafe toggle + [ "$(read_mode_file "%5")" = "read & write" ] + [ ! -f "$HOME/.claude/pane-mode/%other" ] +} From e43abf3622080c5c4c36c8af50f6efac898ff787 Mon Sep 17 00:00:00 2001 From: Tombar Date: Tue, 2 Jun 2026 10:55:14 -0300 Subject: [PATCH 11/30] test(docs): wezterm lua file-contract + luacheck + sudo-timeout Co-Authored-By: Claude Opus 4.8 (1M context) --- test/docs/stubs/wezterm.lua | 8 +++++ test/docs/stubs/wezterm_driver.lua | 17 +++++++++++ test/docs/wezterm.bats | 48 ++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 test/docs/stubs/wezterm.lua create mode 100644 test/docs/stubs/wezterm_driver.lua create mode 100644 test/docs/wezterm.bats diff --git a/test/docs/stubs/wezterm.lua b/test/docs/stubs/wezterm.lua new file mode 100644 index 0000000..8864b12 --- /dev/null +++ b/test/docs/stubs/wezterm.lua @@ -0,0 +1,8 @@ +-- Minimal stand-in for the `wezterm` module so the doc snippet loads under plain lua. +local M = {} +M.action = setmetatable({}, { __index = function() return function() end end }) +function M.action_callback(fn) return fn end -- so keys[].action == the raw callback +function M.on(_, _) end +function M.config_builder() return {} end +M.config_dir = os.getenv("HOME") or "." +return M diff --git a/test/docs/stubs/wezterm_driver.lua b/test/docs/stubs/wezterm_driver.lua new file mode 100644 index 0000000..d09b641 --- /dev/null +++ b/test/docs/stubs/wezterm_driver.lua @@ -0,0 +1,17 @@ +-- wezterm_driver.lua +-- Loads stubs/wezterm.lua as the `wezterm` module, dofiles the extracted snippet, +-- fires its first keybinding action with fake window/pane, prints the resulting mode. +local stub_path = assert(os.getenv("STUB"), "STUB unset") +package.preload["wezterm"] = function() return dofile(stub_path) end + +local mod = dofile(assert(os.getenv("SNIPPET"), "SNIPPET unset")) +local id = assert(arg[1], "pane id arg required") + +local pane = { pane_id = function() return id end } +local win = { + toast_notification = function() end, + get_config_overrides = function() return {} end, + set_config_overrides = function() end, +} +mod.keys[1].action(win, pane) -- runs toggle_mode(id), writes the mode file +io.write(mod.get_mode(id)) -- canonical value the snippet reads back diff --git a/test/docs/wezterm.bats b/test/docs/wezterm.bats new file mode 100644 index 0000000..a91238a --- /dev/null +++ b/test/docs/wezterm.bats @@ -0,0 +1,48 @@ +load helpers + +WZ="$DOCS_REPO_ROOT/docs/toggle/wezterm.md" + +setup() { setup_sandbox; need failsafe; } +teardown() { teardown_sandbox; } + +@test "wezterm snippet is syntactically valid lua" { + [ -n "$LUA_BIN" ] || skip "no lua interpreter" + extract_block "$WZ" lua 1 "Drop-in snippet" > "$TEST_HOME/snip.lua" + run "$LUA_BIN" -e "assert(loadfile('$TEST_HOME/snip.lua'))" + [ "$status" -eq 0 ] +} + +@test "wezterm snippet passes luacheck (no syntax errors)" { + need luacheck + extract_block "$WZ" lua 1 "Drop-in snippet" > "$TEST_HOME/snip.lua" + run luacheck --globals wezterm --no-unused --no-max-line-length "$TEST_HOME/snip.lua" + [[ "$output" != *"syntax error"* ]] +} + +@test "toggle action writes canonical and failsafe agrees" { + [ -n "$LUA_BIN" ] || skip "no lua interpreter" + extract_block "$WZ" lua 1 "Drop-in snippet" > "$TEST_HOME/snip.lua" + local out + out="$(STUB="$STUB_DIR/wezterm.lua" SNIPPET="$TEST_HOME/snip.lua" \ + "$LUA_BIN" "$STUB_DIR/wezterm_driver.lua" "%wz")" + [ "$out" = "read & write" ] + [ "$(read_mode_file "%wz")" = "read & write" ] + WEZTERM_PANE="%wz" run failsafe mode get + [[ "$output" == "read & write"* ]] +} + +@test "badge maps writable -> sudo, read -> r" { + run grep -F 'local badge = (mode == "read & write") and " ⚑ sudo " or " r "' "$WZ" + [ "$status" -eq 0 ] +} + +@test "sudo-timeout mechanism reverts to read" { + local block + block="$(extract_block "$WZ" lua 3 'Make it yours: "sudo mode"')" + [[ "$block" == *"sleep 600"* ]] + [[ "$block" == *"echo read"* ]] + local f="$HOME/.claude/pane-mode/%wz"; printf 'read & write' > "$f" + ( sleep 1; echo read > "$f" ) & + sleep 1.6 + [ "$(read_mode_file "%wz")" = "read" ] +} From f302f06d50972200863946e747f29e62a2dfa06c Mon Sep 17 00:00:00 2001 From: Tombar Date: Tue, 2 Jun 2026 11:39:58 -0300 Subject: [PATCH 12/30] test(docs): make wezterm luacheck test non-vacuous (skip if runtime broken) --- test/docs/wezterm.bats | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/docs/wezterm.bats b/test/docs/wezterm.bats index a91238a..9ab6507 100644 --- a/test/docs/wezterm.bats +++ b/test/docs/wezterm.bats @@ -12,11 +12,16 @@ teardown() { teardown_sandbox; } [ "$status" -eq 0 ] } -@test "wezterm snippet passes luacheck (no syntax errors)" { +@test "wezterm snippet has no luacheck errors" { need luacheck extract_block "$WZ" lua 1 "Drop-in snippet" > "$TEST_HOME/snip.lua" - run luacheck --globals wezterm --no-unused --no-max-line-length "$TEST_HOME/snip.lua" - [[ "$output" != *"syntax error"* ]] + run luacheck --globals wezterm --no-max-line-length "$TEST_HOME/snip.lua" + # luacheck must actually have analyzed the file: a functional run always prints a + # "Total:" summary footer. If it's absent (e.g. luacheck's own runtime is broken + # under this Lua version), skip honestly rather than pass vacuously. + [[ "$output" == *"Total:"* ]] || skip "luacheck runtime not functional here (no report produced)" + # It ran β€” require zero ERRORS (syntax/parse). Style warnings are tolerated. + [[ "$output" == *"0 errors"* ]] } @test "toggle action writes canonical and failsafe agrees" { From c2a9117b8ab8be25e8dc90af67f19ba45a7307b1 Mon Sep 17 00:00:00 2001 From: Tombar Date: Tue, 2 Jun 2026 11:45:06 -0300 Subject: [PATCH 13/30] test(docs): widen wezterm sudo-timeout revert margin for CI --- test/docs/wezterm.bats | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/docs/wezterm.bats b/test/docs/wezterm.bats index 9ab6507..dbe69bd 100644 --- a/test/docs/wezterm.bats +++ b/test/docs/wezterm.bats @@ -48,6 +48,6 @@ teardown() { teardown_sandbox; } [[ "$block" == *"echo read"* ]] local f="$HOME/.claude/pane-mode/%wz"; printf 'read & write' > "$f" ( sleep 1; echo read > "$f" ) & - sleep 1.6 + sleep 2.5 # generous margin over the 1s revert so loaded CI runners don't flake [ "$(read_mode_file "%wz")" = "read" ] } From f875a3cac951b4ee008f662d33768a7a4759f947 Mon Sep 17 00:00:00 2001 From: Tombar Date: Tue, 2 Jun 2026 11:49:02 -0300 Subject: [PATCH 14/30] test(docs): iterm OSC roundtrip + python read_mode/compile/import Co-Authored-By: Claude Opus 4.8 (1M context) --- test/docs/iterm.bats | 60 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 test/docs/iterm.bats diff --git a/test/docs/iterm.bats b/test/docs/iterm.bats new file mode 100644 index 0000000..be0d17b --- /dev/null +++ b/test/docs/iterm.bats @@ -0,0 +1,60 @@ +load helpers + +IT="$DOCS_REPO_ROOT/docs/toggle/iterm.md" + +setup() { setup_sandbox; need failsafe; } +teardown() { teardown_sandbox; } + +@test "shell hook emits OSC 1337 SetUserVar with base64 session id" { + local hook sid out b64 + hook="$(extract_block "$IT" sh 1)" + sid="w1t6p0:DEAD-BEEF" + out="$(ITERM_SESSION_ID="$sid" bash -c "$hook")" + [[ "$out" == *"1337;SetUserVar=failsafe_sid="* ]] + b64="${out#*failsafe_sid=}"; b64="${b64%$'\a'}" + [ "$(printf '%s' "$b64" | base64 -d)" = "$sid" ] +} + +@test "doc python read_mode returns canonical and defaults to read" { + [ -n "$PYTHON_BIN" ] || skip "no python" + extract_block "$IT" python 1 > "$TEST_HOME/it.py" + run "$PYTHON_BIN" - "$TEST_HOME/it.py" <<'PY' +import ast, sys, os, tempfile +src = open(sys.argv[1]).read() +mod = ast.parse(src) +fn = next(n for n in mod.body + if isinstance(n, ast.FunctionDef) and n.name == "read_mode") +ns = {} +exec(compile(ast.Module([fn], []), "read_mode", "exec"), ns) +read_mode = ns["read_mode"] +d = tempfile.mkdtemp() +assert read_mode(os.path.join(d, "missing")) == "read", "missing file -> read" +p = os.path.join(d, "m"); open(p, "w").write("read & write\n") +assert read_mode(p) == "read & write", "canonical preserved" +print("ok") +PY + [ "$status" -eq 0 ] + [[ "$output" == *"ok"* ]] +} + +@test "doc python script compiles and iterm2 imports" { + [ -n "$PYTHON_BIN" ] || skip "no python" + HOME="$ORIG_HOME" "$PYTHON_BIN" -c 'import iterm2' 2>/dev/null || skip "iterm2 not installed" + extract_block "$IT" python 1 > "$TEST_HOME/it.py" + run env HOME="$ORIG_HOME" "$PYTHON_BIN" -m py_compile "$TEST_HOME/it.py" + [ "$status" -eq 0 ] +} + +@test "doc python script passes pyflakes" { + [ -n "$PYTHON_BIN" ] || skip "no python" + HOME="$ORIG_HOME" "$PYTHON_BIN" -c 'import pyflakes' 2>/dev/null || skip "pyflakes not installed" + extract_block "$IT" python 1 > "$TEST_HOME/it.py" + run env HOME="$ORIG_HOME" "$PYTHON_BIN" -m pyflakes "$TEST_HOME/it.py" + [ "$status" -eq 0 ] +} + +@test "no-python alternative: failsafe toggle flips the session file" { + local sid="w1t6p0:GUID-XYZ" + ITERM_SESSION_ID="$sid" failsafe toggle + [ "$(read_mode_file "$sid")" = "read & write" ] +} From 076f099c94404048d42a08d217cf172d774ccffe Mon Sep 17 00:00:00 2001 From: Tombar Date: Tue, 2 Jun 2026 11:50:14 -0300 Subject: [PATCH 15/30] docs(iterm): drop unused async_get_app call (pyflakes: app assigned but never used) Found by the doc-validation harness (test/docs/iterm.bats pyflakes check). The iTerm2 toggle registers its RPC off `connection`; `app` was dead code. --- docs/toggle/iterm.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/toggle/iterm.md b/docs/toggle/iterm.md index 3651f13..b507353 100644 --- a/docs/toggle/iterm.md +++ b/docs/toggle/iterm.md @@ -57,8 +57,6 @@ def read_mode(path): return "read" # missing file = safe default async def main(connection): - app = await iterm2.async_get_app(connection) - # `sid_b64` is the base64 of $ITERM_SESSION_ID, published by the shell hook in step 1. # The trailing '?' makes the reference optional (None if a session has no hook yet). @iterm2.RPC From 419f7e0467f9a1d1a2734cfd3f8366aace50312a Mon Sep 17 00:00:00 2001 From: Tombar Date: Tue, 2 Jun 2026 12:29:25 -0300 Subject: [PATCH 16/30] ci: separate doc-validation workflow (headless bats suite) --- .github/workflows/doc-validation.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/doc-validation.yml diff --git a/.github/workflows/doc-validation.yml b/.github/workflows/doc-validation.yml new file mode 100644 index 0000000..7a800bb --- /dev/null +++ b/.github/workflows/doc-validation.yml @@ -0,0 +1,27 @@ +name: doc-validation +on: + push: + pull_request: + +jobs: + validate-docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Install harness dependencies + run: | + sudo apt-get update + sudo apt-get install -y bats tmux lua5.4 luarocks jq python3-pip + sudo luarocks install luacheck + # System-wide so imports/binaries are HOME-independent (bats sandboxes HOME). + sudo pip install --break-system-packages iterm2 pyflakes + - name: Build failsafe onto PATH + run: | + mkdir -p "$RUNNER_TEMP/bin" + go build -o "$RUNNER_TEMP/bin/failsafe" ./cmd/failsafe + echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + - name: Run headless doc validation + run: bats test/docs/*.bats From a6dfe037f28e9a961d075565b2f150e3c6876670 Mon Sep 17 00:00:00 2001 From: Tombar Date: Tue, 2 Jun 2026 13:44:34 -0300 Subject: [PATCH 17/30] ci(docs): make tmux keybind + wezterm luacheck checks portable across distros - tmux list-keys: accept C-M-t or M-C-t (older tmux reorders modifiers); assert the stable toggle-path + #{pane_id} first. - luacheck: --no-color so ANSI escapes don't split the '0 errors' substring. - bats --print-output-on-failure for diagnosability. --- .github/workflows/doc-validation.yml | 2 +- test/docs/tmux.bats | 5 ++++- test/docs/wezterm.bats | 4 +++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/doc-validation.yml b/.github/workflows/doc-validation.yml index 7a800bb..6c81a82 100644 --- a/.github/workflows/doc-validation.yml +++ b/.github/workflows/doc-validation.yml @@ -24,4 +24,4 @@ jobs: go build -o "$RUNNER_TEMP/bin/failsafe" ./cmd/failsafe echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" - name: Run headless doc validation - run: bats test/docs/*.bats + run: bats --print-output-on-failure test/docs/*.bats diff --git a/test/docs/tmux.bats b/test/docs/tmux.bats index e8dc491..213b3a4 100644 --- a/test/docs/tmux.bats +++ b/test/docs/tmux.bats @@ -43,9 +43,12 @@ teardown() { printf "bind -n C-M-t run-shell \"%s '#{pane_id}'\"\n" "$TOGGLE" > "$conf" tmux -L "$SOCK" -f "$conf" new-session -d -s s run tmux -L "$SOCK" list-keys -T root - [[ "$output" == *"C-M-t"* ]] + [ "$status" -eq 0 ] + # Stable assertions first: the binding targets our toggle script and passes the pane id. [[ "$output" == *"$TOGGLE"* ]] [[ "$output" == *'#{pane_id}'* ]] + # The key itself β€” tmux versions differ on modifier ordering (C-M-t vs M-C-t). + [[ "$output" == *"C-M-t"* || "$output" == *"M-C-t"* ]] } @test "status script colors writable amber / read green" { diff --git a/test/docs/wezterm.bats b/test/docs/wezterm.bats index dbe69bd..a8d903f 100644 --- a/test/docs/wezterm.bats +++ b/test/docs/wezterm.bats @@ -15,7 +15,9 @@ teardown() { teardown_sandbox; } @test "wezterm snippet has no luacheck errors" { need luacheck extract_block "$WZ" lua 1 "Drop-in snippet" > "$TEST_HOME/snip.lua" - run luacheck --globals wezterm --no-max-line-length "$TEST_HOME/snip.lua" + # --no-color: CI luacheck colorizes its summary, which would split the "0 errors" + # substring with ANSI escapes (…0[0m errors). + run luacheck --no-color --globals wezterm --no-max-line-length "$TEST_HOME/snip.lua" # luacheck must actually have analyzed the file: a functional run always prints a # "Total:" summary footer. If it's absent (e.g. luacheck's own runtime is broken # under this Lua version), skip honestly rather than pass vacuously. From d3c1b2633462e80d223c0d488350a75d6eca41be Mon Sep 17 00:00:00 2001 From: Tombar Date: Tue, 2 Jun 2026 13:48:30 -0300 Subject: [PATCH 18/30] test(docs): live WezTerm config-load check, iTerm manual steps, findings REPORT REPORT.md records per-claim PASS/STATIC/LIVE-MANUAL results and the one doc bug the harness caught and fixed (iterm.md unused async_get_app). --- test/docs/REPORT.md | 81 ++++++++++++++++++++++++++++ test/docs/live-gui/iterm-register.md | 20 +++++++ test/docs/live-gui/wezterm-load.bats | 37 +++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 test/docs/REPORT.md create mode 100644 test/docs/live-gui/iterm-register.md create mode 100644 test/docs/live-gui/wezterm-load.bats diff --git a/test/docs/REPORT.md b/test/docs/REPORT.md new file mode 100644 index 0000000..10b9412 --- /dev/null +++ b/test/docs/REPORT.md @@ -0,0 +1,81 @@ +# Doc validation findings β€” 2026-06-02 + +Validates the instructions in `docs/toggle/wezterm.md`, `docs/toggle/iterm.md`, +`docs/toggle/tmux.md`, and `docs/claude-statusline.md` against the shipped `failsafe` +binary by running the **literal fenced code blocks extracted from the docs**. + +- **Headless suite** (`make validate-docs`, 34 tests): green in CI + (`.github/workflows/doc-validation.yml`, ubuntu-latest) and locally. +- **Live GUI pass** (`make validate-docs-live`): real WezTerm. +- Binary: `failsafe 0.0.0-dev` (local) / built from source in CI. +- Local env: Darwin arm64 Β· tmux 3.6b Β· WezTerm 20240203 Β· Lua 5.5 (brew) Β· CI uses + Ubuntu tmux + lua5.4. + +## Per-claim results + +| Doc | Claim | Method | Result | +|---|---|---|---| +| cross-cutting | chain order WEZTERMβ†’TMUXβ†’ITERM | black-box `mode get` w/ competing files | **PASS** | +| cross-cutting | missing file β‡’ `read` | black-box | **PASS** | +| cross-cutting | rego matches `"read"` only | grep `internal/embed/policies/*.rego` | **PASS** | +| cross-cutting | rw/ro aliases β‡’ canonical bytes | `mode set` + read back | **PASS** | +| cross-cutting | `mode get` tab-delimited (`cut -f1`) | black-box | **PASS** | +| statusline | `πŸ”’ read` / `πŸ”“ write` glyphs | pipe JSON to `examples/claude-statusline.sh` | **PASS** | +| statusline | jq adds `~`-cwd + model | with jq | **PASS** | +| statusline | degrades w/o jq (guard only, no cwd) | nojq PATH shim | **PASS** | +| statusline | single-line output | byte check | **PASS** | +| tmux | toggle script flips read↔read&write | run extracted script (via `run-shell`) | **PASS** | +| tmux | `#{pane_id}` == `$TMUX_PANE` | live headless tmux session | **PASS** | +| tmux | `C-M-t` bound to toggle w/ `#{pane_id}` | `list-keys -T root` (registration) | **PASS** | +| tmux | status script colors (sudo/amber, read/green) | run extracted script | **PASS** | +| tmux | no-script `failsafe toggle` toggles target pane | black-box | **PASS** | +| wezterm | snippet is valid lua | `lua` loadfile | **PASS** | +| wezterm | snippet has no luacheck errors | luacheck (runs in CI; skipped local, see Notes) | **PASS** (CI) | +| wezterm | snippet's own `toggle_mode` writes canonical; failsafe agrees | lua stub + driver fires `keys[1].action` | **PASS** | +| wezterm | badge maps rwβ†’`⚑ sudo`, readβ†’`r` | exact-grep doc line | **PASS** | +| wezterm | sudo-timeout auto-revert mechanism | text-ties to doc + ported 1s revert | **PASS** | +| wezterm | toast / `format-tab-title` rendering | β€” | **STATIC** (GUI-only) | +| wezterm | config loads + `Ctrl+Alt+t` registered | `wezterm show-keys` (live, real WezTerm) | **PASS (LIVE)** | +| iterm | shell hook OSC-1337 base64 roundtrip | run extracted hook + `base64 -d` | **PASS** | +| iterm | doc's own `read_mode` canonical/default | exec the AST `FunctionDef` | **PASS** | +| iterm | script `py_compile`s + `import iterm2` | python | **PASS** | +| iterm | script passes pyflakes | `python3 -m pyflakes` | **PASS** (after fix below) | +| iterm | no-python `failsafe toggle` flips session file | black-box | **PASS** | +| iterm | Python runtime registration + keypress | manual (`live-gui/iterm-register.md`) | **LIVE-MANUAL** | + +## Doc bugs found + +- **`docs/toggle/iterm.md` β€” unused `app` (FIXED).** pyflakes flagged + `local variable 'app' is assigned to but never used`: the Python toggle called + `app = await iterm2.async_get_app(connection)` but registered its RPC off `connection` + and never used `app`. Dead API call β€” **removed** (commit `083ead8`). The harness's + pyflakes check now guards against regression. + +## Benign findings (no change made) + +- **`docs/toggle/wezterm.md` β€” `local act = wezterm.action` is unused.** luacheck reports + it as a *warning* (not an error). It's the conventional WezTerm boilerplate alias that + WezTerm's own docs use as an extension point, so it is intentionally retained. The + luacheck test gates on **errors only** (0 errors), so this warning does not fail + validation β€” correctness is enforced, style is not. + +## Notes / portability + +- **luacheck local skip.** Homebrew installs Lua 5.5; luacheck 1.2.0 crashes under it + (`attempt to assign to const variable`). The wezterm luacheck test therefore *skips* + locally (honestly, with a reason β€” never a vacuous pass) and runs for real in CI on + lua5.4, where it passes. Verified locally against a separately-built Lua 5.4 luacheck. +- **tmux keybinding can't be fired headlessly.** `send-keys` injects into the pane app + and bypasses tmux's root key table, so a `bind -n` can't be triggered from a script. + The test validates *registration* (`list-keys`) + direct script execution instead β€” + not a synthesized keypress. +- **tmux modifier-order portability.** `list-keys` renders the key as `C-M-t` (tmux 3.6) + or `M-C-t` (older tmux); the test accepts either and asserts the stable + toggle-path + `#{pane_id}` first. +- **`tmux-toggle.sh` outside tmux.** Under `set -euo pipefail` its trailing + `tmux display-message` exits non-zero when run from a plain shell (after the mode file + is already written). In documented use it's always invoked from a tmux keybinding, so + this is benign β€” but a user running the script by hand from a non-tmux shell will see a + spurious non-zero exit. Minor robustness note, not a bug. +- **GUI-only surfaces are STATIC/LIVE-MANUAL, never dressed as automated:** WezTerm + toasts + `format-tab-title` rendering, and iTerm2's Python-runtime keybinding. diff --git a/test/docs/live-gui/iterm-register.md b/test/docs/live-gui/iterm-register.md new file mode 100644 index 0000000..edcd02c --- /dev/null +++ b/test/docs/live-gui/iterm-register.md @@ -0,0 +1,20 @@ +# iTerm2 runtime registration β€” manual live check + +iTerm2's Python toggle binds a key to `Invoke Script Function` β†’ `failsafe_toggle()`. +That GUI registration + keypress path cannot be scripted reliably, so it is verified by +hand. The automated proxies in `../iterm.bats` already cover the rest: the OSC base64 +roundtrip, the doc's own `read_mode`, `py_compile`, `import iterm2`, and pyflakes. + +## Steps + +1. Add the step-1 shell hook to `~/.zshrc` (or `~/.bashrc`); open a new tab. +2. iTerm2 β†’ **Scripts β†’ Manage β†’ Install Python Runtime**. +3. Save the doc's `failsafe_toggle.py` to + `~/Library/Application Support/iTerm2/Scripts/AutoLaunch/`; launch it via + **Scripts β†’ failsafe_toggle.py**. Confirm **Scripts β†’ Console** shows no error. +4. iTerm2 β†’ **Settings β†’ Keys β†’ Key Bindings β†’ +**: shortcut `Ctrl+Opt+T`, + action **Invoke Script Function**, function `failsafe_toggle()`. +5. Press the key at a shell prompt, then run `failsafe mode get` and confirm the mode + flipped (and the notification, if enabled, appeared). + +Record the result (PASS/FAIL) and the iTerm2 version in `../REPORT.md`. diff --git a/test/docs/live-gui/wezterm-load.bats b/test/docs/live-gui/wezterm-load.bats new file mode 100644 index 0000000..e0d03e4 --- /dev/null +++ b/test/docs/live-gui/wezterm-load.bats @@ -0,0 +1,37 @@ +load ../helpers + +# LIVE GUI-app check β€” NOT run in CI (needs the real WezTerm binary installed). +# Run locally via `make validate-docs-live`. +# +# We can't drive a GUI keypress, but `wezterm show-keys` loads a real config file +# (no window needed) and prints the resolved key table. This proves the doc's +# snippet actually loads in WezTerm and registers the Ctrl+Alt+t binding. + +WZ="$DOCS_REPO_ROOT/docs/toggle/wezterm.md" + +setup() { setup_sandbox; } +teardown() { teardown_sandbox; } + +@test "wezterm loads the doc snippet and registers the Ctrl+Alt+t binding" { + need wezterm + extract_block "$WZ" lua 1 "Drop-in snippet" > "$TEST_HOME/failsafe_toggle.lua" + # Wire it exactly as the doc's "Wire the keybinding into your config" step shows. + cat > "$TEST_HOME/wezterm.lua" <')" + [ -n "$kt" ] + [[ "$kt" == *ALT* ]] + [[ "$kt" == *CTRL* ]] + [[ "$kt" == *EmitEvent* || "$kt" == *user-defined* ]] +} From 0d2cdb904850f25685d3ebf2f05f07fd1e5154b4 Mon Sep 17 00:00:00 2001 From: Tombar Date: Tue, 2 Jun 2026 21:10:47 -0300 Subject: [PATCH 19/30] test(docs): narrated demo + manual GUI test plan for live toggle validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit demo.sh: guided, narrated walkthrough (failsafe ENABLED/DISABLED framing) to run in a WezTerm window while screen-recording. manual-test-plan.md: 19-check live GUI checklist (WezTerm/iTerm/tmux/statusline) with recording + KeyCastr + troubleshooting notes. Local only β€” held back from PR #2 pending the readβ†’enable/disable rename. --- test/docs/live-gui/demo.sh | 53 +++++++ test/docs/live-gui/manual-test-plan.md | 187 +++++++++++++++++++++++++ 2 files changed, 240 insertions(+) create mode 100755 test/docs/live-gui/demo.sh create mode 100644 test/docs/live-gui/manual-test-plan.md diff --git a/test/docs/live-gui/demo.sh b/test/docs/live-gui/demo.sh new file mode 100755 index 0000000..20a9e66 --- /dev/null +++ b/test/docs/live-gui/demo.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Narrated failsafe demo β€” run in your WezTerm test window while screen-recording +# (with KeyCastr showing keypresses). It explains each step so a viewer understands +# WHAT is happening and WHY, and pauses for you to press the keybinding. +# +# bash test/docs/live-gui/demo.sh +set -euo pipefail + +say() { printf '\n\033[1;36mβ–Œ %s\033[0m\n' "$1"; } # cyan banner +note() { printf ' \033[2m%s\033[0m\n' "$1"; } # dim caption +key() { printf '\n \033[1;33m⌨ Press %s now (watch the tab badge), then Enter\033[0m ' "$1"; read -r; } +pause(){ printf ' \033[2m(Enter to continue)\033[0m'; read -r; } + +# Friendly status, derived from the real `failsafe mode get`. The dim (mode: …) is +# the underlying value β€” it goes away once the engine speaks enable/disable natively. +status() { + local m; m="$(failsafe mode get | cut -f1)" + if [ "$m" = "read & write" ]; then + printf ' \033[1;33mπŸ”“ failsafe DISABLED β€” the agent can write\033[0m \033[2m(mode: %s)\033[0m\n' "$m" + else + printf ' \033[1;32mπŸ”’ failsafe ENABLED β€” writes blocked\033[0m \033[2m(mode: %s)\033[0m\n' "$m" + fi +} + +clear +say "failsafe β€” the fail-safe for AI coding agents" +note "While failsafe is ENABLED, the agent can read but every write or mutate" +note "(kubectl apply, terraform apply, rm…) is BLOCKED." +note "Need it to act? DISABLE failsafe for a moment β€” temporary and deliberate, like sudo." +pause + +say "1 Β· This pane is protected" +status +note "failsafe is ENABLED. The tab badge shows r." +pause + +say "2 Β· Disable failsafe to let the agent write" +key "Ctrl+Alt+T" +status +note "failsafe is now DISABLED β€” writes allowed, on purpose. Badge turns amber rw." +pause + +say "3 Β· Re-enable failsafe" +key "Ctrl+Alt+T" +status +note "ENABLED again. (No file = ENABLED by default β€” fail-safe, not fail-open.)" +pause + +say "Per-pane" +note "Each split or tab keeps its own state, so disabling failsafe in one pane" +note "never disables it in another." +echo +say "failsafe: protection is ON by default; turning it off is a visible, deliberate act." diff --git a/test/docs/live-gui/manual-test-plan.md b/test/docs/live-gui/manual-test-plan.md new file mode 100644 index 0000000..971a13b --- /dev/null +++ b/test/docs/live-gui/manual-test-plan.md @@ -0,0 +1,187 @@ +# Manual GUI test plan β€” toggle + statusline docs + +Closes the **STATIC / LIVE-MANUAL** rows in `../REPORT.md`. The automated suite proved the +file-contract logic; this proves the GUI wiring and that the docs' own setup steps work for +a human following them verbatim. + +**Method:** for each environment, follow the doc's setup section exactly, then run the +checks. Record `PASS` / `FAIL` (+ notes) in the boxes. A check is PASS only if the observed +result matches "Expect" exactly. Verify mode with `failsafe mode get` (prints +`\t(file: )`). + +Tip: keep a spare pane open running `watch -n1 failsafe mode get` (or just re-run it) to +watch the file flip in real time. + +--- + +## Recording a demo video (macOS) + +To capture the keypress β†’ mode-flip β†’ toast/badge live: + +- **Easiest β€” `Cmd+Shift+5`:** the capture toolbar appears β†’ choose **Record Entire Screen** + (important: toasts appear top-right, so a window-only crop would miss them) β†’ **Options** + set "Save to" = Desktop, timer = None β†’ **Record**. Do the keypresses, then click the + **stop** square in the menu bar (or `Cmd+Ctrl+Esc`). The `.mov` lands on your Desktop. +- **CLI alternative:** `screencapture -v ~/Desktop/failsafe-demo.mov` then press `Esc` (or + `Ctrl+C`) to stop. Records the full screen. +- **Show the keypresses on screen** (neither QuickTime nor `Cmd+Shift+5` does this): install + **KeyCastr** β€” `brew install --cask keycastr` β†’ launch β†’ grant **Privacy & Security β†’ + Accessibility β†’ KeyCastr**. Use its **"Command keys only"** mode (shows modifier combos + like `βŒƒβŒ₯T`, ignores normal typing) and park the overlay **bottom-center** so it doesn't + collide with the top-right toast. +- **Framing tip:** arrange the test window so the `watch -n 1 failsafe mode get` pane and the + tab badge are both visible, keep the top-right corner in frame for the toast, and the + KeyCastr overlay at the bottom. Press the key a few times so the flip is obvious. +- To shrink/convert for sharing: `ffmpeg -i failsafe-demo.mov -vf scale=1280:-2 demo.mp4` + (or export a GIF). `ffmpeg` via `brew install ffmpeg` if needed. + +--- + +## A. WezTerm β€” `docs/toggle/wezterm.md` + +Setup (a test config was generated for you at `~/.config/wezterm-failsafe-test/`, built from +the doc's own Drop-in snippet via `extract.sh` β€” your real WezTerm config is untouched): + +1. **Launch an isolated test window:** + ``` + wezterm-gui --config-file ~/.config/wezterm-failsafe-test/wezterm.lua + ``` +2. **Split it** (default `Ctrl+Shift+Alt+"` for a vertical split, or open a 2nd tab with + `Ctrl+T`) and in one pane start a **live watcher**: + ``` + watch -n 1 failsafe mode get + ``` +3. **In the other pane, press `Ctrl+Alt+T`** and watch the `watch` pane flip + `read` ⇄ `read & write` live, and the tab badge flip ` r ` ⇄ ` rw `. + +> **Toast not appearing?** That's macOS notification permissions, not failsafe β€” see +> *Troubleshooting* at the bottom. The mode-flip in the `watch` pane is the real proof; +> the toast is cosmetic (the doc marks it "optional"). + +- [ ] **A1 β€” keypress fires the toggle + toast.** Press `Ctrl+Alt+T` in a pane. + Expect: a toast titled `πŸ”’ failsafe` with body `read β†’ read & write`; `failsafe mode get` + now prints `read & write` with a `…/pane-mode/` path. Press again β†’ toast + `read & write β†’ read`, mode back to `read`. +- [ ] **A2 β€” tab-title badge flips.** With the badge block active, look at the tab title. + Expect: ` r ` (dim grey) when read, ` rw ` (amber) when writable; flips when you toggle. +- [ ] **A3 β€” per-pane isolation.** Split the window (two panes). Toggle pane 1 to write. + Expect: `failsafe mode get` in pane 1 = `read & write`, in pane 2 = `read` (each keyed by + its own `WEZTERM_PANE`). +- [ ] **A4 β€” "sudo mode" variant.** Swap in the *"sudo mode"* `toggle_action` and the + `⚑ sudo` badge. Toggle to write. Expect: toast `πŸ”“ failsafe: sudo mode` / + `write enabled β€” with great power…`; badge shows `⚑ sudo`. Toggle off β†’ toast + `πŸ”’ failsafe` / `back to read-only. phew.` +- [ ] **A5 β€” sudo timeout auto-revert.** Add the auto-revert snippet but use **`sleep 20`** + (not 600) for the test. Toggle to write, then leave it. Expect: after ~20s `failsafe mode + get` returns to `read` on its own (badge flips back on the next tab render; no toast fires + on the auto-revert β€” it's a background file write). Restore `600` afterwards. + +## B. iTerm2 (Python path) β€” `docs/toggle/iterm.md` Β§1–3 + +Setup: add the **shell hook** to `~/.zshrc`; open a new tab. Install the Python runtime +(**Scripts β†’ Manage β†’ Install Python Runtime**), save `failsafe_toggle.py` to +`…/iTerm2/Scripts/AutoLaunch/`, launch it (**Scripts β†’ failsafe_toggle.py**), check +**Scripts β†’ Console** for errors. Bind `Ctrl+Opt+T` β†’ **Invoke Script Function** β†’ +`failsafe_toggle()`. + +- [ ] **B0 β€” script registers cleanly.** After launching the script, Console shows no error + and `failsafe_toggle` is registered (it appears under Scripts). +- [ ] **B1 β€” keypress toggles silently + notifies.** At an empty prompt press `Ctrl+Opt+T`. + Expect: macOS notification titled `πŸ”’ failsafe`, body `read β†’ read & write`; + `failsafe mode get` flips. Press again β†’ reverse. +- [ ] **B2 β€” no text injection.** Start typing a command (don't run it), then press + `Ctrl+Opt+T` mid-line. Expect: mode flips, and **no text is injected** into your command + line (this is the Python path's whole advantage over Send Text). +- [ ] **B3 β€” per-session isolation.** Two tabs/sessions; toggle one. Expect: `failsafe mode + get` differs per session (keyed by each `$ITERM_SESSION_ID`). +- [ ] **B4 β€” "sudo mode" notification.** Swap the `osascript` line for the sudo-mode variant. + Expect: notification title `πŸ”“ failsafe: sudo mode` when enabling write. + +## C. iTerm2 (no-Python alternative) β€” `docs/toggle/iterm.md` "Alternative" + +Setup: bind `Ctrl+Opt+T` β†’ **Send Text** β†’ `failsafe toggle\n`. + +- [ ] **C1 β€” Send Text path.** At an **empty** prompt press `Ctrl+Opt+T`. Expect: the line + runs `failsafe toggle`, prints the `read β†’ read & write (…path…)` transition, mode flips. +- [ ] **C2 β€” the documented caveat holds.** Press it while a command is half-typed. Expect: + it injects `failsafe toggle` mid-command (confirming the doc's warning to use it only at an + empty prompt). No need to "fix" β€” just confirm the caveat is real. + +## D. tmux β€” `docs/toggle/tmux.md` + +Setup: save `~/.config/failsafe/tmux-toggle.sh` and `tmux-status.sh` (`chmod +x`), add the +`bind -n C-M-t` line and the `status-right` block to `~/.tmux.conf`, +`tmux source-file ~/.tmux.conf`. + +- [ ] **D1 β€” real keypress (what the headless test couldn't do).** In an interactive tmux + pane press `Ctrl+Alt+T`. Expect: status message `πŸ”’ failsafe: read β†’ read & write`; + `failsafe mode get` flips. Press again β†’ reverse. +- [ ] **D2 β€” status bar indicator.** Watch the status-right. Expect: `πŸ”’ read` (green) when + read, `πŸ”“ sudo` (amber) when writable; flips within ~2s (`status-interval 2`) of a toggle. +- [ ] **D3 β€” per-pane isolation.** Split a window; toggle one pane. Expect: each pane's + `failsafe mode get` is independent (keyed by `$TMUX_PANE`). +- [ ] **D4 β€” nested in WezTerm/iTerm.** Run tmux inside WezTerm (or iTerm). Expect: the + direct-write helper still targets the tmux pane correctly; the **no-script alternative** + (`WEZTERM_PANE= ITERM_SESSION_ID= TMUX_PANE='#{pane_id}' failsafe toggle`) toggles the + right pane and not the outer terminal. + +## E. Claude Code status line β€” `docs/claude-statusline.md` + +Setup: copy `examples/claude-statusline.sh` to `~/.config/failsafe/`, `chmod +x`, point +`~/.claude/settings.json` `statusLine.command` at it, (re)start Claude Code. + +- [ ] **E1 β€” status line renders.** Expect: the bottom line reads + `failsafe πŸ”’ read Β· ~/ Β· `. +- [ ] **E2 β€” end-to-end flip (the money shot).** From the same pane, toggle to write (any + binding above, or `failsafe toggle`). Expect: the Claude Code status line flips to + `failsafe πŸ”“ write Β· …` on its next render. This is the full chain: terminal toggle β†’ + guard mode file β†’ Claude Code status line. +- [ ] **E3 β€” degrade without jq.** Temporarily rename/hide `jq`. Expect: the line still shows + `failsafe πŸ”’ read` / `πŸ”“ write` (just without the `Β· cwd Β· model` suffix). + +--- + +## Results + +Fill in as you go; I'll fold the outcomes (and any doc bugs found) into `../REPORT.md`. + +| Check | Result | Notes | +|---|---|---| +| A1 keypress + toast | | | +| A2 tab badge | | | +| A3 per-pane isolation | | | +| A4 sudo-mode toast/badge | | | +| A5 sudo timeout revert | | | +| B0 script registers | | | +| B1 keypress + notify | | | +| B2 no text injection | | | +| B3 per-session isolation | | | +| B4 sudo-mode notification | | | +| C1 Send Text toggle | | | +| C2 injection caveat | | | +| D1 real C-M-t keypress | | | +| D2 status bar | | | +| D3 per-pane isolation | | | +| D4 nested tmux | | | +| E1 statusline renders | | | +| E2 end-to-end flip | | | +| E3 degrade w/o jq | | | + +--- + +## Troubleshooting + +- **WezTerm/iTerm toast or notification doesn't appear.** It's macOS notification + permission, not failsafe. **System Settings β†’ Notifications β†’** the app (WezTerm / iTerm / + Script Editor for iTerm's osascript) β†’ **Allow notifications** ON, style Banners/Alerts; + turn off **Focus / Do Not Disturb**. The app only shows in that list after it has *tried* + to post once, so toggle a couple times first, then re-check. The **mode flip is the real + proof** (watch `failsafe mode get`); the toast is cosmetic β€” a missing toast with a working + flip is still a PASS (note "toast = OS perms"). +- **`watch` not found.** `brew install watch`, or loop manually: + `while true; do clear; failsafe mode get; sleep 1; done`. +- **Mode doesn't flip at all.** Confirm `failsafe` is on PATH in that window + (`failsafe --version`), and that the file is being written: + `ls -l ~/.claude/pane-mode/`. In WezTerm the file is named by `$WEZTERM_PANE`. +- **Cleanup after testing:** the test config lives at `~/.config/wezterm-failsafe-test/` + (delete anytime); mode files are under `~/.claude/pane-mode/` (safe to `rm`). From 683c63d26af5be14f3c7f25cddee86ea57fcc687 Mon Sep 17 00:00:00 2001 From: Tombar Date: Sun, 7 Jun 2026 15:46:57 -0300 Subject: [PATCH 20/30] chore: gitignore .claude/ (harness worktrees + session state) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 43e282e..6b01bca 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ docs/superpowers/ # mkdocs build output /site/ + +# harness worktrees + session state +.claude/ From 5a44d34275c0215410b138c8b11b1611df4f2c96 Mon Sep 17 00:00:00 2001 From: Tombar Date: Sun, 7 Jun 2026 16:03:39 -0300 Subject: [PATCH 21/30] mode: canonicalize resolved value (migrate legacy, fail-safe to enabled) Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/mode/chain.go | 4 ++-- internal/mode/chain_test.go | 30 ++++++++++++++++++++++++------ internal/mode/source.go | 12 ++++++++++++ 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/internal/mode/chain.go b/internal/mode/chain.go index 4269ae4..4fa26f8 100644 --- a/internal/mode/chain.go +++ b/internal/mode/chain.go @@ -19,10 +19,10 @@ func (c Chain) Resolve(env map[string]string) (string, Source, error) { return "", nil, err } if ok { - return v, s, nil + return Canonicalize(v), s, nil } } - return c.Default, nil, nil + return Canonicalize(c.Default), nil, nil } // FirstWritable returns the first source that is Writable() and whose Path() diff --git a/internal/mode/chain_test.go b/internal/mode/chain_test.go index 16c1d6c..e2f479d 100644 --- a/internal/mode/chain_test.go +++ b/internal/mode/chain_test.go @@ -30,8 +30,8 @@ func TestChain_FirstResolves(t *testing.T) { if err != nil { t.Fatalf("Resolve: %v", err) } - if val != "read & write" { - t.Errorf("val = %q, want 'read & write'", val) + if val != "disabled" { + t.Errorf("val = %q, want 'disabled' (canonicalized from 'read & write')", val) } if src == nil { t.Error("src should be non-nil for resolved value") @@ -50,8 +50,8 @@ func TestChain_AllSkipFallsToDefault(t *testing.T) { if err != nil { t.Fatalf("Resolve: %v", err) } - if val != "read" { - t.Errorf("val = %q, want default 'read'", val) + if val != "enabled" { + t.Errorf("val = %q, want 'enabled' (canonicalized from legacy default 'read')", val) } if src != nil { t.Error("src should be nil when default is used") @@ -83,8 +83,8 @@ func TestChain_PerTTYBeatsGlobal(t *testing.T) { if err != nil { t.Fatalf("Resolve: %v", err) } - if val != "read & write" { - t.Errorf("val = %q, want per-tty value 'read & write'", val) + if val != "disabled" { + t.Errorf("val = %q, want per-tty value 'disabled' (canonicalized from 'read & write')", val) } } @@ -135,6 +135,24 @@ func TestChain_NoTTYFallsToGlobalWritable(t *testing.T) { } } +func TestResolveMigratesLegacyAndFailsSafe(t *testing.T) { + cases := []struct{ in, want string }{ + {"enabled", "enabled"}, + {"disabled", "disabled"}, + {"read", "enabled"}, + {"read & write", "disabled"}, + {"", "enabled"}, + {"garbage", "enabled"}, + } + for _, c := range cases { + ch := Chain{Sources: []Source{EnvSource{Name: "M"}}, Default: "enabled"} + got, _, err := ch.Resolve(map[string]string{"M": c.in}) + if err != nil || got != c.want { + t.Fatalf("Resolve(%q) = %q,%v; want %q", c.in, got, err, c.want) + } + } +} + func TestChain_FirstWritableForToggle(t *testing.T) { chain := Chain{ Sources: []Source{ diff --git a/internal/mode/source.go b/internal/mode/source.go index ca06325..859861f 100644 --- a/internal/mode/source.go +++ b/internal/mode/source.go @@ -14,6 +14,18 @@ import ( "syscall" ) +// Canonicalize maps any resolved mode string to a canonical value, migrating the +// legacy read/read&write vocabulary and FAILING SAFE: any empty, unknown, or +// garbled value resolves to "enabled" (protected). A guard must never fail open. +func Canonicalize(v string) string { + switch strings.TrimSpace(v) { + case "disabled", "read & write": + return "disabled" + default: // "enabled", "read", "", and anything unrecognized + return "enabled" + } +} + // Source resolves to a mode value from some authority (env var, file, etc.). type Source interface { // Resolve returns (value, true, nil) if the source has a value to contribute, From 671aab56ac4df716b90ce70a861e5e0fff9bd56b Mon Sep 17 00:00:00 2001 From: Tombar Date: Sun, 7 Jun 2026 16:04:52 -0300 Subject: [PATCH 22/30] hook: default guard mode is 'enabled' (protected) when no source resolves Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/subcommand/hook.go | 2 +- internal/subcommand/modechain_test.go | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/subcommand/hook.go b/internal/subcommand/hook.go index b78c1ef..a73350f 100644 --- a/internal/subcommand/hook.go +++ b/internal/subcommand/hook.go @@ -273,7 +273,7 @@ func defaultModeChain() *mode.Chain { // Global last-resort fallback (always writable while HOME is set). mode.FileSource{Pattern: "${HOME}/.config/failsafe/mode"}, }, - Default: "read", + Default: "enabled", } } diff --git a/internal/subcommand/modechain_test.go b/internal/subcommand/modechain_test.go index 9cbb76d..d82e3ae 100644 --- a/internal/subcommand/modechain_test.go +++ b/internal/subcommand/modechain_test.go @@ -36,6 +36,14 @@ func TestDefaultModeChain_PerTTYBeforeGlobal(t *testing.T) { } } +func TestDefaultModeChainDefaultsToEnabled(t *testing.T) { + ch := DefaultModeChain() + got, src, _ := ch.Resolve(map[string]string{"HOME": t.TempDir()}) + if got != "enabled" || src != nil { + t.Fatalf("default resolve = %q (src %v); want enabled/nil", got, src) + } +} + // TestResolvePaneID_SkipsGlobalFallback guards the mcp pane-id display: the // global config file resolves in any shell with HOME set, but its trailing // component ("mode") must never be surfaced as a pane identifier. From a88e2a0e8907ec28c37ed2d90c8a326654f7e0be Mon Sep 17 00:00:00 2001 From: Tombar Date: Sun, 7 Jun 2026 16:27:27 -0300 Subject: [PATCH 23/30] facts: emit failsafe_enabled bool + retain legacy mode string --- internal/facts/fact.go | 8 +++++++- internal/facts/fact_test.go | 11 +++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/internal/facts/fact.go b/internal/facts/fact.go index 34afcac..67df44e 100644 --- a/internal/facts/fact.go +++ b/internal/facts/fact.go @@ -48,8 +48,14 @@ func (b Builder) Build() map[string]any { for k, v := range b.Env { env[k] = v } + enabled := b.Mode != "disabled" // fail-safe: anything but explicit disabled is protected + legacyMode := "read" + if !enabled { + legacyMode = "read & write" + } fact := map[string]any{ - "mode": b.Mode, + "failsafe_enabled": enabled, // new boolean policy interface + "mode": legacyMode, // legacy string, retained for back-compat (input.mode == "read") "tool": b.Tool, "verb": b.Parsed.Verb, "subverb": b.Parsed.Subverb, diff --git a/internal/facts/fact_test.go b/internal/facts/fact_test.go index 9dfb840..73882aa 100644 --- a/internal/facts/fact_test.go +++ b/internal/facts/fact_test.go @@ -11,6 +11,17 @@ import ( "github.com/UndermountainCC/failsafe/internal/tools" ) +func TestBuildEmitsBooleanAndLegacyMode(t *testing.T) { + en := Builder{Mode: "enabled"}.Build() + if en["failsafe_enabled"] != true || en["mode"] != "read" { + t.Fatalf("enabled: failsafe_enabled=%v mode=%v", en["failsafe_enabled"], en["mode"]) + } + dis := Builder{Mode: "disabled"}.Build() + if dis["failsafe_enabled"] != false || dis["mode"] != "read & write" { + t.Fatalf("disabled: failsafe_enabled=%v mode=%v", dis["failsafe_enabled"], dis["mode"]) + } +} + func TestBuild_Basic(t *testing.T) { parsed := tools.Parsed{ Verb: "apply", From 41cc3b60120372ba11060a7bc1baaed04e066763 Mon Sep 17 00:00:00 2001 From: Tombar Date: Sun, 7 Jun 2026 16:27:51 -0300 Subject: [PATCH 24/30] validate: failsafe_enabled is a known fact field --- internal/subcommand/validate.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/subcommand/validate.go b/internal/subcommand/validate.go index ae8d258..4b7992b 100644 --- a/internal/subcommand/validate.go +++ b/internal/subcommand/validate.go @@ -110,6 +110,7 @@ func Validate(path string, out io.Writer, opts ValidateOptions) int { // knownFactFields enumerates the top-level keys of the Rego input fact // (spec Β§5.1). Updated whenever the fact schema grows. var knownFactFields = map[string]bool{ + "failsafe_enabled": true, "mode": true, "tool": true, "verb": true, "subverb": true, "flags": true, "positional": true, "env": true, "cwd": true, "now": true, "session": true, "raw": true, From 3dacd03a85aa4fb3b4557d30e160340243943f14 Mon Sep 17 00:00:00 2001 From: Tombar Date: Sun, 7 Jun 2026 17:35:00 -0300 Subject: [PATCH 25/30] policy: boolean fail-safe gate (not failsafe_enabled==false) + failsafe.* namespace (dual-namespace) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrites 7 mode-gating sites in kubectl/helm/aws/terraform.rego from input.mode == "read" to not input.failsafe_enabled == false, so a missing or undefined field BLOCKS (fail-safe) rather than failing open. - Renames all bundled package declarations guard.bundled.* β†’ failsafe.bundled.* (git, failsafe, kubectl, helm, aws, terraform). - Updates validator to accept both guard.* and failsafe.* namespaces via layerSuffix() helper; .failsafe.rego accepts guard.repo OR failsafe.repo. - Adds TDD security tests in internal/embed/policies_failsafe_test.go proving missing failsafe_enabled β†’ BLOCK (fail-safe property). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/embed/policies/aws.rego | 10 +- internal/embed/policies/failsafe.rego | 2 +- internal/embed/policies/git.rego | 2 +- internal/embed/policies/helm.rego | 12 +- internal/embed/policies/kubectl.rego | 8 +- internal/embed/policies/terraform.rego | 10 +- internal/embed/policies_failsafe_test.go | 179 +++++++++++++++++++++++ internal/subcommand/explain_test.go | 2 +- internal/subcommand/validate.go | 62 +++++--- internal/subcommand/validate_test.go | 65 ++++++++ 10 files changed, 309 insertions(+), 43 deletions(-) create mode 100644 internal/embed/policies_failsafe_test.go diff --git a/internal/embed/policies/aws.rego b/internal/embed/policies/aws.rego index d19e9f7..4aee7e6 100644 --- a/internal/embed/policies/aws.rego +++ b/internal/embed/policies/aws.rego @@ -1,4 +1,4 @@ -package guard.bundled.aws +package failsafe.bundled.aws import future.keywords.if import future.keywords.in @@ -10,8 +10,8 @@ import future.keywords.contains # `aws s3 ls` allowed; other s3 verbs blocked. Skip when subverb is empty # (e.g. `aws s3` with no action) β€” that's user error, not a mutation we # need to gate. -block contains {"reason": sprintf("aws s3 %s blocked in read mode", [input.subverb])} if { - input.mode == "read" +block contains {"reason": sprintf("aws s3 %s blocked while failsafe is enabled", [input.subverb])} if { + not input.failsafe_enabled == false input.tool == "aws" input.verb == "s3" input.subverb != "" @@ -20,8 +20,8 @@ block contains {"reason": sprintf("aws s3 %s blocked in read mode", [input.subve # For any other service, allow `describe-*`, `list-*`, `get-*`; block the rest. # Empty subverb (e.g., `aws --help` or `aws ec2`) is also treated as harmless. -block contains {"reason": sprintf("aws %s %s blocked in read mode", [input.verb, input.subverb])} if { - input.mode == "read" +block contains {"reason": sprintf("aws %s %s blocked while failsafe is enabled", [input.verb, input.subverb])} if { + not input.failsafe_enabled == false input.tool == "aws" input.verb != "" input.verb != "sts" diff --git a/internal/embed/policies/failsafe.rego b/internal/embed/policies/failsafe.rego index a06bfee..68eb8db 100644 --- a/internal/embed/policies/failsafe.rego +++ b/internal/embed/policies/failsafe.rego @@ -2,7 +2,7 @@ # polices failsafe). All rules below fire regardless of mode β€” the # read/read-and-write distinction is for kubectl/helm/etc. interactive # mutations, not for the LLM toggling its own safety boundary. -package guard.bundled.failsafe +package failsafe.bundled.failsafe import future.keywords.if import future.keywords.contains diff --git a/internal/embed/policies/git.rego b/internal/embed/policies/git.rego index 211d448..d28e22e 100644 --- a/internal/embed/policies/git.rego +++ b/internal/embed/policies/git.rego @@ -1,4 +1,4 @@ -package guard.bundled.git +package failsafe.bundled.git import future.keywords.if import future.keywords.in diff --git a/internal/embed/policies/helm.rego b/internal/embed/policies/helm.rego index 801a422..f299790 100644 --- a/internal/embed/policies/helm.rego +++ b/internal/embed/policies/helm.rego @@ -1,4 +1,4 @@ -package guard.bundled.helm +package failsafe.bundled.helm import future.keywords.if import future.keywords.in @@ -6,17 +6,17 @@ import future.keywords.contains read_verbs := {"list", "get", "status", "show", "search", "version", "history", "template"} -# Block all non-read verbs in read mode. `repo` requires looking at subverb. -block contains {"reason": sprintf("helm %s blocked in read mode", [input.verb])} if { - input.mode == "read" +# Block all non-read verbs while failsafe is enabled. `repo` requires looking at subverb. +block contains {"reason": sprintf("helm %s blocked while failsafe is enabled", [input.verb])} if { + not input.failsafe_enabled == false input.tool == "helm" input.verb != "" input.verb != "repo" not input.verb in read_verbs } -block contains {"reason": sprintf("helm repo %s blocked in read mode", [input.subverb])} if { - input.mode == "read" +block contains {"reason": sprintf("helm repo %s blocked while failsafe is enabled", [input.subverb])} if { + not input.failsafe_enabled == false input.tool == "helm" input.verb == "repo" input.subverb != "list" diff --git a/internal/embed/policies/kubectl.rego b/internal/embed/policies/kubectl.rego index d7e2256..b597be5 100644 --- a/internal/embed/policies/kubectl.rego +++ b/internal/embed/policies/kubectl.rego @@ -1,4 +1,4 @@ -package guard.bundled.kubectl +package failsafe.bundled.kubectl import future.keywords.if import future.keywords.in @@ -10,9 +10,9 @@ read_verbs := { "api-resources", "api-versions", "auth", "diff", "wait", } -# Block all non-read verbs in read mode, except --dry-run forms of `apply`. -block contains {"reason": sprintf("kubectl %s blocked in read mode", [input.verb])} if { - input.mode == "read" +# Block all non-read verbs while failsafe is enabled, except --dry-run forms of `apply`. +block contains {"reason": sprintf("kubectl %s blocked while failsafe is enabled", [input.verb])} if { + not input.failsafe_enabled == false input.tool == "kubectl" input.verb != "" not input.verb in read_verbs diff --git a/internal/embed/policies/terraform.rego b/internal/embed/policies/terraform.rego index 50dd026..0700cc4 100644 --- a/internal/embed/policies/terraform.rego +++ b/internal/embed/policies/terraform.rego @@ -1,4 +1,4 @@ -package guard.bundled.terraform +package failsafe.bundled.terraform import future.keywords.if import future.keywords.in @@ -6,16 +6,16 @@ import future.keywords.contains read_verbs := {"plan", "show", "output", "validate", "fmt", "providers", "version", "graph"} -block contains {"reason": sprintf("terraform %s blocked in read mode", [input.verb])} if { - input.mode == "read" +block contains {"reason": sprintf("terraform %s blocked while failsafe is enabled", [input.verb])} if { + not input.failsafe_enabled == false input.tool == "terraform" input.verb != "" input.verb != "state" not input.verb in read_verbs } -block contains {"reason": sprintf("terraform state %s blocked in read mode", [input.subverb])} if { - input.mode == "read" +block contains {"reason": sprintf("terraform state %s blocked while failsafe is enabled", [input.subverb])} if { + not input.failsafe_enabled == false input.tool == "terraform" input.verb == "state" not input.subverb in {"list", "show"} diff --git a/internal/embed/policies_failsafe_test.go b/internal/embed/policies_failsafe_test.go new file mode 100644 index 0000000..53db4d3 --- /dev/null +++ b/internal/embed/policies_failsafe_test.go @@ -0,0 +1,179 @@ +// Copyright 2026 Undermountain Coding Company +// SPDX-License-Identifier: Apache-2.0 + +package embed + +// TestFailsafeGate_* verifies the fail-safe semantics of the bundled policies +// after the migration from input.mode == "read" to +// not input.failsafe_enabled == false. +// +// The critical property is fail-safe on missing field: +// - field missing β†’ not undefined == false β†’ not true β†’ false β†’ rule body +// evaluates to true β†’ BLOCK +// - field=true β†’ not true == false β†’ not false β†’ true β†’ BLOCK +// - field=false β†’ not false == false β†’ not false β†’ true β†’ wait, no… +// +// Correct truth table for `not input.failsafe_enabled == false`: +// - missing : (input.failsafe_enabled == false) is undefined β†’ not undefined β†’ true β†’ BLOCK βœ“ +// - true : (true == false) is false β†’ not false β†’ true β†’ BLOCK βœ“ +// - false : (false == false) is true β†’ not true β†’ false β†’ condition fails β†’ rule does NOT fire β†’ ALLOW βœ“ + +import ( + "context" + "strings" + "testing" + + "github.com/open-policy-agent/opa/rego" +) + +// loadAndPrepare reads the named bundled policy, loads it alongside the +// given extra modules, and returns a PreparedEvalQuery for the given query. +func loadAndPrepare(t *testing.T, queryPkg, query string, mods map[string]string) rego.PreparedEvalQuery { + t.Helper() + ctx := context.Background() + opts := []func(*rego.Rego){ + rego.Query(query), + } + for name, body := range mods { + opts = append(opts, rego.Module(name, body)) + } + pq, err := rego.New(opts...).PrepareForEval(ctx) + if err != nil { + t.Fatalf("PrepareForEval(%s): %v", query, err) + } + return pq +} + +// evalBlock executes the prepared query against fact and returns all strings +// collected from the resulting set items' "reason" fields. An empty slice +// means no block rule fired (allow). +func evalBlock(t *testing.T, pq rego.PreparedEvalQuery, fact map[string]any) []string { + t.Helper() + rs, err := pq.Eval(context.Background(), rego.EvalInput(fact)) + if err != nil { + t.Fatalf("Eval: %v", err) + } + var reasons []string + for _, r := range rs { + for _, expr := range r.Expressions { + items, ok := expr.Value.([]any) + if !ok { + continue + } + for _, item := range items { + obj, ok := item.(map[string]any) + if !ok { + continue + } + if reason, ok := obj["reason"].(string); ok { + reasons = append(reasons, reason) + } + } + } + } + return reasons +} + +// bundledModuleMap reads all bundled .rego files and returns them as +// map[filename]body so tests can load a consistent snapshot. +func bundledModuleMap(t *testing.T) map[string]string { + t.Helper() + out := map[string]string{} + for _, name := range BundledPolicyNames() { + body, err := ReadBundledPolicy(name) + if err != nil { + t.Fatalf("ReadBundledPolicy(%s): %v", name, err) + } + out[name] = string(body) + } + return out +} + +// ────────────────────────────────────────────────────────────────────────────── +// kubectl gate + +func kubectlPQ(t *testing.T) rego.PreparedEvalQuery { + t.Helper() + mods := bundledModuleMap(t) + // determine the actual package name from kubectl.rego + body := mods["kubectl.rego"] + var pkg string + for _, line := range strings.Split(body, "\n") { + if strings.HasPrefix(line, "package ") { + pkg = strings.TrimPrefix(line, "package ") + pkg = strings.TrimSpace(pkg) + break + } + } + if pkg == "" { + t.Fatal("could not determine kubectl.rego package") + } + return loadAndPrepare(t, pkg, "data."+pkg+".block", mods) +} + +// TestFailsafeGate_MissingField_Blocks proves the core fail-safe property: +// when the fact has NO failsafe_enabled key at all, a mutating kubectl command +// must still be BLOCKED (fail-safe / fail-closed on ambiguous input). +func TestFailsafeGate_MissingField_Blocks(t *testing.T) { + pq := kubectlPQ(t) + // Deliberately omit failsafe_enabled β€” simulates a misconfigured or + // outdated fact builder that never sets the field. + fact := map[string]any{ + "tool": "kubectl", + "verb": "apply", + } + reasons := evalBlock(t, pq, fact) + if len(reasons) == 0 { + t.Fatal("FAIL-SAFE VIOLATION: kubectl apply with missing failsafe_enabled was ALLOWED β€” it must be BLOCKED") + } + t.Logf("correctly blocked with reason: %v", reasons) +} + +// TestFailsafeGate_True_Blocks proves that failsafe_enabled=true also blocks. +func TestFailsafeGate_True_Blocks(t *testing.T) { + pq := kubectlPQ(t) + fact := map[string]any{ + "tool": "kubectl", + "verb": "apply", + "failsafe_enabled": true, + } + reasons := evalBlock(t, pq, fact) + if len(reasons) == 0 { + t.Fatal("kubectl apply with failsafe_enabled=true must be BLOCKED") + } + t.Logf("correctly blocked: %v", reasons) +} + +// TestFailsafeGate_False_Allows proves that failsafe_enabled=false allows mutations. +func TestFailsafeGate_False_Allows(t *testing.T) { + pq := kubectlPQ(t) + fact := map[string]any{ + "tool": "kubectl", + "verb": "apply", + "failsafe_enabled": false, + } + reasons := evalBlock(t, pq, fact) + if len(reasons) > 0 { + t.Fatalf("kubectl apply with failsafe_enabled=false must be ALLOWED, got blocked: %v", reasons) + } + t.Log("correctly allowed") +} + +// TestFailsafeGate_ReadVerb_AlwaysAllowed proves a read verb is never blocked +// regardless of failsafe_enabled. +func TestFailsafeGate_ReadVerb_AlwaysAllowed(t *testing.T) { + pq := kubectlPQ(t) + for _, fe := range []any{true, false, nil} { + fact := map[string]any{ + "tool": "kubectl", + "verb": "get", + } + if fe != nil { + fact["failsafe_enabled"] = fe + } + reasons := evalBlock(t, pq, fact) + if len(reasons) > 0 { + t.Errorf("kubectl get with failsafe_enabled=%v must be ALLOWED, got blocked: %v", fe, reasons) + } + } +} diff --git a/internal/subcommand/explain_test.go b/internal/subcommand/explain_test.go index 42d4cb2..4f17612 100644 --- a/internal/subcommand/explain_test.go +++ b/internal/subcommand/explain_test.go @@ -18,7 +18,7 @@ func TestExplain_BlockShowsReason(t *testing.T) { if !strings.Contains(out.String(), "Decision: BLOCK") { t.Errorf("missing BLOCK in output: %s", out.String()) } - if !strings.Contains(out.String(), "kubectl apply blocked in read mode") { + if !strings.Contains(out.String(), "kubectl apply blocked while failsafe is enabled") { t.Errorf("missing reason: %s", out.String()) } } diff --git a/internal/subcommand/validate.go b/internal/subcommand/validate.go index 4b7992b..110a081 100644 --- a/internal/subcommand/validate.go +++ b/internal/subcommand/validate.go @@ -31,25 +31,27 @@ func Validate(path string, out io.Writer, opts ValidateOptions) int { } fmt.Fprintln(out, "βœ“ parse OK") - expectedPkg := expectedPackage(path) + expectedSuffix := expectedPackage(path) gotPkg := mod.Package.Path.String() - // Path strings are like "data.guard.repo"; trim "data." + // Path strings are like "data.guard.repo" or "data.failsafe.repo"; trim "data." gotPkg = strings.TrimPrefix(gotPkg, "data.") - // expectedPkg == "" is the "skip strict check" sentinel for paths whose + // expectedSuffix == "" is the "skip strict check" sentinel for paths whose // layer can't be inferred from the filename alone (e.g., user-managed // example files at arbitrary paths). In that case we infer the layer // from the package declaration itself. - if expectedPkg != "" && gotPkg != expectedPkg { - fmt.Fprintf(out, "βœ— package: got %q, want %q (based on filename)\n", gotPkg, expectedPkg) + // When expectedSuffix is non-empty ("repo" or "user"), we compare the + // suffix of the declared package β€” this accepts BOTH guard.repo and + // failsafe.repo (dual-namespace support). + if expectedSuffix != "" && layerSuffix(gotPkg) != expectedSuffix { + fmt.Fprintf(out, "βœ— package: got %q, want suffix %q (e.g. guard.%s or failsafe.%s, based on filename)\n", + gotPkg, expectedSuffix, expectedSuffix, expectedSuffix) return 1 } fmt.Fprintf(out, "βœ“ package: %s\n", gotPkg) - // If filename gave us no layer hint, use the declared package to pick rule allowlist. - allowedKey := expectedPkg - if allowedKey == "" { - allowedKey = gotPkg - } + // allowedKey is the full declared package β€” used by forbiddenRuleNames / + // summarizeAllowed which internally call layerSuffix / isRepoLayer. + allowedKey := gotPkg // Reserved-rule check: only "block" is universally allowed; "allow_override" // is allowed only in the repo layer; helper rules (read_verbs, allowed_dry_run, // is_read_action, etc.) are FREE β€” Rego naturally namespaces helpers within @@ -169,34 +171,54 @@ func refField(ref ast.Ref) string { return "" } -// expectedPackage returns the expected `package guard.X` based on filename. +// layerSuffix strips a guard. or failsafe. namespace prefix, returning e.g. +// "repo" / "user" / "bundled.kubectl". Accepts both legacy and new namespace. +func layerSuffix(pkg string) string { + for _, p := range []string{"failsafe.", "guard."} { + if strings.HasPrefix(pkg, p) { + return strings.TrimPrefix(pkg, p) + } + } + return pkg +} + +// isRepoLayer reports whether pkg is the repo layer in either namespace. +func isRepoLayer(pkg string) bool { return layerSuffix(pkg) == "repo" } + +// expectedPackage returns the expected package path for well-known filenames, +// or "" when the filename gives no layer hint (skip strict equality check). +// For known filenames we accept EITHER the legacy guard.* OR the new failsafe.* +// namespace by returning "" and relying on layerSuffix in forbiddenRuleNames. func expectedPackage(path string) string { base := filepath.Base(path) switch { case base == ".failsafe.rego": - return "guard.repo" + // Accept either guard.repo or failsafe.repo β€” return "" and rely on + // layerSuffix to classify. But we still need to reject wrong.name, so + // we use the suffix check in the caller rather than strict equality. + return "repo" // sentinel: caller will compare layerSuffix(gotPkg) == expectedPackage case base == "policy.rego" && strings.Contains(path, "/.config/failsafe/"): - return "guard.user" + return "user" // same sentinel approach default: - // bundled: package name should be guard.bundled.; we don't validate - // the part here, just accept any guard.bundled.* prefix. - return "" // empty means "skip strict check, accept guard.* prefixes" + // bundled: package name should be {guard,failsafe}.bundled.; + // we don't validate the part here, just accept either prefix. + return "" // empty means "skip strict check" } } // forbiddenRuleNames returns the set of RESERVED rule names that this layer // must NOT declare. Helper rules (anything not in this set) are always free. -// - guard.repo: nothing reserved (both block and allow_override permitted) -// - guard.user / guard.bundled.* / unknown: allow_override is reserved +// - repo layer (guard.repo or failsafe.repo): nothing reserved +// - user / bundled / unknown: allow_override is reserved func forbiddenRuleNames(pkg string) map[string]bool { - if pkg == "guard.repo" { + if isRepoLayer(pkg) { return map[string]bool{} } return map[string]bool{"allow_override": true} } func summarizeAllowed(pkg string) string { - if pkg == "guard.repo" { + if isRepoLayer(pkg) { return "block + allow_override" } return "block" diff --git a/internal/subcommand/validate_test.go b/internal/subcommand/validate_test.go index f2982fc..194402d 100644 --- a/internal/subcommand/validate_test.go +++ b/internal/subcommand/validate_test.go @@ -177,3 +177,68 @@ block contains {"reason": "x"} if { t.Errorf("known fact fields should not warn under --strict; exit=%d, out=%q", code, out.String()) } } + +// ────────────────────────────────────────────────────────────────────────────── +// Dual-namespace tests (Task 3.3): both guard.* and failsafe.* must validate. + +func TestValidate_DualNamespace_FailsafeRepo(t *testing.T) { + // New failsafe.repo namespace in a .failsafe.rego file must be accepted. + path := writePolicy(t, ".failsafe.rego", `package failsafe.repo +import future.keywords.if +import future.keywords.contains + +block contains {"reason": "x"} if { true } +allow_override contains {"reason": "y"} if { true } +`) + var out bytes.Buffer + code := Validate(path, &out, ValidateOptions{}) + if code != 0 { + t.Errorf("failsafe.repo should be accepted; exit=%d, out=%q", code, out.String()) + } +} + +func TestValidate_DualNamespace_GuardRepo_Legacy(t *testing.T) { + // Legacy guard.repo namespace must still be accepted. + path := writePolicy(t, ".failsafe.rego", `package guard.repo +import future.keywords.if +import future.keywords.contains + +block contains {"reason": "x"} if { true } +allow_override contains {"reason": "y"} if { true } +`) + var out bytes.Buffer + code := Validate(path, &out, ValidateOptions{}) + if code != 0 { + t.Errorf("legacy guard.repo should still be accepted; exit=%d, out=%q", code, out.String()) + } +} + +func TestValidate_DualNamespace_GuardUser_Legacy(t *testing.T) { + // Legacy guard.user namespace (without allow_override) must still pass. + path := writePolicy(t, "policy.rego", `package guard.user +import future.keywords.if +import future.keywords.contains + +block contains {"reason": "x"} if { true } +`) + var out bytes.Buffer + code := Validate(path, &out, ValidateOptions{}) + if code != 0 { + t.Errorf("legacy guard.user should still be accepted; exit=%d, out=%q", code, out.String()) + } +} + +func TestValidate_DualNamespace_WrongPackage_Rejected(t *testing.T) { + // Neither guard.repo nor failsafe.repo: must be rejected. + path := writePolicy(t, ".failsafe.rego", `package wrong.namespace +import future.keywords.if +import future.keywords.contains + +block contains {"reason": "x"} if { true } +`) + var out bytes.Buffer + code := Validate(path, &out, ValidateOptions{}) + if code == 0 { + t.Errorf("wrong.namespace in .failsafe.rego should be rejected; out=%q", out.String()) + } +} From 15536fec36e5260a2301ca484a578498bcaa1c52 Mon Sep 17 00:00:00 2001 From: Tombar Date: Sun, 7 Jun 2026 17:40:23 -0300 Subject: [PATCH 26/30] cli+mcp: enabled/disabled vocabulary, rich mode-set aliases, serverInfo failsafe Co-Authored-By: Claude Sonnet 4.6 --- internal/subcommand/explain.go | 2 +- internal/subcommand/mcp.go | 20 +++++++------- internal/subcommand/mcp_test.go | 34 +++++++++++------------ internal/subcommand/mode.go | 17 ++++++------ internal/subcommand/mode_alias_test.go | 37 ++++++++++---------------- internal/subcommand/toggle.go | 8 +++--- internal/subcommand/toggle_test.go | 18 ++++++------- 7 files changed, 63 insertions(+), 73 deletions(-) diff --git a/internal/subcommand/explain.go b/internal/subcommand/explain.go index 76ae597..90567cb 100644 --- a/internal/subcommand/explain.go +++ b/internal/subcommand/explain.go @@ -73,7 +73,7 @@ func Explain(cmdArgs []string, out io.Writer, opts ExplainOptions) int { } mode := opts.Mode if mode == "" { - mode = "read" + mode = "enabled" } engineByCwd := map[string]*policy.Engine{} diff --git a/internal/subcommand/mcp.go b/internal/subcommand/mcp.go index 033c012..1048e5f 100644 --- a/internal/subcommand/mcp.go +++ b/internal/subcommand/mcp.go @@ -64,11 +64,11 @@ func MCP(stdin io.Reader, stdout, stderr io.Writer) int { // mcpTools is the TOOLS list. The description strings are the prompt text // Claude reads to decide when to invoke these tools β€” they are CONTRACT, not -// docs. Copied verbatim from plugin/mcp/guard_mcp.py. +// docs. var mcpTools = []map[string]any{ { "name": "check_mode", - "description": "Check the current infrastructure guard mode for this pane. Returns 'read' (destructive infra commands blocked) or 'read & write' (all commands allowed). Check this before attempting any kubectl/terraform/helm/aws mutating command.", + "description": "Check whether failsafe is enabled for this pane. Returns `enabled` (destructive infra commands blocked) or `disabled` (all commands allowed). Check this before any kubectl/terraform/helm/aws mutating command.", "inputSchema": map[string]any{ "type": "object", "properties": map[string]any{}, @@ -77,7 +77,7 @@ var mcpTools = []map[string]any{ }, { "name": "toggle_mode", - "description": "Toggle the infrastructure guard mode for this pane between 'read' and 'read & write'. IMPORTANT: When you need to run a destructive infra command and the mode is 'read', you MUST call this tool and ask the user to approve the toggle. Do NOT try alternative approaches, workarounds, or suggest the user run the command manually. Stop and request the toggle.", + "description": "Toggle failsafe for this pane between `enabled` and `disabled`. IMPORTANT: when you need a destructive infra command and failsafe is `enabled`, you MUST call this tool and ask the user to approve disabling it. Do NOT work around it or suggest the user run the command manually. Stop and request the toggle.", "inputSchema": map[string]any{ "type": "object", "properties": map[string]any{}, @@ -101,7 +101,7 @@ func handleMCPRequest(req map[string]any) (map[string]any, bool) { "result": map[string]any{ "protocolVersion": "2024-11-05", "capabilities": map[string]any{"tools": map[string]any{}}, - "serverInfo": map[string]any{"name": "guard", "version": "1.0.0"}, + "serverInfo": map[string]any{"name": "failsafe", "version": "1.0.0"}, }, }, true @@ -152,9 +152,9 @@ func handleToolCall(id any, name string) map[string]any { case "toggle_mode": old, _, _ := chain.Resolve(env) paneID := resolvePaneID(chain, env) - newVal := "read & write" - if old == "read & write" { - newVal = "read" + newVal := "disabled" + if old == "disabled" { + newVal = "enabled" } _, path, ok := chain.FirstWritable(env) if !ok { @@ -207,10 +207,10 @@ func mcpToolResult(id any, text string) map[string]any { } func labelFor(modeVal string) string { - if modeVal == "read & write" { - return "rw" + if modeVal == "disabled" { + return "disabled" } - return "r" + return "enabled" } // resolvePaneID picks the pane identifier to surface in tool responses. diff --git a/internal/subcommand/mcp_test.go b/internal/subcommand/mcp_test.go index 02acd3a..88f7a47 100644 --- a/internal/subcommand/mcp_test.go +++ b/internal/subcommand/mcp_test.go @@ -57,8 +57,8 @@ func TestMCP_InitializeReturnsProtocol(t *testing.T) { t.Errorf("protocolVersion=%v, want 2024-11-05", got) } server, _ := result["serverInfo"].(map[string]any) - if got := server["name"]; got != "guard" { - t.Errorf("serverInfo.name=%v, want guard", got) + if got := server["name"]; got != "failsafe" { + t.Errorf("serverInfo.name=%v, want failsafe", got) } if got := server["version"]; got != "1.0.0" { t.Errorf("serverInfo.version=%v, want 1.0.0", got) @@ -76,8 +76,8 @@ func TestMCP_ToolsListReturnsTwoTools(t *testing.T) { t.Fatalf("want 2 tools, got %d", len(tools)) } - wantCheckDesc := "Check the current infrastructure guard mode for this pane. Returns 'read' (destructive infra commands blocked) or 'read & write' (all commands allowed). Check this before attempting any kubectl/terraform/helm/aws mutating command." - wantToggleDesc := "Toggle the infrastructure guard mode for this pane between 'read' and 'read & write'. IMPORTANT: When you need to run a destructive infra command and the mode is 'read', you MUST call this tool and ask the user to approve the toggle. Do NOT try alternative approaches, workarounds, or suggest the user run the command manually. Stop and request the toggle." + wantCheckDesc := "Check whether failsafe is enabled for this pane. Returns `enabled` (destructive infra commands blocked) or `disabled` (all commands allowed). Check this before any kubectl/terraform/helm/aws mutating command." + wantToggleDesc := "Toggle failsafe for this pane between `enabled` and `disabled`. IMPORTANT: when you need a destructive infra command and failsafe is `enabled`, you MUST call this tool and ask the user to approve disabling it. Do NOT work around it or suggest the user run the command manually. Stop and request the toggle." byName := map[string]map[string]any{} for _, t0 := range tools { @@ -133,7 +133,7 @@ func TestMCP_CheckModeReadsChain(t *testing.T) { if err := os.MkdirAll(filepath.Dir(modeFile), 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(modeFile, []byte("read & write"), 0o644); err != nil { + if err := os.WriteFile(modeFile, []byte("disabled"), 0o644); err != nil { t.Fatal(err) } @@ -146,11 +146,11 @@ func TestMCP_CheckModeReadsChain(t *testing.T) { if err := json.Unmarshal([]byte(text), &payload); err != nil { t.Fatalf("content text not JSON: %v (%q)", err, text) } - if payload["mode"] != "read & write" { - t.Errorf("mode=%v, want 'read & write'", payload["mode"]) + if payload["mode"] != "disabled" { + t.Errorf("mode=%v, want 'disabled'", payload["mode"]) } - if payload["label"] != "rw" { - t.Errorf("label=%v, want rw", payload["label"]) + if payload["label"] != "disabled" { + t.Errorf("label=%v, want disabled", payload["label"]) } if payload["pane_id"] != "session-abc" { t.Errorf("pane_id=%v, want session-abc", payload["pane_id"]) @@ -160,13 +160,13 @@ func TestMCP_CheckModeReadsChain(t *testing.T) { func TestMCP_ToggleModeFlipsAndReturnsOldNew(t *testing.T) { home := withIsolatedEnv(t, "ITERM_SESSION_ID", "session-xyz") - // Start in "read" (no file means default "read"; create it explicitly so + // Start in "enabled" (no file means default "enabled"; create it explicitly so // the toggle has a deterministic source to write back to). modeFile := filepath.Join(home, ".claude", "pane-mode", "session-xyz") if err := os.MkdirAll(filepath.Dir(modeFile), 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(modeFile, []byte("read"), 0o644); err != nil { + if err := os.WriteFile(modeFile, []byte("enabled"), 0o644); err != nil { t.Fatal(err) } @@ -179,11 +179,11 @@ func TestMCP_ToggleModeFlipsAndReturnsOldNew(t *testing.T) { if err := json.Unmarshal([]byte(text), &payload); err != nil { t.Fatalf("content text not JSON: %v (%q)", err, text) } - if payload["old"] != "read" { - t.Errorf("old=%v, want read", payload["old"]) + if payload["old"] != "enabled" { + t.Errorf("old=%v, want enabled", payload["old"]) } - if payload["new"] != "read & write" { - t.Errorf("new=%v, want 'read & write'", payload["new"]) + if payload["new"] != "disabled" { + t.Errorf("new=%v, want 'disabled'", payload["new"]) } if payload["pane_id"] != "session-xyz" { t.Errorf("pane_id=%v, want session-xyz", payload["pane_id"]) @@ -194,8 +194,8 @@ func TestMCP_ToggleModeFlipsAndReturnsOldNew(t *testing.T) { if err != nil { t.Fatal(err) } - if string(body) != "read & write" { - t.Errorf("mode file=%q, want 'read & write'", body) + if string(body) != "disabled" { + t.Errorf("mode file=%q, want 'disabled'", body) } } diff --git a/internal/subcommand/mode.go b/internal/subcommand/mode.go index 7c52028..15fd030 100644 --- a/internal/subcommand/mode.go +++ b/internal/subcommand/mode.go @@ -12,17 +12,16 @@ import ( ) // normalizeMode maps user-friendly aliases to the two canonical mode values. -// Canonical values ("read" / "read & write") are what get written to the mode -// file and what the bundled Rego policies match on (input.mode == "read"), so +// Canonical values ("enabled" / "disabled") are what get written to the mode +// file and what the bundled Rego policies match on (input.mode == "enabled"), so // aliases are resolved here at the CLI boundary and never leak into the file or -// the policy layer. Accepts: ro/r/read and rw/w/"read & write" (and a few -// punctuation variants), case-insensitive. +// the policy layer. func normalizeMode(v string) (string, bool) { switch strings.ToLower(strings.TrimSpace(v)) { - case "ro", "r", "read": - return "read", true - case "rw", "w", "read & write", "read&write", "read+write", "readwrite": - return "read & write", true + case "enabled", "enable", "on", "closed", "close", "lock", "ro", "r", "read", "safe": + return "enabled", true + case "disabled", "disable", "off", "open", "unlock", "rw", "w", "write", "sudo": + return "disabled", true default: return "", false } @@ -55,7 +54,7 @@ func ModeGet(out io.Writer, opts ModeOptions) int { func ModeSet(val string, out io.Writer, opts ModeOptions) int { canon, ok := normalizeMode(val) if !ok { - fmt.Fprintf(out, "invalid mode %q (use 'rw' / 'ro', or 'read' / 'read & write')\n", val) + fmt.Fprintf(out, "invalid mode %q (use 'enabled'/'disabled'; aliases: on/off, ro/rw, read/write, lock/sudo)\n", val) return 2 } _, path, ok := opts.Chain.FirstWritable(opts.Env) diff --git a/internal/subcommand/mode_alias_test.go b/internal/subcommand/mode_alias_test.go index 0c86a44..9076ffa 100644 --- a/internal/subcommand/mode_alias_test.go +++ b/internal/subcommand/mode_alias_test.go @@ -9,37 +9,28 @@ import ( "testing" ) -func TestNormalizeMode(t *testing.T) { - cases := []struct { - in string - want string - valid bool - }{ - {"rw", "read & write", true}, - {"ro", "read", true}, - {"r", "read", true}, - {"w", "read & write", true}, - {"RW", "read & write", true}, // case-insensitive - {" ro ", "read", true}, // trimmed - {"read", "read", true}, // canonical still accepted - {"read & write", "read & write", true}, - {"read+write", "read & write", true}, - {"readwrite", "read & write", true}, - {"nonsense", "", false}, - {"", "", false}, +func TestNormalizeModeMatrix(t *testing.T) { + enabled := []string{"enabled", "enable", "on", "closed", "close", "lock", "ro", "r", "read", "safe"} + disabled := []string{"disabled", "disable", "off", "open", "unlock", "rw", "w", "write", "sudo"} + for _, a := range enabled { + if got, ok := normalizeMode(a); !ok || got != "enabled" { + t.Fatalf("%q -> %q,%v; want enabled", a, got, ok) + } } - for _, c := range cases { - got, ok := normalizeMode(c.in) - if ok != c.valid || got != c.want { - t.Errorf("normalizeMode(%q) = (%q, %v), want (%q, %v)", c.in, got, ok, c.want, c.valid) + for _, a := range disabled { + if got, ok := normalizeMode(a); !ok || got != "disabled" { + t.Fatalf("%q -> %q,%v; want disabled", a, got, ok) } } + if _, ok := normalizeMode("bogus"); ok { + t.Fatal("bogus should not normalize") + } } // The whole point of the change: `mode set rw` must write the canonical value // the Rego policies match on, not the alias. func TestModeSet_ShortAliasesWriteCanonical(t *testing.T) { - cases := map[string]string{"rw": "read & write", "ro": "read"} + cases := map[string]string{"rw": "disabled", "ro": "enabled"} for alias, canon := range cases { chain, dir, path := tempChain(t) var out bytes.Buffer diff --git a/internal/subcommand/toggle.go b/internal/subcommand/toggle.go index ea46945..3234185 100644 --- a/internal/subcommand/toggle.go +++ b/internal/subcommand/toggle.go @@ -17,13 +17,13 @@ type ToggleOptions struct { Env map[string]string } -// Toggle flips the first writable mode source between "read" and "read & write". +// Toggle flips the first writable mode source between "enabled" and "disabled". // Atomic via temp-file + rename. func Toggle(out io.Writer, opts ToggleOptions) int { current, _, _ := opts.Chain.Resolve(opts.Env) - next := "read & write" - if current == "read & write" { - next = "read" + next := "disabled" + if current == "disabled" { + next = "enabled" } _, path, ok := opts.Chain.FirstWritable(opts.Env) if !ok { diff --git a/internal/subcommand/toggle_test.go b/internal/subcommand/toggle_test.go index 8893c7a..ecedad7 100644 --- a/internal/subcommand/toggle_test.go +++ b/internal/subcommand/toggle_test.go @@ -18,34 +18,34 @@ func tempChain(t *testing.T) (*mode.Chain, string, string) { path := filepath.Join(dir, "mode") chain := &mode.Chain{ Sources: []mode.Source{mode.FileSource{Pattern: filepath.Join(dir, "mode")}}, - Default: "read", + Default: "enabled", } return chain, dir, path } -func TestToggle_FromReadToReadWrite(t *testing.T) { +func TestToggle_FromEnabledToDisabled(t *testing.T) { chain, dir, path := tempChain(t) - os.WriteFile(path, []byte("read"), 0o644) + os.WriteFile(path, []byte("enabled"), 0o644) var out bytes.Buffer code := Toggle(&out, ToggleOptions{Chain: chain, Env: map[string]string{"HOME": dir}}) if code != 0 { t.Fatalf("exit=%d", code) } body, _ := os.ReadFile(path) - if string(body) != "read & write" { - t.Errorf("after toggle, file = %q, want 'read & write'", body) + if string(body) != "disabled" { + t.Errorf("after toggle, file = %q, want 'disabled'", body) } } func TestModeGet_ShowsValueAndSource(t *testing.T) { chain, dir, path := tempChain(t) - os.WriteFile(path, []byte("read & write"), 0o644) + os.WriteFile(path, []byte("disabled"), 0o644) var out bytes.Buffer code := ModeGet(&out, ModeOptions{Chain: chain, Env: map[string]string{"HOME": dir}}) if code != 0 { t.Fatalf("exit=%d", code) } - if !contains(out.String(), "read & write") { + if !contains(out.String(), "disabled") { t.Errorf("missing value: %q", out.String()) } } @@ -53,12 +53,12 @@ func TestModeGet_ShowsValueAndSource(t *testing.T) { func TestModeSet_WritesValue(t *testing.T) { chain, dir, path := tempChain(t) var out bytes.Buffer - code := ModeSet("read & write", &out, ModeOptions{Chain: chain, Env: map[string]string{"HOME": dir}}) + code := ModeSet("disabled", &out, ModeOptions{Chain: chain, Env: map[string]string{"HOME": dir}}) if code != 0 { t.Fatalf("exit=%d", code) } body, _ := os.ReadFile(path) - if string(body) != "read & write" { + if string(body) != "disabled" { t.Errorf("written value = %q", body) } } From 4cd35a40121ad3be5ec0b16e0072ec5e2501b0ca Mon Sep 17 00:00:00 2001 From: Tombar Date: Sun, 7 Jun 2026 17:57:29 -0300 Subject: [PATCH 27/30] corpus: add failsafe_enabled to fixtures, rename lairguard->failsafe, legacy guard.user back-compat case - Add failsafe_enabled:true to 27 fact.json files (mode=read) and failsafe_enabled:false to 3 (mode="read & write": 03, 23, 26) so bundled policies' `not input.failsafe_enabled == false` gate correctly allows mutations in read & write mode. - Update corpus 30 expected.json reason_contains from "blocked in read mode" to "blocked while failsafe is enabled" to match the renamed reason string in kubectl.rego. - Rename 5 corpus dirs: lairguard-* -> failsafe-* (25-29). - Add TestLegacyGuardUserPolicy_BlocksWithInputMode to internal/embed/policies_failsafe_test.go: compiles a hand-written package guard.user module with input.mode == "read" gate and proves it fires alongside the bundled failsafe.bundled.* namespace, guaranteeing legacy user policies keep working (dual-namespace back-compat proof). All 30 corpus cases pass; go test ./... green. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/embed/policies_failsafe_test.go | 75 +++++++++++++++++++ test/corpus/01-kubectl-get-allowed/fact.json | 2 +- .../02-kubectl-apply-blocked-read/fact.json | 2 +- .../03-kubectl-apply-allowed-rw/fact.json | 2 +- .../fact.json | 2 +- .../05-kubectl-flag-before-verb-get/fact.json | 2 +- .../fact.json | 2 +- test/corpus/07-helm-list-allowed/fact.json | 2 +- test/corpus/08-helm-install-blocked/fact.json | 2 +- .../fact.json | 2 +- .../10-terraform-plan-allowed/fact.json | 2 +- .../11-terraform-apply-blocked/fact.json | 2 +- .../12-terraform-state-list-allowed/fact.json | 2 +- .../13-terraform-state-rm-blocked/fact.json | 2 +- .../14-terraform-chdir-flag-plan/fact.json | 2 +- test/corpus/15-aws-sts-allowed/fact.json | 2 +- test/corpus/16-aws-s3-ls-allowed/fact.json | 2 +- test/corpus/17-aws-s3-rm-blocked/fact.json | 2 +- .../18-aws-eks-describe-allowed/fact.json | 2 +- .../19-aws-eks-delete-blocked/fact.json | 2 +- .../20-aws-region-flag-then-s3-ls/fact.json | 2 +- test/corpus/21-git-status-allowed/fact.json | 2 +- .../22-git-push-allowed-bundled/fact.json | 2 +- .../fact.json | 2 +- test/corpus/24-no-tool-allows/fact.json | 2 +- .../expected.json | 0 .../25-failsafe-toggle-blocked-read/fact.json | 1 + .../fact.json | 1 - .../expected.json | 0 .../26-failsafe-toggle-blocked-rw/fact.json | 1 + .../26-lairguard-toggle-blocked-rw/fact.json | 1 - .../expected.json | 0 .../corpus/27-failsafe-hook-blocked/fact.json | 1 + .../27-lairguard-hook-blocked/fact.json | 1 - .../expected.json | 0 .../28-failsafe-mode-get-allowed/fact.json | 1 + .../28-lairguard-mode-get-allowed/fact.json | 1 - .../expected.json | 0 .../29-failsafe-tools-list-allowed/fact.json | 1 + .../29-lairguard-tools-list-allowed/fact.json | 1 - .../expected.json | 2 +- .../30-kubectl-dynamic-verb-blocked/fact.json | 2 +- 42 files changed, 106 insertions(+), 31 deletions(-) rename test/corpus/{25-lairguard-toggle-blocked-read => 25-failsafe-toggle-blocked-read}/expected.json (100%) create mode 100644 test/corpus/25-failsafe-toggle-blocked-read/fact.json delete mode 100644 test/corpus/25-lairguard-toggle-blocked-read/fact.json rename test/corpus/{26-lairguard-toggle-blocked-rw => 26-failsafe-toggle-blocked-rw}/expected.json (100%) create mode 100644 test/corpus/26-failsafe-toggle-blocked-rw/fact.json delete mode 100644 test/corpus/26-lairguard-toggle-blocked-rw/fact.json rename test/corpus/{27-lairguard-hook-blocked => 27-failsafe-hook-blocked}/expected.json (100%) create mode 100644 test/corpus/27-failsafe-hook-blocked/fact.json delete mode 100644 test/corpus/27-lairguard-hook-blocked/fact.json rename test/corpus/{28-lairguard-mode-get-allowed => 28-failsafe-mode-get-allowed}/expected.json (100%) create mode 100644 test/corpus/28-failsafe-mode-get-allowed/fact.json delete mode 100644 test/corpus/28-lairguard-mode-get-allowed/fact.json rename test/corpus/{29-lairguard-tools-list-allowed => 29-failsafe-tools-list-allowed}/expected.json (100%) create mode 100644 test/corpus/29-failsafe-tools-list-allowed/fact.json delete mode 100644 test/corpus/29-lairguard-tools-list-allowed/fact.json diff --git a/internal/embed/policies_failsafe_test.go b/internal/embed/policies_failsafe_test.go index 53db4d3..597126a 100644 --- a/internal/embed/policies_failsafe_test.go +++ b/internal/embed/policies_failsafe_test.go @@ -177,3 +177,78 @@ func TestFailsafeGate_ReadVerb_AlwaysAllowed(t *testing.T) { } } } + +// ────────────────────────────────────────────────────────────────────────────── +// Back-compat: legacy package guard.user policy + input.mode still works +// +// A user who wrote their own policy using the OLD package namespace +// (guard.user) and OLD field (input.mode == "read") must still have it +// respected alongside the bundled failsafe.bundled.* policies. This test +// compiles a hand-written legacy guard.user module, evaluates it against a +// fact that carries BOTH input.mode and input.failsafe_enabled, and asserts +// the legacy rule fires β€” proving the dual-namespace design is intact and +// old user policies don't silently break. +// +// The legacy policy blocks `kubectl get` when mode=="read". The bundled +// kubectl.rego would ALLOW kubectl get (it is in read_verbs), so any block +// here comes purely from the legacy user module β€” cleanly isolating the +// back-compat path. + +const legacyGuardUserPolicy = ` +package guard.user + +import future.keywords.if +import future.keywords.contains + +# Legacy rule: blocks kubectl get in read mode. +# Written before the failsafe_enabled field existed. +block contains {"reason": "legacy"} if { + input.mode == "read" + input.tool == "kubectl" + input.verb == "get" +} +` + +func TestLegacyGuardUserPolicy_BlocksWithInputMode(t *testing.T) { + ctx := context.Background() + + // Load the legacy user module alongside the full bundled policy set so + // the OPA compiler sees both namespaces simultaneously. + mods := bundledModuleMap(t) + mods["legacy_guard_user.rego"] = legacyGuardUserPolicy + + opts := []func(*rego.Rego){ + rego.Query("data.guard.user.block"), + } + for name, body := range mods { + opts = append(opts, rego.Module(name, body)) + } + pq, err := rego.New(opts...).PrepareForEval(ctx) + if err != nil { + t.Fatalf("PrepareForEval: %v", err) + } + + // Fact carries BOTH the new field (failsafe_enabled:true) and the legacy + // field (mode:"read"). The legacy rule gates only on input.mode. + fact := map[string]any{ + "mode": "read", + "failsafe_enabled": true, + "tool": "kubectl", + "verb": "get", + } + reasons := evalBlock(t, pq, fact) + if len(reasons) == 0 { + t.Fatal("BACK-COMPAT VIOLATION: legacy guard.user policy with input.mode==\"read\" did not block β€” legacy user policies must still be evaluated") + } + found := false + for _, r := range reasons { + if r == "legacy" { + found = true + break + } + } + if !found { + t.Fatalf("expected reason \"legacy\" from legacy guard.user policy, got: %v", reasons) + } + t.Logf("back-compat confirmed: legacy guard.user blocked with reason: %v", reasons) +} diff --git a/test/corpus/01-kubectl-get-allowed/fact.json b/test/corpus/01-kubectl-get-allowed/fact.json index 2a09618..3659d6d 100644 --- a/test/corpus/01-kubectl-get-allowed/fact.json +++ b/test/corpus/01-kubectl-get-allowed/fact.json @@ -1 +1 @@ -{"mode":"read","tool":"kubectl","verb":"get","positional":["pods"]} +{"mode":"read","failsafe_enabled":true,"tool":"kubectl","verb":"get","positional":["pods"]} diff --git a/test/corpus/02-kubectl-apply-blocked-read/fact.json b/test/corpus/02-kubectl-apply-blocked-read/fact.json index 07aeb8e..5eb6c77 100644 --- a/test/corpus/02-kubectl-apply-blocked-read/fact.json +++ b/test/corpus/02-kubectl-apply-blocked-read/fact.json @@ -1 +1 @@ -{"mode":"read","tool":"kubectl","verb":"apply","positional":["-f","x.yaml"]} +{"mode":"read","failsafe_enabled":true,"tool":"kubectl","verb":"apply","positional":["-f","x.yaml"]} diff --git a/test/corpus/03-kubectl-apply-allowed-rw/fact.json b/test/corpus/03-kubectl-apply-allowed-rw/fact.json index 33ad2b0..6042f0f 100644 --- a/test/corpus/03-kubectl-apply-allowed-rw/fact.json +++ b/test/corpus/03-kubectl-apply-allowed-rw/fact.json @@ -1 +1 @@ -{"mode":"read & write","tool":"kubectl","verb":"apply","positional":["-f","x.yaml"]} +{"mode":"read & write","failsafe_enabled":false,"tool":"kubectl","verb":"apply","positional":["-f","x.yaml"]} diff --git a/test/corpus/04-kubectl-apply-dryrun-server-allowed/fact.json b/test/corpus/04-kubectl-apply-dryrun-server-allowed/fact.json index 79b2e7a..fd939dc 100644 --- a/test/corpus/04-kubectl-apply-dryrun-server-allowed/fact.json +++ b/test/corpus/04-kubectl-apply-dryrun-server-allowed/fact.json @@ -1 +1 @@ -{"mode":"read","tool":"kubectl","verb":"apply","flags":{"dry-run":"server"},"positional":["-f","x.yaml"]} +{"mode":"read","failsafe_enabled":true,"tool":"kubectl","verb":"apply","flags":{"dry-run":"server"},"positional":["-f","x.yaml"]} diff --git a/test/corpus/05-kubectl-flag-before-verb-get/fact.json b/test/corpus/05-kubectl-flag-before-verb-get/fact.json index b51e23e..274d554 100644 --- a/test/corpus/05-kubectl-flag-before-verb-get/fact.json +++ b/test/corpus/05-kubectl-flag-before-verb-get/fact.json @@ -1 +1 @@ -{"mode":"read","tool":"kubectl","verb":"get","flags":{"context":"arn:aws:eks:us-west-2:123456789012:cluster/dev"},"positional":["pods"]} +{"mode":"read","failsafe_enabled":true,"tool":"kubectl","verb":"get","flags":{"context":"arn:aws:eks:us-west-2:123456789012:cluster/dev"},"positional":["pods"]} diff --git a/test/corpus/06-kubectl-flag-before-verb-apply-blocked/fact.json b/test/corpus/06-kubectl-flag-before-verb-apply-blocked/fact.json index 7e7ea5c..7c37f77 100644 --- a/test/corpus/06-kubectl-flag-before-verb-apply-blocked/fact.json +++ b/test/corpus/06-kubectl-flag-before-verb-apply-blocked/fact.json @@ -1 +1 @@ -{"mode":"read","tool":"kubectl","verb":"apply","flags":{"context":"arn:aws:eks:us-west-2:123456789012:cluster/dev"},"positional":["-f","x.yaml"]} +{"mode":"read","failsafe_enabled":true,"tool":"kubectl","verb":"apply","flags":{"context":"arn:aws:eks:us-west-2:123456789012:cluster/dev"},"positional":["-f","x.yaml"]} diff --git a/test/corpus/07-helm-list-allowed/fact.json b/test/corpus/07-helm-list-allowed/fact.json index b1de6dc..016abab 100644 --- a/test/corpus/07-helm-list-allowed/fact.json +++ b/test/corpus/07-helm-list-allowed/fact.json @@ -1 +1 @@ -{"mode":"read","tool":"helm","verb":"list"} +{"mode":"read","failsafe_enabled":true,"tool":"helm","verb":"list"} diff --git a/test/corpus/08-helm-install-blocked/fact.json b/test/corpus/08-helm-install-blocked/fact.json index 1eb4084..bb9b09c 100644 --- a/test/corpus/08-helm-install-blocked/fact.json +++ b/test/corpus/08-helm-install-blocked/fact.json @@ -1 +1 @@ -{"mode":"read","tool":"helm","verb":"install","positional":["foo","bar"]} +{"mode":"read","failsafe_enabled":true,"tool":"helm","verb":"install","positional":["foo","bar"]} diff --git a/test/corpus/09-helm-namespace-flag-before-list/fact.json b/test/corpus/09-helm-namespace-flag-before-list/fact.json index 71f7c88..63b8def 100644 --- a/test/corpus/09-helm-namespace-flag-before-list/fact.json +++ b/test/corpus/09-helm-namespace-flag-before-list/fact.json @@ -1 +1 @@ -{"mode":"read","tool":"helm","verb":"list","flags":{"namespace":"kube-system"}} +{"mode":"read","failsafe_enabled":true,"tool":"helm","verb":"list","flags":{"namespace":"kube-system"}} diff --git a/test/corpus/10-terraform-plan-allowed/fact.json b/test/corpus/10-terraform-plan-allowed/fact.json index 72dd2da..ea6103c 100644 --- a/test/corpus/10-terraform-plan-allowed/fact.json +++ b/test/corpus/10-terraform-plan-allowed/fact.json @@ -1 +1 @@ -{"mode":"read","tool":"terraform","verb":"plan"} +{"mode":"read","failsafe_enabled":true,"tool":"terraform","verb":"plan"} diff --git a/test/corpus/11-terraform-apply-blocked/fact.json b/test/corpus/11-terraform-apply-blocked/fact.json index aaf90d6..023db14 100644 --- a/test/corpus/11-terraform-apply-blocked/fact.json +++ b/test/corpus/11-terraform-apply-blocked/fact.json @@ -1 +1 @@ -{"mode":"read","tool":"terraform","verb":"apply"} +{"mode":"read","failsafe_enabled":true,"tool":"terraform","verb":"apply"} diff --git a/test/corpus/12-terraform-state-list-allowed/fact.json b/test/corpus/12-terraform-state-list-allowed/fact.json index 475149e..04d4f6a 100644 --- a/test/corpus/12-terraform-state-list-allowed/fact.json +++ b/test/corpus/12-terraform-state-list-allowed/fact.json @@ -1 +1 @@ -{"mode":"read","tool":"terraform","verb":"state","subverb":"list"} +{"mode":"read","failsafe_enabled":true,"tool":"terraform","verb":"state","subverb":"list"} diff --git a/test/corpus/13-terraform-state-rm-blocked/fact.json b/test/corpus/13-terraform-state-rm-blocked/fact.json index 683ca6f..9993218 100644 --- a/test/corpus/13-terraform-state-rm-blocked/fact.json +++ b/test/corpus/13-terraform-state-rm-blocked/fact.json @@ -1 +1 @@ -{"mode":"read","tool":"terraform","verb":"state","subverb":"rm"} +{"mode":"read","failsafe_enabled":true,"tool":"terraform","verb":"state","subverb":"rm"} diff --git a/test/corpus/14-terraform-chdir-flag-plan/fact.json b/test/corpus/14-terraform-chdir-flag-plan/fact.json index 9f9aac1..7e043ca 100644 --- a/test/corpus/14-terraform-chdir-flag-plan/fact.json +++ b/test/corpus/14-terraform-chdir-flag-plan/fact.json @@ -1 +1 @@ -{"mode":"read","tool":"terraform","verb":"plan","flags":{"chdir":"modules/foo"}} +{"mode":"read","failsafe_enabled":true,"tool":"terraform","verb":"plan","flags":{"chdir":"modules/foo"}} diff --git a/test/corpus/15-aws-sts-allowed/fact.json b/test/corpus/15-aws-sts-allowed/fact.json index 62d7f88..11f6b87 100644 --- a/test/corpus/15-aws-sts-allowed/fact.json +++ b/test/corpus/15-aws-sts-allowed/fact.json @@ -1 +1 @@ -{"mode":"read","tool":"aws","verb":"sts","subverb":"get-caller-identity"} +{"mode":"read","failsafe_enabled":true,"tool":"aws","verb":"sts","subverb":"get-caller-identity"} diff --git a/test/corpus/16-aws-s3-ls-allowed/fact.json b/test/corpus/16-aws-s3-ls-allowed/fact.json index 44f00a3..337dfba 100644 --- a/test/corpus/16-aws-s3-ls-allowed/fact.json +++ b/test/corpus/16-aws-s3-ls-allowed/fact.json @@ -1 +1 @@ -{"mode":"read","tool":"aws","verb":"s3","subverb":"ls"} +{"mode":"read","failsafe_enabled":true,"tool":"aws","verb":"s3","subverb":"ls"} diff --git a/test/corpus/17-aws-s3-rm-blocked/fact.json b/test/corpus/17-aws-s3-rm-blocked/fact.json index 65b845d..67dc050 100644 --- a/test/corpus/17-aws-s3-rm-blocked/fact.json +++ b/test/corpus/17-aws-s3-rm-blocked/fact.json @@ -1 +1 @@ -{"mode":"read","tool":"aws","verb":"s3","subverb":"rm","positional":["s3://bucket/key"]} +{"mode":"read","failsafe_enabled":true,"tool":"aws","verb":"s3","subverb":"rm","positional":["s3://bucket/key"]} diff --git a/test/corpus/18-aws-eks-describe-allowed/fact.json b/test/corpus/18-aws-eks-describe-allowed/fact.json index 5eaaf20..290e004 100644 --- a/test/corpus/18-aws-eks-describe-allowed/fact.json +++ b/test/corpus/18-aws-eks-describe-allowed/fact.json @@ -1 +1 @@ -{"mode":"read","tool":"aws","verb":"eks","subverb":"describe-cluster","positional":["--name","foo"]} +{"mode":"read","failsafe_enabled":true,"tool":"aws","verb":"eks","subverb":"describe-cluster","positional":["--name","foo"]} diff --git a/test/corpus/19-aws-eks-delete-blocked/fact.json b/test/corpus/19-aws-eks-delete-blocked/fact.json index 9950644..19af306 100644 --- a/test/corpus/19-aws-eks-delete-blocked/fact.json +++ b/test/corpus/19-aws-eks-delete-blocked/fact.json @@ -1 +1 @@ -{"mode":"read","tool":"aws","verb":"eks","subverb":"delete-cluster","positional":["--name","foo"]} +{"mode":"read","failsafe_enabled":true,"tool":"aws","verb":"eks","subverb":"delete-cluster","positional":["--name","foo"]} diff --git a/test/corpus/20-aws-region-flag-then-s3-ls/fact.json b/test/corpus/20-aws-region-flag-then-s3-ls/fact.json index 7de7edb..4496409 100644 --- a/test/corpus/20-aws-region-flag-then-s3-ls/fact.json +++ b/test/corpus/20-aws-region-flag-then-s3-ls/fact.json @@ -1 +1 @@ -{"mode":"read","tool":"aws","verb":"s3","subverb":"ls","flags":{"region":"us-west-2"}} +{"mode":"read","failsafe_enabled":true,"tool":"aws","verb":"s3","subverb":"ls","flags":{"region":"us-west-2"}} diff --git a/test/corpus/21-git-status-allowed/fact.json b/test/corpus/21-git-status-allowed/fact.json index 83a0462..7c9ff2c 100644 --- a/test/corpus/21-git-status-allowed/fact.json +++ b/test/corpus/21-git-status-allowed/fact.json @@ -1 +1 @@ -{"mode":"read","tool":"git","verb":"status"} +{"mode":"read","failsafe_enabled":true,"tool":"git","verb":"status"} diff --git a/test/corpus/22-git-push-allowed-bundled/fact.json b/test/corpus/22-git-push-allowed-bundled/fact.json index a0c7daa..a8ec9ae 100644 --- a/test/corpus/22-git-push-allowed-bundled/fact.json +++ b/test/corpus/22-git-push-allowed-bundled/fact.json @@ -1 +1 @@ -{"mode":"read","tool":"git","verb":"push","positional":["origin","main"]} +{"mode":"read","failsafe_enabled":true,"tool":"git","verb":"push","positional":["origin","main"]} diff --git a/test/corpus/23-readwrite-mode-allows-everything/fact.json b/test/corpus/23-readwrite-mode-allows-everything/fact.json index 33ad2b0..6042f0f 100644 --- a/test/corpus/23-readwrite-mode-allows-everything/fact.json +++ b/test/corpus/23-readwrite-mode-allows-everything/fact.json @@ -1 +1 @@ -{"mode":"read & write","tool":"kubectl","verb":"apply","positional":["-f","x.yaml"]} +{"mode":"read & write","failsafe_enabled":false,"tool":"kubectl","verb":"apply","positional":["-f","x.yaml"]} diff --git a/test/corpus/24-no-tool-allows/fact.json b/test/corpus/24-no-tool-allows/fact.json index e256bc1..0eccc97 100644 --- a/test/corpus/24-no-tool-allows/fact.json +++ b/test/corpus/24-no-tool-allows/fact.json @@ -1 +1 @@ -{"mode":"read"} +{"mode":"read","failsafe_enabled":true} diff --git a/test/corpus/25-lairguard-toggle-blocked-read/expected.json b/test/corpus/25-failsafe-toggle-blocked-read/expected.json similarity index 100% rename from test/corpus/25-lairguard-toggle-blocked-read/expected.json rename to test/corpus/25-failsafe-toggle-blocked-read/expected.json diff --git a/test/corpus/25-failsafe-toggle-blocked-read/fact.json b/test/corpus/25-failsafe-toggle-blocked-read/fact.json new file mode 100644 index 0000000..a5279d1 --- /dev/null +++ b/test/corpus/25-failsafe-toggle-blocked-read/fact.json @@ -0,0 +1 @@ +{"mode":"read","failsafe_enabled":true,"tool":"failsafe","verb":"toggle"} diff --git a/test/corpus/25-lairguard-toggle-blocked-read/fact.json b/test/corpus/25-lairguard-toggle-blocked-read/fact.json deleted file mode 100644 index cc73074..0000000 --- a/test/corpus/25-lairguard-toggle-blocked-read/fact.json +++ /dev/null @@ -1 +0,0 @@ -{"mode":"read","tool":"failsafe","verb":"toggle"} diff --git a/test/corpus/26-lairguard-toggle-blocked-rw/expected.json b/test/corpus/26-failsafe-toggle-blocked-rw/expected.json similarity index 100% rename from test/corpus/26-lairguard-toggle-blocked-rw/expected.json rename to test/corpus/26-failsafe-toggle-blocked-rw/expected.json diff --git a/test/corpus/26-failsafe-toggle-blocked-rw/fact.json b/test/corpus/26-failsafe-toggle-blocked-rw/fact.json new file mode 100644 index 0000000..55e4d7b --- /dev/null +++ b/test/corpus/26-failsafe-toggle-blocked-rw/fact.json @@ -0,0 +1 @@ +{"mode":"read & write","failsafe_enabled":false,"tool":"failsafe","verb":"toggle"} diff --git a/test/corpus/26-lairguard-toggle-blocked-rw/fact.json b/test/corpus/26-lairguard-toggle-blocked-rw/fact.json deleted file mode 100644 index 007876f..0000000 --- a/test/corpus/26-lairguard-toggle-blocked-rw/fact.json +++ /dev/null @@ -1 +0,0 @@ -{"mode":"read & write","tool":"failsafe","verb":"toggle"} diff --git a/test/corpus/27-lairguard-hook-blocked/expected.json b/test/corpus/27-failsafe-hook-blocked/expected.json similarity index 100% rename from test/corpus/27-lairguard-hook-blocked/expected.json rename to test/corpus/27-failsafe-hook-blocked/expected.json diff --git a/test/corpus/27-failsafe-hook-blocked/fact.json b/test/corpus/27-failsafe-hook-blocked/fact.json new file mode 100644 index 0000000..041615c --- /dev/null +++ b/test/corpus/27-failsafe-hook-blocked/fact.json @@ -0,0 +1 @@ +{"mode":"read","failsafe_enabled":true,"tool":"failsafe","verb":"hook"} diff --git a/test/corpus/27-lairguard-hook-blocked/fact.json b/test/corpus/27-lairguard-hook-blocked/fact.json deleted file mode 100644 index b14a6a6..0000000 --- a/test/corpus/27-lairguard-hook-blocked/fact.json +++ /dev/null @@ -1 +0,0 @@ -{"mode":"read","tool":"failsafe","verb":"hook"} diff --git a/test/corpus/28-lairguard-mode-get-allowed/expected.json b/test/corpus/28-failsafe-mode-get-allowed/expected.json similarity index 100% rename from test/corpus/28-lairguard-mode-get-allowed/expected.json rename to test/corpus/28-failsafe-mode-get-allowed/expected.json diff --git a/test/corpus/28-failsafe-mode-get-allowed/fact.json b/test/corpus/28-failsafe-mode-get-allowed/fact.json new file mode 100644 index 0000000..9db05c4 --- /dev/null +++ b/test/corpus/28-failsafe-mode-get-allowed/fact.json @@ -0,0 +1 @@ +{"mode":"read","failsafe_enabled":true,"tool":"failsafe","verb":"mode","subverb":"get"} diff --git a/test/corpus/28-lairguard-mode-get-allowed/fact.json b/test/corpus/28-lairguard-mode-get-allowed/fact.json deleted file mode 100644 index 4eaea30..0000000 --- a/test/corpus/28-lairguard-mode-get-allowed/fact.json +++ /dev/null @@ -1 +0,0 @@ -{"mode":"read","tool":"failsafe","verb":"mode","subverb":"get"} diff --git a/test/corpus/29-lairguard-tools-list-allowed/expected.json b/test/corpus/29-failsafe-tools-list-allowed/expected.json similarity index 100% rename from test/corpus/29-lairguard-tools-list-allowed/expected.json rename to test/corpus/29-failsafe-tools-list-allowed/expected.json diff --git a/test/corpus/29-failsafe-tools-list-allowed/fact.json b/test/corpus/29-failsafe-tools-list-allowed/fact.json new file mode 100644 index 0000000..427675b --- /dev/null +++ b/test/corpus/29-failsafe-tools-list-allowed/fact.json @@ -0,0 +1 @@ +{"mode":"read","failsafe_enabled":true,"tool":"failsafe","verb":"tools","subverb":"list"} diff --git a/test/corpus/29-lairguard-tools-list-allowed/fact.json b/test/corpus/29-lairguard-tools-list-allowed/fact.json deleted file mode 100644 index f2dd42b..0000000 --- a/test/corpus/29-lairguard-tools-list-allowed/fact.json +++ /dev/null @@ -1 +0,0 @@ -{"mode":"read","tool":"failsafe","verb":"tools","subverb":"list"} diff --git a/test/corpus/30-kubectl-dynamic-verb-blocked/expected.json b/test/corpus/30-kubectl-dynamic-verb-blocked/expected.json index af26c14..072f6cf 100644 --- a/test/corpus/30-kubectl-dynamic-verb-blocked/expected.json +++ b/test/corpus/30-kubectl-dynamic-verb-blocked/expected.json @@ -1 +1 @@ -{"block":true,"reason_contains":"blocked in read mode"} +{"block":true,"reason_contains":"blocked while failsafe is enabled"} diff --git a/test/corpus/30-kubectl-dynamic-verb-blocked/fact.json b/test/corpus/30-kubectl-dynamic-verb-blocked/fact.json index 01c94b1..130a88a 100644 --- a/test/corpus/30-kubectl-dynamic-verb-blocked/fact.json +++ b/test/corpus/30-kubectl-dynamic-verb-blocked/fact.json @@ -1 +1 @@ -{"mode":"read","tool":"kubectl","verb":""} +{"mode":"read","failsafe_enabled":true,"tool":"kubectl","verb":""} From cb6f705c0c012e3c978258bebd9aac980cba55f2 Mon Sep 17 00:00:00 2001 From: Tombar Date: Sun, 7 Jun 2026 18:13:15 -0300 Subject: [PATCH 28/30] docs: enabled/disabled vocabulary, failsafe.* namespace, failsafe_enabled fact (mode legacy) Co-Authored-By: Claude Sonnet 4.6 --- .failsafe.rego | 2 +- CHANGELOG.md | 48 ++++++++++++++++ README.md | 14 ++--- docs/claude-statusline.md | 6 +- docs/explanation/roadmap.md | 8 +-- docs/explanation/why-failsafe.md | 2 +- docs/how-to/custom-tool-parser.md | 2 +- docs/how-to/explain-a-command.md | 8 +-- docs/how-to/per-cluster-policy.md | 8 +-- docs/how-to/repo-policy.md | 6 +- docs/reference/bundled-policies.md | 66 +++++++++++----------- docs/reference/cli.md | 26 ++++----- docs/reference/configuration.md | 12 ++-- docs/reference/fact-schema.md | 6 +- docs/reference/modes.md | 16 +++--- docs/toggle/iterm.md | 24 ++++---- docs/toggle/tmux.md | 16 +++--- docs/toggle/wezterm.md | 44 +++++++-------- docs/tutorials/getting-started.md | 24 ++++---- examples/README.md | 2 +- examples/claude-statusline.sh | 6 +- examples/policies/per-cluster.rego | 4 +- examples/policies/per-repo-protection.rego | 2 +- 23 files changed, 202 insertions(+), 150 deletions(-) create mode 100644 CHANGELOG.md diff --git a/.failsafe.rego b/.failsafe.rego index 444f9ea..703def0 100644 --- a/.failsafe.rego +++ b/.failsafe.rego @@ -1,6 +1,6 @@ # .failsafe.rego β€” repo policy for failsafe's own repo. # Demonstrates: a `block` rule and an `allow_override` rule. -package guard.repo +package failsafe.repo import future.keywords.if import future.keywords.in diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..25043a5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,48 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +--- + +## [Unreleased] + +### Changed + +- **Canonical mode vocabulary**: the two mode values are now `enabled` (failsafe active, + bundled blocks fire) and `disabled` (failsafe bypassed, user/repo blocks still apply). + The old strings `read` and `read & write` are retained as **legacy aliases** in the + `mode set` input parser and the `input.mode` fact field for back-compatibility with + existing scripts and Rego rules. + +- **Primary Rego fact is now `input.failsafe_enabled` (bool)**: `true` when the mode is + `enabled`, `false` when `disabled`. Bundled policies gate on + `not input.failsafe_enabled == false` (fail-safe form: unknown == not-false == block). + The legacy field `input.mode` (string, `"read"` / `"read & write"`) is retained and will + continue to work in existing rules β€” no migration required. + +- **Rego namespace `failsafe.*`**: bundled policies now use `failsafe.bundled.`; + user policies use `failsafe.user`; repo policies use `failsafe.repo`. The legacy + `guard.*` namespace (`guard.bundled.*`, `guard.user`, `guard.repo`) is still honored + (dual-namespace) β€” existing `.failsafe.rego` files with `package guard.repo` do not need + to change. + +- **Expanded `mode set` aliases**: `failsafe mode set` now accepts a richer set of + case-insensitive aliases. + + | Input | Canonical value written | + |-------|------------------------| + | `enabled`, `enable`, `on`, `closed`, `close`, `lock`, `ro`, `r`, `read`, `safe` | `enabled` | + | `disabled`, `disable`, `off`, `open`, `unlock`, `rw`, `w`, `write`, `sudo` | `disabled` | + +- **Default mode is `enabled`**: unchanged in behavior, but now reflected by the canonical + string `enabled` rather than `read`. + +- **Block reason prose updated**: bundled policies now emit + `" blocked while failsafe is enabled"` instead of + `" blocked in read mode"`. + +- **Status line / badge vocabulary**: `failsafe mode get` outputs `enabled` or `disabled`; + the bundled `claude-statusline.sh` helper renders `πŸ”’ enabled` / `πŸ”“ disabled`; tab + badges in terminal snippets use `on` / `off`. diff --git a/README.md b/README.md index f25fd77..039cdc8 100644 --- a/README.md +++ b/README.md @@ -100,14 +100,14 @@ you keep another writable. | Mode | | Behavior | | --- | --- | --- | -| `read` | πŸ”’ | **Default.** Bundled policies block mutating verbs across `kubectl`, `helm`, `terraform`, `aws`. | -| `read & write` | πŸ”“ | Bundled blocks bypassed. User and repo policies still apply. | +| `enabled` | πŸ”’ | **Default.** Bundled policies block mutating verbs across `kubectl`, `helm`, `terraform`, `aws`. | +| `disabled` | πŸ”“ | Bundled blocks bypassed. User and repo policies still apply. | ```bash -failsafe toggle # flip the current pane -failsafe mode get # show the effective mode -failsafe mode set rw # read & write (aliases: rw / w) -failsafe mode set ro # read-only (aliases: ro / r) +failsafe toggle # flip the current pane +failsafe mode get # show the effective mode +failsafe mode set disabled # disable failsafe (aliases: off / rw / w / sudo) +failsafe mode set enabled # re-enable failsafe (aliases: on / ro / r / safe) ``` **One-keystroke toggles, badges & status** β€” bind it in your terminal so you never type the command: @@ -126,7 +126,7 @@ policy-as-code. They cascade across three layers: **bundled defaults** β†’ **use (`~/.config/failsafe/`) β†’ **repo** (`.failsafe.rego`). ```rego -package guard.user +package failsafe.user import future.keywords.if import future.keywords.in diff --git a/docs/claude-statusline.md b/docs/claude-statusline.md index 0aad9b1..7520240 100644 --- a/docs/claude-statusline.md +++ b/docs/claude-statusline.md @@ -6,7 +6,7 @@ SPDX-License-Identifier: CC-BY-4.0 # Claude Code status line: always-on guard mode Claude Code can render a custom status line at the bottom of the session. Wire failsafe -into it and you always see whether the agent is in **πŸ”’ read** (safe) or **πŸ”“ write** +into it and you always see whether the agent is in **πŸ”’ enabled** (safe, failsafe active) or **πŸ”“ disabled** (it can mutate). No guessing, and no surprise `kubectl apply`. The snippet: [`examples/claude-statusline.sh`](https://github.com/UndermountainCC/failsafe/blob/main/examples/claude-statusline.sh). @@ -19,8 +19,8 @@ current pane's mode (`failsafe mode get`, resolved via the same mode-source chai uses) and prints, e.g.: ``` -failsafe πŸ”’ read Β· ~/code/infra Β· Opus -failsafe πŸ”“ write Β· ~/code/infra Β· Opus +failsafe πŸ”’ enabled Β· ~/code/infra Β· Opus +failsafe πŸ”“ disabled Β· ~/code/infra Β· Opus ``` Because it reads the same per-pane mode the guard enforces, the line flips the instant you diff --git a/docs/explanation/roadmap.md b/docs/explanation/roadmap.md index 4f4ce97..a0e85c9 100644 --- a/docs/explanation/roadmap.md +++ b/docs/explanation/roadmap.md @@ -63,13 +63,13 @@ block contains {"reason": "npm install without --ignore-scripts runs postinstall input.tool == "npm" input.verb == "install" not input.flags["ignore-scripts"] - input.mode == "read" + not input.failsafe_enabled == false } -# Block npx unconditionally in read mode β€” it fetches and runs remote code. -block contains {"reason": "npx fetches and executes remote code; flip to write mode if you intend this"} if { +# Block npx unconditionally while failsafe is enabled β€” it fetches and runs remote code. +block contains {"reason": "npx fetches and executes remote code; disable failsafe if you intend this"} if { input.tool == "npx" - input.mode == "read" + not input.failsafe_enabled == false } ``` diff --git a/docs/explanation/why-failsafe.md b/docs/explanation/why-failsafe.md index 758a8a1..a4d8a70 100644 --- a/docs/explanation/why-failsafe.md +++ b/docs/explanation/why-failsafe.md @@ -79,7 +79,7 @@ See [Design Principles](./design-principles.md) for the full fail-closed discuss ## Read-only by default, per pane -Beyond comprehension, failsafe establishes a mode boundary. Every terminal pane starts in `read` mode: the bundled policies block all mutating verbs across `kubectl`, `helm`, `terraform`, `aws`, and `git`. You flip a pane to `read & write` when you intend to make changes, and it flips back when you close it or reset it. +Beyond comprehension, failsafe establishes a mode boundary. Every terminal pane starts in `enabled` mode: the bundled policies block all mutating verbs across `kubectl`, `helm`, `terraform`, `aws`, and `git`. You flip a pane to `disabled` when you intend to make changes, and it flips back when you close it or reset it. The per-pane granularity matters: you can run an agent in one pane (armored, read-only) while keeping another agent shell in another pane fully writable. The guard does not get in your way. It gets in the agent's way when the agent is about to do something you have not consciously allowed. diff --git a/docs/how-to/custom-tool-parser.md b/docs/how-to/custom-tool-parser.md index aa0a1e0..1ce7f68 100644 --- a/docs/how-to/custom-tool-parser.md +++ b/docs/how-to/custom-tool-parser.md @@ -94,7 +94,7 @@ gh (user YAML at /Users/you/.config/failsafe/tools/gh.yaml) A loaded parser alone does not block anything: it only makes facts available. Add a `block` rule to your user policy (`~/.config/failsafe/policy.rego`) to act on the new tool: ```rego -package guard.user +package failsafe.user import future.keywords.if import future.keywords.contains diff --git a/docs/how-to/explain-a-command.md b/docs/how-to/explain-a-command.md index 4833c36..204843c 100644 --- a/docs/how-to/explain-a-command.md +++ b/docs/how-to/explain-a-command.md @@ -49,14 +49,14 @@ Reason : prod is read-only When a command contains multiple tool calls chained with `&&` or `;`, each call gets its own `── call N ──` block. explain stops at the **first block**, mirroring the hook. -## explain always evaluates in read mode +## explain always evaluates in enabled mode -`explain` is a dry-run of the **default** guard: it always evaluates in `read` mode and +`explain` is a dry-run of the **default** guard: it always evaluates in `enabled` mode and ignores the pane's mode (and `FAILSAFE_MODE`). That is deliberate: it answers the question that matters: *would this be blocked by default?* -`read & write` only ever *bypasses* bundled `block` rules, so a command that `explain` shows -as blocked by a bundled rule would be allowed in a write pane (your user and repo policies +`disabled` mode only ever *bypasses* bundled `block` rules, so a command that `explain` shows +as blocked by a bundled rule would be allowed in a disabled pane (your user and repo policies still apply). To see a live decision under the pane's actual mode, run the command through your agent so the [`failsafe hook`](../reference/cli.md#hook) path evaluates it. The hook reads the [mode-source chain](../reference/modes.md); `explain` does not. diff --git a/docs/how-to/per-cluster-policy.md b/docs/how-to/per-cluster-policy.md index 8732a9b..d89f6dd 100644 --- a/docs/how-to/per-cluster-policy.md +++ b/docs/how-to/per-cluster-policy.md @@ -26,10 +26,10 @@ The value in the **NAME** column becomes `input.kubectl.cluster_name` inside you mkdir -p ~/.config/failsafe ``` -Open `~/.config/failsafe/policy.rego` in your editor. It must declare `package guard.user`: +Open `~/.config/failsafe/policy.rego` in your editor. It must declare `package failsafe.user`: ```rego -package guard.user +package failsafe.user import future.keywords.if import future.keywords.in @@ -58,7 +58,7 @@ block contains {"reason": "kubectl delete namespace blocked on dev cluster"} if This is the policy from [`examples/policies/per-cluster.rego`](https://github.com/UndermountainCC/failsafe/blob/main/examples/policies/per-cluster.rego). Copy it as a starting point and adjust cluster names. !!! note "User layer blocks survive write mode" - `~/.config/failsafe/policy.rego` lives in the user layer and fires even when a pane is in `read & write` mode. Only a repo-level `allow_override` in a trusted `.failsafe.rego` can lift a user-layer block. See [Policy cascade](../explanation/policy-cascade.md). + `~/.config/failsafe/policy.rego` lives in the user layer and fires even when a pane is in `disabled` mode. Only a repo-level `allow_override` in a trusted `.failsafe.rego` can lift a user-layer block. See [Policy cascade](../explanation/policy-cascade.md). ## 3. Validate before relying on it @@ -70,7 +70,7 @@ Expected output: ``` βœ“ parse OK -βœ“ package: guard.user +βœ“ package: failsafe.user βœ“ rule names: no reserved-rule violations βœ“ rule shapes: all block/allow_override return {"reason": ...} βœ“ fact-field references: all known diff --git a/docs/how-to/repo-policy.md b/docs/how-to/repo-policy.md index 5a41843..d309e55 100644 --- a/docs/how-to/repo-policy.md +++ b/docs/how-to/repo-policy.md @@ -15,12 +15,12 @@ In the root of the repository: touch .failsafe.rego ``` -The file must declare `package guard.repo`. This is the only layer that can write `allow_override` rules to loosen a bundled or user-level block. +The file must declare `package failsafe.repo`. This is the only layer that can write `allow_override` rules to loosen a bundled or user-level block. Here is the policy from [`examples/policies/per-repo-protection.rego`](https://github.com/UndermountainCC/failsafe/blob/main/examples/policies/per-repo-protection.rego) as a starting point: ```rego -package guard.repo +package failsafe.repo import future.keywords.if import future.keywords.in @@ -57,7 +57,7 @@ The validator checks the package name, rule names, rule shapes (`{"reason": …} ``` βœ“ parse OK -βœ“ package: guard.repo +βœ“ package: failsafe.repo βœ“ rule names: no reserved-rule violations βœ“ rule shapes: all block/allow_override return {"reason": ...} βœ“ fact-field references: all known diff --git a/docs/reference/bundled-policies.md b/docs/reference/bundled-policies.md index f02761b..478644d 100644 --- a/docs/reference/bundled-policies.md +++ b/docs/reference/bundled-policies.md @@ -5,15 +5,15 @@ SPDX-License-Identifier: CC-BY-4.0 # Bundled Policies -The exact verbs and subverbs allowed or blocked by each bundled policy in `read` mode. +The exact verbs and subverbs allowed or blocked by each bundled policy while failsafe is enabled. -Bundled policies live in `internal/embed/policies/` and are compiled into the binary. They are always the first layer in the chain (evaluated before user and repo policies). In `read & write` mode, no bundled `block` rule fires. Mode is checked as the first condition in every rule. +Bundled policies live in `internal/embed/policies/` and are compiled into the binary. They are always the first layer in the chain (evaluated before user and repo policies). When failsafe is `disabled`, no bundled `block` rule fires. Mode is checked as the first condition in every rule via `input.failsafe_enabled`. -Policy packages follow the naming convention `guard.bundled.`. +Policy packages follow the naming convention `failsafe.bundled.` (legacy `guard.bundled.` still accepted). --- -## `kubectl`: `guard.bundled.kubectl` +## `kubectl`: `failsafe.bundled.kubectl` Source: `internal/embed/policies/kubectl.rego` @@ -25,9 +25,9 @@ version cluster-info config explain api-resources api-versions auth diff wait ``` -**Special carve-out for `apply --dry-run`:** `kubectl apply` with `--dry-run=client`, `--dry-run=server`, or `--dry-run=true` is allowed even in read mode. +**Special carve-out for `apply --dry-run`:** `kubectl apply` with `--dry-run=client`, `--dry-run=server`, or `--dry-run=true` is allowed even while failsafe is enabled. -**All other verbs are blocked** with reason: `"kubectl blocked in read mode"`. +**All other verbs are blocked** with reason: `"kubectl blocked while failsafe is enabled"`. Examples of blocked verbs: `delete`, `scale`, `patch`, `drain`, `cordon`, `uncordon`, `taint`, `create`, `replace`, `rollout`, `label`, `annotate`, `expose`, `set`, `run`, `cp`, `attach`. @@ -40,8 +40,8 @@ read_verbs := { "api-resources", "api-versions", "auth", "diff", "wait", } -block contains {"reason": sprintf("kubectl %s blocked in read mode", [input.verb])} if { - input.mode == "read" +block contains {"reason": sprintf("kubectl %s blocked while failsafe is enabled", [input.verb])} if { + not input.failsafe_enabled == false input.tool == "kubectl" input.verb != "" not input.verb in read_verbs @@ -56,7 +56,7 @@ allowed_dry_run if { --- -## `helm`: `guard.bundled.helm` +## `helm`: `failsafe.bundled.helm` Source: `internal/embed/policies/helm.rego` @@ -66,9 +66,9 @@ Source: `internal/embed/policies/helm.rego` list get status show search version history template ``` -**`repo` verb, special subverb handling:** `helm repo list` is allowed; any other `helm repo ` (e.g. `add`, `remove`, `update`) is blocked with reason `"helm repo blocked in read mode"`. +**`repo` verb, special subverb handling:** `helm repo list` is allowed; any other `helm repo ` (e.g. `add`, `remove`, `update`) is blocked with reason `"helm repo blocked while failsafe is enabled"`. -**All other verbs are blocked** with reason `"helm blocked in read mode"`. +**All other verbs are blocked** with reason `"helm blocked while failsafe is enabled"`. Examples of blocked verbs: `install`, `upgrade`, `uninstall`, `rollback`, `push`, `package`, `dependency`. @@ -77,16 +77,16 @@ Examples of blocked verbs: `install`, `upgrade`, `uninstall`, `rollback`, `push` ```rego read_verbs := {"list", "get", "status", "show", "search", "version", "history", "template"} -block contains {"reason": sprintf("helm %s blocked in read mode", [input.verb])} if { - input.mode == "read" +block contains {"reason": sprintf("helm %s blocked while failsafe is enabled", [input.verb])} if { + not input.failsafe_enabled == false input.tool == "helm" input.verb != "" input.verb != "repo" not input.verb in read_verbs } -block contains {"reason": sprintf("helm repo %s blocked in read mode", [input.subverb])} if { - input.mode == "read" +block contains {"reason": sprintf("helm repo %s blocked while failsafe is enabled", [input.subverb])} if { + not input.failsafe_enabled == false input.tool == "helm" input.verb == "repo" input.subverb != "list" @@ -95,7 +95,7 @@ block contains {"reason": sprintf("helm repo %s blocked in read mode", [input.su --- -## `terraform` / `tofu`: `guard.bundled.terraform` +## `terraform` / `tofu`: `failsafe.bundled.terraform` Source: `internal/embed/policies/terraform.rego` @@ -107,9 +107,9 @@ Both `terraform` and `tofu` binaries are matched by the same tool parser and eva plan show output validate fmt providers version graph ``` -**`state` verb, special subverb handling:** `terraform state list` and `terraform state show` are allowed; any other subverb (e.g. `mv`, `rm`, `pull`, `push`) is blocked with reason `"terraform state blocked in read mode"`. +**`state` verb, special subverb handling:** `terraform state list` and `terraform state show` are allowed; any other subverb (e.g. `mv`, `rm`, `pull`, `push`) is blocked with reason `"terraform state blocked while failsafe is enabled"`. -**All other verbs are blocked** with reason `"terraform blocked in read mode"`. +**All other verbs are blocked** with reason `"terraform blocked while failsafe is enabled"`. Examples of blocked verbs: `apply`, `destroy`, `import`, `init`, `refresh`, `taint`, `untaint`, `workspace` (non-read subverbs). @@ -118,16 +118,16 @@ Examples of blocked verbs: `apply`, `destroy`, `import`, `init`, `refresh`, `tai ```rego read_verbs := {"plan", "show", "output", "validate", "fmt", "providers", "version", "graph"} -block contains {"reason": sprintf("terraform %s blocked in read mode", [input.verb])} if { - input.mode == "read" +block contains {"reason": sprintf("terraform %s blocked while failsafe is enabled", [input.verb])} if { + not input.failsafe_enabled == false input.tool == "terraform" input.verb != "" input.verb != "state" not input.verb in read_verbs } -block contains {"reason": sprintf("terraform state %s blocked in read mode", [input.subverb])} if { - input.mode == "read" +block contains {"reason": sprintf("terraform state %s blocked while failsafe is enabled", [input.subverb])} if { + not input.failsafe_enabled == false input.tool == "terraform" input.verb == "state" not input.subverb in {"list", "show"} @@ -136,7 +136,7 @@ block contains {"reason": sprintf("terraform state %s blocked in read mode", [in --- -## `aws`: `guard.bundled.aws` +## `aws`: `failsafe.bundled.aws` Source: `internal/embed/policies/aws.rego` @@ -149,26 +149,26 @@ The AWS CLI uses a ` ` pattern, parsed as `verb` (service) + - Any operation whose name starts with `describe-`, `list-`, or `get-` on any service. - A bare service invocation with no operation (e.g. `aws ec2`, `aws --help`). -**Blocked in read mode:** +**Blocked while failsafe is enabled:** -- `aws s3 ` where `subverb` is not `ls` and not empty. Reason: `"aws s3 blocked in read mode"`. -- Any `aws ` where service is not `sts` or `s3`, operation is not empty, and operation does not start with `describe-`, `list-`, or `get-`. Reason: `"aws blocked in read mode"`. +- `aws s3 ` where `subverb` is not `ls` and not empty. Reason: `"aws s3 blocked while failsafe is enabled"`. +- Any `aws ` where service is not `sts` or `s3`, operation is not empty, and operation does not start with `describe-`, `list-`, or `get-`. Reason: `"aws blocked while failsafe is enabled"`. Examples of blocked operations: `aws s3 rm`, `aws ec2 run-instances`, `aws ec2 terminate-instances`, `aws eks create-cluster`, `aws eks delete-cluster`, `aws iam create-role`, `aws ecr batch-delete-image`. **Full rule (exact source):** ```rego -block contains {"reason": sprintf("aws s3 %s blocked in read mode", [input.subverb])} if { - input.mode == "read" +block contains {"reason": sprintf("aws s3 %s blocked while failsafe is enabled", [input.subverb])} if { + not input.failsafe_enabled == false input.tool == "aws" input.verb == "s3" input.subverb != "" input.subverb != "ls" } -block contains {"reason": sprintf("aws %s %s blocked in read mode", [input.verb, input.subverb])} if { - input.mode == "read" +block contains {"reason": sprintf("aws %s %s blocked while failsafe is enabled", [input.verb, input.subverb])} if { + not input.failsafe_enabled == false input.tool == "aws" input.verb != "" input.verb != "sts" @@ -184,7 +184,7 @@ is_read_action(action) if startswith(action, "get-") --- -## `git`: `guard.bundled.git` +## `git`: `failsafe.bundled.git` Source: `internal/embed/policies/git.rego` @@ -194,11 +194,11 @@ The file exists so that `failsafe policies list` includes `git` in the bundled s --- -## `failsafe`: `guard.bundled.failsafe` +## `failsafe`: `failsafe.bundled.failsafe` Source: `internal/embed/policies/failsafe.rego` -This policy guards the `failsafe` binary itself (dogfood). Rules fire **regardless of mode**: the `read`/`read & write` distinction does not apply. +This policy guards the `failsafe` binary itself (dogfood). Rules fire **regardless of mode**: the `enabled`/`disabled` distinction does not apply. | Verb | Decision | Reason | |------|----------|--------| diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 41c3dde..b1ddc8c 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -43,7 +43,7 @@ The hot path. Reads one Claude Code `PreToolUse` JSON envelope from **stdin**, e **Stdout on block:** ```json -{"decision":"block","reason":"kubectl delete blocked in read mode"} +{"decision":"block","reason":"kubectl delete blocked while failsafe is enabled"} ``` **Stdout on allow (plain):** empty. Claude Code interprets exit 0 with no stdout as allow. @@ -90,7 +90,7 @@ Exposes two MCP tools: | Tool | Description | |------|-------------| -| `check_mode` | Returns the current mode (`read` or `read & write`) and pane ID. No arguments. | +| `check_mode` | Returns the current mode (`enabled` or `disabled`) and pane ID. No arguments. | | `toggle_mode` | Flips the mode atomically. No arguments. Returns `{old, new, pane_id}`. | Both tools use the same mode chain as `hook` (env β†’ pane-mode files β†’ TTY file β†’ global file). @@ -110,12 +110,12 @@ Both tools use the same mode chain as `hook` (env β†’ pane-mode files β†’ TTY fi failsafe toggle ``` -Flips the first writable mode source between `read` and `read & write`. Writes atomically (temp-file + rename). +Flips the first writable mode source between `enabled` and `disabled`. Writes atomically (temp-file + rename). Prints ` β†’ ()` on success, e.g.: ``` -read β†’ read & write (/home/user/.claude/pane-mode/12345) +enabled β†’ disabled (/home/user/.claude/pane-mode/12345) ``` **What it reads/writes:** the first writable source in the mode chain whose path resolves in the current environment. See [Modes](modes.md) for the chain order. @@ -138,9 +138,9 @@ failsafe mode get Prints the effective mode and its source, e.g.: ``` -read & write (file: /home/user/.claude/pane-mode/12345) -read (default; no source resolved) -read (env) +disabled (file: /home/user/.claude/pane-mode/12345) +enabled (default; no source resolved) +enabled (env) ``` **Exit codes:** @@ -162,8 +162,8 @@ Sets the mode by writing to the first writable source. Accepts aliases: | Alias | Canonical value written | |-------|------------------------| -| `ro`, `r`, `read` | `read` | -| `rw`, `w`, `read & write`, `read&write`, `read+write`, `readwrite` | `read & write` | +| `enabled`, `enable`, `on`, `closed`, `close`, `lock`, `ro`, `r`, `read`, `safe` | `enabled` | +| `disabled`, `disable`, `off`, `open`, `unlock`, `rw`, `w`, `write`, `sudo` | `disabled` | Matching is case-insensitive. @@ -197,13 +197,13 @@ Positional: ns payments Flags: context = arn:aws:eks:us-east-1:…:cluster/prod Effective cwd: /home/user/project -Mode: read +Mode: enabled Policy chain (3 modules at this cwd): [bundled] bundled/kubectl.rego [user] /home/user/.config/failsafe/policy.rego [repo] /home/user/project/.failsafe.rego [trusted] Decision: BLOCK -Reason : kubectl delete blocked in read mode +Reason : kubectl delete blocked while failsafe is enabled ``` **Exit codes:** @@ -329,8 +329,8 @@ failsafe validate [--strict] Lints a `.rego` file for use as a failsafe policy. Checks in order: 1. **Parse**: valid Rego syntax. -2. **Package**: `package guard.repo` for `.failsafe.rego`, `package guard.user` for files under `~/.config/failsafe/`. -3. **Rule names**: `allow_override` is reserved for `guard.repo`; bundled and user layers may only declare `block`. +2. **Package**: `package failsafe.repo` for `.failsafe.rego`, `package failsafe.user` for files under `~/.config/failsafe/`. (Legacy `guard.repo` / `guard.user` are still accepted β€” dual-namespace.) +3. **Rule names**: `allow_override` is reserved for `failsafe.repo`; bundled and user layers may only declare `block`. 4. **Rule shape**: every `block` and `allow_override` rule must produce `{"reason": }`. 5. **Fact-field references**: `input.` references are checked against the known field list; unknowns emit a warning. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 5a3a534..4ff4adc 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -17,8 +17,8 @@ All paths under `~/.config/failsafe/` are created by failsafe as needed (mode 07 | Path | Layer | Description | |------|-------|-------------| -| `~/.config/failsafe/policy.rego` | User | User-level Rego policy. Must declare `package guard.user`. May contain `block` rules only; `allow_override` is reserved for the repo layer. Loaded on every hook call; missing file is silently skipped. | -| `.failsafe.rego` | Repo | Repository-level Rego policy, placed at the repo root. Must declare `package guard.repo`. May contain both `block` and `allow_override` rules. Ignored until the repo is trusted via `failsafe trust`. Discovered by walking up from the effective `cwd` toward `$HOME` (exclusive). Multiple `.failsafe.rego` files in nested directories are all loaded. | +| `~/.config/failsafe/policy.rego` | User | User-level Rego policy. Must declare `package failsafe.user` (legacy `package guard.user` still accepted). May contain `block` rules only; `allow_override` is reserved for the repo layer. Loaded on every hook call; missing file is silently skipped. | +| `.failsafe.rego` | Repo | Repository-level Rego policy, placed at the repo root. Must declare `package failsafe.repo` (legacy `package guard.repo` still accepted). May contain both `block` and `allow_override` rules. Ignored until the repo is trusted via `failsafe trust`. Discovered by walking up from the effective `cwd` toward `$HOME` (exclusive). Multiple `.failsafe.rego` files in nested directories are all loaded. | ### Tool definitions @@ -41,7 +41,7 @@ See `examples/tools/` in the repo for YAML tool templates. | `ts` | string | UTC timestamp, RFC3339. | | `decision` | string | `"block"`, `"allow"`, or `"allow_override"`. | | `reason` | string | Block reason or override reason. Omitted for plain allows. | -| `mode` | string | Mode at decision time: `"read"` or `"read & write"`. | +| `mode` | string | Mode at decision time: `"enabled"` or `"disabled"`. | | `tool` | string | Registry tool name. Omitted for refuse/parse blocks. | | `verb` | string | Parsed verb. Omitted when empty or not applicable. | | `subverb` | string | Parsed subverb. Omitted when empty. | @@ -67,7 +67,7 @@ See `examples/tools/` in the repo for YAML tool templates. | Path | Description | |------|-------------| -| `~/.claude/pane-mode/` | Per-pane mode file for WezTerm. Created/updated by `failsafe toggle` / `mode set` when `WEZTERM_PANE` is set. Content: `read` or `read & write`. | +| `~/.claude/pane-mode/` | Per-pane mode file for WezTerm. Created/updated by `failsafe toggle` / `mode set` when `WEZTERM_PANE` is set. Content: `enabled` or `disabled`. | | `~/.claude/pane-mode/` | Per-pane mode file for tmux. | | `~/.claude/pane-mode/` | Per-pane mode file for iTerm2. | | `~/.claude/pane-mode/` | Per-pane mode file for Kitty. | @@ -75,7 +75,7 @@ See `examples/tools/` in the repo for YAML tool templates. | `~/.config/failsafe/tty-` | Per-controlling-terminal mode file. Created when no multiplexer env var is set. The `` is the decimal `Rdev` of `/dev/tty`. | | `~/.config/failsafe/mode` | Global fallback mode file. Read when no higher-priority source resolves. | -All mode files contain a single line (`read` or `read & write`). For the full resolution order, see [Modes](modes.md). +All mode files contain a single line (`enabled` or `disabled`). For the full resolution order, see [Modes](modes.md). --- @@ -83,7 +83,7 @@ All mode files contain a single line (`read` or `read & write`). For the full re | Variable | Default | Effect | |----------|---------|--------| -| `FAILSAFE_MODE` | (unset) | When set to `read` or `read & write`, overrides all file-based mode sources. Highest priority in the chain. Not affected by `failsafe toggle` or `mode set`. | +| `FAILSAFE_MODE` | (unset) | When set to `enabled` or `disabled`, overrides all file-based mode sources. Highest priority in the chain. Not affected by `failsafe toggle` or `mode set`. | | `FAILSAFE_LOG` | (unset) | Controls where decisions are logged. Set to `off` to disable logging entirely. Set to an absolute path to log to that file instead of the default `~/.config/failsafe/decisions.jsonl`. | | `HOME` | (OS-provided) | Used to resolve all `~/.config/failsafe/` and `~/.claude/` paths. Set explicitly in test environments. | | `WEZTERM_PANE` | (unset) | Pane identifier for WezTerm. Enables priority-2 mode source `~/.claude/pane-mode/${WEZTERM_PANE}`. | diff --git a/docs/reference/fact-schema.md b/docs/reference/fact-schema.md index 82764cf..65c7572 100644 --- a/docs/reference/fact-schema.md +++ b/docs/reference/fact-schema.md @@ -15,7 +15,8 @@ Policies reference fields as `input.`. All fields are always present (nev | Field | Type | Source | Description | |-------|------|--------|-------------| -| `input.mode` | `string` | Mode chain | `"read"` or `"read & write"`. Bundled policies gate on `input.mode == "read"`. | +| `input.failsafe_enabled` | `bool` | Mode chain | **Primary mode interface.** `true` when failsafe is active (`enabled` mode); `false` when disabled. Bundled policies gate on `not input.failsafe_enabled == false`. Use this field in new policies. | +| `input.mode` | `string` | Mode chain | **Legacy alias.** `"read"` when failsafe is enabled, `"read & write"` when disabled. Retained for back-compat with existing Rego rules that used the old vocabulary. Prefer `input.failsafe_enabled` in new code. | | `input.tool` | `string` | Tool registry | Registry name of the matched tool: `"kubectl"`, `"helm"`, `"terraform"`, `"aws"`, `"git"`, `"failsafe"`. | | `input.verb` | `string` | Tool parser | First non-flag positional after the tool token. E.g. `"delete"` for `kubectl delete pods`. Empty string when no verb is present. | | `input.subverb` | `string` | Tool parser | Second-level positional for compound commands. E.g. `"list"` for `terraform state list`, `"list"` for `helm repo list`. Empty string when not present. | @@ -115,6 +116,7 @@ When using `failsafe test `, each `fact.json` is the raw `input` map seria ```json { + "failsafe_enabled": true, "mode": "read", "tool": "kubectl", "verb": "delete", @@ -129,3 +131,5 @@ When using `failsafe test `, each `fact.json` is the raw `input` map seria "kubectl": { "current_context": "arn:aws:eks:us-east-1:123456789:cluster/prod", "cluster_name": "prod" } } ``` + +`failsafe_enabled` is `true` here because the pane's mode is `enabled` (the default). When mode is `disabled`, `failsafe_enabled` is `false` and `mode` (legacy) carries `"read & write"`. diff --git a/docs/reference/modes.md b/docs/reference/modes.md index d2a99aa..fd97917 100644 --- a/docs/reference/modes.md +++ b/docs/reference/modes.md @@ -13,8 +13,8 @@ The two mode values and the exact source-resolution chain that determines which | Value | Meaning | |-------|---------| -| `read` | Default. Bundled policies block all mutating verbs for `kubectl`, `helm`, `terraform`/`tofu`, and `aws`. User and repo policy `block` rules also apply. | -| `read & write` | Bundled blocks are bypassed. User policy (`~/.config/failsafe/policy.rego`) and repo policy (`.failsafe.rego`) `block` rules still apply. | +| `enabled` | Default. failsafe is active: bundled policies block all mutating verbs for `kubectl`, `helm`, `terraform`/`tofu`, and `aws`. User and repo policy `block` rules also apply. | +| `disabled` | failsafe bundled blocks are bypassed. User policy (`~/.config/failsafe/policy.rego`) and repo policy (`.failsafe.rego`) `block` rules still apply. | The `allow_override` mechanism is separate from mode: a repo policy can override a block at any mode. See the [bundled policies](bundled-policies.md) and [configuration](configuration.md) pages. @@ -22,7 +22,7 @@ The `allow_override` mechanism is separate from mode: a repo policy can override ## Mode source chain -Sources are tried in order. The **first source that returns a value** wins; all remaining sources are skipped. If no source resolves, the hard-coded default `"read"` is used. +Sources are tried in order. The **first source that returns a value** wins; all remaining sources are skipped. If no source resolves, the hard-coded default `"enabled"` is used. | Priority | Source type | Pattern / variable | Notes | |----------|-------------|-------------------|-------| @@ -34,11 +34,11 @@ Sources are tried in order. The **first source that returns a value** wins; all | 6 | File | `${HOME}/.claude/pane-mode/${CLAUDE_SESSION_ID}` | Per-session mode keyed on `CLAUDE_SESSION_ID`. Skipped when unset. | | 7 | TTY file | `${HOME}/.config/failsafe/tty-` | Per-controlling-terminal mode. The device ID comes from `stat(/dev/tty).Rdev`. Skipped in headless/CI environments where `/dev/tty` is unavailable. | | 8 | File | `${HOME}/.config/failsafe/mode` | Global fallback, shared across all terminals that have no multiplexer variable set. | -| (none) | Default | `"read"` | Hard-coded. Used when all sources are skipped. | +| (none) | Default | `"enabled"` | Hard-coded. Used when all sources are skipped. | ### File format -All file sources contain a single line: either `read` or `read & write`. Trailing whitespace is trimmed. The file is written atomically (temp-file + rename) by `toggle` and `mode set`. +All file sources contain a single line: either `enabled` or `disabled`. Trailing whitespace is trimmed. The file is written atomically (temp-file + rename) by `toggle` and `mode set`. ### Variable expansion @@ -62,7 +62,7 @@ In headless environments (CI, daemon, `ssh` without a tty), `open("/dev/tty")` f | Input | Canonical value written | |-------|------------------------| -| `ro`, `r`, `read` | `read` | -| `rw`, `w`, `read & write`, `read&write`, `read+write`, `readwrite` | `read & write` | +| `enabled`, `enable`, `on`, `closed`, `close`, `lock`, `ro`, `r`, `read`, `safe` | `enabled` | +| `disabled`, `disable`, `off`, `open`, `unlock`, `rw`, `w`, `write`, `sudo` | `disabled` | -The canonical values are what the Rego policy layer reads: `input.mode == "read"` matches only the exact string `"read"`. +The primary Rego interface is **`input.failsafe_enabled`** (bool): `true` when mode is `enabled`, `false` when `disabled`. The legacy field **`input.mode`** (string) is also present for back-compat, carrying `"read"` (when enabled) or `"read & write"` (when disabled) β€” see [Fact Schema](fact-schema.md). diff --git a/docs/toggle/iterm.md b/docs/toggle/iterm.md index b507353..a8d8a24 100644 --- a/docs/toggle/iterm.md +++ b/docs/toggle/iterm.md @@ -7,7 +7,7 @@ SPDX-License-Identifier: CC-BY-4.0 failsafe resolves the current session's mode from `~/.claude/pane-mode/$ITERM_SESSION_ID` (see the mode-source chain in the README). The goal: bind a key (say `Ctrl+Opt+T`) that -flips the **focused** session between `read` and `read & write` without injecting text +flips the **focused** session between `enabled` and `disabled` without injecting text into your shell. > **The one catch.** iTerm2's Python API gives a script the session's *GUID* @@ -42,7 +42,7 @@ Save as `~/Library/Application Support/iTerm2/Scripts/AutoLaunch/failsafe_toggle ```python #!/usr/bin/env python3 # failsafe per-session mode toggle for iTerm2. -# Flips ~/.claude/pane-mode/$ITERM_SESSION_ID between "read" and "read & write". +# Flips ~/.claude/pane-mode/$ITERM_SESSION_ID between "enabled" and "disabled". import base64 import os import iterm2 @@ -52,9 +52,9 @@ MODE_DIR = os.path.expanduser("~/.claude/pane-mode") def read_mode(path): try: with open(path) as f: - return (f.read().strip() or "read") + return (f.read().strip() or "enabled") except FileNotFoundError: - return "read" # missing file = safe default + return "enabled" # missing file = safe default async def main(connection): # `sid_b64` is the base64 of $ITERM_SESSION_ID, published by the shell hook in step 1. @@ -68,7 +68,7 @@ async def main(connection): path = os.path.join(MODE_DIR, sid) current = read_mode(path) - new_mode = "read & write" if current == "read" else "read" + new_mode = "disabled" if current == "enabled" else "enabled" with open(path, "w") as f: f.write(new_mode) # canonical value the Rego policies match @@ -118,22 +118,22 @@ toggles silently regardless of what the shell is doing. ## "sudo mode" -Write-enable is failsafe's `sudo`, so make the notification say so. Swap the `osascript` +Disabling failsafe is your `sudo`, so make the notification say so. Swap the `osascript` line in the script for: ```python -title = "πŸ”“ failsafe: sudo mode" if new_mode == "read & write" else "πŸ”’ failsafe" -sub = "write enabled β€” with great power…" if new_mode == "read & write" else "back to read-only" +title = "πŸ”“ failsafe: sudo mode" if new_mode == "disabled" else "πŸ”’ failsafe" +sub = "write enabled β€” with great power…" if new_mode == "disabled" else "back to enabled" os.system(f"osascript -e 'display notification \"{sub}\" with title \"{title}\"' >/dev/null 2>&1") ``` See the WezTerm guide's *"sudo mode"* section for the matching badge and the **sudo -timeout** trick (auto-revert to read-only after N minutes; the same idea works here: -`os.system(f\"( sleep 600; echo read > '{path}' ) &\")` right after the write). +timeout** trick (auto-revert to enabled after N minutes; the same idea works here: +`os.system(f\"( sleep 600; echo enabled > '{path}' ) &\")` right after the write). ## Notes -- The file always stores the **canonical** value (`read` / `read & write`), the same - thing `failsafe mode set rw` / `ro` normalize to, so the toggle and the CLI agree. +- The file always stores the **canonical** value (`enabled` / `disabled`), the same + thing `failsafe mode set on` / `off` normalize to, so the toggle and the CLI agree. - Per-session isolation depends on `$ITERM_SESSION_ID` being unique per session, which iTerm guarantees. If a session predates the step-1 hook, open a fresh tab. diff --git a/docs/toggle/tmux.md b/docs/toggle/tmux.md index a58268d..e10a5af 100644 --- a/docs/toggle/tmux.md +++ b/docs/toggle/tmux.md @@ -10,7 +10,7 @@ mode-source chain in the README). tmux's `#{pane_id}` format **equals** that `$T env var, so a key binding can write the exact file the guard reads instantly, with no chain ambiguity. Missing file = `read` (the safe default). -Bind a key to flip the focused pane between `read` and `read & write`. +Bind a key to flip the focused pane between `enabled` and `disabled`. ## 1. The toggle helper @@ -27,8 +27,8 @@ dir="$HOME/.claude/pane-mode" file="$dir/$pane" mkdir -p "$dir" -current="$(cat "$file" 2>/dev/null || echo read)" -[ "$current" = "read & write" ] && next="read" || next="read & write" +current="$(cat "$file" 2>/dev/null || echo enabled)" +[ "$current" = "disabled" ] && next="enabled" || next="disabled" printf '%s' "$next" > "$file" # canonical value the Rego policies match tmux display-message "πŸ”’ failsafe: $current β†’ $next" @@ -57,11 +57,11 @@ Show the focused pane's mode in the status bar. Save as ```bash #!/usr/bin/env bash -mode="$(cat "$HOME/.claude/pane-mode/${1:-}" 2>/dev/null || echo read)" -if [ "$mode" = "read & write" ]; then +mode="$(cat "$HOME/.claude/pane-mode/${1:-}" 2>/dev/null || echo enabled)" +if [ "$mode" = "disabled" ]; then printf '#[fg=yellow,bold]πŸ”“ sudo#[default]' # write enabled β€” make it loud else - printf '#[fg=green]πŸ”’ read#[default]' + printf '#[fg=green]πŸ”’ on#[default]' fi ``` @@ -92,8 +92,8 @@ bind -n C-M-t run-shell "WEZTERM_PANE= ITERM_SESSION_ID= TMUX_PANE='#{pane_id}' ## Notes -- The file always stores the **canonical** value (`read` / `read & write`), the same - thing `failsafe mode set rw` / `ro` normalize to, so the toggle, the status bar, and +- The file always stores the **canonical** value (`enabled` / `disabled`), the same + thing `failsafe mode set on` / `off` normalize to, so the toggle, the status bar, and the CLI all agree. - `$TMUX_PANE` (e.g. `%5`) is unique per pane and stable for the pane's life, so each split keeps its own mode. diff --git a/docs/toggle/wezterm.md b/docs/toggle/wezterm.md index b3ae023..0cbf18d 100644 --- a/docs/toggle/wezterm.md +++ b/docs/toggle/wezterm.md @@ -10,7 +10,7 @@ failsafe resolves the current pane's mode from `~/.claude/pane-mode/$WEZTERM_PAN `pane:pane_id()`, so the toggle can **write that file directly**: no subprocess, no `failsafe` call, instant. Missing file = `read` (the safe default). -Bind `Ctrl+Alt+T` to flip the focused pane between `read` and `read & write`. +Bind `Ctrl+Alt+T` to flip the focused pane between `enabled` and `disabled`. ## Drop-in snippet @@ -21,7 +21,7 @@ dependencies beyond WezTerm itself. local wezterm = require("wezterm") local act = wezterm.action --- ~/.claude/pane-mode/ holds "read" or "read & write". +-- ~/.claude/pane-mode/ holds "enabled" or "disabled". -- This must match failsafe's mode-source chain ($WEZTERM_PANE). local function mode_dir() return (os.getenv("HOME") or "") .. "/.claude/pane-mode" @@ -33,16 +33,16 @@ end local function get_mode(pane_id) local f = io.open(mode_path(pane_id), "r") - if not f then return "read" end -- missing file = safe default + if not f then return "enabled" end -- missing file = safe default local line = f:read("*l") f:close() if line and #line > 0 then return line end - return "read" + return "enabled" end local function toggle_mode(pane_id) local current = get_mode(pane_id) - local next_mode = (current == "read") and "read & write" or "read" + local next_mode = (current == "enabled") and "disabled" or "enabled" os.execute("mkdir -p '" .. mode_dir() .. "'") local f = io.open(mode_path(pane_id), "w") if f then @@ -79,17 +79,17 @@ for _, k in ipairs(toggler.keys) do end ``` -## Optional: a tab-title badge (`r` / `rw`) +## Optional: a tab-title badge (`on` / `off`) Show the focused pane's mode in the tab title so the state is always visible. ```lua wezterm.on("format-tab-title", function(tab) local mode = toggler.get_mode(tab.active_pane.pane_id) - local badge = (mode == "read & write") and " rw " or " r " + local badge = (mode == "disabled") and " off " or " on " return { - -- amber when writable (caution), dim when read-only - { Foreground = { Color = (mode == "read & write") and "#ffb02e" or "#7a756c" } }, + -- amber when disabled/writable (caution), dim when enabled/read-only + { Foreground = { Color = (mode == "disabled") and "#ffb02e" or "#7a756c" } }, { Text = badge }, { Text = tab.active_pane.title }, } @@ -98,7 +98,7 @@ end) ## Make it yours: "sudo mode" -Flipping a pane to `read & write` is failsafe's `sudo`: you're handing the agent the +Flipping a pane to `disabled` is failsafe's `sudo`: you're handing the agent the sharp knives, on purpose, for a moment. Lean into it so the elevated state is impossible to miss. @@ -106,10 +106,10 @@ to miss. -- "sudo make me a sandwich." β€” https://xkcd.com/149/ local toggle_action = wezterm.action_callback(function(window, pane) local old, new = toggle_mode(pane:pane_id()) - if new == "read & write" then + if new == "disabled" then window:toast_notification("πŸ”“ failsafe: sudo mode", "write enabled β€” with great power…", nil, 4000) else - window:toast_notification("πŸ”’ failsafe", "back to read-only. phew.", nil, 2500) + window:toast_notification("πŸ”’ failsafe", "back to enabled. phew.", nil, 2500) end window:set_config_overrides(window:get_config_overrides() or {}) end) @@ -118,30 +118,30 @@ end) Badge it as `sudo` when elevated: ```lua -local badge = (mode == "read & write") and " ⚑ sudo " or " r " +local badge = (mode == "disabled") and " ⚑ sudo " or " on " ``` **Bonus: sudo timeout.** Real `sudo` forgets you after a few minutes; a *fail*-safe -should too. Auto-revert a pane to read-only after N minutes of write, so you never walk +should too. Auto-revert a pane to enabled after N minutes of disabled, so you never walk away with the knives out: ```lua --- inside toggle_mode, right after writing "read & write": -if next_mode == "read & write" then +-- inside toggle_mode, right after writing "disabled": +if next_mode == "disabled" then os.execute(string.format( - "( sleep 600; echo read > '%s' ) >/dev/null 2>&1 &", -- 10 min, detached + "( sleep 600; echo enabled > '%s' ) >/dev/null 2>&1 &", -- 10 min, detached mode_path(pane_id))) end ``` -(The label is cosmetic: the file still stores the canonical `read & write`, so policies +(The label is cosmetic: the file still stores the canonical `disabled`, so policies and `failsafe mode get` are unaffected.) ## Notes -- The file always stores the **canonical** value (`read` / `read & write`) because - that is what the bundled Rego policies match on (`input.mode == "read"`). The CLI's - `rw` / `ro` aliases (`failsafe mode set rw`) normalize to the same canonical value, so - the WezTerm toggle and the CLI stay compatible. +- The file always stores the **canonical** value (`enabled` / `disabled`) because + that is what the bundled Rego policies match on (`input.failsafe_enabled`). The CLI's + `rw` / `ro` / `on` / `off` aliases (`failsafe mode set disabled`) normalize to the same + canonical value, so the WezTerm toggle and the CLI stay compatible. - Prefer not to write the file from Lua? Replace `toggle_mode` with a spawn of `failsafe toggle`, but the direct write is instant and needs no binary on PATH. diff --git a/docs/tutorials/getting-started.md b/docs/tutorials/getting-started.md index 4880f80..a6f3822 100644 --- a/docs/tutorials/getting-started.md +++ b/docs/tutorials/getting-started.md @@ -83,14 +83,14 @@ You should see: Verb: delete Positional: ns payments Effective cwd: /your/current/dir -Mode: read +Mode: enabled Policy chain (1 modules at this cwd): [bundled] kubectl.rego Decision: BLOCK -Reason : kubectl delete blocked in read mode +Reason : kubectl delete blocked while failsafe is enabled ``` -`explain` always evaluates in **read** mode, the safe default. The bundled `kubectl.rego` policy blocks every non-read verb (`delete`, `apply`, `scale`, …) when mode is `read`. `kubectl get`, `kubectl describe`, and `kubectl logs` would be allowed. +`explain` always evaluates in **enabled** mode, the safe default. The bundled `kubectl.rego` policy blocks every mutating verb (`delete`, `apply`, `scale`, …) while failsafe is enabled. `kubectl get`, `kubectl describe`, and `kubectl logs` would be allowed. !!! success "You just verified the guard" failsafe parsed the command, identified the tool (`kubectl`), extracted the verb (`delete`), and blocked it before any agent or cluster was involved. @@ -99,10 +99,10 @@ Reason : kubectl delete blocked in read mode ## Step 4: Flip to write mode and back -When you *do* want to allow mutations, for example while you are actively working in a cluster you trust, flip the current pane to write: +When you *do* want to allow mutations, for example while you are actively working in a cluster you trust, disable failsafe for the current pane: ```bash -failsafe mode set rw +failsafe mode set disabled ``` Confirm the mode changed: @@ -112,7 +112,7 @@ failsafe mode get ``` ```console -read & write +disabled ``` Now run a read-only `kubectl get` to confirm allowed commands still pass through: @@ -133,12 +133,12 @@ Decision: ALLOW ``` !!! note - `explain` always evaluates in read mode regardless of your pane's current mode: it is a safe dry-run tool. To observe the effect of write mode on a live agent session, set `rw` and then run Claude Code in that pane; `failsafe hook` (not `explain`) reads the pane's actual mode file. + `explain` always evaluates in enabled mode regardless of your pane's current mode: it is a safe dry-run tool. To observe the effect of disabled mode on a live agent session, set `disabled` and then run Claude Code in that pane; `failsafe hook` (not `explain`) reads the pane's actual mode file. -When you are done, lock the pane back to read-only: +When you are done, re-enable failsafe for the pane: ```bash -failsafe mode set ro +failsafe mode set enabled ``` Or use the shorthand toggle (useful for a terminal keybinding): @@ -147,10 +147,10 @@ Or use the shorthand toggle (useful for a terminal keybinding): failsafe toggle ``` -`failsafe mode get` will confirm you are back to `read`. +`failsafe mode get` will confirm you are back to `enabled`. !!! tip "Per-pane, not per-shell" - Mode is stored per pane (keyed by `$WEZTERM_PANE`, `$TMUX_PANE`, or `$ITERM_SESSION_ID`). You can keep an agent pane locked to `read` while a human pane next to it is in `read & write`. See the toggle docs for one-keystroke bindings in [WezTerm](../toggle/wezterm.md), [iTerm2](../toggle/iterm.md), and [tmux](../toggle/tmux.md). + Mode is stored per pane (keyed by `$WEZTERM_PANE`, `$TMUX_PANE`, or `$ITERM_SESSION_ID`). You can keep an agent pane in `enabled` mode while a human pane next to it is in `disabled`. See the toggle docs for one-keystroke bindings in [WezTerm](../toggle/wezterm.md), [iTerm2](../toggle/iterm.md), and [tmux](../toggle/tmux.md). --- @@ -158,7 +158,7 @@ failsafe toggle - failsafe is installed and on your `PATH`. - Claude Code will call `failsafe hook` before every `Bash` command an agent runs. -- The bundled policies block destructive `kubectl`, `helm`, `terraform`, and `aws` commands while your pane is in read mode (the default). +- The bundled policies block destructive `kubectl`, `helm`, `terraform`, and `aws` commands while failsafe is enabled (the default). - You know how to flip a pane to write, verify the mode, and flip it back. --- diff --git a/examples/README.md b/examples/README.md index b28129e..b06056f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,7 +6,7 @@ Drop-in starting points for failsafe policies and tool definitions. - `policies/per-cluster.rego` β€” Per-cluster kubectl rules (prod is read-only; dev allows mutations except namespace deletion). Place in - `~/.config/failsafe/policy.rego` (and change the package to `guard.user`). + `~/.config/failsafe/policy.rego` (and change the package to `failsafe.user`). - `policies/per-repo-protection.rego` β€” Repo-level: kubectl apply via CI only, and no force-push to acme default branches. Place at `/.failsafe.rego`. diff --git a/examples/claude-statusline.sh b/examples/claude-statusline.sh index 8d62d2e..13ecb69 100755 --- a/examples/claude-statusline.sh +++ b/examples/claude-statusline.sh @@ -5,7 +5,7 @@ # failsafe β–Έ Claude Code status line helper. # # Shows the current guard mode (and cwd / model) at the bottom of Claude Code, so -# you always know whether the agent can mutate infra β€” πŸ”’ read or πŸ”“ write. +# you always know whether the agent can mutate infra β€” πŸ”’ enabled or πŸ”“ disabled. # # Wire it in ~/.claude/settings.json: # { @@ -25,8 +25,8 @@ input="$(cat)" # Effective guard mode for the current pane/session (mode-source chain in the README). mode="$(failsafe mode get 2>/dev/null | cut -f1)" case "$mode" in - "read & write") guard="πŸ”“ write" ;; - *) guard="πŸ”’ read" ;; + "disabled") guard="πŸ”“ disabled" ;; + *) guard="πŸ”’ enabled" ;; esac # cwd + model are nice context when jq is present; degrade gracefully without it. diff --git a/examples/policies/per-cluster.rego b/examples/policies/per-cluster.rego index 65abbc3..f3a1803 100644 --- a/examples/policies/per-cluster.rego +++ b/examples/policies/per-cluster.rego @@ -1,6 +1,6 @@ # Example: per-cluster kubectl policy. Place in ~/.config/failsafe/policy.rego -# (after dropping `package guard.repo` for `package guard.user`). -package guard.user +# (after dropping `package failsafe.repo` for `package failsafe.user`). +package failsafe.user import future.keywords.if import future.keywords.in diff --git a/examples/policies/per-repo-protection.rego b/examples/policies/per-repo-protection.rego index 8714597..1494c12 100644 --- a/examples/policies/per-repo-protection.rego +++ b/examples/policies/per-repo-protection.rego @@ -1,5 +1,5 @@ # Example: protect specific repos. Place at /.failsafe.rego. -package guard.repo +package failsafe.repo import future.keywords.if import future.keywords.in From 222a5ef2ea1a3060508f216c943e21b090d067d6 Mon Sep 17 00:00:00 2001 From: Tombar Date: Sun, 7 Jun 2026 19:05:12 -0300 Subject: [PATCH 29/30] test(docs): harness asserts enabled/disabled + failsafe_enabled boolean, legacy mode back-compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update all doc-validation bats assertions from the old read/read-&-write vocabulary to the renamed enabled/disabled canonical values: - helpers.bats: mode-file round-trip uses "disabled" - crosscutting.bats: chain-order writes disabled/enabled; missing-file default is "enabled"; rego meta-test replaced with two assertions β€” (a) all deny rules use `not input.failsafe_enabled == false` and (b) no rule compares `input.mode ==`; back-compat test asserts `mode get | cut -f1` returns `enabled`/`disabled`; alias round-trip updated to new on/off/rw/ro/sudo/lock vocabulary - statusline.bats: glyph/label assertions β†’ `πŸ”’ enabled` / `πŸ”“ disabled` - wezterm.bats: toggle expects `disabled` (from default enabled); badge grep updated to `(mode == "disabled") and " off " or " on "`; sudo-timeout checks `echo enabled` - tmux.bats: toggle flip expects disabled/enabled; status-script test checks `πŸ”’ on`; no-script toggle expects "disabled" - iterm.bats: read_mode default assertion β†’ "enabled"; toggle flips to "disabled" - extract.bats: status-indicator block check β†’ `πŸ”’ on` - live-gui/demo.sh: status() compares "disabled" (drops the legacy bridge comment) - live-gui/manual-test-plan.md: all expect strings β†’ enabled/disabled, on/off badges - REPORT.md: per-claim table and doc-bugs section updated Also fixes a doc bug in docs/toggle/tmux.md: sudo-timeout prose said `echo read` (stale); corrected to `echo enabled`. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/toggle/tmux.md | 2 +- test/docs/REPORT.md | 23 ++++++----- test/docs/crosscutting.bats | 54 ++++++++++++++++---------- test/docs/extract.bats | 2 +- test/docs/helpers.bats | 4 +- test/docs/iterm.bats | 10 ++--- test/docs/live-gui/demo.sh | 9 ++--- test/docs/live-gui/manual-test-plan.md | 36 ++++++++--------- test/docs/statusline.bats | 20 +++++----- test/docs/tmux.bats | 16 ++++---- test/docs/wezterm.bats | 21 +++++----- 11 files changed, 108 insertions(+), 89 deletions(-) diff --git a/docs/toggle/tmux.md b/docs/toggle/tmux.md index e10a5af..3ce15f8 100644 --- a/docs/toggle/tmux.md +++ b/docs/toggle/tmux.md @@ -76,7 +76,7 @@ The `#[fg=…]` codes are interpreted by tmux, so a writable pane glows amber an `πŸ”“ sudo`, failsafe's `sudo` (see the WezTerm guide's [*"sudo mode"*](wezterm.md#make-it-yours-sudo-mode) section for the full meme and the auto-revert *sudo timeout* trick, which works here too: append -`( sleep 600; echo read > "$file" ) &` after the write in `tmux-toggle.sh`). +`( sleep 600; echo enabled > "$file" ) &` after the write in `tmux-toggle.sh`). ## Alternative: no helper script diff --git a/test/docs/REPORT.md b/test/docs/REPORT.md index 10b9412..6f70ed0 100644 --- a/test/docs/REPORT.md +++ b/test/docs/REPORT.md @@ -16,28 +16,28 @@ binary by running the **literal fenced code blocks extracted from the docs**. | Doc | Claim | Method | Result | |---|---|---|---| | cross-cutting | chain order WEZTERMβ†’TMUXβ†’ITERM | black-box `mode get` w/ competing files | **PASS** | -| cross-cutting | missing file β‡’ `read` | black-box | **PASS** | -| cross-cutting | rego matches `"read"` only | grep `internal/embed/policies/*.rego` | **PASS** | -| cross-cutting | rw/ro aliases β‡’ canonical bytes | `mode set` + read back | **PASS** | -| cross-cutting | `mode get` tab-delimited (`cut -f1`) | black-box | **PASS** | -| statusline | `πŸ”’ read` / `πŸ”“ write` glyphs | pipe JSON to `examples/claude-statusline.sh` | **PASS** | +| cross-cutting | missing file β‡’ `enabled` | black-box | **PASS** | +| cross-cutting | rego gates on boolean `failsafe_enabled` (not `input.mode` string) | grep `internal/embed/policies/*.rego` | **PASS** | +| cross-cutting | on/off aliases β‡’ `enabled`/`disabled` canonical bytes | `mode set` + read back | **PASS** | +| cross-cutting | `mode get` tab-delimited (`cut -f1`) returns `enabled`/`disabled` | black-box | **PASS** | +| statusline | `πŸ”’ enabled` / `πŸ”“ disabled` glyphs | pipe JSON to `examples/claude-statusline.sh` | **PASS** | | statusline | jq adds `~`-cwd + model | with jq | **PASS** | | statusline | degrades w/o jq (guard only, no cwd) | nojq PATH shim | **PASS** | | statusline | single-line output | byte check | **PASS** | -| tmux | toggle script flips read↔read&write | run extracted script (via `run-shell`) | **PASS** | +| tmux | toggle script flips enabled↔disabled | run extracted script (via `run-shell`) | **PASS** | | tmux | `#{pane_id}` == `$TMUX_PANE` | live headless tmux session | **PASS** | | tmux | `C-M-t` bound to toggle w/ `#{pane_id}` | `list-keys -T root` (registration) | **PASS** | -| tmux | status script colors (sudo/amber, read/green) | run extracted script | **PASS** | +| tmux | status script colors (sudo/amber, on/green) | run extracted script | **PASS** | | tmux | no-script `failsafe toggle` toggles target pane | black-box | **PASS** | | wezterm | snippet is valid lua | `lua` loadfile | **PASS** | | wezterm | snippet has no luacheck errors | luacheck (runs in CI; skipped local, see Notes) | **PASS** (CI) | | wezterm | snippet's own `toggle_mode` writes canonical; failsafe agrees | lua stub + driver fires `keys[1].action` | **PASS** | -| wezterm | badge maps rwβ†’`⚑ sudo`, readβ†’`r` | exact-grep doc line | **PASS** | +| wezterm | badge maps disabledβ†’`off`, enabledβ†’`on` | exact-grep doc line | **PASS** | | wezterm | sudo-timeout auto-revert mechanism | text-ties to doc + ported 1s revert | **PASS** | | wezterm | toast / `format-tab-title` rendering | β€” | **STATIC** (GUI-only) | | wezterm | config loads + `Ctrl+Alt+t` registered | `wezterm show-keys` (live, real WezTerm) | **PASS (LIVE)** | | iterm | shell hook OSC-1337 base64 roundtrip | run extracted hook + `base64 -d` | **PASS** | -| iterm | doc's own `read_mode` canonical/default | exec the AST `FunctionDef` | **PASS** | +| iterm | doc's own `read_mode` canonical/default (`enabled`) | exec the AST `FunctionDef` | **PASS** | | iterm | script `py_compile`s + `import iterm2` | python | **PASS** | | iterm | script passes pyflakes | `python3 -m pyflakes` | **PASS** (after fix below) | | iterm | no-python `failsafe toggle` flips session file | black-box | **PASS** | @@ -50,6 +50,11 @@ binary by running the **literal fenced code blocks extracted from the docs**. `app = await iterm2.async_get_app(connection)` but registered its RPC off `connection` and never used `app`. Dead API call β€” **removed** (commit `083ead8`). The harness's pyflakes check now guards against regression. +- **`docs/toggle/tmux.md` β€” sudo-timeout prose said `echo read` (FIXED, Phase 7).** The + prose note at the end of the Status indicator section referenced + `( sleep 600; echo read > "$file" ) &` as the auto-revert snippet, but the renamed + canonical value is `enabled`. Updated to `echo enabled` (consistent with wezterm.md which + already had the correct `echo enabled` in its lua code block). ## Benign findings (no change made) diff --git a/test/docs/crosscutting.bats b/test/docs/crosscutting.bats index 542ce6b..a1a3102 100644 --- a/test/docs/crosscutting.bats +++ b/test/docs/crosscutting.bats @@ -5,47 +5,61 @@ teardown() { teardown_sandbox; } # tmux.md states the chain order WEZTERM_PANE -> TMUX_PANE -> ITERM_SESSION_ID. @test "chain order: WEZTERM_PANE wins over TMUX_PANE (black-box)" { - write_mode_file "%w" "read & write" - write_mode_file "%t" "read" + write_mode_file "%w" "disabled" + write_mode_file "%t" "enabled" WEZTERM_PANE="%w" TMUX_PANE="%t" run failsafe mode get [ "$status" -eq 0 ] - [[ "$output" == "read & write"* ]] + [[ "$output" == "disabled"* ]] [[ "$output" == *"/pane-mode/%w"* ]] } -# All four docs: "missing file = read (safe default)". -@test "missing mode file resolves to read" { +# All four docs: "missing file = enabled (safe default)". +@test "missing mode file resolves to enabled" { WEZTERM_PANE="%none" run failsafe mode get - [[ "$output" == read* ]] + [[ "$output" == enabled* ]] } -# All four docs: the canonical value is what the bundled Rego policies match. -@test "every rego mode comparison is exactly input.mode == \"read\"" { - run grep -rhoE 'input\.mode == "[^"]*"' "$DOCS_REPO_ROOT"/internal/embed/policies/*.rego +# All four docs: the bundled Rego policies gate on the boolean failsafe_enabled, +# not on the legacy input.mode string. +@test "bundled rego gates on failsafe_enabled boolean (not input.mode string)" { + # (a) every deny rule uses the boolean failsafe_enabled flag + run grep -rn 'not input.failsafe_enabled == false' "$DOCS_REPO_ROOT"/internal/embed/policies/ [ "$status" -eq 0 ] - while IFS= read -r line; do - [ "$line" = 'input.mode == "read"' ] - done <<< "$output" + [ -n "$output" ] # at least one match exists + # (b) no deny rule compares input.mode == anything + run grep -rn 'input\.mode ==' "$DOCS_REPO_ROOT"/internal/embed/policies/ + [ "$status" -ne 0 ] || [ -z "$output" ] # grep found nothing +} + +# Back-compat: even though the guard is boolean, mode get still emits a human-readable +# value in field 1 and the file stores the canonical bytes. +@test "mode get field-1 is enabled or disabled (legacy mode back-compat)" { + write_mode_file "%w" "enabled" + WEZTERM_PANE="%w" run bash -c 'failsafe mode get | cut -f1' + [ "$output" = "enabled" ] + write_mode_file "%w" "disabled" + WEZTERM_PANE="%w" run bash -c 'failsafe mode get | cut -f1' + [ "$output" = "disabled" ] } # Docs claim rw/ro aliases normalize to the canonical bytes written to the file. @test "mode set aliases write canonical bytes" { - for a in rw w "read & write"; do - write_mode_file "%a" "read" + for a in rw w off write sudo disabled; do + write_mode_file "%a" "enabled" WEZTERM_PANE="%a" failsafe mode set "$a" - [ "$(read_mode_file "%a")" = "read & write" ] + [ "$(read_mode_file "%a")" = "disabled" ] done - for a in ro r read; do - write_mode_file "%a" "read & write" + for a in ro r on read lock enabled; do + write_mode_file "%a" "disabled" WEZTERM_PANE="%a" failsafe mode set "$a" - [ "$(read_mode_file "%a")" = "read" ] + [ "$(read_mode_file "%a")" = "enabled" ] done } # claude-statusline.sh relies on `failsafe mode get | cut -f1` β€” assert the value # is the first tab-delimited field. @test "mode get output is tab-delimited (value in field 1)" { - write_mode_file "%w" "read & write" + write_mode_file "%w" "disabled" WEZTERM_PANE="%w" run bash -c 'failsafe mode get | cut -f1' - [ "$output" = "read & write" ] + [ "$output" = "disabled" ] } diff --git a/test/docs/extract.bats b/test/docs/extract.bats index 77aa4ea..59c03a2 100644 --- a/test/docs/extract.bats +++ b/test/docs/extract.bats @@ -14,7 +14,7 @@ EX() { bash "$DOCS_REPO_ROOT/test/docs/lib/extract.sh" "$@"; } run EX "$DOCS_REPO_ROOT/docs/toggle/tmux.md" bash 1 "Status indicator" [ "$status" -eq 0 ] [[ "$output" == *"πŸ”“ sudo"* ]] - [[ "$output" == *"πŸ”’ read"* ]] + [[ "$output" == *"πŸ”’ on"* ]] } @test "extract.sh without anchor counts globally" { diff --git a/test/docs/helpers.bats b/test/docs/helpers.bats index 9b491ac..4902986 100644 --- a/test/docs/helpers.bats +++ b/test/docs/helpers.bats @@ -10,8 +10,8 @@ teardown() { teardown_sandbox; } } @test "mode-file helpers round-trip" { - write_mode_file "%5" "read & write" - [ "$(read_mode_file "%5")" = "read & write" ] + write_mode_file "%5" "disabled" + [ "$(read_mode_file "%5")" = "disabled" ] } @test "need skips when a tool is absent" { diff --git a/test/docs/iterm.bats b/test/docs/iterm.bats index be0d17b..2c816ab 100644 --- a/test/docs/iterm.bats +++ b/test/docs/iterm.bats @@ -15,7 +15,7 @@ teardown() { teardown_sandbox; } [ "$(printf '%s' "$b64" | base64 -d)" = "$sid" ] } -@test "doc python read_mode returns canonical and defaults to read" { +@test "doc python read_mode returns canonical and defaults to enabled" { [ -n "$PYTHON_BIN" ] || skip "no python" extract_block "$IT" python 1 > "$TEST_HOME/it.py" run "$PYTHON_BIN" - "$TEST_HOME/it.py" <<'PY' @@ -28,9 +28,9 @@ ns = {} exec(compile(ast.Module([fn], []), "read_mode", "exec"), ns) read_mode = ns["read_mode"] d = tempfile.mkdtemp() -assert read_mode(os.path.join(d, "missing")) == "read", "missing file -> read" -p = os.path.join(d, "m"); open(p, "w").write("read & write\n") -assert read_mode(p) == "read & write", "canonical preserved" +assert read_mode(os.path.join(d, "missing")) == "enabled", "missing file -> enabled" +p = os.path.join(d, "m"); open(p, "w").write("disabled\n") +assert read_mode(p) == "disabled", "canonical preserved" print("ok") PY [ "$status" -eq 0 ] @@ -56,5 +56,5 @@ PY @test "no-python alternative: failsafe toggle flips the session file" { local sid="w1t6p0:GUID-XYZ" ITERM_SESSION_ID="$sid" failsafe toggle - [ "$(read_mode_file "$sid")" = "read & write" ] + [ "$(read_mode_file "$sid")" = "disabled" ] } diff --git a/test/docs/live-gui/demo.sh b/test/docs/live-gui/demo.sh index 20a9e66..97217c5 100755 --- a/test/docs/live-gui/demo.sh +++ b/test/docs/live-gui/demo.sh @@ -11,14 +11,13 @@ note() { printf ' \033[2m%s\033[0m\n' "$1"; } # dim captio key() { printf '\n \033[1;33m⌨ Press %s now (watch the tab badge), then Enter\033[0m ' "$1"; read -r; } pause(){ printf ' \033[2m(Enter to continue)\033[0m'; read -r; } -# Friendly status, derived from the real `failsafe mode get`. The dim (mode: …) is -# the underlying value β€” it goes away once the engine speaks enable/disable natively. +# Friendly status, derived from the real `failsafe mode get`. status() { local m; m="$(failsafe mode get | cut -f1)" - if [ "$m" = "read & write" ]; then - printf ' \033[1;33mπŸ”“ failsafe DISABLED β€” the agent can write\033[0m \033[2m(mode: %s)\033[0m\n' "$m" + if [ "$m" = "disabled" ]; then + printf ' \033[1;33mπŸ”“ failsafe DISABLED β€” the agent can write\033[0m \033[2m(%s)\033[0m\n' "$m" else - printf ' \033[1;32mπŸ”’ failsafe ENABLED β€” writes blocked\033[0m \033[2m(mode: %s)\033[0m\n' "$m" + printf ' \033[1;32mπŸ”’ failsafe ENABLED β€” writes blocked\033[0m \033[2m(%s)\033[0m\n' "$m" fi } diff --git a/test/docs/live-gui/manual-test-plan.md b/test/docs/live-gui/manual-test-plan.md index 971a13b..97a0cfc 100644 --- a/test/docs/live-gui/manual-test-plan.md +++ b/test/docs/live-gui/manual-test-plan.md @@ -52,28 +52,28 @@ the doc's own Drop-in snippet via `extract.sh` β€” your real WezTerm config is u watch -n 1 failsafe mode get ``` 3. **In the other pane, press `Ctrl+Alt+T`** and watch the `watch` pane flip - `read` ⇄ `read & write` live, and the tab badge flip ` r ` ⇄ ` rw `. + `enabled` ⇄ `disabled` live, and the tab badge flip ` on ` ⇄ ` off `. > **Toast not appearing?** That's macOS notification permissions, not failsafe β€” see > *Troubleshooting* at the bottom. The mode-flip in the `watch` pane is the real proof; > the toast is cosmetic (the doc marks it "optional"). - [ ] **A1 β€” keypress fires the toggle + toast.** Press `Ctrl+Alt+T` in a pane. - Expect: a toast titled `πŸ”’ failsafe` with body `read β†’ read & write`; `failsafe mode get` - now prints `read & write` with a `…/pane-mode/` path. Press again β†’ toast - `read & write β†’ read`, mode back to `read`. + Expect: a toast titled `πŸ”’ failsafe` with body `enabled β†’ disabled`; `failsafe mode get` + now prints `disabled` with a `…/pane-mode/` path. Press again β†’ toast + `disabled β†’ enabled`, mode back to `enabled`. - [ ] **A2 β€” tab-title badge flips.** With the badge block active, look at the tab title. - Expect: ` r ` (dim grey) when read, ` rw ` (amber) when writable; flips when you toggle. + Expect: ` on ` (dim grey) when enabled, ` off ` (amber) when disabled; flips when you toggle. - [ ] **A3 β€” per-pane isolation.** Split the window (two panes). Toggle pane 1 to write. - Expect: `failsafe mode get` in pane 1 = `read & write`, in pane 2 = `read` (each keyed by + Expect: `failsafe mode get` in pane 1 = `disabled`, in pane 2 = `enabled` (each keyed by its own `WEZTERM_PANE`). - [ ] **A4 β€” "sudo mode" variant.** Swap in the *"sudo mode"* `toggle_action` and the `⚑ sudo` badge. Toggle to write. Expect: toast `πŸ”“ failsafe: sudo mode` / `write enabled β€” with great power…`; badge shows `⚑ sudo`. Toggle off β†’ toast - `πŸ”’ failsafe` / `back to read-only. phew.` + `πŸ”’ failsafe` / `back to enabled. phew.` - [ ] **A5 β€” sudo timeout auto-revert.** Add the auto-revert snippet but use **`sleep 20`** - (not 600) for the test. Toggle to write, then leave it. Expect: after ~20s `failsafe mode - get` returns to `read` on its own (badge flips back on the next tab render; no toast fires + (not 600) for the test. Toggle to `disabled`, then leave it. Expect: after ~20s `failsafe mode + get` returns to `enabled` on its own (badge flips back on the next tab render; no toast fires on the auto-revert β€” it's a background file write). Restore `600` afterwards. ## B. iTerm2 (Python path) β€” `docs/toggle/iterm.md` Β§1–3 @@ -87,7 +87,7 @@ Setup: add the **shell hook** to `~/.zshrc`; open a new tab. Install the Python - [ ] **B0 β€” script registers cleanly.** After launching the script, Console shows no error and `failsafe_toggle` is registered (it appears under Scripts). - [ ] **B1 β€” keypress toggles silently + notifies.** At an empty prompt press `Ctrl+Opt+T`. - Expect: macOS notification titled `πŸ”’ failsafe`, body `read β†’ read & write`; + Expect: macOS notification titled `πŸ”’ failsafe`, body `enabled β†’ disabled`; `failsafe mode get` flips. Press again β†’ reverse. - [ ] **B2 β€” no text injection.** Start typing a command (don't run it), then press `Ctrl+Opt+T` mid-line. Expect: mode flips, and **no text is injected** into your command @@ -102,7 +102,7 @@ Setup: add the **shell hook** to `~/.zshrc`; open a new tab. Install the Python Setup: bind `Ctrl+Opt+T` β†’ **Send Text** β†’ `failsafe toggle\n`. - [ ] **C1 β€” Send Text path.** At an **empty** prompt press `Ctrl+Opt+T`. Expect: the line - runs `failsafe toggle`, prints the `read β†’ read & write (…path…)` transition, mode flips. + runs `failsafe toggle`, prints the `enabled β†’ disabled (…path…)` transition, mode flips. - [ ] **C2 β€” the documented caveat holds.** Press it while a command is half-typed. Expect: it injects `failsafe toggle` mid-command (confirming the doc's warning to use it only at an empty prompt). No need to "fix" β€” just confirm the caveat is real. @@ -114,10 +114,10 @@ Setup: save `~/.config/failsafe/tmux-toggle.sh` and `tmux-status.sh` (`chmod +x` `tmux source-file ~/.tmux.conf`. - [ ] **D1 β€” real keypress (what the headless test couldn't do).** In an interactive tmux - pane press `Ctrl+Alt+T`. Expect: status message `πŸ”’ failsafe: read β†’ read & write`; + pane press `Ctrl+Alt+T`. Expect: status message `πŸ”’ failsafe: enabled β†’ disabled`; `failsafe mode get` flips. Press again β†’ reverse. -- [ ] **D2 β€” status bar indicator.** Watch the status-right. Expect: `πŸ”’ read` (green) when - read, `πŸ”“ sudo` (amber) when writable; flips within ~2s (`status-interval 2`) of a toggle. +- [ ] **D2 β€” status bar indicator.** Watch the status-right. Expect: `πŸ”’ on` (green) when + enabled, `πŸ”“ sudo` (amber) when disabled; flips within ~2s (`status-interval 2`) of a toggle. - [ ] **D3 β€” per-pane isolation.** Split a window; toggle one pane. Expect: each pane's `failsafe mode get` is independent (keyed by `$TMUX_PANE`). - [ ] **D4 β€” nested in WezTerm/iTerm.** Run tmux inside WezTerm (or iTerm). Expect: the @@ -131,13 +131,13 @@ Setup: copy `examples/claude-statusline.sh` to `~/.config/failsafe/`, `chmod +x` `~/.claude/settings.json` `statusLine.command` at it, (re)start Claude Code. - [ ] **E1 β€” status line renders.** Expect: the bottom line reads - `failsafe πŸ”’ read Β· ~/ Β· `. -- [ ] **E2 β€” end-to-end flip (the money shot).** From the same pane, toggle to write (any + `failsafe πŸ”’ enabled Β· ~/ Β· `. +- [ ] **E2 β€” end-to-end flip (the money shot).** From the same pane, toggle to disabled (any binding above, or `failsafe toggle`). Expect: the Claude Code status line flips to - `failsafe πŸ”“ write Β· …` on its next render. This is the full chain: terminal toggle β†’ + `failsafe πŸ”“ disabled Β· …` on its next render. This is the full chain: terminal toggle β†’ guard mode file β†’ Claude Code status line. - [ ] **E3 β€” degrade without jq.** Temporarily rename/hide `jq`. Expect: the line still shows - `failsafe πŸ”’ read` / `πŸ”“ write` (just without the `Β· cwd Β· model` suffix). + `failsafe πŸ”’ enabled` / `πŸ”“ disabled` (just without the `Β· cwd Β· model` suffix). --- diff --git a/test/docs/statusline.bats b/test/docs/statusline.bats index a2eed0e..e2c3284 100644 --- a/test/docs/statusline.bats +++ b/test/docs/statusline.bats @@ -15,38 +15,38 @@ run_sl() { # $1 = json; pipes it into the real script head -1 "$SL" | grep -q 'bash' } -@test "read mode renders the lock + read" { - write_mode_file "sess1" "read" +@test "enabled mode renders the lock + enabled" { + write_mode_file "sess1" "enabled" run run_sl "${JSON/\%CWD\%//tmp/x}" - [[ "$output" == "failsafe πŸ”’ read"* ]] + [[ "$output" == "failsafe πŸ”’ enabled"* ]] } -@test "read & write mode renders the open lock + write" { - write_mode_file "sess1" "read & write" +@test "disabled mode renders the open lock + disabled" { + write_mode_file "sess1" "disabled" run run_sl "${JSON/\%CWD\%//tmp/x}" - [[ "$output" == "failsafe πŸ”“ write"* ]] + [[ "$output" == "failsafe πŸ”“ disabled"* ]] } @test "with jq, cwd is tilde-substituted and model appended" { need jq - write_mode_file "sess1" "read" + write_mode_file "sess1" "enabled" run run_sl "${JSON/\%CWD\%/$HOME/code/infra}" [[ "$output" == *"~/code/infra"* ]] [[ "$output" == *"Opus"* ]] } @test "without jq it still prints the guard mode (graceful degrade)" { - write_mode_file "sess1" "read & write" + write_mode_file "sess1" "disabled" local p; p="$(make_nojq_path)" run env PATH="$p" "$SL" <<< "${JSON/\%CWD\%//tmp/x}" - [[ "$output" == "failsafe πŸ”“ write"* ]] + [[ "$output" == "failsafe πŸ”“ disabled"* ]] # Prove the jq-enrichment branch was actually skipped (no cwd appended), not # merely that the guard label survived. [[ "$output" != *"/tmp/x"* ]] } @test "output is a single line" { - write_mode_file "sess1" "read" + write_mode_file "sess1" "enabled" run run_sl "${JSON/\%CWD\%//tmp/x}" [ "${#lines[@]}" -eq 1 ] } diff --git a/test/docs/tmux.bats b/test/docs/tmux.bats index 213b3a4..73d0588 100644 --- a/test/docs/tmux.bats +++ b/test/docs/tmux.bats @@ -14,15 +14,15 @@ teardown() { teardown_sandbox } -@test "toggle script flips read <-> read & write" { +@test "toggle script flips enabled <-> disabled" { # The toggle script ends with `tmux display-message`, which requires a tmux # server β€” run it via run-shell so it has a proper tmux context (just as it # would be invoked from a real key binding). tmux -L "$SOCK" new-session -d -s s -x 80 -y 24 tmux -L "$SOCK" run-shell "$TOGGLE '%5'" - [ "$(read_mode_file "%5")" = "read & write" ] + [ "$(read_mode_file "%5")" = "disabled" ] tmux -L "$SOCK" run-shell "$TOGGLE '%5'" - [ "$(read_mode_file "%5")" = "read" ] + [ "$(read_mode_file "%5")" = "enabled" ] } @test "#{pane_id} equals \$TMUX_PANE inside a real session" { @@ -51,19 +51,19 @@ teardown() { [[ "$output" == *"C-M-t"* || "$output" == *"M-C-t"* ]] } -@test "status script colors writable amber / read green" { +@test "status script colors disabled amber / enabled green" { local STAT="$TEST_HOME/tmux-status.sh" extract_block "$TMUX_DOC" bash 1 "Status indicator" > "$STAT"; chmod +x "$STAT" - write_mode_file "%5" "read & write" + write_mode_file "%5" "disabled" run "$STAT" "%5" [[ "$output" == *"πŸ”“ sudo"* ]]; [[ "$output" == *"fg=yellow"* ]] - write_mode_file "%5" "read" + write_mode_file "%5" "enabled" run "$STAT" "%5" - [[ "$output" == *"πŸ”’ read"* ]]; [[ "$output" == *"fg=green"* ]] + [[ "$output" == *"πŸ”’ on"* ]]; [[ "$output" == *"fg=green"* ]] } @test "no-script alternative toggles only the target pane" { WEZTERM_PANE= ITERM_SESSION_ID= TMUX_PANE="%5" failsafe toggle - [ "$(read_mode_file "%5")" = "read & write" ] + [ "$(read_mode_file "%5")" = "disabled" ] [ ! -f "$HOME/.claude/pane-mode/%other" ] } diff --git a/test/docs/wezterm.bats b/test/docs/wezterm.bats index a8d903f..452d5a0 100644 --- a/test/docs/wezterm.bats +++ b/test/docs/wezterm.bats @@ -32,24 +32,25 @@ teardown() { teardown_sandbox; } local out out="$(STUB="$STUB_DIR/wezterm.lua" SNIPPET="$TEST_HOME/snip.lua" \ "$LUA_BIN" "$STUB_DIR/wezterm_driver.lua" "%wz")" - [ "$out" = "read & write" ] - [ "$(read_mode_file "%wz")" = "read & write" ] + # Snippet default is "enabled"; one toggle flips it to "disabled". + [ "$out" = "disabled" ] + [ "$(read_mode_file "%wz")" = "disabled" ] WEZTERM_PANE="%wz" run failsafe mode get - [[ "$output" == "read & write"* ]] + [[ "$output" == "disabled"* ]] } -@test "badge maps writable -> sudo, read -> r" { - run grep -F 'local badge = (mode == "read & write") and " ⚑ sudo " or " r "' "$WZ" +@test "badge maps disabled -> off, enabled -> on" { + run grep -F 'local badge = (mode == "disabled") and " off " or " on "' "$WZ" [ "$status" -eq 0 ] } -@test "sudo-timeout mechanism reverts to read" { +@test "sudo-timeout mechanism reverts to enabled" { local block block="$(extract_block "$WZ" lua 3 'Make it yours: "sudo mode"')" [[ "$block" == *"sleep 600"* ]] - [[ "$block" == *"echo read"* ]] - local f="$HOME/.claude/pane-mode/%wz"; printf 'read & write' > "$f" - ( sleep 1; echo read > "$f" ) & + [[ "$block" == *"echo enabled"* ]] + local f="$HOME/.claude/pane-mode/%wz"; printf 'disabled' > "$f" + ( sleep 1; printf 'enabled' > "$f" ) & sleep 2.5 # generous margin over the 1s revert so loaded CI runners don't flake - [ "$(read_mode_file "%wz")" = "read" ] + [ "$(read_mode_file "%wz")" = "enabled" ] } From 4c001ae3d20521c1301961d8782f379b96a42cf3 Mon Sep 17 00:00:00 2001 From: Tombar Date: Sun, 7 Jun 2026 19:15:03 -0300 Subject: [PATCH 30/30] cleanup: refresh stale mode-vocabulary comments (enabled/disabled, boolean gate) --- internal/auditlog/auditlog.go | 2 +- internal/mode/source.go | 5 +++-- internal/subcommand/mode.go | 7 ++++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/internal/auditlog/auditlog.go b/internal/auditlog/auditlog.go index 9c234e7..86e5013 100644 --- a/internal/auditlog/auditlog.go +++ b/internal/auditlog/auditlog.go @@ -25,7 +25,7 @@ type Record struct { Time time.Time Decision string // "block" | "allow" | "allow_override" Reason string // block reason or override reason; empty for a plain allow - Mode string // "read" | "read & write" + Mode string // "enabled" | "disabled" (older log entries may carry legacy "read"/"read & write") Tool string // registry tool name; empty for refuse/parse blocks Verb string Subverb string diff --git a/internal/mode/source.go b/internal/mode/source.go index 859861f..a77c5b1 100644 --- a/internal/mode/source.go +++ b/internal/mode/source.go @@ -2,8 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 // Package mode implements the configurable mode-source chain (spec Β§3.4). -// Mode is binary: "read" or "read & write". Sources are tried in the order -// declared in config; first to return a value wins. +// Mode is binary: "enabled" or "disabled" (legacy "read" / "read & write" values +// are migrated by Canonicalize). Sources are tried in the order declared in +// config; first to return a value wins. package mode import ( diff --git a/internal/subcommand/mode.go b/internal/subcommand/mode.go index 15fd030..8d8d48b 100644 --- a/internal/subcommand/mode.go +++ b/internal/subcommand/mode.go @@ -13,9 +13,10 @@ import ( // normalizeMode maps user-friendly aliases to the two canonical mode values. // Canonical values ("enabled" / "disabled") are what get written to the mode -// file and what the bundled Rego policies match on (input.mode == "enabled"), so -// aliases are resolved here at the CLI boundary and never leak into the file or -// the policy layer. +// file; the fact builder derives the boolean input.failsafe_enabled (which the +// bundled policies gate on via `not input.failsafe_enabled == false`) and the +// legacy input.mode string from them. Aliases are resolved here at the CLI +// boundary and never leak into the file or the policy layer. func normalizeMode(v string) (string, bool) { switch strings.ToLower(strings.TrimSpace(v)) { case "enabled", "enable", "on", "closed", "close", "lock", "ro", "r", "read", "safe":