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

267 lines
7.7 KiB
Go

// 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
}