3eeb5b46d7
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
153 lines
3.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|