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
128 lines
3.7 KiB
Go
128 lines
3.7 KiB
Go
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)
|
|
}
|
|
}
|