Files
vikingowl 3eeb5b46d7 feat(safety): pre-launch cwd classifier + context banner
Implements S-1 through S-7 of the startup-safety-banner plan.

Adds a pre-launch safety check that classifies the current working
directory into three tiers and gates the launch:

  TierRefuse  /, /etc, /sys, /proc, /usr, /var, /bin, /sbin, /boot,
              /root, /dev (Linux) and /System, /Library, /private,
              /Applications (macOS). Refuses with exit 2 unless
              --dangerously-allow-anywhere is passed.

  TierWarn    $HOME, ~/Desktop, ~/Downloads, ~/Documents, ~/.config,
              ~/.local, ~/.cache, /tmp, and similar dumping grounds.
              Prints a banner and reads a single y/Y from stdin to
              confirm; any other input (or EOF, including piped/
              scripted invocation) aborts with exit 1.

  TierOK      Anywhere with a recognized project marker (.gnoma/,
              go.mod, package.json, pyproject.toml, Cargo.toml,
              Makefile, Dockerfile, build.gradle*, pom.xml) or
              inside a git repo. No prompt; banner only.

Project markers and git-repo presence override the TierWarn check —
a project dir inside $HOME stays TierOK. The require_project_marker
config knob can flip that for strict users.

Container detection: when /.dockerenv or /run/.containerenv exists,
TierRefuse downgrades to TierWarn (devcontainers often chroot to /
or similar). Best-effort; false positives only soften the gate.

The context banner is always rendered (TierOK, TierWarn, TierRefuse
alike) and summarizes: cwd, git branch + dirty state, project type,
provider/model, modes (permission, incognito, prefer), and a
top-level sensitive-file inventory. Inventory matches .env,
.env.*, env.local; private-key extensions (.pem, .key, .crt, .p12,
.pfx); SSH key names (id_rsa, id_ed25519, ...); credentials files;
.netrc / .pgpass; KeePass vaults; and .ssh/ .aws/ .kube/ .gcloud/
.azure/ .docker/ directories. Precision-tested: .envrc and
secret_handler.go do NOT match. Bounded at 1000 entries.

Architecture:
- internal/safety/cwd.go — Classification + symlink-resolving tier
  classifier with platform-specific roots and container detection.
- internal/safety/sensitive.go — pattern-based top-level scanner,
  deterministic ordering, scanLimit guard against pathological dirs.
- internal/safety/banner.go — pure render functions for the warn
  prefix, refuse message, and context banner. Safe for golden-string
  testing.
- internal/config/config.go — new [safety] section with three
  config keys, defaults applied via ResolvedSafety() helper. Pointer
  fields distinguish "user omitted" from "user set to false."
- cmd/gnoma/main.go — gate runs after subcommand dispatch (so
  `gnoma providers / profile / slm / router` skip the prompt) and
  before provider creation. --dangerously-allow-anywhere bypasses
  the gate with an explicit log warning.

The runtime keypress reads up to 8 bytes from os.Stdin and accepts
only "y" / "Y" trimmed; EOF returns false (piped invocations
without the flag will abort). Documented in the readYesConfirmation
helper. Manual smoke (per plan):
  - `cd / && gnoma -p test` → refuses
  - `cd ~ && gnoma` → warns + keypress
  - `cd ~/git/some-repo && gnoma` → banner only
  - subcommands skip the gate entirely

Linux + macOS classification; Windows path handling deferred per
plan (treated as TierOK there until follow-up).

Refs: docs/superpowers/plans/2026-05-23-startup-safety-banner.md
2026-05-23 22:19:39 +02:00

153 lines
3.9 KiB
Go

package safety
import (
"os"
"path/filepath"
"testing"
"somegit.dev/Owlibou/gnoma/internal/config"
)
func defaultCfg() config.ResolvedSafetySection {
return config.ResolvedSafetySection{
RefuseInSystemDirs: true,
WarnInHome: true,
RequireProjectMarker: false,
}
}
func TestClassifyCWD_SystemRoots(t *testing.T) {
cfg := defaultCfg()
cases := []string{"/etc", "/etc/foo", "/sys", "/proc/1", "/var/log", "/usr/local"}
for _, p := range cases {
t.Run(p, func(t *testing.T) {
c := ClassifyCWD(p, cfg)
// When running inside a container, system roots are
// downgraded to warn. The CI/container case is acceptable.
if c.Tier == TierRefuse {
return
}
if c.Tier == TierWarn && isInContainer() {
return
}
t.Errorf("%s tier = %v, want refuse (or warn under container)", p, c.Tier)
})
}
}
func TestClassifyCWD_HomeIsWarn(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil || home == "" {
t.Skip("UserHomeDir unavailable")
}
cfg := defaultCfg()
c := ClassifyCWD(home, cfg)
if c.Tier != TierWarn {
t.Errorf("$HOME tier = %v, want warn", c.Tier)
}
}
func TestClassifyCWD_TmpIsWarn(t *testing.T) {
cfg := defaultCfg()
c := ClassifyCWD("/tmp", cfg)
if c.Tier != TierWarn {
t.Errorf("/tmp tier = %v, want warn", c.Tier)
}
}
func TestClassifyCWD_ProjectMarkerForcesOK(t *testing.T) {
dir := t.TempDir()
// Drop a project marker.
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test"), 0o600); err != nil {
t.Fatal(err)
}
cfg := defaultCfg()
c := ClassifyCWD(dir, cfg)
if c.Tier != TierOK {
t.Errorf("dir with go.mod tier = %v, want ok", c.Tier)
}
}
func TestClassifyCWD_GitRepoIsOK(t *testing.T) {
dir := t.TempDir()
// Drop a .git directory (file would also be accepted — git worktrees).
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o700); err != nil {
t.Fatal(err)
}
cfg := defaultCfg()
c := ClassifyCWD(dir, cfg)
if c.Tier != TierOK {
t.Errorf("dir with .git tier = %v, want ok", c.Tier)
}
}
func TestClassifyCWD_RequireProjectMarker_GitRepoWithoutMarker(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o700); err != nil {
t.Fatal(err)
}
cfg := defaultCfg()
cfg.RequireProjectMarker = true
c := ClassifyCWD(dir, cfg)
if c.Tier != TierWarn {
t.Errorf("git repo without marker under RequireProjectMarker tier = %v, want warn", c.Tier)
}
}
func TestClassifyCWD_ProjectInsideHomeIsOK(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil || home == "" {
t.Skip("UserHomeDir unavailable")
}
// Project markers anywhere — including inside $HOME — must
// override the personal-dumping-ground warn.
dir := filepath.Join(home, ".gnoma-safety-test-tmp")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Skipf("could not create test dir: %v", err)
}
defer func() { _ = os.RemoveAll(dir) }()
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test"), 0o600); err != nil {
t.Fatal(err)
}
cfg := defaultCfg()
c := ClassifyCWD(dir, cfg)
if c.Tier != TierOK {
t.Errorf("project dir inside $HOME tier = %v, want ok", c.Tier)
}
}
func TestClassifyCWD_RefuseDisabled(t *testing.T) {
cfg := defaultCfg()
cfg.RefuseInSystemDirs = false
c := ClassifyCWD("/etc", cfg)
if c.Tier == TierRefuse {
t.Errorf("with refuse_in_system_dirs=false, /etc tier = %v, want warn or ok", c.Tier)
}
}
func TestClassifyCWD_WarnInHomeDisabled(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil || home == "" {
t.Skip("UserHomeDir unavailable")
}
cfg := defaultCfg()
cfg.WarnInHome = false
c := ClassifyCWD(home, cfg)
if c.Tier != TierOK {
t.Errorf("with warn_in_home=false, $HOME tier = %v, want ok", c.Tier)
}
}
func TestTier_String(t *testing.T) {
cases := map[Tier]string{
TierOK: "ok",
TierWarn: "warn",
TierRefuse: "refuse",
}
for tier, want := range cases {
if got := tier.String(); got != want {
t.Errorf("%d.String() = %q, want %q", tier, got, want)
}
}
}