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
267 lines
7.7 KiB
Go
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
|
|
}
|