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

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