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"
|
||||
subprocprov "somegit.dev/Owlibou/gnoma/internal/provider/subprocess"
|
||||
"somegit.dev/Owlibou/gnoma/internal/router"
|
||||
"somegit.dev/Owlibou/gnoma/internal/safety"
|
||||
"somegit.dev/Owlibou/gnoma/internal/security"
|
||||
"somegit.dev/Owlibou/gnoma/internal/session"
|
||||
"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)")
|
||||
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)")
|
||||
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")
|
||||
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{
|
||||
"mistral": true, "anthropic": true, "openai": 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.
|
||||
// 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 {
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
|
||||
@@ -17,6 +17,7 @@ type Config struct {
|
||||
Session SessionSection `toml:"session"`
|
||||
SLM SLMSection `toml:"slm"`
|
||||
Router RouterSection `toml:"router"`
|
||||
Safety SafetySection `toml:"safety"`
|
||||
CLIAgents CLIAgentsSection `toml:"cli_agents"`
|
||||
Arms []ArmConfig `toml:"arms"`
|
||||
Hooks []HookConfig `toml:"hooks"`
|
||||
@@ -93,6 +94,55 @@ type CLIAgentsSection map[string]string
|
||||
// RouterSection holds router-level overrides. Most routing decisions are
|
||||
// driven automatically by arm capabilities and the bandit; this section
|
||||
// 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 {
|
||||
// ForceTwoStage forces the two-stage tool-routing path regardless of
|
||||
// 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