Files
vikingowl a23eb6b92c style: gofmt drift from prior commits
Pure whitespace cleanup surfaced when 'make check' ran gofmt over the
tree. Mostly struct-field column alignment in internal/safety/banner.go
(SessionInfo) and the var(...) flag block in cmd/gnoma/main.go after
--dangerously-allow-anywhere was added without realignment. Verified
zero substantive changes via 'git diff --ignore-all-space
--ignore-blank-lines'.
2026-05-24 16:33:17 +02:00

145 lines
4.3 KiB
Go

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")
// Field labels are padded to 9 characters so the ":" separators
// align in monospace output. "sensitive" sets the width; everything
// else pads to match.
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, " ")
}