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
137 lines
3.6 KiB
Go
137 lines
3.6 KiB
Go
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
|
|
}
|