Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0029e71
feat: protect restore with target recovery and automatic rollback
danielxxomg Oct 3, 2026
06f0d71
docs: record T13 commit, chain strategy and review preflight state
danielxxomg Oct 3, 2026
4c8b7f6
feat: revert restored targets with drift-safe undo
danielxxomg Oct 3, 2026
82065e1
docs: record T14 commit and review preflight state
danielxxomg Oct 3, 2026
b82d89d
docs: close F2 stabilization with GGA PASSED and delivery evidence
danielxxomg Oct 3, 2026
bfbafa8
feat: warn on restore version mismatch and fail closed on newer manif…
danielxxomg Oct 3, 2026
e5779bf
fix: case-fold restore containment and clear verified GGA MUST debt
danielxxomg Oct 3, 2026
ed83eec
docs: record F3/T17 commits and the GGA hook index defect
danielxxomg Oct 3, 2026
43ffe04
refactor: move backup listing to actions and restore picker to tui sc…
danielxxomg Oct 3, 2026
ceee957
docs: record T18 extraction and open the T19 journey matrix
danielxxomg Oct 3, 2026
d27d18d
test: add eight-stage real-binary journey matrix
danielxxomg Oct 3, 2026
70fe955
docs: record T19 journey matrix delivery and remaining roadmap
danielxxomg Oct 3, 2026
889eea1
ci: make the GGA review gate actually review the PR
danielxxomg Oct 3, 2026
a9e99c6
ci: sync GGA CI version and drop diff-only so the gate reviews files
danielxxomg Oct 3, 2026
a00a2ec
ci: give GGA a local base ref and fail loudly on a vacuous review
danielxxomg Oct 3, 2026
1aa0662
docs: record PR #48 and the repaired GGA CI gate
danielxxomg Oct 3, 2026
8199b9d
test: make the device-flow login isolation check robust on slow CI ru…
danielxxomg Oct 3, 2026
a4f07d4
ci: fix the collapsed test matrix so tests actually run on all three OS
danielxxomg Oct 3, 2026
a1f8178
test: stop a Windows-only unit test from launching a live TUI program
danielxxomg Oct 3, 2026
b23d46d
test: isolate cmd tests from the real home on Windows
danielxxomg Oct 3, 2026
8928572
test: force the cmd test package non-interactive on every OS
danielxxomg Oct 3, 2026
41dbecd
test: restore every mutated global in cmd tests to stop shuffle pollu…
danielxxomg Oct 3, 2026
c055037
fix: refuse to start the profile wizard without a terminal
danielxxomg Oct 3, 2026
8959649
test: skip the chmod-failure restore test on Windows
danielxxomg Oct 3, 2026
4b8c1ee
docs: record T21 CI matrix repair and the defects it exposed
danielxxomg Oct 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ jobs:
strategy:
fail-fast: false
matrix:
# `os` MUST be a base matrix key. If it only appears in `include`, every
# include object is applied to the single base combination and the last
# one wins, collapsing the matrix to a single job. That silently
# reduced this job to macOS only: `go test` never ran on ubuntu or
# windows, despite the advertised 3-OS matrix.
os: [ubuntu-latest, windows-latest, macos-latest]
go-version: ['1.25']
# -race is expensive (~2x time, high memory). Scope it to ubuntu-latest
# on every PR; the full race sweep across all platforms runs weekly in
Expand Down
34 changes: 29 additions & 5 deletions .github/workflows/gga.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,21 @@ jobs:
# Full history so GGA can detect the base branch locally
# for the PR diff range (shallow checkouts have no main ref).
fetch-depth: 0
# Checkout the PR head, NOT the default pull_request merge ref.
# On a merge ref, `main...HEAD` is empty because the merge commit
# already contains main, so GGA reports "No matching files changed
# in PR" and the gate passes without reviewing anything.
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
- name: Install GGA
run: |
git clone --depth 1 --branch v2.8.1 \
# Keep CI in sync with the locally verified version. v2.8.1 no-ops in
# --pr-mode --diff-only on a PR head checkout (reports "No matching
# files changed in PR" even with a non-empty diff).
git clone --depth 1 --branch v2.10.1 \
https://github.com/Gentleman-Programming/gentleman-guardian-angel /tmp/gga
cd /tmp/gga
./install.sh
Expand Down Expand Up @@ -76,14 +84,30 @@ jobs:
GGA_PROVIDER: "opencode:commandcode/meta/muse-spark-1.3-contributor"
PR_BASE_BRANCH: "main"
run: |
# The repo .gga is gitignored, so synthesize the CI equivalent:
# GGA v2.8.1 only reads PR_BASE_BRANCH from the config file,
# not from the environment.
cat << 'EOF' > .gga
PROVIDER="opencode:commandcode/meta/muse-spark-1.3-contributor"
RULES_FILE="AGENTS.md"
FILE_PATTERNS="*.go,*.mod,*.md,*.yml,*.yaml"
STRICT_MODE="true"
TIMEOUT="900"
PR_BASE_BRANCH="main"
EOF
gga run --pr-mode --diff-only
# GGA resolves the base branch as a local ref. After checking out the
# PR head sha there is no local `main` branch, so the range resolves
# to nothing and the review no-ops. Recreate the local ref.
git branch -f main origin/main
gga run --pr-mode 2>&1 | tee gga-output.txt

- name: Guard against a vacuous review
run: |
# A silent no-op must never look like a passing review.
if [ -z "$(git diff --name-only "origin/${{ github.event.pull_request.base.ref }}...HEAD")" ]; then
echo "::error::The PR diff against ${{ github.event.pull_request.base.ref }} is empty; the review gate did not run."
exit 1
fi
if grep -q 'No matching files' gga-output.txt; then
echo "::error::GGA reported 'No matching files changed in PR' despite a non-empty diff. The review gate did not review anything."
cat gga-output.txt
exit 1
fi
echo "PR diff contains $(git diff --name-only "origin/${{ github.event.pull_request.base.ref }}...HEAD" | wc -l) changed file(s); GGA reviewed the PR"
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
- 👤 **Machine Profiles** — `bak profile` commands to scope backups per machine with independent adapter, category, preset, provider, and encryption settings
- 🖥️ **Cross-Platform** — Works on Windows, macOS, and Linux with path normalization
- 🎯 **Interactive Picker** — TUI with bubbletea for selective category backup
- ↩️ **Undo** — Git-backed safety net scoped to ~/.bak with `bak undo` (git revert)
- ↩️ **Undo** — Target-level undo with fail-closed drift protection and Git-backed rollback in ~/.bak via `bak undo`
- 📦 **Export** — Export backups as portable tar.gz archives

## Why bak?
Expand Down Expand Up @@ -143,8 +143,8 @@ bak diff 20260604-150405 20260605-080000
| Command | Description |
|---------|-------------|
| `bak backup [--preset quick\|full\|skills] [--profile <name>]` | Create a backup |
| `bak restore [--dry-run] [--force] <id>` | Restore a backup (shows diff and prompts; --force skips confirmation only) |
| `bak undo` | Revert the last operation in ~/.bak via git revert |
| `bak restore [--dry-run] [--force] <id>` | Restore a backup (shows diff and prompts; warns on version mismatch, fails on newer schema; --force skips confirmation only) |
| `bak undo` | Revert the last restore operation on target files and in ~/.bak with drift protection |
| `bak list [--provider <name>]` | List local or cloud backups |
| `bak pick` | Interactive TUI picker |
| `bak push [id] [--provider <name>] [--profile <name>]` | Push to a cloud backend |
Expand Down Expand Up @@ -505,10 +505,13 @@ classDiagram

- ✅ **Interactive confirmation & dry-run** — Always preview changes before restore; interactive confirmation required unless bypassed with `--force`
- ✅ **Mandatory integrity** — SHA-256 checksum and manifest integrity checks cannot be bypassed, even under `--force` (which only skips the confirmation prompt)
- ✅ **Target recovery & automatic rollback** — Before modifying target files on restore, captures affected target pre-state in private local recovery storage (`~/.bak/recovery/<point-id>`) with restricted permissions (0700/0600); stops at first copy or chmod failure and attempts automatic rollback of all attempted targets
- ✅ **Permission preservation (0.4.0)** — Manifest schema 0.4.0 records portable file permission mode bits and reapplies them on restore; legacy 0.3.0 manifests restore in degraded mode with an explicit warning
- ✅ **Git-backed safety in ~/.bak** — Tracks backup snapshots and metadata in `~/.bak` with instant rollback via `bak undo` (`git revert`). Note: target tool configuration directories are not auto-committed to Git during restore
- ✅ **Target-level undo & drift protection** — `bak undo` restores actual target configuration files to their pre-restore state using local recovery snapshots, while creating a revert commit in `~/.bak`. If any target file was modified, deleted, created, or replaced since the restore, undo fails closed before writing any files to protect subsequent user work
- ✅ **Version compatibility & schema gating** — Warns on `stderr` when restoring backups created by a different or unversioned/development `bak` version; fails closed before any target write or recovery preparation if the manifest schema is newer than supported (`0.4.0`), prompting the user to upgrade
- ✅ **Secret exclusion** — Automatically detects recognized token families (GitHub `ghp_*`, `gho_*`, `ghu_*`, `ghs_*`, `ghr_*`, OpenAI `sk-*`, Anthropic `sk-ant-*`, Slack `xoxb-*`, `xoxp-*`) and generates `.env.example` templates with redacted placeholders instead of storing real secrets
- ✅ **Path validation** — Prevents path traversal attacks by validating that all restored paths stay within the user home directory
- ✅ **Executable journey matrix proof** — Validated by an eight-stage real-binary journey test suite (`tests/e2e/journey_matrix_test.go`): discovery, mutation/deletion diff recovery, dry-run zero-write guarantees, apply correctness (with POSIX permission bit preservation on non-Windows platforms), checksum verification, tamper fail-closed rejection, partial failure rollback, and target undo drift protection (verified locally on Linux; Windows and macOS behaviors are validated in CI)

## Contributing

Expand Down
18 changes: 17 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,27 @@ Instead of backing up real secrets, bak generates a `.env.example` template with
- **Mandatory under `--force`**: Manifest and checksum integrity verification cannot be bypassed. The `--force` flag skips interactive confirmation only, never integrity checks.
- **Permission preservation (0.4.0)**: Manifest schema `0.4.0` preserves portable file permission bits (`Mode`) at backup time and reapplies them during restore. Any chmod failures are reported as restore errors.
- **Degraded 0.3.0 handling**: Legacy `0.3.0` manifests lacking mode metadata are loaded and restored in degraded mode, explicitly warning the user that restored files lack original mode metadata rather than claiming exact permission restoration.
- **Version compatibility warning**: On restore, `bak` compares the backup tool version (`bak_version`) with the running tool version. Any mismatch (including unknown, development, or empty versions on either side) produces a clear warning on stderr naming the backup ID and both versions without blocking restore.
- **Schema version gating**: Manifest schema versions newer than supported (`0.4.0`) cannot be safely interpreted and fail closed with an actionable error before any target write or recovery preparation, instructing the user to upgrade `bak`. Legacy known versions (`0.3.0`) continue to restore in degraded mode.
- **Real-binary journey verification**: An eight-stage real-binary journey matrix (`tests/e2e/journey_matrix_test.go`) characterizes discovery, mutation/deletion diff recovery, dry-run zero-write guarantees, apply correctness, manifest verification, tamper fail-closed rejection, partial failure rollback, and target undo drift protection.

### Target Recovery and Automatic Rollback

Restore operations are protected by private local target recovery snapshots:

- **Pre-restore target capture**: Before any target file is modified during restore, `bak` captures the affected target files (original bytes, permission mode bits, and absence status) in private local recovery storage under `~/.bak/recovery/<point-id>`.
- **Fail-closed preparation**: If target inspection, recovery staging, or the pre-restore commit fails, the restore operation aborts immediately before any target file is modified.
- **Automatic rollback on failure**: If writing any target file or applying file permissions fails, the restore halts immediately and attempts rollback of every attempted target file (including partially written files). Targets that did not exist before restore are deleted; targets that previously existed are restored to their original bytes and permissions.
- **Preserved recovery evidence**: Recovery evidence is retained locally under `~/.bak/recovery/<point-id>` with restricted permissions (0700/0600) for diagnostics and manual recovery. The operation returns an error reporting the original failure and rollback outcome.
- **Private local plaintext storage**: Recovery data is stored unencrypted in local private storage under `~/.bak/recovery/` with restrictive permissions. It is never pushed to cloud providers or included in cloud backup archives.
- **Best-effort limitations**: Rollback makes a best-effort attempt to revert target files across failures, but cannot promise race-proof atomicity against external concurrent processes or OS-level permission revocations during rollback.

### Git Safety Net

Local backup operations are protected by Git history tracking within `~/.bak`:

- **Scoped to `~/.bak`**: Git tracking is initialized within `~/.bak` to track local backup metadata and snapshots. Target configuration directories (such as `~/.config/opencode/`) are not auto-committed to Git by bak during restore operations (`GitDir` integration on target configs is unwired).
- **`bak undo`**: Reverts the last state in the `~/.bak` repository via `git revert` — safe, non-destructive, and history-preserving.
- **`bak undo`**: Reverts the last restore operation by restoring target configuration files to their pre-restore state using recovery point metadata, and records a `git revert` commit in the `~/.bak` repository — safe, non-destructive, and history-preserving.
- **No force-push**: The tool never force-pushes or rewrites Git history.

### Dry-Run and Confirmation
Expand All @@ -108,8 +122,10 @@ This prevents accidental overwrites. There is no silent restoration path.
## Known Limitations

- **Local Git required for undo**: The `bak undo` feature requires Git to be installed and operates on the `~/.bak` repository.
- **Target undo & drift protection**: `bak undo` projects pre-restore snapshots back to target configuration files and requires an exact match with post-restore state. If any target file has drifted (content changed, deleted, permission modified, or replaced with a directory or symlink), `bak undo` refuses all changes before writing any file. Best-effort limits: target undo requires un-drifted local state and a valid applied recovery point; concurrent external modifications during undo are not race-proof.
- **Token in environment**: `GITHUB_TOKEN` and other cloud provider credentials passed via environment variables are readable by any process with access to the user's environment.
- **Local backups at rest**: Backups stored locally under `~/.bak/backups/` are **never** encrypted on disk. AES-256-GCM encryption applies exclusively to cloud push/pull archives when configured per profile. Users must rely on OS filesystem permissions and full-disk encryption for local backup confidentiality.
- **Platform limits on permission assertions**: File permission preservation (`0.4.0` mode bits) is verified on non-Windows platforms (Linux and macOS); Windows filesystems do not support POSIX permission bits. Local automated suite proof is executed on Linux; Windows and macOS runtime behaviors remain CI evidence.

## Dependencies

Expand Down
37 changes: 6 additions & 31 deletions cmd/diff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"

"github.com/spf13/cobra"

configtest "github.com/danielxxomg/bak-cli/internal/config/testutil"
"github.com/danielxxomg/bak-cli/internal/manifest"
)

Expand Down Expand Up @@ -121,12 +121,7 @@ func TestDiffCmd_Args(t *testing.T) {

func TestRunDiff_AllCategories(t *testing.T) {
tmpDir := t.TempDir()
switch runtime.GOOS {
case "windows":
t.Setenv("USERPROFILE", tmpDir)
default:
t.Setenv("HOME", tmpDir)
}
configtest.SetConfigHome(t, tmpDir)

id1, id2 := stageDiffBackups(t, tmpDir)

Expand Down Expand Up @@ -162,12 +157,7 @@ func TestRunDiff_AllCategories(t *testing.T) {

func TestRunDiff_IdenticalBackups(t *testing.T) {
tmpDir := t.TempDir()
switch runtime.GOOS {
case "windows":
t.Setenv("USERPROFILE", tmpDir)
default:
t.Setenv("HOME", tmpDir)
}
configtest.SetConfigHome(t, tmpDir)

backupsDir := filepath.Join(tmpDir, ".bak", "backups")
os.MkdirAll(backupsDir, 0755)
Expand Down Expand Up @@ -211,12 +201,7 @@ func TestRunDiff_IdenticalBackups(t *testing.T) {

func TestRunDiff_MissingBackup(t *testing.T) {
tmpDir := t.TempDir()
switch runtime.GOOS {
case "windows":
t.Setenv("USERPROFILE", tmpDir)
default:
t.Setenv("HOME", tmpDir)
}
configtest.SetConfigHome(t, tmpDir)

deps, _, _ := setupTestDeps(t)
cmd := &cobra.Command{}
Expand All @@ -231,12 +216,7 @@ func TestRunDiff_MissingBackup(t *testing.T) {

func TestRunDiff_TraversalBlocked(t *testing.T) {
tmpDir := t.TempDir()
switch runtime.GOOS {
case "windows":
t.Setenv("USERPROFILE", tmpDir)
default:
t.Setenv("HOME", tmpDir)
}
configtest.SetConfigHome(t, tmpDir)

// Create a valid backup so the first ID resolves successfully.
backupsDir := filepath.Join(tmpDir, ".bak", "backups")
Expand All @@ -263,12 +243,7 @@ func TestRunDiff_TraversalBlocked(t *testing.T) {

func TestRunDiff_EmptyManifests(t *testing.T) {
tmpDir := t.TempDir()
switch runtime.GOOS {
case "windows":
t.Setenv("USERPROFILE", tmpDir)
default:
t.Setenv("HOME", tmpDir)
}
configtest.SetConfigHome(t, tmpDir)

backupsDir := filepath.Join(tmpDir, ".bak", "backups")
os.MkdirAll(backupsDir, 0755)
Expand Down
18 changes: 11 additions & 7 deletions cmd/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,13 @@ func TestRunLoginWithDeps_ConfigLoaderError(t *testing.T) {
func TestRunLoginWithDeps_NonTTYGuard(t *testing.T) {
// Override isTTY to simulate non-interactive terminal.
origIsTTY := isTTY
t.Cleanup(func() { isTTY = origIsTTY })
isTTY = func() bool { return false }
defer func() { isTTY = origIsTTY }()

// Enable interactive mode.
origInteractive := loginInteractive
t.Cleanup(func() { loginInteractive = origInteractive })
loginInteractive = true
defer func() { loginInteractive = origInteractive }()

deps, _, _ := setupTestDeps(t)
cmd := &cobra.Command{}
Expand Down Expand Up @@ -94,10 +94,14 @@ func TestRunLogin_EmptyToken(t *testing.T) {
if err == nil {
t.Fatal("expected error from un-authorized device login, got nil")
}
// Must resolve in seconds, not the server-advertised 10 minutes — proves no
// real network call and the test cannot hang CI.
if elapsed > 2*time.Second {
t.Errorf("login exceeded 2s (elapsed=%v); test is not isolated from the network", elapsed)
// Must resolve in seconds, well below a real device-flow expiry, so this
// proves no real network call and the test cannot hang CI. The budget is
// deliberately generous: the loop honours the server-advertised expires_in
// (1s here) but CI runners can overshoot that by whole seconds under load,
// and a tight ceiling turned a slow macOS runner into a red build. 30s is
// still ~30x the advertised expiry and nowhere near a real 15-minute flow.
if elapsed > 30*time.Second {
t.Errorf("login took %v, far longer than the 1s server-advertised expiry; test is not isolated from the network", elapsed)
}
if !strings.Contains(err.Error(), "timed out") && !strings.Contains(err.Error(), "token") {
t.Errorf("expected a token/timeout error, got: %v", err)
Expand All @@ -115,8 +119,8 @@ func TestRunLogin_NonGitHubProviders(t *testing.T) {
for _, tt := range tests {
t.Run(tt.provider, func(t *testing.T) {
orig := loginProvider
t.Cleanup(func() { loginProvider = orig })
loginProvider = tt.provider
defer func() { loginProvider = orig }()

err := runLogin(nil, nil)
if err == nil {
Expand Down
2 changes: 1 addition & 1 deletion cmd/pick_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import (
func TestRunPickWithDeps_NonTTYGuard(t *testing.T) {
// Override isTTY to simulate non-interactive terminal.
origIsTTY := isTTY
t.Cleanup(func() { isTTY = origIsTTY })
isTTY = func() bool { return false }
defer func() { isTTY = origIsTTY }()

deps, _, _ := setupTestDeps(t)
cmd := &cobra.Command{}
Expand Down
Loading
Loading