diff --git a/.github/workflows/record-docs.yml b/.github/workflows/record-docs.yml new file mode 100644 index 0000000..0667c8a --- /dev/null +++ b/.github/workflows/record-docs.yml @@ -0,0 +1,44 @@ +# Copyright 2026 Undermountain Coding Company +# SPDX-License-Identifier: Apache-2.0 + +name: record-docs + +# Regenerate the documentation asciicast recordings on demand. +# +# GATED to manual dispatch on purpose — same posture as docs.yml. virtui is an +# early-stage (v0.2.0) recorder, so it is deliberately NOT wired into the +# required `ci` gate: a flaky upstream must never be able to block `main`. +# Run this when a demoed command's output changes; download the rendered +# artifacts and commit the ones you want to embed. +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + record: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: install virtui (recorder) + run: go install github.com/honeybadge-labs/virtui/cmd/virtui@latest + - name: install agg (.cast -> .gif renderer) + run: | + curl -fsSL -o /usr/local/bin/agg \ + https://github.com/asciinema/agg/releases/latest/download/agg-x86_64-unknown-linux-gnu + chmod +x /usr/local/bin/agg + - name: record + render demos + run: scripts/record-docs.sh --render + - uses: actions/upload-artifact@v4 + with: + name: doc-casts + path: | + docs/assets/casts/*.cast + docs/assets/casts/*.gif + docs/assets/casts/*.svg + if-no-files-found: warn diff --git a/README.md b/README.md index 039cdc8..43d63a1 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,11 @@ $ kubectl --context arn:aws:eks:us-east-1:…:cluster/prod delete ns payments flip this pane: failsafe toggle ``` +

+ failsafe explain blocking a kubectl delete on prod, recorded against the real binary +
↑ a real recording — failsafe explain against the shipped binary, regenerated by scripts/record-docs.sh +

+ The only thing between a hijacked or careless agent and a wiped cluster is you, clicking "allow" on the hundredth prompt — and that fails exactly when you're tired or moving fast. **failsafe is the guard that doesn't get tired.** diff --git a/docs/assets/casts/.gitignore b/docs/assets/casts/.gitignore new file mode 100644 index 0000000..098d636 --- /dev/null +++ b/docs/assets/casts/.gitignore @@ -0,0 +1,3 @@ +# Rendered GIFs are heavy build artifacts — regenerate with scripts/record-docs.sh --render. +# .cast (source) and .svg (embedded in docs) ARE committed. +*.gif diff --git a/docs/assets/casts/audit.cast b/docs/assets/casts/audit.cast new file mode 100644 index 0000000..e0c871b --- /dev/null +++ b/docs/assets/casts/audit.cast @@ -0,0 +1,29 @@ +{"version":2,"width":92,"height":22,"timestamp":1784331898} +[0.005935, "o", "\r\nThe default interactive shell is now zsh.\r\nTo update your account to use zsh, please run `chsh -s /bin/zsh`.\r\nFor more details, please visit https://support.apple.com/kb/HT208050.\r\n"] +[0.006175, "o", "\u001b[?1034h"] +[0.006191, "o", "bash-3.2$ "] +[0.013003, "i", "export PS1='$ '; cd \"/var/folders/12/mydd4m455zn9t__48syck4940000gn/T//failsafe-docs-home/project\"; clear\r"] +[0.013056, "o", "e"] +[0.013092, "o", "xport PS1='$ '; "] +[0.013099, "o", "cd"] +[0.013206, "o", " \"/var/folders/12/mydd4m455zn9t"] +[0.013214, "o", "__"] +[0.013315, "o", "48syck4940000gn/T//fa"] +[0.013368, "o", "ilsafe-do \rcs-"] +[0.013432, "o", "home/project\"; clear\r\n"] +[0.015773, "o", "\u001b[3J\u001b[H\u001b[2J"] +[0.016056, "o", "$ "] +[0.543033, "i", "failsafe audit\r"] +[0.543163, "o", "f"] +[0.543232, "o", "ailsafe audit\r\n"] +[0.557055, "o", "Policy layers in effect at /var/folders/12/mydd4m455zn9t__48syck4940000gn/T/failsafe-docs-home/project:\r\n\r\n [bundled]\r\n"] +[0.557309, "o", " bundled/aws.rego — 2 block rule(s)\r\n"] +[0.557386, "o", " bundled/failsafe.rego — 2 block rule(s)\r\n"] +[0.557536, "o", " bundled/git.rego — 0 block rule(s)\r\n"] +[0.557630, "o", " bundled/helm.rego — 2 block rule(s)\r\n"] +[0.557722, "o", " bundled/kubectl.rego — 1 block rule(s)\r\n"] +[0.557803, "o", " bundled/terraform.rego — 2 block rule(s)\r\n\r\n9 block rules total, 0 allow_override.\r\n"] +[0.558949, "o", "$ "] +[2.585943, "i", "exit\r"] +[2.586019, "o", "ex"] +[2.586093, "o", "it\r\nexit\r\n"] diff --git a/docs/assets/casts/audit.svg b/docs/assets/casts/audit.svg new file mode 100644 index 0000000..9455ff9 --- /dev/null +++ b/docs/assets/casts/audit.svg @@ -0,0 +1 @@ +Thedefaultinteractiveshellisnowzsh.Toupdateyouraccounttousezsh,pleaserun`chsh-s/bin/zsh`.Formoredetails,pleasevisithttps://support.apple.com/kb/HT208050.bash-3.2$exportPS1='$';cd"/var/folders/12/mydd4m455zn9t__48syck4940000gn/T//failsafe-do$$failsafeauditPolicylayersineffectat/var/folders/12/mydd4m455zn9t__48syck4940000gn/T/failsafe-docs-home/project:[bundled]bundled/aws.rego2blockrule(s)bundled/failsafe.rego2blockrule(s)bundled/git.rego0blockrule(s)bundled/helm.rego2blockrule(s)bundled/kubectl.rego1blockrule(s)bundled/terraform.rego2blockrule(s)9blockrulestotal,0allow_override.bash-3.2$bash-3.2$ebash-3.2$exportPS1='$';bash-3.2$exportPS1='$';cdbash-3.2$exportPS1='$';cd"/var/folders/12/mydd4m455zn9tbash-3.2$exportPS1='$';cd"/var/folders/12/mydd4m455zn9t__bash-3.2$exportPS1='$';cd"/var/folders/12/mydd4m455zn9t__48syck4940000gn/T//facs-cs-home/project";clear$f$ex$exitexit \ No newline at end of file diff --git a/docs/assets/casts/explain-block.cast b/docs/assets/casts/explain-block.cast new file mode 100644 index 0000000..04aa745 --- /dev/null +++ b/docs/assets/casts/explain-block.cast @@ -0,0 +1,33 @@ +{"version":2,"width":92,"height":22,"timestamp":1784331884} +[0.007669, "o", "\r\nThe default interactive shell is now zsh.\r\nTo update your account to use zsh, please run `chsh -s /bin/zsh`.\r\nFor more details, please visit https://support.apple.com/kb/HT208050.\r\n"] +[0.010914, "o", "\u001b[?1034h"] +[0.011090, "o", "bash-3.2$ "] +[0.016704, "i", "export PS1='$ '; cd \"/var/folders/12/mydd4m455zn9t__48syck4940000gn/T//failsafe-docs-home/project\"; clear\r"] +[0.016757, "o", "e"] +[0.016839, "o", "xport PS1='$ '; cd \"/var/fol"] +[0.016850, "o", "de"] +[0.016962, "o", "rs/12/mydd4m455zn9t__48sy"] +[0.017024, "o", "ck4940000gn/"] +[0.017032, "o", "T"] +[0.017127, "o", "//failsafe-do \rcs-hom"] +[0.017198, "o", "e/project\"; clear\r\n"] +[0.021285, "o", "\u001b[3J\u001b[H\u001b[2J"] +[0.021574, "o", "$ "] +[0.544513, "i", "failsafe explain \"kubectl --context arn:aws:eks:us-east-1:123456789012:cluster/prod delete ns payments\"\r"] +[0.544583, "o", "f"] +[0.544639, "o", "ailsafe explain \"k"] +[0.544713, "o", "ubectl --context "] +[0.544726, "o", "ar"] +[0.544847, "o", "n:aws:eks:us-east-1:"] +[0.544859, "o", "12"] +[0.544984, "o", "3456789012:cluste"] +[0.545062, "o", "r/prod de"] +[0.545137, "o", "lete \r ns pa"] +[0.545211, "o", "yments\"\r\n"] +[0.848167, "o", "── call 1: kubectl ──\r\nVerb: delete\r\nFlags:\r\n"] +[0.848216, "o", " context = arn:aws:eks:us-east-1:123456789012:cluster/prod\r\nPositional: ns payments\r\nEffective cwd: /var/folders/12/mydd4m455zn9t__48syck4940000gn/T/failsafe-docs-home/project\r\nMode: enabled\r\nPolicy chain (6 modules at this cwd):\r\n [bundled] bundled/aws.rego\r\n [bundled] bundled/failsafe.rego\r\n [bundled] bundled/git.rego\r\n [bundled] bundled/helm.rego\r\n [bundled] bundled/kubectl.rego\r\n [bundled] bundled/terraform.rego\r\nDecision: BLOCK\r\nReason : kubectl delete blocked while failsafe is enabled\r\n"] +[0.849408, "o", "$ "] +[2.882566, "i", "exit\r"] +[2.882745, "o", "e"] +[2.882788, "o", "xit\r\n"] +[2.882877, "o", "exit\r\n"] diff --git a/docs/assets/casts/explain-block.svg b/docs/assets/casts/explain-block.svg new file mode 100644 index 0000000..5caab03 --- /dev/null +++ b/docs/assets/casts/explain-block.svg @@ -0,0 +1 @@ +Thedefaultinteractiveshellisnowzsh.Toupdateyouraccounttousezsh,pleaserun`chsh-s/bin/zsh`.Formoredetails,pleasevisithttps://support.apple.com/kb/HT208050.bash-3.2$exportPS1='$';cd"/var/folders/12/mydd4m455zn9t__48syck4940000gn/T//failsafe-do$$failsafeexplain"kubectl--contextarn:aws:eks:us-east-1:123456789012:cluster/proddeletenspayments"──call1:kubectl──Verb:deleteFlags:context=arn:aws:eks:us-east-1:123456789012:cluster/prodPositional:nspaymentsEffectivecwd:/var/folders/12/mydd4m455zn9t__48syck4940000gn/T/failsafe-docs-home/projectMode:enabledPolicychain(6modulesatthiscwd):[bundled]bundled/aws.rego[bundled]bundled/failsafe.rego[bundled]bundled/git.rego[bundled]bundled/helm.rego[bundled]bundled/kubectl.rego[bundled]bundled/terraform.regoDecision:BLOCKReason:kubectldeleteblockedwhilefailsafeisenabled$exitbash-3.2$bash-3.2$ebash-3.2$exportPS1='$';cd"/var/folbash-3.2$exportPS1='$';cd"/var/foldebash-3.2$exportPS1='$';cd"/var/folders/12/mydd4m455zn9t__48sybash-3.2$exportPS1='$';cd"/var/folders/12/mydd4m455zn9t__48syck4940000gn/bash-3.2$exportPS1='$';cd"/var/folders/12/mydd4m455zn9t__48syck4940000gn/Tcs-homcs-home/project";clear$f$failsafeexplain"k$failsafeexplain"kubectl--context$failsafeexplain"kubectl--contextar$failsafeexplain"kubectl--contextarn:aws:eks:us-east-1:$failsafeexplain"kubectl--contextarn:aws:eks:us-east-1:12$failsafeexplain"kubectl--contextarn:aws:eks:us-east-1:123456789012:cluste$failsafeexplain"kubectl--contextarn:aws:eks:us-east-1:123456789012:cluster/proddenspa$eexit \ No newline at end of file diff --git a/docs/assets/casts/mode-toggle.cast b/docs/assets/casts/mode-toggle.cast new file mode 100644 index 0000000..76a3cb8 --- /dev/null +++ b/docs/assets/casts/mode-toggle.cast @@ -0,0 +1,43 @@ +{"version":2,"width":92,"height":22,"timestamp":1784331888} +[0.005927, "o", "\r\nThe default interactive shell is now zsh.\r\nTo update your account to use zsh, please run `chsh -s /bin/zsh`.\r\nFor more details, please visit https://support.apple.com/kb/HT208050.\r\n"] +[0.006178, "o", "\u001b[?1034h"] +[0.006212, "o", "bash-3.2$ "] +[0.014081, "i", "export PS1='$ '; cd \"/var/folders/12/mydd4m455zn9t__48syck4940000gn/T//failsafe-docs-home/project\"; clear\r"] +[0.014288, "o", "e"] +[0.014310, "o", "xport PS1='$"] +[0.014358, "o", " '; cd \"/var/folde"] +[0.014367, "o", "rs/"] +[0.014445, "o", "12/mydd4m455zn9t__48sy"] +[0.014452, "o", "ck"] +[0.014535, "o", "4940000gn/T//failsa"] +[0.014578, "o", "fe-do \rcs-home/"] +[0.014585, "o", "pr"] +[0.014589, "o", "oj"] +[0.014664, "o", "ect\"; clear\r\n"] +[0.016854, "o", "\u001b[3J\u001b[H\u001b[2J"] +[0.017117, "o", "$ "] +[0.539295, "i", "failsafe mode get\r"] +[0.539375, "o", "f"] +[0.539437, "o", "ailsafe mode get\r\n"] +[0.550818, "o", "enabled\t(default; no source resolved)\r\n"] +[0.551704, "o", "$ "] +[2.583063, "i", "failsafe mode set disabled\r"] +[2.583205, "o", "f"] +[2.583281, "o", "ailsafe mode set disabled\r\n"] +[2.598676, "o", "disabled\t(file: /var/folders/12/mydd4m455zn9t__48syck4940000gn/T//failsafe-docs-home/.claude/pane-mode/w0t1p0:E03E0B4D-6630-49D4-96E2-ADCE77DB2402)\r\n"] +[2.599698, "o", "$ "] +[4.614719, "i", "failsafe mode get\r"] +[4.614865, "o", "f"] +[4.614953, "o", "ailsafe mode get\r\n"] +[4.629334, "o", "disabled\t(file: /var/folders/12/mydd4m455zn9t__48syck4940000gn/T//failsafe-docs-home/.claude/pane-mode/w0t1p0:E03E0B4D-6630-49D4-96E2-ADCE77DB2402)\r\n"] +[4.630511, "o", "$ "] +[7.160456, "i", "failsafe mode set enabled\r"] +[7.160543, "o", "f"] +[7.160604, "o", "ailsafe mode set e"] +[7.160689, "o", "nabled\r\n"] +[7.171934, "o", "enabled\t(file: /var/folders/12/mydd4m455zn9t__48syck4940000gn/T//failsafe-docs-home/.claude/pane-mode/w0t1p0:E03E0B4D-6630-49D4-96E2-ADCE77DB2402)\r\n"] +[7.172671, "o", "$ "] +[9.711063, "i", "exit\r"] +[9.711225, "o", "e"] +[9.711286, "o", "xit\r\n"] +[9.711402, "o", "exit\r\n"] diff --git a/docs/assets/casts/mode-toggle.svg b/docs/assets/casts/mode-toggle.svg new file mode 100644 index 0000000..22dbb04 --- /dev/null +++ b/docs/assets/casts/mode-toggle.svg @@ -0,0 +1 @@ +Thedefaultinteractiveshellisnowzsh.Toupdateyouraccounttousezsh,pleaserun`chsh-s/bin/zsh`.Formoredetails,pleasevisithttps://support.apple.com/kb/HT208050.bash-3.2$exportPS1='$';cd"/var/folders/12/mydd4m455zn9t__48syck4940000gn/T//failsafe-do$$f$failsafemodegetenabled(default;nosourceresolved)$failsafemodesetdisableddisabled(file:/var/folders/12/mydd4m455zn9t__48syck4940000gn/T//failsafe-docs-home/.claude/pane-mode/w0t1p0:E03E0B4D-6630-49D4-96E2-ADCE77DB2402)$failsafemodesetenabledenabled(file:/var/folders/12/mydd4m455zn9t__48syck4940000gn/T//failsafe-docs-home/.claude/pane-mode/w0t1p0:E03E0B4D-6630-49D4-96E2-ADCE77DB2402)$exitbash-3.2$bash-3.2$ebash-3.2$exportPS1='$bash-3.2$exportPS1='$';cd"/var/foldebash-3.2$exportPS1='$';cd"/var/folders/bash-3.2$exportPS1='$';cd"/var/folders/12/mydd4m455zn9t__48sybash-3.2$exportPS1='$';cd"/var/folders/12/mydd4m455zn9t__48syckbash-3.2$exportPS1='$';cd"/var/folders/12/mydd4m455zn9t__48syck4940000gn/T//failsacs-home/cs-home/prcs-home/projcs-home/project";clear$failsafemodesete$eexit \ No newline at end of file diff --git a/docs/demos/README.md b/docs/demos/README.md new file mode 100644 index 0000000..4e67b43 --- /dev/null +++ b/docs/demos/README.md @@ -0,0 +1,73 @@ + + +# Documentation demos (asciicast automation) + +This directory documents the **recording** half of the docs flow. It pairs with +the doc-validation harness (`test/docs/`): + +| Concern | Owner | What it guarantees | +|---------|-------|--------------------| +| The copy-paste snippets in `docs/` actually run against the real binary | `test/docs/` (bats) | correctness — snippets don't drift | +| A demo *shows* those same commands running | `scripts/record-docs.sh` (virtui) | currency — the GIF/SVG matches today's binary | + +Both drive the **real `failsafe` binary** from the **same commands**, so prose, +tests, and recordings stay in lock-step. + +## How it works + +`scripts/record-docs.sh` uses [virtui](https://github.com/honeybadge-labs/virtui) +("Playwright for the terminal") to drive `failsafe` through a real PTY and record +each session to [asciicast v2](https://docs.asciinema.org/manual/asciicast/v2/): + +1. Build `failsafe` into a throwaway bindir and put it on `PATH`. +2. Point `$HOME` at a sandbox so demos never touch your real `~/.config/failsafe` + (same isolation trick as the harness's `helpers.bash`). +3. Start the virtui daemon, then for each scenario spawn a recorded `bash` + session, type the documented command, and `--wait` for the expected screen + text before moving on (deterministic, no fixed `sleep`s). +4. Write `docs/assets/casts/.cast`. +5. Optionally render each `.cast` to an embeddable `.svg`/`.gif` (`--render`) — + GitHub markdown and MkDocs do **not** play asciicast inline. + +```bash +scripts/record-docs.sh # record .cast files +scripts/record-docs.sh --render # also render .svg (svg-term) or .gif (agg) +``` + +If `virtui` is not installed the script **skips cleanly** (exit 0) with an +install hint — it never fails a build just because the recorder is absent, the +same "missing tool ⇒ skip, never a vacuous pass" rule the harness uses. + +## Scenarios + +| Cast | Command | Documents | +|------|---------|-----------| +| `explain-block.cast` | `failsafe explain "kubectl … delete ns payments"` | tutorials/getting-started, README — the guard blocking a destructive command | +| `mode-toggle.cast` | `failsafe mode get / set disabled / set enabled` | reference/modes, getting-started step 4 | +| `audit.cast` | `failsafe audit` | how-to/repo-policy — the policy chain in force | + +Add a scenario by appending one `record_scenario` block in the script; keep the +command byte-identical to the doc snippet it illustrates. + +## What is **not** automated (honest split) + +virtui is headless/VT100, so it can only record the **CLI** surface. The +GUI-only surfaces — WezTerm toasts & `format-tab-title`, iTerm2's Python-runtime +keybinding — are not recordable here and remain **manual**, exactly the +STATIC / LIVE-MANUAL surfaces called out in `test/docs/REPORT.md`. This script +automates the CLI documentation flow, not the *full* flow. + +## Requirements + +| Tool | Purpose | Install | +|------|---------|---------| +| [virtui](https://github.com/honeybadge-labs/virtui) | record the sessions | `go install github.com/honeybadge-labs/virtui/cmd/virtui@latest` | +| [agg](https://github.com/asciinema/agg) | `.cast` → `.gif` (optional) | `brew install agg` | +| [svg-term-cli](https://github.com/marionebl/svg-term-cli) | `.cast` → `.svg` (optional) | `npm i -g svg-term-cli` | + +> **Status:** prototype. virtui is early-stage (v0.2.0). The recorder is wired as +> an **on-demand** tool (and a `workflow_dispatch`-only CI job), not a required +> gate, so an unstable upstream can never block `main`. diff --git a/docs/how-to/explain-a-command.md b/docs/how-to/explain-a-command.md index 204843c..8f362e9 100644 --- a/docs/how-to/explain-a-command.md +++ b/docs/how-to/explain-a-command.md @@ -21,6 +21,10 @@ failsafe explain kubectl delete ns payments Both forms are equivalent: the command is re-assembled and fed through the same shell parser the hook uses. +![failsafe explain blocking a destructive command](../assets/casts/explain-block.svg) + +*Recording regenerated by [`scripts/record-docs.sh`](https://github.com/UndermountainCC/failsafe/blob/main/scripts/record-docs.sh).* + ## Reading the output ``` diff --git a/docs/how-to/repo-policy.md b/docs/how-to/repo-policy.md index d309e55..5f4de1e 100644 --- a/docs/how-to/repo-policy.md +++ b/docs/how-to/repo-policy.md @@ -109,6 +109,12 @@ Decision: BLOCK Reason : this repo: kubectl apply via CI only — submit a PR ``` +To see every layer that applies at a path at once — not just one command's verdict — run `failsafe audit`: + +![failsafe audit listing the policy chain in force](../assets/casts/audit.svg) + +*Recording regenerated by [`scripts/record-docs.sh`](https://github.com/UndermountainCC/failsafe/blob/main/scripts/record-docs.sh).* + ## 5. Commit and review with teammates `.failsafe.rego` is a first-class source file. Commit it, review it in PRs, and keep it next to the infra code it governs. diff --git a/docs/reference/modes.md b/docs/reference/modes.md index fd97917..3cfb776 100644 --- a/docs/reference/modes.md +++ b/docs/reference/modes.md @@ -18,6 +18,12 @@ The two mode values and the exact source-resolution chain that determines which 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. +Flipping the current pane between the two values with `mode get` / `mode set`: + +![failsafe mode get, set disabled, set enabled](../assets/casts/mode-toggle.svg) + +*Recording regenerated by [`scripts/record-docs.sh`](https://github.com/UndermountainCC/failsafe/blob/main/scripts/record-docs.sh).* + --- ## Mode source chain diff --git a/docs/tutorials/getting-started.md b/docs/tutorials/getting-started.md index a6f3822..cc5b77e 100644 --- a/docs/tutorials/getting-started.md +++ b/docs/tutorials/getting-started.md @@ -95,6 +95,12 @@ Reason : kubectl delete blocked while failsafe is enabled !!! 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. +The same run, recorded against the real binary: + +![failsafe explain blocking kubectl delete on prod](../assets/casts/explain-block.svg) + +*Recording regenerated by [`scripts/record-docs.sh`](https://github.com/UndermountainCC/failsafe/blob/main/scripts/record-docs.sh) — see [docs/demos](https://github.com/UndermountainCC/failsafe/tree/main/docs/demos).* + --- ## Step 4: Flip to write mode and back @@ -115,6 +121,10 @@ failsafe mode get disabled ``` +Flipping to `disabled` and back, recorded: + +![failsafe mode get, set disabled, set enabled](../assets/casts/mode-toggle.svg) + Now run a read-only `kubectl get` to confirm allowed commands still pass through: ```bash diff --git a/scripts/record-docs.sh b/scripts/record-docs.sh new file mode 100755 index 0000000..a9af27c --- /dev/null +++ b/scripts/record-docs.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# Copyright 2026 Undermountain Coding Company +# SPDX-License-Identifier: Apache-2.0 +# +# record-docs.sh — drive the failsafe CLI under virtui and emit asciicast +# recordings for the documentation site. +# +# This is the *recording* half of the docs automation flow. It is a sibling to +# the doc-validation harness (test/docs/): that harness proves the +# copy-paste snippets in docs/ actually run against the real binary; this script +# produces the demo recordings that *show* them running, from the same commands, +# so the two never drift. +# +# It is deliberately scoped to the HEADLESS CLI surface (explain / mode / audit / +# report). The GUI terminal-integration surfaces (WezTerm toasts & tab-title, +# iTerm2's Python keybinding) are NOT recordable headlessly and stay manual — +# see docs/demos/README.md, same honest split as test/docs/REPORT.md. +# +# Requirements (all optional — missing tools downgrade gracefully, never fail): +# - virtui https://github.com/honeybadge-labs/virtui (the recorder) +# - agg https://github.com/asciinema/agg (.cast -> .gif) [optional render] +# - svg-term npm i -g svg-term-cli (.cast -> .svg) [optional render] +# +# Usage: +# scripts/record-docs.sh # record all scenarios into docs/assets/casts/ +# scripts/record-docs.sh --render # also render .cast -> .svg/.gif if a renderer is present +# COLS=100 ROWS=24 scripts/record-docs.sh +set -euo pipefail + +# ── Resolve paths ──────────────────────────────────────────────────────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +OUT_DIR="${OUT_DIR:-$REPO_ROOT/docs/assets/casts}" +COLS="${COLS:-92}" +ROWS="${ROWS:-22}" +# Pause (seconds) held AFTER each command's output, so playback lingers on the +# result before moving on instead of blurring past it. virtui records in real +# wall-clock time, so a host-side idle gap becomes a visible pause baked into the +# cast (re-rendering can't add it — it must be recorded). STEP_PAUSE=0 = fast. +STEP_PAUSE="${STEP_PAUSE:-2}" +RENDER=0 +[[ "${1:-}" == "--render" ]] && RENDER=1 + +mkdir -p "$OUT_DIR" + +log() { printf '\033[1;34m▸\033[0m %s\n' "$*" >&2; } +warn() { printf '\033[1;33m!\033[0m %s\n' "$*" >&2; } +skip() { printf '\033[1;33mSKIP\033[0m %s\n' "$*" >&2; exit 0; } + +# ── Preflight: virtui ──────────────────────────────────────────────────────── +command -v virtui >/dev/null 2>&1 || skip \ + "virtui not found — install from https://github.com/honeybadge-labs/virtui (go install …/cmd/virtui@latest). No recordings produced." + +# ── Build the binary under test into a throwaway bindir ────────────────────── +BINDIR="$(mktemp -d)" +log "building failsafe -> $BINDIR/failsafe" +( cd "$REPO_ROOT" && go build -o "$BINDIR/failsafe" ./cmd/failsafe ) +export PATH="$BINDIR:$PATH" + +# ── Sandbox HOME so demos never touch the real ~/.config/failsafe ──────────── +# failsafe resolves all of its state from $HOME (policy.rego, trusted-repos.yaml, +# decisions.jsonl, pane-mode files). A throwaway HOME keeps demos side-effect +# free, mirroring helpers.bash in the doc-validation harness. The path is FIXED +# (not mktemp-random) so the cwd printed by `explain` is readable and identical +# on every re-record — committed SVGs then diff only when behaviour changes. +SANDBOX_HOME="${TMPDIR:-/tmp}/failsafe-docs-home" +rm -rf "$SANDBOX_HOME" +export HOME="$SANDBOX_HOME" +mkdir -p "$SANDBOX_HOME/project" +unset FAILSAFE_MODE FAILSAFE_LOG 2>/dev/null || true + +# ── virtui daemon lifecycle ────────────────────────────────────────────────── +DAEMON_STARTED=0 +cleanup() { + [[ "$DAEMON_STARTED" == 1 ]] && virtui daemon stop >/dev/null 2>&1 || true + rm -rf "$BINDIR" "$SANDBOX_HOME" +} +trap cleanup EXIT + +log "starting virtui daemon" +virtui daemon start >/dev/null 2>&1 && DAEMON_STARTED=1 || warn "daemon may already be running" + +# ── record_scenario NAME -- "cmd|wait-for-text" ["cmd|wait" …] ─────────────── +# Spawns a recorded bash session, normalises the prompt, runs each step typing +# the command and waiting for the given screen text, then exits to flush the cast. +record_scenario() { + local name="$1"; shift + [[ "$1" == "--" ]] && shift + local cast="$OUT_DIR/$name.cast" + log "recording $name -> $cast" + + # `virtui run` prints a human block ("session_id: \npid: …"); pull the id + # out of it so we don't take a hard dependency on jq. + local sid + sid="$(virtui run --record --record-path "$cast" --cols "$COLS" --rows "$ROWS" bash \ + | sed -n 's/^session_id: //p')" + [[ -n "$sid" ]] || { warn "could not start session for $name"; return 1; } + + # Quiet, deterministic prompt; start from the sandbox project dir. + virtui exec "$sid" "export PS1='\$ '; cd \"$HOME/project\"; clear" --wait-stable >/dev/null + + local step cmd want + for step in "$@"; do + cmd="${step%%|*}" + want="${step#*|}" + if [[ "$want" == "$step" || -z "$want" ]]; then + virtui exec "$sid" "$cmd" --wait-stable >/dev/null + else + virtui exec "$sid" "$cmd" --wait "$want" >/dev/null + fi + sleep "$STEP_PAUSE" # linger on this command's output before the next one + done + + virtui exec "$sid" "exit" --wait-stable >/dev/null 2>&1 || true + virtui kill "$sid" >/dev/null 2>&1 || true # finalize/flush the recording +} + +# ── Scenarios — the headless CLI demos ─────────────────────────────────────── +# Each scenario maps to a doc page. Keep the commands identical to the snippets +# the doc-validation harness checks, so the recording and the prose can't drift. + +# 1. The money shot: the guard blocking a destructive command (tutorials/getting-started, README). +record_scenario "explain-block" -- \ + "failsafe explain \"kubectl --context arn:aws:eks:us-east-1:123456789012:cluster/prod delete ns payments\"|Decision: BLOCK" + +# 2. The enabled/disabled mode switch (reference/modes, tutorials/getting-started step 4). +# Later steps use --wait-stable (no |text): their words already sit on the +# screen from earlier steps, so a text wait would fire before the output lands. +record_scenario "mode-toggle" -- \ + "failsafe mode get|enabled" \ + "failsafe mode set disabled|disabled" \ + "failsafe mode get" \ + "failsafe mode set enabled" + +# 3. The policy chain in force (how-to/repo-policy, reference). +record_scenario "audit" -- \ + "failsafe audit|block rule" + +log "done — casts in $OUT_DIR" +ls -1 "$OUT_DIR"/*.cast 2>/dev/null >&2 || true + +# ── Optional render: .cast -> embeddable .svg / .gif ───────────────────────── +# GitHub markdown and MkDocs do NOT play asciicast inline, so an embeddable +# artifact is required to actually surface a demo in the docs. +if [[ "$RENDER" == 1 ]]; then + if command -v svg-term >/dev/null 2>&1; then + for cast in "$OUT_DIR"/*.cast; do + log "render $(basename "$cast") -> svg" + svg-term --in "$cast" --out "${cast%.cast}.svg" --window --no-cursor + done + elif command -v agg >/dev/null 2>&1; then + for cast in "$OUT_DIR"/*.cast; do + log "render $(basename "$cast") -> gif" + agg "$cast" "${cast%.cast}.gif" + done + else + warn "--render requested but neither svg-term nor agg found; left .cast files only." + fi +fi