diff --git a/cmd/gnoma/main.go b/cmd/gnoma/main.go index 36272bc..78c4ff1 100644 --- a/cmd/gnoma/main.go +++ b/cmd/gnoma/main.go @@ -30,6 +30,7 @@ import ( "somegit.dev/Owlibou/gnoma/internal/provider/openaicompat" subprocprov "somegit.dev/Owlibou/gnoma/internal/provider/subprocess" "somegit.dev/Owlibou/gnoma/internal/router" + "somegit.dev/Owlibou/gnoma/internal/safety" "somegit.dev/Owlibou/gnoma/internal/security" "somegit.dev/Owlibou/gnoma/internal/session" "somegit.dev/Owlibou/gnoma/internal/skill" @@ -68,6 +69,7 @@ func main() { permMode = flag.String("permission", "auto", "permission mode (default, accept_edits, bypass, deny, plan, auto)") incognito = flag.Bool("incognito", false, "incognito mode — no persistence, no learning") profileFlag = flag.String("profile", "", "config profile to load (empty = default_profile from base config)") + allowAnywhere = flag.Bool("dangerously-allow-anywhere", false, "bypass the cwd safety classifier — only use if you know what you're doing") verbose = flag.Bool("verbose", false, "enable debug logging") version = flag.Bool("version", false, "print version and exit") ) @@ -183,6 +185,40 @@ func main() { } } + // Pre-launch safety check (cwd classification + context banner). + // Runs after subcommand dispatch so `gnoma providers / profile / + // slm / router` don't trigger the prompt. Bypass: pass + // --dangerously-allow-anywhere. See + // docs/superpowers/plans/2026-05-23-startup-safety-banner.md. + if !*allowAnywhere { + cwdAbs, _ := os.Getwd() + safetyCfg := cfg.Safety.ResolvedSafety() + classification := safety.ClassifyCWD(cwdAbs, safetyCfg) + switch classification.Tier { + case safety.TierRefuse: + fmt.Fprint(os.Stderr, safety.RenderRefuse(classification)) + os.Exit(2) + case safety.TierWarn: + fmt.Fprint(os.Stderr, safety.RenderWarnPrefix(classification)) + if !readYesConfirmation(os.Stdin) { + fmt.Fprintln(os.Stderr, "aborted.") + os.Exit(1) + } + } + // Context banner shown for every tier (informational). + banner := safety.RenderContextBanner(classification, safety.SessionInfo{ + Version: buildVersion, + Provider: cfg.Provider.Default, + Model: cfg.Provider.Model, + Permission: cfg.Permission.Mode, + Incognito: *incognito, + Prefer: cfg.Router.Prefer, + }, safety.ScanCWDForSensitive(cwdAbs)) + fmt.Fprint(os.Stderr, banner) + } else { + logger.Warn("cwd safety check bypassed via --dangerously-allow-anywhere") + } + knownProviders := map[string]bool{ "mistral": true, "anthropic": true, "openai": true, "google": true, "ollama": true, "llamacpp": true, @@ -1593,6 +1629,23 @@ func runSLMCommand(args []string, cfg *gnomacfg.Config, logger *slog.Logger) int } // humanBytes formats a byte count as a human-readable string. +// readYesConfirmation reads a single line from r and returns true only +// if the trimmed input is "y" or "Y" (any other input, including EOF +// and empty line, returns false). Used by the cwd safety check to gate +// TierWarn launches behind explicit consent. When stdin isn't a TTY +// (piped / scripted invocation), io.ReadString hits EOF immediately +// and returns false — non-interactive callers must pass +// --dangerously-allow-anywhere. +func readYesConfirmation(r io.Reader) bool { + buf := make([]byte, 8) + n, _ := r.Read(buf) + if n == 0 { + return false + } + s := strings.TrimSpace(string(buf[:n])) + return s == "y" || s == "Y" +} + func humanBytes(n int64) string { const unit = 1024 if n < unit { diff --git a/internal/config/config.go b/internal/config/config.go index 81ad98e..3fd3bf8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ type Config struct { Session SessionSection `toml:"session"` SLM SLMSection `toml:"slm"` Router RouterSection `toml:"router"` + Safety SafetySection `toml:"safety"` CLIAgents CLIAgentsSection `toml:"cli_agents"` Arms []ArmConfig `toml:"arms"` Hooks []HookConfig `toml:"hooks"` @@ -93,6 +94,55 @@ type CLIAgentsSection map[string]string // RouterSection holds router-level overrides. Most routing decisions are // driven automatically by arm capabilities and the bandit; this section // exists for the rare overrides that don't fit elsewhere. +// SafetySection controls the pre-launch dir-safety classifier — refuse +// in system roots, warn+keypress in $HOME and other dumping grounds, +// OK inside any git repo or project marker. Always shows a context +// banner regardless of tier. See +// docs/superpowers/plans/2026-05-23-startup-safety-banner.md. +type SafetySection struct { + // RefuseInSystemDirs gates the refuse path. When false, system + // roots like / and /etc are treated as warn-tier instead of refuse. + // Default: true. + RefuseInSystemDirs *bool `toml:"refuse_in_system_dirs"` + // WarnInHome gates the warn-tier check for $HOME and common + // dumping grounds (~/Desktop, ~/Downloads, /tmp). When false, + // these all become OK-tier (banner still shown). Default: true. + WarnInHome *bool `toml:"warn_in_home"` + // RequireProjectMarker, when true, treats any directory without + // a recognized project marker as warn-tier (even inside a git + // repo). Default: false — git repo is enough by default. + RequireProjectMarker bool `toml:"require_project_marker"` +} + +// ResolvedSafety returns the effective Safety settings with defaults +// applied for any unset pointer fields. Pointer fields are used in the +// struct so we can distinguish "user omitted the key" from "user set +// it to false." +func (s SafetySection) ResolvedSafety() ResolvedSafetySection { + refuse := true + if s.RefuseInSystemDirs != nil { + refuse = *s.RefuseInSystemDirs + } + warn := true + if s.WarnInHome != nil { + warn = *s.WarnInHome + } + return ResolvedSafetySection{ + RefuseInSystemDirs: refuse, + WarnInHome: warn, + RequireProjectMarker: s.RequireProjectMarker, + } +} + +// ResolvedSafetySection is the SafetySection with defaults applied. +// Consumers (cmd/gnoma/main.go, internal/safety) read this rather than +// the raw config to avoid re-deriving defaults at each call site. +type ResolvedSafetySection struct { + RefuseInSystemDirs bool + WarnInHome bool + RequireProjectMarker bool +} + type RouterSection struct { // ForceTwoStage forces the two-stage tool-routing path regardless of // arm context window. Useful for debugging or for forcing the behavior diff --git a/internal/safety/banner.go b/internal/safety/banner.go new file mode 100644 index 0000000..72bb5d6 --- /dev/null +++ b/internal/safety/banner.go @@ -0,0 +1,141 @@ +package safety + +import ( + "fmt" + "path/filepath" + "strings" +) + +// SessionInfo carries the bits of session state the banner shows. +// Caller passes whatever is known at launch time; empty fields are +// omitted from the rendered banner. +type SessionInfo struct { + Version string // e.g. "0.2.1" + GitBranch string // empty if not in a git repo + GitDirty bool // true if working tree has uncommitted changes + ProjectType string // free-form, e.g. "Go module (somegit.dev/...)" + Provider string // e.g. "ollama" + Model string // e.g. "qwen3-coder:30b" + Permission string // e.g. "auto", "accept_edits" + Incognito bool + Prefer string // "auto" / "local" / "cloud" + Tenant string // optional, e.g. Kubernetes context name +} + +// RenderContextBanner returns the always-shown banner with cwd, git, +// project, model, modes, and sensitive-file inventory. Result includes +// a trailing newline. Deterministic — safe for golden-string testing. +func RenderContextBanner(c Classification, info SessionInfo, sensitive []Match) string { + var sb strings.Builder + + header := "gnoma" + if info.Version != "" { + header += " " + info.Version + } + header += " — ready" + sb.WriteString(header + "\n") + + writeField(&sb, "cwd ", c.Path) + if info.GitBranch != "" { + state := "clean" + if info.GitDirty { + state = "dirty" + } + writeField(&sb, "git ", fmt.Sprintf("%s (%s)", info.GitBranch, state)) + } + if info.ProjectType != "" { + writeField(&sb, "project ", info.ProjectType) + } + if info.Provider != "" || info.Model != "" { + writeField(&sb, "provider", strings.TrimSpace(info.Provider+" / "+info.Model)) + } + modes := renderModes(info) + if modes != "" { + writeField(&sb, "mode ", modes) + } + if info.Tenant != "" { + writeField(&sb, "tenant ", info.Tenant) + } + + if len(sensitive) > 0 { + summary := fmt.Sprintf("%d match", len(sensitive)) + if len(sensitive) != 1 { + summary = fmt.Sprintf("%d matches", len(sensitive)) + } + names := make([]string, 0, len(sensitive)) + shown := len(sensitive) + if shown > 3 { + shown = 3 + } + for i := 0; i < shown; i++ { + names = append(names, filepath.Base(sensitive[i].Path)) + } + if len(sensitive) > shown { + names = append(names, fmt.Sprintf("+%d more", len(sensitive)-shown)) + } + writeField(&sb, "sensitive", fmt.Sprintf("%s: %s", summary, strings.Join(names, ", "))) + } else { + writeField(&sb, "sensitive", "0 matches in cwd") + } + + sb.WriteString("---\n") + return sb.String() +} + +// RenderWarnPrefix returns the banner text shown above the context +// banner when the cwd is TierWarn. The caller is responsible for +// reading a confirmation keystroke after printing this. Empty when +// the tier isn't TierWarn. +func RenderWarnPrefix(c Classification) string { + if c.Tier != TierWarn { + return "" + } + return fmt.Sprintf( + "WARNING: cwd is %s (%s).\n"+ + " Any file the model reads / writes / executes is in your\n"+ + " personal directory — including .ssh/, .aws/, shell history,\n"+ + " browser profiles.\n"+ + " Continue? [y/N] ", + c.Path, c.Reason, + ) +} + +// RenderRefuse returns the banner text shown when the cwd is +// TierRefuse. Caller prints this and exits non-zero. +func RenderRefuse(c Classification) string { + if c.Tier != TierRefuse { + return "" + } + return fmt.Sprintf( + "ERROR: gnoma will not start in %s.\n"+ + " This directory (%s) contains system-critical files that\n"+ + " should never be edited by a model. To override (you almost\n"+ + " certainly should not), pass --dangerously-allow-anywhere.\n", + c.Path, c.Reason, + ) +} + +func writeField(sb *strings.Builder, label, value string) { + if value == "" { + return + } + sb.WriteString(label + " : " + value + "\n") +} + +func renderModes(info SessionInfo) string { + var parts []string + if info.Permission != "" { + parts = append(parts, "permission="+info.Permission) + } + if info.Incognito { + parts = append(parts, "incognito=on") + } else if info.Permission != "" || info.Prefer != "" { + // Show incognito=off only when other modes are also rendered; + // keeps a bare banner from being noisier than necessary. + parts = append(parts, "incognito=off") + } + if info.Prefer != "" && info.Prefer != "auto" { + parts = append(parts, "prefer="+info.Prefer) + } + return strings.Join(parts, " ") +} diff --git a/internal/safety/banner_test.go b/internal/safety/banner_test.go new file mode 100644 index 0000000..5aee270 --- /dev/null +++ b/internal/safety/banner_test.go @@ -0,0 +1,127 @@ +package safety + +import ( + "strings" + "testing" +) + +func TestRenderContextBanner_BasicFields(t *testing.T) { + c := Classification{Tier: TierOK, Path: "/home/cn/git/foo", Reason: "inside a git repo"} + info := SessionInfo{ + Version: "0.2.1", + GitBranch: "dev", + GitDirty: false, + ProjectType: "Go module", + Provider: "ollama", + Model: "qwen3-coder:30b", + Permission: "auto", + Incognito: false, + Prefer: "auto", + } + out := RenderContextBanner(c, info, nil) + + want := []string{ + "gnoma 0.2.1 — ready", + "cwd", + "/home/cn/git/foo", + "git", + "dev (clean)", + "project", + "Go module", + "provider", + "ollama / qwen3-coder:30b", + "mode", + "permission=auto", + "sensitive", + "0 matches in cwd", + "---", + } + for _, w := range want { + if !strings.Contains(out, w) { + t.Errorf("banner missing %q\nfull output:\n%s", w, out) + } + } +} + +func TestRenderContextBanner_DirtyGit(t *testing.T) { + c := Classification{Tier: TierOK, Path: "/somewhere", Reason: "ok"} + info := SessionInfo{Version: "x", GitBranch: "main", GitDirty: true} + out := RenderContextBanner(c, info, nil) + if !strings.Contains(out, "main (dirty)") { + t.Errorf("dirty git not surfaced:\n%s", out) + } +} + +func TestRenderContextBanner_SensitiveMatches(t *testing.T) { + c := Classification{Tier: TierWarn, Path: "/home/cn", Reason: "home"} + info := SessionInfo{Version: "x"} + matches := []Match{ + {Path: "/home/cn/.env", Reason: "env file"}, + {Path: "/home/cn/id_rsa", Reason: "private key"}, + {Path: "/home/cn/.ssh", Reason: "credentials directory"}, + {Path: "/home/cn/aws_credentials", Reason: "credentials file"}, + } + out := RenderContextBanner(c, info, matches) + // 4 matches, banner truncates to 3 + "+N more" + if !strings.Contains(out, "4 matches") { + t.Errorf("expected '4 matches' summary, got:\n%s", out) + } + if !strings.Contains(out, "+1 more") { + t.Errorf("expected +1 more truncation, got:\n%s", out) + } +} + +func TestRenderContextBanner_OmitsEmptyFields(t *testing.T) { + c := Classification{Tier: TierOK, Path: "/x", Reason: ""} + info := SessionInfo{} // everything empty + out := RenderContextBanner(c, info, nil) + if strings.Contains(out, "provider :") { + t.Errorf("empty provider/model should be omitted:\n%s", out) + } + if strings.Contains(out, "git :") { + t.Errorf("empty git branch should be omitted:\n%s", out) + } +} + +func TestRenderWarnPrefix(t *testing.T) { + c := Classification{Tier: TierWarn, Path: "/home/cn", Reason: "personal directory"} + out := RenderWarnPrefix(c) + if !strings.Contains(out, "WARNING") { + t.Errorf("warn prefix missing WARNING:\n%s", out) + } + if !strings.Contains(out, "/home/cn") { + t.Errorf("warn prefix missing path:\n%s", out) + } + if !strings.Contains(out, "[y/N]") { + t.Errorf("warn prefix missing keypress prompt:\n%s", out) + } +} + +func TestRenderWarnPrefix_EmptyOnNonWarnTier(t *testing.T) { + if got := RenderWarnPrefix(Classification{Tier: TierOK}); got != "" { + t.Errorf("non-warn tier should produce empty warn prefix, got %q", got) + } + if got := RenderWarnPrefix(Classification{Tier: TierRefuse}); got != "" { + t.Errorf("refuse tier should produce empty warn prefix, got %q", got) + } +} + +func TestRenderRefuse(t *testing.T) { + c := Classification{Tier: TierRefuse, Path: "/etc", Reason: "system directory"} + out := RenderRefuse(c) + if !strings.Contains(out, "ERROR") { + t.Errorf("refuse banner missing ERROR:\n%s", out) + } + if !strings.Contains(out, "/etc") { + t.Errorf("refuse banner missing path:\n%s", out) + } + if !strings.Contains(out, "--dangerously-allow-anywhere") { + t.Errorf("refuse banner missing override hint:\n%s", out) + } +} + +func TestRenderRefuse_EmptyOnNonRefuseTier(t *testing.T) { + if got := RenderRefuse(Classification{Tier: TierOK}); got != "" { + t.Errorf("non-refuse tier should produce empty refuse text, got %q", got) + } +} diff --git a/internal/safety/cwd.go b/internal/safety/cwd.go new file mode 100644 index 0000000..4ddba15 --- /dev/null +++ b/internal/safety/cwd.go @@ -0,0 +1,266 @@ +// Package safety implements gnoma's pre-launch directory-safety +// classifier and context banner. See +// docs/superpowers/plans/2026-05-23-startup-safety-banner.md for the +// full design. +// +// The classifier categorizes the current working directory into one of +// three tiers (OK, Warn, Refuse) and renders an informational banner +// summarizing where gnoma is about to run. The runtime (cmd/gnoma) is +// responsible for the user-interaction part (printing the banner, +// gating on a keypress under TierWarn, exiting under TierRefuse). +package safety + +import ( + "os" + "path/filepath" + "runtime" + "strings" + + "somegit.dev/Owlibou/gnoma/internal/config" +) + +// Tier classifies the safety risk of the current working directory. +type Tier int + +const ( + // TierOK — directory is safe to operate in. Either inside a git + // repo, or contains a recognized project marker. + TierOK Tier = iota + // TierWarn — sensitive personal directory ($HOME, ~/Downloads, + // /tmp, etc.). The runtime should banner + keypress before + // continuing. + TierWarn + // TierRefuse — system root or near-root (/etc, /sys, /usr, etc.). + // The runtime should refuse to launch unless overridden. + TierRefuse +) + +// String returns a human-readable tier name. +func (t Tier) String() string { + switch t { + case TierOK: + return "ok" + case TierWarn: + return "warn" + case TierRefuse: + return "refuse" + default: + return "unknown" + } +} + +// Classification carries the tier plus a human-readable reason and the +// resolved-symlink absolute path that was classified. +type Classification struct { + Tier Tier + Path string // absolute, symlink-resolved cwd + Reason string // short message suitable for banner display +} + +// ClassifyCWD inspects the given absolute cwd path and returns its +// safety tier under the given config. Resolves symlinks before +// classification so a symlink like ~/etc-mirror → /etc doesn't fool +// the check. +// +// Project markers (.git/, .gnoma/, go.mod, package.json, +// pyproject.toml, Cargo.toml, Makefile, Dockerfile) force TierOK +// regardless of parent dir, unless require_project_marker is true (in +// which case lack of any marker forces at least TierWarn). +// +// Container detection: when /.dockerenv or /run/.containerenv exists, +// refuse-tier roots are downgraded to warn-tier (containers typically +// run from /workspace or /app which is "OK" but the root itself can +// be /). Implemented via a flag carried through the helpers. +func ClassifyCWD(cwd string, cfg config.ResolvedSafetySection) Classification { + abs, err := filepath.Abs(cwd) + if err != nil { + abs = cwd + } + resolved, err := filepath.EvalSymlinks(abs) + if err != nil { + resolved = abs + } + + if hasProjectMarker(resolved) { + return Classification{Tier: TierOK, Path: resolved, Reason: "project marker present"} + } + + if isInGitRepo(resolved) { + if cfg.RequireProjectMarker { + return Classification{ + Tier: TierWarn, + Path: resolved, + Reason: "in git repo but no recognized project marker (require_project_marker=true)", + } + } + return Classification{Tier: TierOK, Path: resolved, Reason: "inside a git repo"} + } + + inContainer := isInContainer() + + if isSystemRoot(resolved) { + if cfg.RefuseInSystemDirs && !inContainer { + return Classification{Tier: TierRefuse, Path: resolved, Reason: "system directory"} + } + // Containers downgrade refuse to warn — running from / inside + // a container is common (some devcontainers chroot there). + return Classification{Tier: TierWarn, Path: resolved, Reason: "system directory (container)"} + } + + if isPersonalDumpingGround(resolved) { + if cfg.WarnInHome { + return Classification{Tier: TierWarn, Path: resolved, Reason: "personal directory ($HOME, /tmp, or common dumping ground)"} + } + return Classification{Tier: TierOK, Path: resolved, Reason: "personal directory (warn_in_home=false)"} + } + + if cfg.RequireProjectMarker { + return Classification{Tier: TierWarn, Path: resolved, Reason: "no recognized project marker (require_project_marker=true)"} + } + return Classification{Tier: TierOK, Path: resolved, Reason: "no risk indicators"} +} + +// projectMarkers are filenames whose presence in the cwd's top level +// signals "this is a project root." `.git` is intentionally NOT in +// this list — git presence is handled by isInGitRepo so the +// RequireProjectMarker config knob can distinguish "git repo but no +// project file" (warn-tier under that knob) from "go.mod exists" +// (always ok-tier). +var projectMarkers = []string{ + ".gnoma", + "go.mod", + "package.json", + "pyproject.toml", + "Cargo.toml", + "Makefile", + "Dockerfile", + "build.gradle", + "build.gradle.kts", + "pom.xml", +} + +func hasProjectMarker(path string) bool { + for _, m := range projectMarkers { + if _, err := os.Stat(filepath.Join(path, m)); err == nil { + return true + } + } + return false +} + +// isInGitRepo walks up from path looking for a .git directory or file. +// Stops at the filesystem root. +func isInGitRepo(path string) bool { + cur := path + for { + gitPath := filepath.Join(cur, ".git") + if info, err := os.Stat(gitPath); err == nil { + _ = info + return true + } + parent := filepath.Dir(cur) + if parent == cur { + return false + } + cur = parent + } +} + +// systemRoots lists directories (and their descendants) that are +// considered too dangerous to operate inside without an explicit +// override. Platform-specific entries are added in the helpers below. +var systemRoots = []string{ + "/etc", + "/sys", + "/proc", + "/usr", + "/var", + "/bin", + "/sbin", + "/boot", + "/root", + "/dev", +} + +// systemRootsMacOS lists additional roots that exist only on macOS. +var systemRootsMacOS = []string{ + "/System", + "/Library", + "/private", + "/Applications", +} + +// isSystemRoot reports whether path is at or under a known system +// root. Includes "/" itself (no path prefix would match it +// otherwise). +func isSystemRoot(path string) bool { + if path == "/" { + return true + } + roots := systemRoots + if runtime.GOOS == "darwin" { + roots = append(append([]string(nil), systemRoots...), systemRootsMacOS...) + } + for _, root := range roots { + if path == root || strings.HasPrefix(path, root+"/") { + return true + } + } + return false +} + +// personalDumpingGrounds lists directories that typically hold mixed +// sensitive/non-sensitive files — usually-fine for ad-hoc poking, but +// worth a confirmation prompt because a model with tool access can +// easily reach .ssh keys, config files, browser profiles, etc. +// +// The check is exact path match against the user's home dir plus +// resolved sub-paths, NOT a prefix match — a project inside ~/git/foo +// shouldn't trigger warn just because it's under $HOME. The git/marker +// checks above already capture that. +func isPersonalDumpingGround(path string) bool { + home, err := os.UserHomeDir() + if err != nil || home == "" { + // If we can't resolve $HOME, fall back to a conservative + // warn-anywhere stance for /tmp. + return path == "/tmp" || strings.HasPrefix(path, "/tmp/") + } + + if path == home { + return true + } + + dumps := []string{ + home, + filepath.Join(home, "Desktop"), + filepath.Join(home, "Downloads"), + filepath.Join(home, "Documents"), + filepath.Join(home, "Music"), + filepath.Join(home, "Pictures"), + filepath.Join(home, "Videos"), + filepath.Join(home, ".config"), + filepath.Join(home, ".local"), + filepath.Join(home, ".cache"), + "/tmp", + } + for _, d := range dumps { + if path == d { + return true + } + } + return false +} + +// isInContainer reports whether the process appears to be running +// inside a Linux container. Two common signals: /.dockerenv (Docker) +// and /run/.containerenv (Podman). Best-effort — false negatives are +// acceptable; false positives just downgrade refuse-tier paths to +// warn, which is the lesser failure. +func isInContainer() bool { + for _, marker := range []string{"/.dockerenv", "/run/.containerenv"} { + if _, err := os.Stat(marker); err == nil { + return true + } + } + return false +} diff --git a/internal/safety/cwd_test.go b/internal/safety/cwd_test.go new file mode 100644 index 0000000..0860dec --- /dev/null +++ b/internal/safety/cwd_test.go @@ -0,0 +1,152 @@ +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) + } + } +} diff --git a/internal/safety/sensitive.go b/internal/safety/sensitive.go new file mode 100644 index 0000000..d242e67 --- /dev/null +++ b/internal/safety/sensitive.go @@ -0,0 +1,136 @@ +package safety + +import ( + "os" + "path/filepath" + "sort" + "strings" +) + +// Match represents a sensitive file found in the cwd's top level. +type Match struct { + Path string // path relative to cwd, e.g. ".env" or ".ssh" + Reason string // short label, e.g. "env file", "private key" +} + +// sensitivePatterns is the rule table. Each entry has a check that +// runs against a single dirent (with d.Name() and d.IsDir() readily +// available) plus a label for reporting. +var sensitivePatterns = []struct { + Label string + Match func(name string, isDir bool) bool +}{ + {"env file", func(name string, isDir bool) bool { + if isDir { + return false + } + // Match `.env`, `.env.foo`, `env.local`, but NOT `.envrc` + // (envrc is direnv config, not credential storage). + if name == ".env" || strings.HasPrefix(name, ".env.") { + return true + } + if name == "env.local" || strings.HasPrefix(name, "env.local.") { + return true + } + return false + }}, + {"private key", func(name string, isDir bool) bool { + if isDir { + return false + } + low := strings.ToLower(name) + if strings.HasSuffix(low, ".pem") || strings.HasSuffix(low, ".key") || + strings.HasSuffix(low, ".crt") || strings.HasSuffix(low, ".p12") || + strings.HasSuffix(low, ".pfx") { + return true + } + // SSH private-key default names. + if name == "id_rsa" || name == "id_ed25519" || name == "id_ecdsa" || name == "id_dsa" { + return true + } + return false + }}, + {"credentials file", func(name string, isDir bool) bool { + if isDir { + return false + } + low := strings.ToLower(name) + // Match credential-y filenames without being too aggressive. + // "credentials" as a substring is fine (e.g. ".aws_credentials") + // but we'd rather not flag every "secret-something.go" source + // file. Restrict "secret" matches to filenames that look like + // data, not source. + if strings.Contains(low, "credentials") { + return true + } + if strings.HasSuffix(low, ".secret") || strings.HasSuffix(low, ".secrets") { + return true + } + return false + }}, + {"shell secrets", func(name string, isDir bool) bool { + if isDir { + return false + } + return name == ".netrc" || name == ".pgpass" + }}, + {"password vault", func(name string, isDir bool) bool { + if isDir { + return false + } + low := strings.ToLower(name) + return strings.HasSuffix(low, ".kdbx") || strings.HasSuffix(low, ".kbdx") + }}, + {"credentials directory", func(name string, isDir bool) bool { + if !isDir { + return false + } + switch name { + case ".ssh", ".aws", ".kube", ".gcloud", ".azure", ".docker": + return true + } + return false + }}, +} + +// scanLimit caps the number of dir entries inspected. Prevents a +// pathological case (cwd handed a giant temp dir, /tmp with thousands +// of files, etc.) from making the safety scan slow. +const scanLimit = 1000 + +// ScanCWDForSensitive walks the cwd's top level (no recursion) and +// returns sensitive matches. Conservative by design: only matches the +// rules in sensitivePatterns. Bounded to scanLimit entries to keep +// the safety check fast even in pathological directories. +// +// Results are sorted by path for deterministic ordering — both the +// banner and the tests rely on this. +func ScanCWDForSensitive(cwd string) []Match { + entries, err := os.ReadDir(cwd) + if err != nil { + return nil + } + + var matches []Match + for i, entry := range entries { + if i >= scanLimit { + break + } + name := entry.Name() + isDir := entry.IsDir() + for _, p := range sensitivePatterns { + if p.Match(name, isDir) { + matches = append(matches, Match{ + Path: filepath.Join(cwd, name), + Reason: p.Label, + }) + break + } + } + } + + sort.Slice(matches, func(i, j int) bool { + return matches[i].Path < matches[j].Path + }) + return matches +} diff --git a/internal/safety/sensitive_test.go b/internal/safety/sensitive_test.go new file mode 100644 index 0000000..e0af337 --- /dev/null +++ b/internal/safety/sensitive_test.go @@ -0,0 +1,118 @@ +package safety + +import ( + "os" + "path/filepath" + "sort" + "testing" +) + +func TestScanCWDForSensitive_Matches(t *testing.T) { + dir := t.TempDir() + // Sensitive files we expect to flag. + sensitive := []string{ + ".env", + ".env.local", + "id_rsa", + "private.pem", + "aws_credentials", + ".netrc", + "vault.kdbx", + } + // Non-sensitive control files. + control := []string{ + ".envrc", // direnv config, not a credential + "main.go", + "README.md", + "secret_handler.go", // source code, not data + } + for _, f := range sensitive { + if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + for _, f := range control { + if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + // Sensitive directory. + if err := os.MkdirAll(filepath.Join(dir, ".ssh"), 0o700); err != nil { + t.Fatal(err) + } + + matches := ScanCWDForSensitive(dir) + + wantNames := append([]string{}, sensitive...) + wantNames = append(wantNames, ".ssh") + sort.Strings(wantNames) + + gotNames := make([]string, 0, len(matches)) + for _, m := range matches { + gotNames = append(gotNames, filepath.Base(m.Path)) + } + sort.Strings(gotNames) + + if len(gotNames) != len(wantNames) { + t.Errorf("matched %d files (%v), want %d (%v)", len(gotNames), gotNames, len(wantNames), wantNames) + } + for i, n := range wantNames { + if i >= len(gotNames) || gotNames[i] != n { + t.Errorf("match[%d] = %q, want %q (got=%v want=%v)", i, gotNames[i], n, gotNames, wantNames) + } + } +} + +func TestScanCWDForSensitive_EmptyDir(t *testing.T) { + dir := t.TempDir() + matches := ScanCWDForSensitive(dir) + if len(matches) != 0 { + t.Errorf("empty dir matched %v, want none", matches) + } +} + +func TestScanCWDForSensitive_PrecisionNoFalsePositives(t *testing.T) { + dir := t.TempDir() + // .envrc is direnv, NOT credential storage. + if err := os.WriteFile(filepath.Join(dir, ".envrc"), []byte("export FOO=bar"), 0o600); err != nil { + t.Fatal(err) + } + // "secret_handler.go" is source, NOT secrets data. + if err := os.WriteFile(filepath.Join(dir, "secret_handler.go"), []byte("package main"), 0o600); err != nil { + t.Fatal(err) + } + + matches := ScanCWDForSensitive(dir) + if len(matches) != 0 { + t.Errorf("precision regression: %v should not flag anything, got %v", []string{".envrc", "secret_handler.go"}, matches) + } +} + +func TestScanCWDForSensitive_BoundedScan(t *testing.T) { + dir := t.TempDir() + // Populate just over the scan limit. The function should not panic + // or hang. Result count is at most scanLimit (matches may be 0 if + // the entries beyond the cap happen to be sensitive — that's OK, + // the bound is a safety knob, not a correctness one). + for i := 0; i < scanLimit+10; i++ { + if err := os.WriteFile(filepath.Join(dir, "file"+itoa(i)), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + _ = ScanCWDForSensitive(dir) // mustn't panic +} + +// itoa avoids importing strconv just for one use. +func itoa(n int) string { + if n == 0 { + return "0" + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + return string(buf[i:]) +}