a23eb6b92c
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'.
145 lines
4.3 KiB
Go
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, " ")
|
|
}
|