Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
35 changes: 35 additions & 0 deletions cli/internal/doctor/checks_antigravity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package doctor

import (
"bytes"
"runtime"
"strings"
"testing"
)

// TestCheckAntigravity_AbsolutePathAccepted guards the C20 fix (#691): the
// AGY_APP_DATA absolute-path check used strings.HasPrefix(_, "/"), which
// false-FAILed an absolute Windows path (C:\Users\...\antigravity-cli) whenever
// agy was on PATH on Windows. filepath.IsAbs recognizes each OS's absolute form,
// so an OS-native absolute path must PASS. On the windows-latest runner this
// exercises the exact regression; on ubuntu it confirms the POSIX form still
// passes.
func TestCheckAntigravity_AbsolutePathAccepted(t *testing.T) {
abs := "/home/me/.gemini/antigravity-cli"
if runtime.GOOS == "windows" {
abs = `C:\Users\me\.gemini\antigravity-cli`
}

sys := newSys(map[string]string{"AGY_APP_DATA": abs}, []string{"agy"}, nil)
var buf bytes.Buffer
rep := capture(&buf)
checkAntigravity(sys, rep)

out := buf.String()
if strings.Contains(out, "AGY_APP_DATA is relative or unset") {
t.Errorf("absolute path %q was false-FAILed:\n%s", abs, out)
}
if !strings.Contains(out, "AGY_APP_DATA is absolute") {
t.Errorf("absolute path %q should report absolute:\n%s", abs, out)
}
}
5 changes: 4 additions & 1 deletion cli/internal/doctor/checks_deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,10 @@ func checkAntigravity(sys *System, rep *Report) {
}

agyData := sys.env("AGY_APP_DATA", filepath.Join(sys.home(), ".gemini", "antigravity-cli"))
if strings.HasPrefix(agyData, "/") {
// filepath.IsAbs, not HasPrefix(_, "/"): an absolute Windows path
// (C:\Users\...\.gemini\antigravity-cli) is not '/'-rooted, so the POSIX-only
// check false-FAILed it whenever agy was on PATH on Windows (#691 / C20).
if filepath.IsAbs(agyData) {
rep.Pass("AGY_APP_DATA is absolute")
} else {
rep.Fail("AGY_APP_DATA is relative or unset: " + agyData)
Expand Down
130 changes: 130 additions & 0 deletions scripts/install-git-hooks.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# install-git-hooks.ps1 - deploy the GUARD-001 global memory-sink dispatcher and
# wire it machine-wide via core.hooksPath. The Windows twin of
# scripts/install-git-hooks.sh (which always noted "a Windows setup twin is a
# tracked follow-up" -- this is it, #691).
#
# Bootstrap stays shell (ADR-020 C7): the deployed `dotf` release carries no
# source tree, so setup-windows.ps1 places the dispatcher and `dotf doctor`
# verifies the wiring thereafter. The dispatcher hooks are POSIX and run under
# Git-for-Windows `sh`, so no per-OS hook rewrite is needed -- only the
# deploy + wire.
#
# Sourced by setup-windows.ps1; also runnable standalone to (re)install:
# .\scripts\install-git-hooks.ps1 [SourceDir] [DotfilesDir]
#
# Idempotent (clean mirror + converge) and safety-first: an unrelated pre-existing
# core.hooksPath is preserved, never clobbered (a global hooksPath has machine-wide
# blast radius). Functions take src/dest as args so Pester can drive them against
# fixtures under a throwaway GIT_CONFIG_GLOBAL with no real ~/.gitconfig mutation.
#
# ASCII-only by repo convention (pattern-powershell-ascii-only).

Set-StrictMode -Version Latest

# Deploy-GitHooks: clean-mirror the dispatcher tree into the deploy mirror. A
# clean mirror (remove-then-copy, not a bare copy) so a hook removed upstream
# never lingers and keeps firing -- a stale security hook is worse than none.
# Returns $true on success, $false on a guard trip or error.
function Deploy-GitHooks {
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '',
Justification = 'GitHooks is inherently plural; mirrors install-git-hooks.sh and the git-hooks/ dispatcher dir')]
param(
[Parameter(Mandatory)][string]$Source,
[Parameter(Mandatory)][string]$Destination
)

if (-not (Test-Path -LiteralPath $Source -PathType Container)) {
Write-Host "[FAIL] install-git-hooks: source dispatcher dir not found: $Source" -ForegroundColor Red
return $false
}
if (-not (Test-Path -LiteralPath (Join-Path $Source 'pre-commit') -PathType Leaf)) {
Write-Host "[FAIL] install-git-hooks: $Source has no pre-commit dispatcher -- refusing to deploy" -ForegroundColor Red
return $false
}

# Guard the destructive mirror: the dest MUST be a non-root *\git-hooks path.
# Defends against an empty/misconfigured DOTFILES_DIR turning Remove-Item loose.
$normDest = $Destination.TrimEnd('\', '/')
if ($normDest -notmatch '[\\/]git-hooks$') {
Write-Host "[FAIL] install-git-hooks: refusing to mirror to unexpected path: '$Destination'" -ForegroundColor Red
return $false
}
if ($normDest -eq (Join-Path $HOME 'git-hooks').TrimEnd('\', '/') -or
$normDest -match '^[A-Za-z]:[\\/]git-hooks$' -or $normDest -match '^[\\/]git-hooks$') {
Write-Host "[FAIL] install-git-hooks: refusing unsafe dest: '$Destination'" -ForegroundColor Red
return $false
}

# Self-mirror guard (#695): if src and dest resolve to the same directory, the
# clean-mirror below (remove dest THEN copy) would delete the dispatcher and
# copy nothing back. Nothing to mirror onto itself; report already-in-place.
$srcFull = (Resolve-Path -LiteralPath $Source).Path.TrimEnd('\', '/')
$destFull = if (Test-Path -LiteralPath $Destination) {
(Resolve-Path -LiteralPath $Destination).Path.TrimEnd('\', '/')
} else { $normDest }
if ($srcFull -eq $destFull) {
Write-Host "[INFO] install-git-hooks: src and dest are the same directory ($Destination) -- skipping mirror (already in place)" -ForegroundColor Blue
Write-Host "[SUCCESS] GUARD dispatcher already in place at $Destination" -ForegroundColor Green
return $true
}

if (Test-Path -LiteralPath $Destination) { Remove-Item -LiteralPath $Destination -Recurse -Force }
New-Item -ItemType Directory -Path $Destination -Force | Out-Null
Copy-Item -Path (Join-Path $Source '*') -Destination $Destination -Recurse -Force
Write-Host "[SUCCESS] GUARD dispatcher deployed to $Destination" -ForegroundColor Green
return $true
}

# Set-GlobalHooksPath: point git's global core.hooksPath at the deployed
# dispatcher -- but ONLY when unset. An unrelated value is preserved (machine-wide
# blast radius) and surfaced as a warning; an already-correct value is a no-op.
# Mirrors the `dotf doctor` checkGuardHooks contract.
function Set-GlobalHooksPath {
param([Parameter(Mandatory)][string]$Target)

$current = (git config --global --get core.hooksPath 2>$null)
if ($current) { $current = "$current".Trim() }

if (-not $current) {
git config --global core.hooksPath $Target
if ($LASTEXITCODE -eq 0) {
Write-Host "[SUCCESS] core.hooksPath wired to the GUARD dispatcher ($Target)" -ForegroundColor Green
return $true
}
Write-Host "[FAIL] install-git-hooks: failed to set core.hooksPath" -ForegroundColor Red
return $false
}
if ($current -eq $Target) {
Write-Host "[INFO] core.hooksPath already wired to the GUARD dispatcher" -ForegroundColor Blue
return $true
}
Write-Host "[WARNING] core.hooksPath is '$current' (not the GUARD dispatcher) -- preserving it; the memory-sink guard is INACTIVE. Point it at $Target, or chain the dispatcher from your hooks, manually." -ForegroundColor Yellow
return $true
}

# Install-GitHooks: deploy + wire. Defaults resolve the repo's git-hooks/ (this
# script's sibling parent) and the ~/.dotfiles deploy mirror.
function Install-GitHooks {
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '',
Justification = 'GitHooks is inherently plural; mirrors install-git-hooks.sh and the git-hooks/ dispatcher dir')]
param(
[string]$Source = (Join-Path $PSScriptRoot '..\git-hooks'),
[string]$DotfilesDir = $(if ($env:DOTFILES_DIR) { $env:DOTFILES_DIR } else { Join-Path $HOME '.dotfiles' })
)

if (-not $DotfilesDir -or $DotfilesDir.TrimEnd('\', '/') -eq $HOME.TrimEnd('\', '/') -or
$DotfilesDir -match '^[A-Za-z]:[\\/]?$') {
Write-Host "[FAIL] install-git-hooks: unsafe DOTFILES_DIR: '$DotfilesDir'" -ForegroundColor Red
return $false
}

$dest = Join-Path $DotfilesDir 'git-hooks'
if (-not (Deploy-GitHooks -Source $Source -Destination $dest)) { return $false }
if (-not (Set-GlobalHooksPath -Target $dest)) { return $false }
return $true
}

# Run standalone only when executed directly (not when dot-sourced by setup).
if ($MyInvocation.InvocationName -ne '.') {
$null = Install-GitHooks @args
}
21 changes: 21 additions & 0 deletions setup-windows.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,27 @@ if (Test-Path $packagesSource) {
Write-Warn "packages.json not found at $packagesSource"
}

# ============================================================================
# 1b2. GUARD-001 MEMORY-SINK HOOKS (#691)
# ============================================================================
# Deploy the dispatcher into the ~/.dotfiles mirror and wire core.hooksPath
# machine-wide, so the guard that rejects MEMORY.md/memory/ outside the vault is
# active in every repo -- parity with setup-linux.sh (which had no Windows twin).
# The wired path (Join-Path $DotfilesDest 'git-hooks') equals the Go
# filepath.Join(cfg.DotfilesDir, "git-hooks") that `dotf doctor` verifies.
# Non-fatal: doctor verifies + repairs thereafter.
$installHooksPs1 = Join-Path $PSScriptRoot 'scripts\install-git-hooks.ps1'
if (Test-Path -LiteralPath $installHooksPs1) {
. $installHooksPs1
if (Install-GitHooks -Source (Join-Path $PSScriptRoot 'git-hooks') -DotfilesDir $DotfilesDest) {
Write-Success "GUARD-001 memory-sink hooks installed"
} else {
Write-Warn "GUARD-001 hooks install incomplete (continuing; run 'dotf doctor --fix')"
}
} else {
Write-Warn "scripts\install-git-hooks.ps1 not found; skipping memory-sink guard install"
}

# ============================================================================
# 1c. DEVELOPER TOOLS (via winget)
# ============================================================================
Expand Down
118 changes: 118 additions & 0 deletions specs/BUG-032-windows-guard-001/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
---
id: "BUG-032-windows-guard-001"
type: spec
status: implementing # draft | implementing | verifying | archived
created: "2026-07-14"
issue: "mlorentedev/dotfiles#691" # repo#NNN — GitHub issue / Project item that tracks this spec
tags: [spec, proposal, guard-001, memory-sink, powershell, doctor, fresh-machine, security]
template_version: "1.0"
---

# BUG-032-windows-guard-001

Install the GUARD-001 memory-sink dispatcher on Windows (the setup twin
`install-git-hooks.sh` always flagged as a follow-up) and fix the POSIX-only
absolute-path check that made `dotf doctor` false-FAIL the agy AGY_APP_DATA on
Windows.

## Why

Two Windows-side gaps in the memory-sink guard and doctor (audit C20 + the
GUARD-001 Windows gap):

- **GUARD-001 never installs on Windows.** `setup-windows.ps1` has zero
`core.hooksPath` / git-hooks deploy references (grep-confirmed). AGENTS.md's
"MEMORY SINGLE-SINK (GUARD-001)" advertises a machine-wide pre-commit guard that
rejects `MEMORY.md`/`memory/` outside the vault — on Windows it simply does not
exist, so an agent can commit memory artifacts into any repo. The Linux setup
deploys the dispatcher and wires the global hook via
`scripts/install-git-hooks.sh`; there is no Windows equivalent, and
`install-git-hooks.sh` itself notes "a Windows setup twin is a tracked
follow-up." Empirically on a real box: `dotf doctor` reports
`[FAIL] dispatcher not found at C:\Users\<u>\.dotfiles\git-hooks — run dotfiles
setup to deploy git-hooks/`, and `core.hooksPath` is unset.
- **C20 — doctor false-FAIL on a valid Windows path.** `checks_deploy.go:350`
gates `AGY_APP_DATA` with `strings.HasPrefix(agyData, "/")`. An absolute Windows
path (`C:\Users\…\.gemini\antigravity-cli`) does not start with `/`, so whenever
agy is on PATH on Windows `dotf doctor` reports "AGY_APP_DATA is relative or
unset" — the same POSIX-only assumption the #551 `contractOS` work removed
elsewhere in this package.

## What

- **`scripts/install-git-hooks.ps1`** — the PowerShell twin of
`install-git-hooks.sh`. Two functions plus an entry point, matching the shell
contract exactly:
- `Deploy-GitHooks <src> <dest>`: clean-mirror the dispatcher tree into
`<dest>` (remove-then-copy, so a hook removed upstream never lingers — a stale
security hook is worse than none). Same safety guards: refuse a `<dest>` that
is not a `*\git-hooks` path, refuse `$HOME\git-hooks` / a drive root, and no-op
when `src` and `dest` resolve to the same directory (#695 self-empty guard).
- `Set-GlobalHooksPath <target>`: wire global `core.hooksPath` to `<target>`
**only when unset**; an unrelated pre-existing value is preserved and warned
(machine-wide blast radius), an already-correct value is a no-op. Mirrors the
`dotf doctor` `checkGuardHooks` contract.
- `Install-GitHooks [src] [dotfilesDir]`: deploy + wire, defaulting `src` to the
repo's `git-hooks/` and `dotfilesDir` to `~/.dotfiles`.
- **`setup-windows.ps1`** sources `install-git-hooks.ps1` and calls
`Install-GitHooks`, non-fatal (a warning on failure, like the Linux side), so a
fresh Windows box ends with the dispatcher at `~/.dotfiles\git-hooks` and
`core.hooksPath` wired. The wired path is `Join-Path $DotfilesDest 'git-hooks'`,
which round-trips byte-for-byte through `git config` and equals the Go
`filepath.Join(cfg.DotfilesDir, "git-hooks")` the doctor compares against
(verified empirically), so `dotf doctor` then PASSES on Windows.
- **C20 fix (Go).** `checks_deploy.go`: `strings.HasPrefix(agyData, "/")` ->
`filepath.IsAbs(agyData)`, so an absolute Windows path passes.
- **Tests.** Pester for `install-git-hooks.ps1` (clean-mirror deploys the
dispatcher; re-deploy prunes a stale hook; wire sets an unset hooksPath;
preserves an unrelated value; refuses an unsafe dest) driven against fixtures
under an isolated temp dir with a throwaway `--file` git config — no real
`~/.gitconfig` mutation. Go unit test for the `filepath.IsAbs` branch.

## Out of scope

- **Porting the guard install to Go.** Bootstrap stays shell (ADR-020 C7): the
deployed `dotf` release carries no source tree, so it cannot self-deploy
`git-hooks/`; the setup bootstrap places them and `dotf doctor` verifies. The
`.ps1` twin mirrors the existing `.sh`; this is not a new-tooling case.
- **The dispatcher hooks themselves.** They are POSIX and already run under
Git-for-Windows `sh`; unchanged.
- **The other fresh-machine bugs** (#690, #688) — their own issues.

## Risks / open questions

- **Path-match (resolved).** `git config --global core.hooksPath` stores a
backslash Windows path and `--get` returns it unchanged (verified with a
throwaway config file), so the PS-wired `~/.dotfiles\git-hooks` equals the
doctor's `filepath.Join(cfg.DotfilesDir, "git-hooks")` and `current == target`
holds. No forward/back-slash normalization drift.
- **Machine-wide blast radius.** Global `core.hooksPath` affects every repo. The
wire is unset-only + preserve-existing + warn, identical to the Linux twin and
the doctor `--fix`; never clobbered.
- **Idempotency.** Clean-mirror + unset-only wire are safe to re-run each setup.
- **Windows CI.** The `test-windows` Pester job runs `setup-windows.ps1` e2e on a
runner; the new install runs there. PSScriptAnalyzer must be extended to cover
the new script (currently only 4 files; tracked in #692) — meanwhile it is
linted locally.

## Acceptance criteria

- [ ] `scripts/install-git-hooks.ps1` `Deploy-GitHooks` clean-mirrors `git-hooks/`
to a `*\git-hooks` dest and refuses unsafe dests.
- [ ] `Set-GlobalHooksPath` wires an unset `core.hooksPath`, no-ops when already
correct, preserves+warns on an unrelated value.
- [ ] `setup-windows.ps1` deploys the dispatcher to `~/.dotfiles\git-hooks` and
wires `core.hooksPath`; afterward `dotf doctor` GUARD section PASSES on
Windows (verified end-to-end on a real box).
- [ ] `dotf doctor` reports AGY_APP_DATA absolute for a Windows path
(`filepath.IsAbs`), no false-FAIL.
- [ ] Pester green for `install-git-hooks.ps1`; Go test green for the IsAbs branch;
`go build/vet/test ./...` + PSScriptAnalyzer (changed files) clean; ASCII-only.

## References

- GH issue: [#691](https://github.com/mlorentedev/dotfiles/issues/691)
- Linux twin: `scripts/install-git-hooks.sh` (deploy + wire, the contract mirrored)
- GUARD-001 history: #398 (dispatcher), #415 (verifier), #418 (wiring), #695 (self-empty guard)
- ADR-020 C7 (bootstrap stays shell); ADR-025 (cross-machine paths)
- Sibling fresh-machine bugs: #690, #688; PSSA coverage gap: #692
41 changes: 41 additions & 0 deletions specs/BUG-032-windows-guard-001/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
tags: [spec, tasks, guard-001, powershell, doctor]
created: "2026-07-14"
---

# Tasks - BUG-032-windows-guard-001

> TDD order. One task = one focused commit.

## Setup

- [x] Branch created from main: `fix/windows-guard-001-memory-sink`
- [x] `proposal.md` complete; acceptance criteria testable
- [x] Path-match risk resolved empirically before coding (git config round-trip)

## Implementation

- [x] Go: failing test for the C20 branch — an OS-absolute AGY_APP_DATA must PASS
(`TestCheckAntigravity_AbsolutePathAccepted`, OS-aware); proved red under
`HasPrefix("/")`, green under `filepath.IsAbs`
- [x] Go: `checks_deploy.go` `strings.HasPrefix(agyData,"/")` -> `filepath.IsAbs`
- [x] `scripts/install-git-hooks.ps1`: `Deploy-GitHooks` (clean mirror + safety
guards), `Set-GlobalHooksPath` (wire-when-unset, preserve, no-op),
`Install-GitHooks` (deploy+wire); dot-source-safe entry guard
- [x] Pester: `tests/install-git-hooks.Tests.ps1` (mirror/prune/refuse-unsafe;
wire/no-op/preserve via throwaway GIT_CONFIG_GLOBAL) — 8/8
- [x] `setup-windows.ps1`: source + `Install-GitHooks -DotfilesDir $DotfilesDest`
(non-fatal), as a new GUARD-001 section
- [x] Empirical e2e on this Windows box: install + `dotf doctor` GUARD = all ok

## Closing

- [x] Every acceptance criterion covered by a test or the e2e run
- [x] `go build`/`vet`/`test ./...` clean (golangci-lint: CI)
- [x] PSScriptAnalyzer clean on changed files (PSUseSingularNouns suppressed with
justification for the inherently-plural GitHooks nouns); ASCII-only
- [x] No unrelated changes (pre-existing setup-windows.ps1 em-dashes untouched, #692)
- [x] `verification.md` filled in
- [ ] PR opened referencing this spec folder

> `features.json` omitted, matching the BUG-029/030/031 precedent.
Loading