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:
@@ -30,6 +30,7 @@ import (
|
|||||||
"somegit.dev/Owlibou/gnoma/internal/provider/openaicompat"
|
"somegit.dev/Owlibou/gnoma/internal/provider/openaicompat"
|
||||||
subprocprov "somegit.dev/Owlibou/gnoma/internal/provider/subprocess"
|
subprocprov "somegit.dev/Owlibou/gnoma/internal/provider/subprocess"
|
||||||
"somegit.dev/Owlibou/gnoma/internal/router"
|
"somegit.dev/Owlibou/gnoma/internal/router"
|
||||||
|
"somegit.dev/Owlibou/gnoma/internal/safety"
|
||||||
"somegit.dev/Owlibou/gnoma/internal/security"
|
"somegit.dev/Owlibou/gnoma/internal/security"
|
||||||
"somegit.dev/Owlibou/gnoma/internal/session"
|
"somegit.dev/Owlibou/gnoma/internal/session"
|
||||||
"somegit.dev/Owlibou/gnoma/internal/skill"
|
"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)")
|
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")
|
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)")
|
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")
|
verbose = flag.Bool("verbose", false, "enable debug logging")
|
||||||
version = flag.Bool("version", false, "print version and exit")
|
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{
|
knownProviders := map[string]bool{
|
||||||
"mistral": true, "anthropic": true, "openai": true,
|
"mistral": true, "anthropic": true, "openai": true,
|
||||||
"google": true, "ollama": true, "llamacpp": 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.
|
// 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 {
|
func humanBytes(n int64) string {
|
||||||
const unit = 1024
|
const unit = 1024
|
||||||
if n < unit {
|
if n < unit {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type Config struct {
|
|||||||
Session SessionSection `toml:"session"`
|
Session SessionSection `toml:"session"`
|
||||||
SLM SLMSection `toml:"slm"`
|
SLM SLMSection `toml:"slm"`
|
||||||
Router RouterSection `toml:"router"`
|
Router RouterSection `toml:"router"`
|
||||||
|
Safety SafetySection `toml:"safety"`
|
||||||
CLIAgents CLIAgentsSection `toml:"cli_agents"`
|
CLIAgents CLIAgentsSection `toml:"cli_agents"`
|
||||||
Arms []ArmConfig `toml:"arms"`
|
Arms []ArmConfig `toml:"arms"`
|
||||||
Hooks []HookConfig `toml:"hooks"`
|
Hooks []HookConfig `toml:"hooks"`
|
||||||
@@ -93,6 +94,55 @@ type CLIAgentsSection map[string]string
|
|||||||
// RouterSection holds router-level overrides. Most routing decisions are
|
// RouterSection holds router-level overrides. Most routing decisions are
|
||||||
// driven automatically by arm capabilities and the bandit; this section
|
// driven automatically by arm capabilities and the bandit; this section
|
||||||
// exists for the rare overrides that don't fit elsewhere.
|
// 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 {
|
type RouterSection struct {
|
||||||
// ForceTwoStage forces the two-stage tool-routing path regardless of
|
// ForceTwoStage forces the two-stage tool-routing path regardless of
|
||||||
// arm context window. Useful for debugging or for forcing the behavior
|
// arm context window. Useful for debugging or for forcing the behavior
|
||||||
|
|||||||
@@ -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, " ")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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:])
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user