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
This commit is contained in:
2026-05-23 22:19:39 +02:00
parent f9094f68f3
commit 3eeb5b46d7
8 changed files with 1043 additions and 0 deletions
+53
View File
@@ -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 {
+50
View File
@@ -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
+141
View File
@@ -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, " ")
}
+127
View File
@@ -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)
}
}
+266
View File
@@ -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
}
+152
View File
@@ -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)
}
}
}
+136
View File
@@ -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
}
+118
View File
@@ -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:])
}