From ad2dde6a07f8c0e86af645204c3209716542185c Mon Sep 17 00:00:00 2001 From: claude-developer-1 Date: Sun, 15 Mar 2026 19:43:38 +0300 Subject: [PATCH 01/14] feat: add Shannon entropy-based redaction Implements entropy-based automatic redaction as a complement to pattern-based redaction. High-entropy tokens (API keys, tokens, random secrets) are detected statistically and replaced with [REDACTED] before sending commands to LLM providers. - internal/redact/entropy.go: Entropy() function + EntropyRedactor struct - internal/redact/entropy_test.go: tests for entropy calc and redaction behavior - internal/config/config.go: per-provider entropy_redaction_enabled field, global entropy_threshold/entropy_min_length config, UpdateProviderEntropyRedaction() - internal/llm/multi.go: entropy redactor applied per-provider in Query() - redact_cmd.go: guard-sh redact entropy on/off subcommand - main.go: entropy redactor init, entropy state in status output - help.go: entropy redact commands documented - config.default.yaml: entropy_threshold (4.5) and entropy_min_length (20) defaults Closes #10 Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + CLAUDE.md | 36 +++++++++ config.default.yaml | 12 ++- help.go | 4 +- internal/config/config.go | 65 ++++++++++++--- internal/llm/multi.go | 41 ++++++---- internal/llm/multi_test.go | 68 ++++++++++++++-- internal/redact/entropy.go | 67 ++++++++++++++++ internal/redact/entropy_test.go | 136 ++++++++++++++++++++++++++++++++ main.go | 38 ++++++++- redact_cmd.go | 66 +++++++++++++++- 11 files changed, 495 insertions(+), 39 deletions(-) create mode 100644 internal/redact/entropy.go create mode 100644 internal/redact/entropy_test.go diff --git a/.gitignore b/.gitignore index ac55d07..eb83ea8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ guard-sh .claude/ .claude-identity +.github-app-private-key.pem diff --git a/CLAUDE.md b/CLAUDE.md index 5ddc308..39ecc85 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,9 +7,45 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co At the start of every session, read `.claude-identity` from the repo root if it exists. It contains: - `DEVELOPER_ID` — your identity (e.g. `claude-developer-1`); use this when referring to yourself - `BRANCH_PREFIX` — prefix for all branches you create (e.g. `claude-1/feature-name`) +- `GITHUB_APP_ID` — GitHub App ID used for authenticated pushes +- `GITHUB_APP_CLIENT_ID` — GitHub App Client ID +- `GITHUB_APP_CLIENT_SECRET` — GitHub App Client Secret +- `GITHUB_APP_PRIVATE_KEY_PATH` — path to the App's RSA private key (relative to repo root, gitignored) +- `GITHUB_REPO` — target repo in `owner/repo` format If `.claude-identity` does not exist, you are working in the base repo as the human owner. +## Pushing with GitHub App Authentication + +Never use the human owner's git credentials. Always authenticate as the GitHub App defined in `.claude-identity`. + +Steps to push: + +```bash +# 1. Source identity +source .claude-identity + +# 2. Generate JWT (valid 10 min) +PRIVATE_KEY=$(cat "$GITHUB_APP_PRIVATE_KEY_PATH") +NOW=$(date +%s) +EXP=$((NOW + 600)) +HEADER=$(echo -n '{"alg":"RS256","typ":"JWT"}' | openssl base64 -e | tr -d '=' | tr '/+' '_-' | tr -d '\n') +PAYLOAD=$(echo -n "{\"iat\":$NOW,\"exp\":$EXP,\"iss\":$GITHUB_APP_ID}" | openssl base64 -e | tr -d '=' | tr '/+' '_-' | tr -d '\n') +SIG=$(echo -n "${HEADER}.${PAYLOAD}" | openssl dgst -sha256 -sign "$GITHUB_APP_PRIVATE_KEY_PATH" | openssl base64 -e | tr -d '=' | tr '/+' '_-' | tr -d '\n') +JWT="${HEADER}.${PAYLOAD}.${SIG}" + +# 3. Get installation ID +INSTALLATION_ID=$(curl -s -H "Authorization: Bearer $JWT" -H "Accept: application/vnd.github+json" \ + https://api.github.com/app/installations | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['id'])") + +# 4. Get installation access token +TOKEN=$(curl -s -X POST -H "Authorization: Bearer $JWT" -H "Accept: application/vnd.github+json" \ + https://api.github.com/app/installations/$INSTALLATION_ID/access_tokens | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])") + +# 5. Push using token +git push https://x-access-token:${TOKEN}@github.com/${GITHUB_REPO}.git HEAD +``` + ## What This Project Does **guard-sh** is a shell safety layer. It hooks into bash/zsh to intercept commands before execution, queries an LLM to assess risk, and prompts the user for confirmation if the command is deemed risky. Safe commands are whitelisted or cached to skip the LLM call. diff --git a/config.default.yaml b/config.default.yaml index 5bb7f72..99011cb 100644 --- a/config.default.yaml +++ b/config.default.yaml @@ -16,12 +16,22 @@ cache_max_size: 1000 # Patterns to redact from commands before sending to LLM providers. # Uses Go regular expressions. Matches are replaced with [REDACTED]. -# Per-provider redaction can be toggled with: guard-sh redact on/off +# Per-provider redaction can be toggled with: guard-sh redact pattern on/off redact_patterns: - '(?i)(password|passwd|secret|api[_-]?key|token|auth)\s*[=:]\s*\S+' - '(?i)--password\s+\S+' - 'AKIA[0-9A-Z]{16}' +# Shannon entropy threshold (bits/char) for entropy-based redaction. +# Tokens with entropy above this value are replaced with [REDACTED]. +# Typical English text: ~3.5 bits/char. Random secrets: typically >4.5. +# Per-provider entropy redaction can be toggled with: guard-sh redact entropy on/off +entropy_threshold: 4.5 + +# Minimum token length for entropy-based redaction. +# Tokens shorter than this are never redacted (reduces false positives). +entropy_min_length: 20 + # Commands that are always considered safe — API is never called for these. # Only the base command name is needed (e.g. "ls" covers "ls -la", "ls /tmp", etc.). # If multiple commands are chained (&&, ||, ;, |), ALL parts must be whitelisted. diff --git a/help.go b/help.go index fd08c3b..80f2e97 100644 --- a/help.go +++ b/help.go @@ -47,7 +47,9 @@ func runHelp() { // Redact fmt.Printf(" %s\n", b("redact")) fmt.Printf(" %s %s\n", c("guard-sh redact pattern on"), d("interactively enable pattern-based redaction for a provider")) - fmt.Printf(" %s %s\n\n", c("guard-sh redact pattern off"), d("interactively disable pattern-based redaction for a provider")) + fmt.Printf(" %s %s\n", c("guard-sh redact pattern off"), d("interactively disable pattern-based redaction for a provider")) + fmt.Printf(" %s %s\n", c("guard-sh redact entropy on"), d("interactively enable entropy-based redaction for a provider")) + fmt.Printf(" %s %s\n\n", c("guard-sh redact entropy off"), d("interactively disable entropy-based redaction for a provider")) // Cache fmt.Printf(" %s\n", b("cache")) diff --git a/internal/config/config.go b/internal/config/config.go index 5c7730a..693e0be 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,10 +11,11 @@ import ( ) type ProviderConfig struct { - APIKey string `yaml:"api_key"` - Model string `yaml:"model"` - Host string `yaml:"host"` + APIKey string `yaml:"api_key"` + Model string `yaml:"model"` + Host string `yaml:"host"` PatternBasedRedactionEnabled *bool `yaml:"pattern_based_redaction_enabled"` + EntropyRedactEnabled *bool `yaml:"entropy_redaction_enabled"` } // RedactionEnabled returns whether pattern-based redaction is enabled for this provider. @@ -26,14 +27,25 @@ func (p *ProviderConfig) RedactionEnabled() bool { return *p.PatternBasedRedactionEnabled } +// EntropyRedactionEnabled returns whether entropy-based redaction is enabled for this provider. +// Defaults to false if not explicitly set. +func (p *ProviderConfig) EntropyRedactionEnabled() bool { + if p == nil || p.EntropyRedactEnabled == nil { + return false + } + return *p.EntropyRedactEnabled +} + type Config struct { - ProviderOrder []string `yaml:"provider_order"` - Providers map[string]*ProviderConfig `yaml:"providers"` - TimeoutSeconds int `yaml:"timeout_seconds"` - CacheEnabled *bool `yaml:"cache_enabled"` - CacheMaxSize int `yaml:"cache_max_size"` - CommandWhitelist []string `yaml:"command_whitelist"` - RedactPatterns []string `yaml:"redact_patterns"` + ProviderOrder []string `yaml:"provider_order"` + Providers map[string]*ProviderConfig `yaml:"providers"` + TimeoutSeconds int `yaml:"timeout_seconds"` + CacheEnabled *bool `yaml:"cache_enabled"` + CacheMaxSize int `yaml:"cache_max_size"` + CommandWhitelist []string `yaml:"command_whitelist"` + RedactPatterns []string `yaml:"redact_patterns"` + EntropyThreshold float64 `yaml:"entropy_threshold"` + EntropyMinLength int `yaml:"entropy_min_length"` } func (c *Config) Get(name string) (*ProviderConfig, error) { @@ -416,6 +428,39 @@ func UpdateProviderRedaction(name string, enabled bool) error { return os.WriteFile(cfgPath, []byte(strings.Join(result, "\n")), 0600) } +// UpdateProviderEntropyRedaction sets entropy_redaction_enabled for a specific provider. +func UpdateProviderEntropyRedaction(name string, enabled bool) error { + cfgPath := filepath.Join(Dir(), "config.yaml") + data, err := os.ReadFile(cfgPath) + if err != nil { + return fmt.Errorf("config not found at %s", cfgPath) + } + lines := strings.Split(string(data), "\n") + start, end := findProviderBlock(lines, name) + if start < 0 { + return fmt.Errorf("provider %q not found in config", name) + } + + val := "true" + if !enabled { + val = "false" + } + newLine := " entropy_redaction_enabled: " + val + + for i := start + 1; i < end; i++ { + if strings.HasPrefix(strings.TrimSpace(lines[i]), "entropy_redaction_enabled:") { + lines[i] = newLine + return os.WriteFile(cfgPath, []byte(strings.Join(lines, "\n")), 0600) + } + } + + result := make([]string, 0, len(lines)+1) + result = append(result, lines[:end]...) + result = append(result, newLine) + result = append(result, lines[end:]...) + return os.WriteFile(cfgPath, []byte(strings.Join(result, "\n")), 0600) +} + func DefaultHost(provider string) string { switch provider { case "ollama": diff --git a/internal/llm/multi.go b/internal/llm/multi.go index f3a5999..910980f 100644 --- a/internal/llm/multi.go +++ b/internal/llm/multi.go @@ -21,22 +21,26 @@ const ( // Multi tries each provider in order, falling back to the next on error. type Multi struct { - providers []guard.Provider - names []string - prompts map[string]string // per-provider prompt overrides - redactor *redact.Redactor - redactEnabled map[string]bool // per-provider redaction toggle - debug io.Writer + providers []guard.Provider + names []string + prompts map[string]string // per-provider prompt overrides + redactor *redact.Redactor + redactEnabled map[string]bool // per-provider pattern redaction toggle + entropyRedactor *redact.EntropyRedactor + entropyRedactEnabled map[string]bool // per-provider entropy redaction toggle + debug io.Writer } -func NewMulti(names []string, providers []guard.Provider, prompts map[string]string, redactor *redact.Redactor, redactEnabled map[string]bool, debug io.Writer) *Multi { +func NewMulti(names []string, providers []guard.Provider, prompts map[string]string, redactor *redact.Redactor, redactEnabled map[string]bool, entropyRedactor *redact.EntropyRedactor, entropyRedactEnabled map[string]bool, debug io.Writer) *Multi { return &Multi{ - names: names, - providers: providers, - prompts: prompts, - redactor: redactor, - redactEnabled: redactEnabled, - debug: debug, + names: names, + providers: providers, + prompts: prompts, + redactor: redactor, + redactEnabled: redactEnabled, + entropyRedactor: entropyRedactor, + entropyRedactEnabled: entropyRedactEnabled, + debug: debug, } } @@ -49,9 +53,16 @@ func (m *Multi) Query(ctx context.Context, systemPrompt, command string) (string } cmd := command if m.redactor != nil && m.redactEnabled[name] { - cmd = m.redactor.Redact(command) + cmd = m.redactor.Redact(cmd) if m.debug != nil && cmd != command { - fmt.Fprintf(m.debug, " %sredact%s %s→ %q%s\n", dbgDim, dbgReset, dbgDim, cmd, dbgReset) + fmt.Fprintf(m.debug, " %sredact (pattern)%s %s→ %q%s\n", dbgDim, dbgReset, dbgDim, cmd, dbgReset) + } + } + if m.entropyRedactor != nil && m.entropyRedactEnabled[name] { + before := cmd + cmd = m.entropyRedactor.Redact(cmd) + if m.debug != nil && cmd != before { + fmt.Fprintf(m.debug, " %sredact (entropy)%s %s→ %q%s\n", dbgDim, dbgReset, dbgDim, cmd, dbgReset) } } if m.debug != nil { diff --git a/internal/llm/multi_test.go b/internal/llm/multi_test.go index 84a9944..59dfa1a 100644 --- a/internal/llm/multi_test.go +++ b/internal/llm/multi_test.go @@ -25,7 +25,7 @@ func (m *mockProvider) Query(_ context.Context, _, cmd string) (string, error) { func TestMulti_FirstProviderSucceeds(t *testing.T) { p1 := &mockProvider{response: "OK"} p2 := &mockProvider{response: "fallback"} - m := NewMulti([]string{"p1", "p2"}, []guard.Provider{p1, p2}, nil, nil, nil, nil) + m := NewMulti([]string{"p1", "p2"}, []guard.Provider{p1, p2}, nil, nil, nil, nil, nil, nil) result, err := m.Query(context.Background(), "", "ls") if err != nil { @@ -45,7 +45,7 @@ func TestMulti_FirstProviderSucceeds(t *testing.T) { func TestMulti_FallbackOnError(t *testing.T) { p1 := &mockProvider{err: errors.New("rate limit")} p2 := &mockProvider{response: "Deletes everything"} - m := NewMulti([]string{"p1", "p2"}, []guard.Provider{p1, p2}, nil, nil, nil, nil) + m := NewMulti([]string{"p1", "p2"}, []guard.Provider{p1, p2}, nil, nil, nil, nil, nil, nil) result, err := m.Query(context.Background(), "", "rm -rf /") if err != nil { @@ -65,7 +65,7 @@ func TestMulti_FallbackOnError(t *testing.T) { func TestMulti_AllFail(t *testing.T) { p1 := &mockProvider{err: errors.New("error 1")} p2 := &mockProvider{err: errors.New("error 2")} - m := NewMulti([]string{"p1", "p2"}, []guard.Provider{p1, p2}, nil, nil, nil, nil) + m := NewMulti([]string{"p1", "p2"}, []guard.Provider{p1, p2}, nil, nil, nil, nil, nil, nil) _, err := m.Query(context.Background(), "", "rm -rf /") if err == nil { @@ -77,7 +77,7 @@ func TestMulti_AllFail(t *testing.T) { } func TestMulti_EmptyProviders(t *testing.T) { - m := NewMulti(nil, nil, nil, nil, nil, nil) + m := NewMulti(nil, nil, nil, nil, nil, nil, nil, nil) _, err := m.Query(context.Background(), "", "ls") if err == nil { t.Error("expected error with no providers, got nil") @@ -91,7 +91,7 @@ func TestMulti_RedactionApplied(t *testing.T) { t.Fatal(err) } redactEnabled := map[string]bool{"p1": true} - m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, r, redactEnabled, nil) + m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, r, redactEnabled, nil, nil, nil) _, err = m.Query(context.Background(), "", "mysql password=secret") if err != nil { @@ -109,7 +109,7 @@ func TestMulti_RedactionDisabledForProvider(t *testing.T) { t.Fatal(err) } redactEnabled := map[string]bool{"p1": false} - m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, r, redactEnabled, nil) + m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, r, redactEnabled, nil, nil, nil) _, err = m.Query(context.Background(), "", "mysql password=secret") if err != nil { @@ -129,7 +129,7 @@ func TestMulti_RedactionPerProvider(t *testing.T) { t.Fatal(err) } redactEnabled := map[string]bool{"p1": true, "p2": false, "p3": false} - m := NewMulti([]string{"p1", "p2", "p3"}, []guard.Provider{p1, p2, p3}, nil, r, redactEnabled, nil) + m := NewMulti([]string{"p1", "p2", "p3"}, []guard.Provider{p1, p2, p3}, nil, r, redactEnabled, nil, nil, nil) m.Query(context.Background(), "", "mysql password=secret") @@ -137,3 +137,57 @@ func TestMulti_RedactionPerProvider(t *testing.T) { t.Errorf("p1: expected redacted, got %q", p1.lastCommand) } } + +func TestMulti_EntropyRedactionApplied(t *testing.T) { + p := &mockProvider{response: "OK"} + er := redact.NewEntropyRedactor(4.5, 20) + entropyEnabled := map[string]bool{"p1": true} + m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, nil, nil, er, entropyEnabled, nil) + + secret := "ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456" + _, err := m.Query(context.Background(), "", "curl -H Authorization:"+secret) + if err != nil { + t.Fatal(err) + } + if p.lastCommand == "curl -H Authorization:"+secret { + t.Errorf("expected entropy redaction, command unchanged: %q", p.lastCommand) + } +} + +func TestMulti_EntropyRedactionDisabledForProvider(t *testing.T) { + p := &mockProvider{response: "OK"} + er := redact.NewEntropyRedactor(4.5, 20) + entropyEnabled := map[string]bool{"p1": false} + m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, nil, nil, er, entropyEnabled, nil) + + input := "ls -la /tmp" + _, err := m.Query(context.Background(), "", input) + if err != nil { + t.Fatal(err) + } + if p.lastCommand != input { + t.Errorf("expected unchanged command when entropy disabled, got %q", p.lastCommand) + } +} + +func TestMulti_PatternAndEntropyRedactionBothApplied(t *testing.T) { + p := &mockProvider{response: "OK"} + r, err := redact.New([]string{`(?i)password=\S+`}) + if err != nil { + t.Fatal(err) + } + er := redact.NewEntropyRedactor(4.5, 20) + redactEnabled := map[string]bool{"p1": true} + entropyEnabled := map[string]bool{"p1": true} + m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, r, redactEnabled, er, entropyEnabled, nil) + + secret := "ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456" + input := "curl password=foo token:" + secret + _, err = m.Query(context.Background(), "", input) + if err != nil { + t.Fatal(err) + } + if p.lastCommand == input { + t.Errorf("expected redaction to occur, got unchanged: %q", p.lastCommand) + } +} diff --git a/internal/redact/entropy.go b/internal/redact/entropy.go new file mode 100644 index 0000000..eaf53ce --- /dev/null +++ b/internal/redact/entropy.go @@ -0,0 +1,67 @@ +package redact + +import ( + "math" + "strings" +) + +// Entropy computes the Shannon entropy of s in bits per character. +func Entropy(s string) float64 { + if len(s) == 0 { + return 0 + } + freq := make(map[rune]float64) + for _, c := range s { + freq[c]++ + } + n := float64(len([]rune(s))) + var h float64 + for _, count := range freq { + p := count / n + h -= p * math.Log2(p) + } + return h +} + +// EntropyRedactor redacts whitespace-delimited tokens whose Shannon entropy +// exceeds a threshold, replacing them with [REDACTED]. +type EntropyRedactor struct { + threshold float64 + minLength int +} + +// NewEntropyRedactor creates an EntropyRedactor with the given threshold (bits/char) +// and minimum token length. Tokens shorter than minLength are never redacted. +func NewEntropyRedactor(threshold float64, minLength int) *EntropyRedactor { + return &EntropyRedactor{threshold: threshold, minLength: minLength} +} + +// Redact replaces whitespace-delimited tokens with entropy above the threshold +// with [REDACTED], preserving all surrounding whitespace. +func (e *EntropyRedactor) Redact(s string) string { + var result strings.Builder + i := 0 + for i < len(s) { + if isSpaceByte(s[i]) { + result.WriteByte(s[i]) + i++ + continue + } + j := i + for j < len(s) && !isSpaceByte(s[j]) { + j++ + } + token := s[i:j] + if len(token) >= e.minLength && Entropy(token) >= e.threshold { + result.WriteString("[REDACTED]") + } else { + result.WriteString(token) + } + i = j + } + return result.String() +} + +func isSpaceByte(b byte) bool { + return b == ' ' || b == '\t' || b == '\n' || b == '\r' +} diff --git a/internal/redact/entropy_test.go b/internal/redact/entropy_test.go new file mode 100644 index 0000000..159131b --- /dev/null +++ b/internal/redact/entropy_test.go @@ -0,0 +1,136 @@ +package redact + +import ( + "math" + "testing" +) + +func TestEntropy_EmptyString(t *testing.T) { + if got := Entropy(""); got != 0 { + t.Errorf("expected 0 for empty string, got %f", got) + } +} + +func TestEntropy_SingleChar(t *testing.T) { + if got := Entropy("aaaa"); got != 0 { + t.Errorf("expected 0 for single repeated char, got %f", got) + } +} + +func TestEntropy_UniformDistribution(t *testing.T) { + // "ab" has 2 unique chars over 2 positions → entropy = 1.0 bits + got := Entropy("ab") + want := 1.0 + if math.Abs(got-want) > 1e-9 { + t.Errorf("Entropy(\"ab\") = %f, want %f", got, want) + } +} + +func TestEntropy_NormalText(t *testing.T) { + // Normal English words have low entropy + cases := []string{"hello", "world", "password", "username", "delete"} + for _, s := range cases { + got := Entropy(s) + if got >= 4.5 { + t.Errorf("Entropy(%q) = %f, expected < 4.5 for normal word", s, got) + } + } +} + +func TestEntropy_HighEntropySecrets(t *testing.T) { + // Typical secrets have high entropy + cases := []string{ + "ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456", // GitHub PAT-like + "AKIAIOSFODNN7EXAMPLE1234567890AB", // AWS key-like + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", // JWT header (base64) + "sk-abcdefghijklmnopqrstuvwxyz1234567890ABCD", // OpenAI key-like + } + for _, s := range cases { + got := Entropy(s) + if got < 4.0 { + t.Errorf("Entropy(%q) = %f, expected >= 4.0 for secret-like string", s, got) + } + } +} + +func TestEntropyRedactor_RedactsHighEntropyToken(t *testing.T) { + r := NewEntropyRedactor(4.5, 20) + secret := "ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456" + input := "curl -H Authorization:" + secret + got := r.Redact(input) + if got == input { + t.Errorf("expected redaction, got unchanged: %q", got) + } + if contains(got, secret) { + t.Errorf("secret still present after redaction: %q", got) + } +} + +func TestEntropyRedactor_PreservesNormalCommand(t *testing.T) { + r := NewEntropyRedactor(4.5, 20) + cases := []string{ + "ls -la /tmp", + "git status", + "echo hello world", + "rm -rf /tmp/testdir", + "docker ps --all", + } + for _, input := range cases { + got := r.Redact(input) + if got != input { + t.Errorf("Redact(%q) = %q, expected unchanged", input, got) + } + } +} + +func TestEntropyRedactor_MinLengthSkipsShortTokens(t *testing.T) { + r := NewEntropyRedactor(3.0, 20) // low threshold but high min length + // "abcdefgh" has high-ish entropy but is only 8 chars → should not be redacted + input := "cmd abcdefghijkl" + got := r.Redact(input) + if got != input { + t.Errorf("expected unchanged (too short), got %q", got) + } +} + +func TestEntropyRedactor_PreservesWhitespace(t *testing.T) { + r := NewEntropyRedactor(4.5, 20) + // Two spaces between tokens should be preserved + input := "cmd normal word" + got := r.Redact(input) + if got != input { + t.Errorf("whitespace not preserved: got %q, want %q", got, input) + } +} + +func TestEntropyRedactor_MultipleHighEntropyTokens(t *testing.T) { + r := NewEntropyRedactor(4.5, 20) + secret1 := "ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456" + secret2 := "sk-abcdefghijklmnopqrstuvwxyz1234567890ABCD" + input := secret1 + " " + secret2 + got := r.Redact(input) + if contains(got, secret1) || contains(got, secret2) { + t.Errorf("secrets still present after redaction: %q", got) + } +} + +func TestEntropyRedactor_NilSafe(t *testing.T) { + r := NewEntropyRedactor(4.5, 20) + got := r.Redact("") + if got != "" { + t.Errorf("expected empty string, got %q", got) + } +} + +func contains(s, substr string) bool { + return len(substr) > 0 && len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStr(s, substr)) +} + +func containsStr(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/main.go b/main.go index c690cfb..c3bb306 100644 --- a/main.go +++ b/main.go @@ -125,13 +125,31 @@ func runStatus(args []string) { } fmt.Printf(" %s%d%s %s%s%s\n", dim, i+1, reset, cyan, name, reset) fmt.Printf(" %s %smodel%s %s%s%s\n", dim+" "+reset, dim, reset, dim, model, reset) - fmt.Printf(" %s %spattern based redaction%s %s\n\n", dim+" "+reset, dim, reset, redactBadge) + entropyBadge := "" + if p.EntropyRedactionEnabled() { + entropyBadge = green + "● on" + reset + } else { + entropyBadge = dim + "○ off" + reset + } + fmt.Printf(" %s %spattern based redaction%s %s\n", dim+" "+reset, dim, reset, redactBadge) + fmt.Printf(" %s %sentropy based redaction%s %s\n\n", dim+" "+reset, dim, reset, entropyBadge) } + fmt.Printf("\n %sredaction%s\n", bold, reset) if len(cfg.RedactPatterns) > 0 { - fmt.Printf("\n %sredaction%s\n", bold, reset) fmt.Printf(" %s %s%d patterns active%s\n", label("patterns"), dim, len(cfg.RedactPatterns), reset) + } else { + fmt.Printf(" %s %snone%s\n", label("patterns"), dim, reset) + } + entropyThreshold := cfg.EntropyThreshold + if entropyThreshold <= 0 { + entropyThreshold = 4.5 } + entropyMinLen := cfg.EntropyMinLength + if entropyMinLen <= 0 { + entropyMinLen = 20 + } + fmt.Printf(" %s %sthreshold %.1f, min length %d%s\n", label("entropy "), dim, entropyThreshold, entropyMinLen, reset) if len(cfg.CommandWhitelist) > 0 { fmt.Printf("\n %swhitelist%s\n", bold, reset) @@ -403,7 +421,21 @@ func main() { redactEnabled[name] = cfg.Providers[name].RedactionEnabled() } - g := guard.New(llm.NewMulti(names, providers, providerPrompts, redactor, redactEnabled, debugOut), defaultPrompt, config.Dir(), cfg.CommandWhitelist, cacheMaxSize, debugOut) + entropyThreshold := cfg.EntropyThreshold + if entropyThreshold <= 0 { + entropyThreshold = 4.5 + } + entropyMinLen := cfg.EntropyMinLength + if entropyMinLen <= 0 { + entropyMinLen = 20 + } + entropyRedactor := redact.NewEntropyRedactor(entropyThreshold, entropyMinLen) + entropyRedactEnabled := make(map[string]bool, len(names)) + for _, name := range names { + entropyRedactEnabled[name] = cfg.Providers[name].EntropyRedactionEnabled() + } + + g := guard.New(llm.NewMulti(names, providers, providerPrompts, redactor, redactEnabled, entropyRedactor, entropyRedactEnabled, debugOut), defaultPrompt, config.Dir(), cfg.CommandWhitelist, cacheMaxSize, debugOut) timeout := cfg.TimeoutSeconds if timeout <= 0 { diff --git a/redact_cmd.go b/redact_cmd.go index e6da38e..6ea3413 100644 --- a/redact_cmd.go +++ b/redact_cmd.go @@ -11,15 +11,17 @@ import ( func runRedact(args []string) { if len(args) == 0 { fmt.Fprintf(os.Stderr, "Usage: guard-sh redact [on|off]\n") - fmt.Fprintf(os.Stderr, "Types: pattern\n") + fmt.Fprintf(os.Stderr, "Types: pattern, entropy\n") os.Exit(2) } switch args[0] { case "pattern": runRedactType("pattern", args[1:]) + case "entropy": + runRedactEntropyType(args[1:]) default: fmt.Fprintf(os.Stderr, "guard-sh: unknown redaction type %q\n", args[0]) - fmt.Fprintf(os.Stderr, "Types: pattern\n") + fmt.Fprintf(os.Stderr, "Types: pattern, entropy\n") os.Exit(2) } } @@ -40,6 +42,66 @@ func runRedactType(redactType string, args []string) { } } +func runRedactEntropyType(args []string) { + if len(args) == 0 { + fmt.Fprintf(os.Stderr, "Usage: guard-sh redact entropy [on|off]\n") + os.Exit(2) + } + switch args[0] { + case "on": + runRedactEntropySet(true) + case "off": + runRedactEntropySet(false) + default: + fmt.Fprintf(os.Stderr, "Usage: guard-sh redact entropy [on|off]\n") + os.Exit(2) + } +} + +func runRedactEntropySet(enable bool) { + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "guard-sh: %v\n", err) + os.Exit(1) + } + if len(cfg.ProviderOrder) == 0 { + fmt.Fprintln(os.Stderr, "guard-sh: no providers configured") + os.Exit(1) + } + + action := "enable" + if !enable { + action = "disable" + } + fmt.Printf("\n %sentropy redaction: %s%s\n\n", bold, action, reset) + + for i, name := range cfg.ProviderOrder { + p := cfg.Providers[name] + badge := statusBadge(map[bool]string{true: "on", false: "off"}[p.EntropyRedactionEnabled()]) + fmt.Printf(" %s%d%s %s%-10s%s %s\n", dim, i+1, reset, cyan, name, reset, badge) + } + + reader := bufio.NewReader(os.Stdin) + fmt.Printf("\n %s>%s ", dim, reset) + idx, err := pickNumber(reader, len(cfg.ProviderOrder)) + if err != nil { + fmt.Fprintf(os.Stderr, "guard-sh: %v\n", err) + os.Exit(1) + } + + name := cfg.ProviderOrder[idx-1] + if err := config.UpdateProviderEntropyRedaction(name, enable); err != nil { + fmt.Fprintf(os.Stderr, "guard-sh: %v\n", err) + os.Exit(1) + } + + if enable { + fmt.Printf("\n %s● entropy redaction enabled for %s%s\n\n", green, name, reset) + } else { + fmt.Printf("\n %s○ entropy redaction disabled for %s%s\n\n", red, name, reset) + } +} + func runRedactPatternSet(enable bool) { cfg, err := config.Load() if err != nil { From 01055dbb45d16224f875686e30caadc2789ccd46 Mon Sep 17 00:00:00 2001 From: claude-developer-1 Date: Sun, 15 Mar 2026 20:01:26 +0300 Subject: [PATCH 02/14] fix: align entropy config with per-provider nested format Restructures entropy-based redaction config to match the per-provider nested format specified in issue #10: providers: claude: redact_entropy: enabled: true threshold: 4.5 min_length: 20 - EntropyConfig struct nested under ProviderConfig.RedactEntropy - Removed flat global entropy_threshold/entropy_min_length from Config - Per-provider EntropyRedactor instances (each with its own threshold/min_length) - UpdateProviderEntropyRedaction creates/updates nested redact_entropy block - Multi uses map[string]*EntropyRedactor instead of shared redactor + enabled map - Status shows threshold and min_length when entropy is on Co-Authored-By: Claude Sonnet 4.6 --- config.default.yaml | 19 ++++---- internal/config/config.go | 89 ++++++++++++++++++++++++++++---------- internal/llm/multi.go | 36 ++++++++------- internal/llm/multi_test.go | 27 ++++++------ main.go | 39 +++++------------ 5 files changed, 117 insertions(+), 93 deletions(-) diff --git a/config.default.yaml b/config.default.yaml index 99011cb..7966313 100644 --- a/config.default.yaml +++ b/config.default.yaml @@ -22,15 +22,16 @@ redact_patterns: - '(?i)--password\s+\S+' - 'AKIA[0-9A-Z]{16}' -# Shannon entropy threshold (bits/char) for entropy-based redaction. -# Tokens with entropy above this value are replaced with [REDACTED]. -# Typical English text: ~3.5 bits/char. Random secrets: typically >4.5. -# Per-provider entropy redaction can be toggled with: guard-sh redact entropy on/off -entropy_threshold: 4.5 - -# Minimum token length for entropy-based redaction. -# Tokens shorter than this are never redacted (reduces false positives). -entropy_min_length: 20 +# Entropy-based redaction is configured per-provider under the providers block. +# Enable it with: guard-sh redact entropy on +# Or add manually to a provider: +# +# providers: +# claude: +# redact_entropy: +# enabled: true +# threshold: 4.5 # bits/char — typical English ~3.5, secrets typically >4.5 +# min_length: 20 # ignore tokens shorter than this (reduces false positives) # Commands that are always considered safe — API is never called for these. # Only the base command name is needed (e.g. "ls" covers "ls -la", "ls /tmp", etc.). diff --git a/internal/config/config.go b/internal/config/config.go index 693e0be..5addc12 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,12 +10,19 @@ import ( "gopkg.in/yaml.v3" ) +// EntropyConfig holds per-provider entropy-based redaction settings. +type EntropyConfig struct { + Enabled *bool `yaml:"enabled"` + Threshold float64 `yaml:"threshold"` + MinLength int `yaml:"min_length"` +} + type ProviderConfig struct { - APIKey string `yaml:"api_key"` - Model string `yaml:"model"` - Host string `yaml:"host"` - PatternBasedRedactionEnabled *bool `yaml:"pattern_based_redaction_enabled"` - EntropyRedactEnabled *bool `yaml:"entropy_redaction_enabled"` + APIKey string `yaml:"api_key"` + Model string `yaml:"model"` + Host string `yaml:"host"` + PatternBasedRedactionEnabled *bool `yaml:"pattern_based_redaction_enabled"` + RedactEntropy *EntropyConfig `yaml:"redact_entropy"` } // RedactionEnabled returns whether pattern-based redaction is enabled for this provider. @@ -30,22 +37,36 @@ func (p *ProviderConfig) RedactionEnabled() bool { // EntropyRedactionEnabled returns whether entropy-based redaction is enabled for this provider. // Defaults to false if not explicitly set. func (p *ProviderConfig) EntropyRedactionEnabled() bool { - if p == nil || p.EntropyRedactEnabled == nil { + if p == nil || p.RedactEntropy == nil || p.RedactEntropy.Enabled == nil { return false } - return *p.EntropyRedactEnabled + return *p.RedactEntropy.Enabled +} + +// GetEntropyThreshold returns the entropy threshold for this provider, defaulting to 4.5. +func (p *ProviderConfig) GetEntropyThreshold() float64 { + if p == nil || p.RedactEntropy == nil || p.RedactEntropy.Threshold <= 0 { + return 4.5 + } + return p.RedactEntropy.Threshold +} + +// GetEntropyMinLength returns the minimum token length for entropy redaction, defaulting to 20. +func (p *ProviderConfig) GetEntropyMinLength() int { + if p == nil || p.RedactEntropy == nil || p.RedactEntropy.MinLength <= 0 { + return 20 + } + return p.RedactEntropy.MinLength } type Config struct { - ProviderOrder []string `yaml:"provider_order"` - Providers map[string]*ProviderConfig `yaml:"providers"` - TimeoutSeconds int `yaml:"timeout_seconds"` - CacheEnabled *bool `yaml:"cache_enabled"` - CacheMaxSize int `yaml:"cache_max_size"` - CommandWhitelist []string `yaml:"command_whitelist"` - RedactPatterns []string `yaml:"redact_patterns"` - EntropyThreshold float64 `yaml:"entropy_threshold"` - EntropyMinLength int `yaml:"entropy_min_length"` + ProviderOrder []string `yaml:"provider_order"` + Providers map[string]*ProviderConfig `yaml:"providers"` + TimeoutSeconds int `yaml:"timeout_seconds"` + CacheEnabled *bool `yaml:"cache_enabled"` + CacheMaxSize int `yaml:"cache_max_size"` + CommandWhitelist []string `yaml:"command_whitelist"` + RedactPatterns []string `yaml:"redact_patterns"` } func (c *Config) Get(name string) (*ProviderConfig, error) { @@ -428,7 +449,8 @@ func UpdateProviderRedaction(name string, enabled bool) error { return os.WriteFile(cfgPath, []byte(strings.Join(result, "\n")), 0600) } -// UpdateProviderEntropyRedaction sets entropy_redaction_enabled for a specific provider. +// UpdateProviderEntropyRedaction sets redact_entropy.enabled for a specific provider. +// If the redact_entropy block does not exist, it is created with default threshold and min_length. func UpdateProviderEntropyRedaction(name string, enabled bool) error { cfgPath := filepath.Join(Dir(), "config.yaml") data, err := os.ReadFile(cfgPath) @@ -445,18 +467,39 @@ func UpdateProviderEntropyRedaction(name string, enabled bool) error { if !enabled { val = "false" } - newLine := " entropy_redaction_enabled: " + val + // Look for existing redact_entropy: block within the provider block for i := start + 1; i < end; i++ { - if strings.HasPrefix(strings.TrimSpace(lines[i]), "entropy_redaction_enabled:") { - lines[i] = newLine - return os.WriteFile(cfgPath, []byte(strings.Join(lines, "\n")), 0600) + if strings.TrimSpace(lines[i]) == "redact_entropy:" { + // Find enabled: within the sub-block (6-space indent) + for j := i + 1; j < end; j++ { + if !strings.HasPrefix(lines[j], " ") { + break + } + if strings.HasPrefix(strings.TrimSpace(lines[j]), "enabled:") { + lines[j] = " enabled: " + val + return os.WriteFile(cfgPath, []byte(strings.Join(lines, "\n")), 0600) + } + } + // enabled: not found — insert right after redact_entropy: + result := make([]string, 0, len(lines)+1) + result = append(result, lines[:i+1]...) + result = append(result, " enabled: "+val) + result = append(result, lines[i+1:]...) + return os.WriteFile(cfgPath, []byte(strings.Join(result, "\n")), 0600) } } - result := make([]string, 0, len(lines)+1) + // No redact_entropy block — append it before end of provider block + newBlock := []string{ + " redact_entropy:", + " enabled: " + val, + " threshold: 4.5", + " min_length: 20", + } + result := make([]string, 0, len(lines)+len(newBlock)) result = append(result, lines[:end]...) - result = append(result, newLine) + result = append(result, newBlock...) result = append(result, lines[end:]...) return os.WriteFile(cfgPath, []byte(strings.Join(result, "\n")), 0600) } diff --git a/internal/llm/multi.go b/internal/llm/multi.go index 910980f..d1e8b09 100644 --- a/internal/llm/multi.go +++ b/internal/llm/multi.go @@ -21,26 +21,24 @@ const ( // Multi tries each provider in order, falling back to the next on error. type Multi struct { - providers []guard.Provider - names []string - prompts map[string]string // per-provider prompt overrides - redactor *redact.Redactor - redactEnabled map[string]bool // per-provider pattern redaction toggle - entropyRedactor *redact.EntropyRedactor - entropyRedactEnabled map[string]bool // per-provider entropy redaction toggle - debug io.Writer + providers []guard.Provider + names []string + prompts map[string]string // per-provider prompt overrides + redactor *redact.Redactor + redactEnabled map[string]bool // per-provider pattern redaction toggle + entropyRedactors map[string]*redact.EntropyRedactor // per-provider entropy redactors + debug io.Writer } -func NewMulti(names []string, providers []guard.Provider, prompts map[string]string, redactor *redact.Redactor, redactEnabled map[string]bool, entropyRedactor *redact.EntropyRedactor, entropyRedactEnabled map[string]bool, debug io.Writer) *Multi { +func NewMulti(names []string, providers []guard.Provider, prompts map[string]string, redactor *redact.Redactor, redactEnabled map[string]bool, entropyRedactors map[string]*redact.EntropyRedactor, debug io.Writer) *Multi { return &Multi{ - names: names, - providers: providers, - prompts: prompts, - redactor: redactor, - redactEnabled: redactEnabled, - entropyRedactor: entropyRedactor, - entropyRedactEnabled: entropyRedactEnabled, - debug: debug, + names: names, + providers: providers, + prompts: prompts, + redactor: redactor, + redactEnabled: redactEnabled, + entropyRedactors: entropyRedactors, + debug: debug, } } @@ -58,9 +56,9 @@ func (m *Multi) Query(ctx context.Context, systemPrompt, command string) (string fmt.Fprintf(m.debug, " %sredact (pattern)%s %s→ %q%s\n", dbgDim, dbgReset, dbgDim, cmd, dbgReset) } } - if m.entropyRedactor != nil && m.entropyRedactEnabled[name] { + if er, ok := m.entropyRedactors[name]; ok && er != nil { before := cmd - cmd = m.entropyRedactor.Redact(cmd) + cmd = er.Redact(cmd) if m.debug != nil && cmd != before { fmt.Fprintf(m.debug, " %sredact (entropy)%s %s→ %q%s\n", dbgDim, dbgReset, dbgDim, cmd, dbgReset) } diff --git a/internal/llm/multi_test.go b/internal/llm/multi_test.go index 59dfa1a..62c7675 100644 --- a/internal/llm/multi_test.go +++ b/internal/llm/multi_test.go @@ -25,7 +25,7 @@ func (m *mockProvider) Query(_ context.Context, _, cmd string) (string, error) { func TestMulti_FirstProviderSucceeds(t *testing.T) { p1 := &mockProvider{response: "OK"} p2 := &mockProvider{response: "fallback"} - m := NewMulti([]string{"p1", "p2"}, []guard.Provider{p1, p2}, nil, nil, nil, nil, nil, nil) + m := NewMulti([]string{"p1", "p2"}, []guard.Provider{p1, p2}, nil, nil, nil, nil, nil) result, err := m.Query(context.Background(), "", "ls") if err != nil { @@ -45,7 +45,7 @@ func TestMulti_FirstProviderSucceeds(t *testing.T) { func TestMulti_FallbackOnError(t *testing.T) { p1 := &mockProvider{err: errors.New("rate limit")} p2 := &mockProvider{response: "Deletes everything"} - m := NewMulti([]string{"p1", "p2"}, []guard.Provider{p1, p2}, nil, nil, nil, nil, nil, nil) + m := NewMulti([]string{"p1", "p2"}, []guard.Provider{p1, p2}, nil, nil, nil, nil, nil) result, err := m.Query(context.Background(), "", "rm -rf /") if err != nil { @@ -65,7 +65,7 @@ func TestMulti_FallbackOnError(t *testing.T) { func TestMulti_AllFail(t *testing.T) { p1 := &mockProvider{err: errors.New("error 1")} p2 := &mockProvider{err: errors.New("error 2")} - m := NewMulti([]string{"p1", "p2"}, []guard.Provider{p1, p2}, nil, nil, nil, nil, nil, nil) + m := NewMulti([]string{"p1", "p2"}, []guard.Provider{p1, p2}, nil, nil, nil, nil, nil) _, err := m.Query(context.Background(), "", "rm -rf /") if err == nil { @@ -77,7 +77,7 @@ func TestMulti_AllFail(t *testing.T) { } func TestMulti_EmptyProviders(t *testing.T) { - m := NewMulti(nil, nil, nil, nil, nil, nil, nil, nil) + m := NewMulti(nil, nil, nil, nil, nil, nil, nil) _, err := m.Query(context.Background(), "", "ls") if err == nil { t.Error("expected error with no providers, got nil") @@ -91,7 +91,7 @@ func TestMulti_RedactionApplied(t *testing.T) { t.Fatal(err) } redactEnabled := map[string]bool{"p1": true} - m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, r, redactEnabled, nil, nil, nil) + m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, r, redactEnabled, nil, nil) _, err = m.Query(context.Background(), "", "mysql password=secret") if err != nil { @@ -109,7 +109,7 @@ func TestMulti_RedactionDisabledForProvider(t *testing.T) { t.Fatal(err) } redactEnabled := map[string]bool{"p1": false} - m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, r, redactEnabled, nil, nil, nil) + m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, r, redactEnabled, nil, nil) _, err = m.Query(context.Background(), "", "mysql password=secret") if err != nil { @@ -129,7 +129,7 @@ func TestMulti_RedactionPerProvider(t *testing.T) { t.Fatal(err) } redactEnabled := map[string]bool{"p1": true, "p2": false, "p3": false} - m := NewMulti([]string{"p1", "p2", "p3"}, []guard.Provider{p1, p2, p3}, nil, r, redactEnabled, nil, nil, nil) + m := NewMulti([]string{"p1", "p2", "p3"}, []guard.Provider{p1, p2, p3}, nil, r, redactEnabled, nil, nil) m.Query(context.Background(), "", "mysql password=secret") @@ -141,8 +141,8 @@ func TestMulti_RedactionPerProvider(t *testing.T) { func TestMulti_EntropyRedactionApplied(t *testing.T) { p := &mockProvider{response: "OK"} er := redact.NewEntropyRedactor(4.5, 20) - entropyEnabled := map[string]bool{"p1": true} - m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, nil, nil, er, entropyEnabled, nil) + entropyRedactors := map[string]*redact.EntropyRedactor{"p1": er} + m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, nil, nil, entropyRedactors, nil) secret := "ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456" _, err := m.Query(context.Background(), "", "curl -H Authorization:"+secret) @@ -156,9 +156,8 @@ func TestMulti_EntropyRedactionApplied(t *testing.T) { func TestMulti_EntropyRedactionDisabledForProvider(t *testing.T) { p := &mockProvider{response: "OK"} - er := redact.NewEntropyRedactor(4.5, 20) - entropyEnabled := map[string]bool{"p1": false} - m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, nil, nil, er, entropyEnabled, nil) + // p1 has no entry in entropyRedactors → disabled + m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, nil, nil, nil, nil) input := "ls -la /tmp" _, err := m.Query(context.Background(), "", input) @@ -178,8 +177,8 @@ func TestMulti_PatternAndEntropyRedactionBothApplied(t *testing.T) { } er := redact.NewEntropyRedactor(4.5, 20) redactEnabled := map[string]bool{"p1": true} - entropyEnabled := map[string]bool{"p1": true} - m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, r, redactEnabled, er, entropyEnabled, nil) + entropyRedactors := map[string]*redact.EntropyRedactor{"p1": er} + m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, r, redactEnabled, entropyRedactors, nil) secret := "ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456" input := "curl password=foo token:" + secret diff --git a/main.go b/main.go index c3bb306..5963d54 100644 --- a/main.go +++ b/main.go @@ -125,31 +125,20 @@ func runStatus(args []string) { } fmt.Printf(" %s%d%s %s%s%s\n", dim, i+1, reset, cyan, name, reset) fmt.Printf(" %s %smodel%s %s%s%s\n", dim+" "+reset, dim, reset, dim, model, reset) - entropyBadge := "" + entropyBadge := dim + "○ off" + reset + entropyDetail := "" if p.EntropyRedactionEnabled() { entropyBadge = green + "● on" + reset - } else { - entropyBadge = dim + "○ off" + reset + entropyDetail = fmt.Sprintf("%s (threshold %.1f, min length %d)%s", dim, p.GetEntropyThreshold(), p.GetEntropyMinLength(), reset) } fmt.Printf(" %s %spattern based redaction%s %s\n", dim+" "+reset, dim, reset, redactBadge) - fmt.Printf(" %s %sentropy based redaction%s %s\n\n", dim+" "+reset, dim, reset, entropyBadge) + fmt.Printf(" %s %sentropy based redaction%s %s%s\n\n", dim+" "+reset, dim, reset, entropyBadge, entropyDetail) } - fmt.Printf("\n %sredaction%s\n", bold, reset) if len(cfg.RedactPatterns) > 0 { + fmt.Printf("\n %sredaction%s\n", bold, reset) fmt.Printf(" %s %s%d patterns active%s\n", label("patterns"), dim, len(cfg.RedactPatterns), reset) - } else { - fmt.Printf(" %s %snone%s\n", label("patterns"), dim, reset) - } - entropyThreshold := cfg.EntropyThreshold - if entropyThreshold <= 0 { - entropyThreshold = 4.5 } - entropyMinLen := cfg.EntropyMinLength - if entropyMinLen <= 0 { - entropyMinLen = 20 - } - fmt.Printf(" %s %sthreshold %.1f, min length %d%s\n", label("entropy "), dim, entropyThreshold, entropyMinLen, reset) if len(cfg.CommandWhitelist) > 0 { fmt.Printf("\n %swhitelist%s\n", bold, reset) @@ -421,21 +410,15 @@ func main() { redactEnabled[name] = cfg.Providers[name].RedactionEnabled() } - entropyThreshold := cfg.EntropyThreshold - if entropyThreshold <= 0 { - entropyThreshold = 4.5 - } - entropyMinLen := cfg.EntropyMinLength - if entropyMinLen <= 0 { - entropyMinLen = 20 - } - entropyRedactor := redact.NewEntropyRedactor(entropyThreshold, entropyMinLen) - entropyRedactEnabled := make(map[string]bool, len(names)) + entropyRedactors := make(map[string]*redact.EntropyRedactor, len(names)) for _, name := range names { - entropyRedactEnabled[name] = cfg.Providers[name].EntropyRedactionEnabled() + p := cfg.Providers[name] + if p.EntropyRedactionEnabled() { + entropyRedactors[name] = redact.NewEntropyRedactor(p.GetEntropyThreshold(), p.GetEntropyMinLength()) + } } - g := guard.New(llm.NewMulti(names, providers, providerPrompts, redactor, redactEnabled, entropyRedactor, entropyRedactEnabled, debugOut), defaultPrompt, config.Dir(), cfg.CommandWhitelist, cacheMaxSize, debugOut) + g := guard.New(llm.NewMulti(names, providers, providerPrompts, redactor, redactEnabled, entropyRedactors, debugOut), defaultPrompt, config.Dir(), cfg.CommandWhitelist, cacheMaxSize, debugOut) timeout := cfg.TimeoutSeconds if timeout <= 0 { From a9c48f84e3af2659dbdaa93be0e20f0b2b603065 Mon Sep 17 00:00:00 2001 From: claude-developer-1 Date: Sun, 15 Mar 2026 20:09:31 +0300 Subject: [PATCH 03/14] refactor: restructure redaction config to nested format per review Adopts the config format requested in PR #11 review comment: redaction: # global (mandatory) pattern_based: enabled: true patterns: [...] shannon_entropy_based: enabled: false threshold: 4.5 min_length: 20 providers: claude: redaction: # optional per-provider override shannon_entropy_based: enabled: true threshold: 4.0 min_length: 16 Changes: - New PatternRedactionConfig, EntropyRedactionConfig, RedactionConfig structs - Config.Redaction replaces flat RedactPatterns field - ProviderConfig.Redaction replaces PatternBasedRedactionEnabled + RedactEntropy - EffectivePatternRedaction/EffectiveEntropyRedaction merge global + per-provider - Multi uses patternRedactors map (per-provider) instead of shared redactor - UpdateProviderPatternRedaction/UpdateProviderEntropyRedaction write nested YAML - README.org redaction section fully rewritten - config.default.yaml updated to new format Co-Authored-By: Claude Sonnet 4.6 --- README.org | 77 ++++++++--- config.default.yaml | 33 +++-- internal/config/config.go | 267 ++++++++++++++++++++++++------------- internal/llm/multi.go | 14 +- internal/llm/multi_test.go | 44 +++--- main.go | 70 +++++----- redact_cmd.go | 8 +- 7 files changed, 316 insertions(+), 197 deletions(-) diff --git a/README.org b/README.org index aa8512e..1c8b1ed 100644 --- a/README.org +++ b/README.org @@ -146,47 +146,82 @@ The LLM prompt is stored at =~/.config/guard-sh/prompt.txt=. Edit it to adjust w :CUSTOM_ID: redaction :END: -guard-sh can redact sensitive values from commands before sending them to any LLM provider. Patterns are Go regular expressions stored in =redact_patterns= in your config. Matches are replaced with =[REDACTED]=. +guard-sh can redact sensitive values from commands before sending them to any LLM provider. Two methods are supported: pattern-based and Shannon entropy-based. Matches are replaced with =[REDACTED]=. -Default patterns cover common cases: +Redaction is configured globally under =redaction:= in your config, and can be overridden per-provider under =providers.NAME.redaction=. #+begin_src yaml -redact_patterns: - - '(?i)(password|passwd|secret|api[_-]?key|token|auth)\s*[=:]\s*\S+' - - '(?i)--password\s+\S+' - - 'AKIA[0-9A-Z]{16}' +redaction: + pattern_based: + enabled: true + patterns: + - '(?i)(password|passwd|secret|api[_-]?key|token|auth)\s*[=:]\s*\S+' + - '(?i)--password\s+\S+' + - 'AKIA[0-9A-Z]{16}' + shannon_entropy_based: + enabled: false + threshold: 4.5 # bits/char — typical English ~3.5, secrets typically >4.5 + min_length: 20 # ignore tokens shorter than this #+end_src -You can add your own patterns. For example: +*** Pattern-based redaction + +Uses Go regular expressions. Any token matching a pattern is replaced with =[REDACTED]=. On by default for all providers. + +You can add your own patterns: #+begin_src yaml -redact_patterns: - - '(?i)(password|passwd|secret|api[_-]?key|token|auth)\s*[=:]\s*\S+' - - '(?i)--password\s+\S+' - - 'AKIA[0-9A-Z]{16}' - - 'ghp_[a-zA-Z0-9]{36}' # GitHub personal access tokens - - 'xox[baprs]-[a-zA-Z0-9-]+' # Slack tokens +redaction: + pattern_based: + enabled: true + patterns: + - '(?i)(password|passwd|secret|api[_-]?key|token|auth)\s*[=:]\s*\S+' + - '(?i)--password\s+\S+' + - 'AKIA[0-9A-Z]{16}' + - 'ghp_[a-zA-Z0-9]{36}' # GitHub personal access tokens + - 'xox[baprs]-[a-zA-Z0-9-]+' # Slack tokens #+end_src -Redaction is enabled per provider. The default is on for all providers. Toggle it interactively: +Toggle per-provider: + +#+begin_src bash +guard-sh redact pattern on # interactively enable for a provider +guard-sh redact pattern off # interactively disable for a provider +#+end_src + +*** Shannon entropy-based redaction + +Automatically detects high-entropy strings (API keys, random tokens, private key material) without requiring explicit patterns. Any whitespace-delimited token above the entropy threshold is replaced with =[REDACTED]=. Off by default. + +Toggle per-provider: #+begin_src bash -guard-sh redact pattern on # interactively enable pattern redaction for a provider -guard-sh redact pattern off # interactively disable pattern redaction for a provider +guard-sh redact entropy on # interactively enable for a provider +guard-sh redact entropy off # interactively disable for a provider #+end_src -Or set it directly in the config: +*** Per-provider overrides + +A provider can override any part of the global redaction config: #+begin_src yaml providers: - gemini: - api_key: YOUR_KEY - pattern_based_redaction_enabled: true # default, can omit ollama: host: http://localhost:11434/api/chat - pattern_based_redaction_enabled: false # disable for local model + redaction: # optional — overrides global for this provider + pattern_based: + enabled: false # disable pattern redaction for local model + gemini: + api_key: YOUR_KEY + redaction: + shannon_entropy_based: + enabled: true + threshold: 4.0 # more sensitive than global default + min_length: 16 #+end_src +Fields not specified in the per-provider block fall back to the global value. + ** Provider-specific prompts To use a different prompt for a specific provider, create =prompt_PROVIDERNAME.txt= in the same directory: diff --git a/config.default.yaml b/config.default.yaml index 7966313..c084b9e 100644 --- a/config.default.yaml +++ b/config.default.yaml @@ -14,24 +14,21 @@ cache_enabled: true # Maximum number of cached responses to keep. Oldest entries are evicted when full. cache_max_size: 1000 -# Patterns to redact from commands before sending to LLM providers. -# Uses Go regular expressions. Matches are replaced with [REDACTED]. -# Per-provider redaction can be toggled with: guard-sh redact pattern on/off -redact_patterns: - - '(?i)(password|passwd|secret|api[_-]?key|token|auth)\s*[=:]\s*\S+' - - '(?i)--password\s+\S+' - - 'AKIA[0-9A-Z]{16}' - -# Entropy-based redaction is configured per-provider under the providers block. -# Enable it with: guard-sh redact entropy on -# Or add manually to a provider: -# -# providers: -# claude: -# redact_entropy: -# enabled: true -# threshold: 4.5 # bits/char — typical English ~3.5, secrets typically >4.5 -# min_length: 20 # ignore tokens shorter than this (reduces false positives) +# Redaction: sensitive values are replaced with [REDACTED] before sending to LLM providers. +# Per-provider overrides can be added under providers.NAME.redaction (optional). +# Toggle per-provider with: guard-sh redact pattern on/off +# guard-sh redact entropy on/off +redaction: + pattern_based: + enabled: true + patterns: + - '(?i)(password|passwd|secret|api[_-]?key|token|auth)\s*[=:]\s*\S+' + - '(?i)--password\s+\S+' + - 'AKIA[0-9A-Z]{16}' + shannon_entropy_based: + enabled: false + threshold: 4.5 # bits/char — typical English ~3.5, secrets typically >4.5 + min_length: 20 # ignore tokens shorter than this (reduces false positives) # Commands that are always considered safe — API is never called for these. # Only the base command name is needed (e.g. "ls" covers "ls -la", "ls /tmp", etc.). diff --git a/internal/config/config.go b/internal/config/config.go index 5addc12..74e1944 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,53 +10,117 @@ import ( "gopkg.in/yaml.v3" ) -// EntropyConfig holds per-provider entropy-based redaction settings. -type EntropyConfig struct { - Enabled *bool `yaml:"enabled"` - Threshold float64 `yaml:"threshold"` - MinLength int `yaml:"min_length"` +// PatternRedactionConfig holds pattern-based redaction settings. +type PatternRedactionConfig struct { + Enabled *bool `yaml:"enabled"` + Patterns []string `yaml:"patterns"` } -type ProviderConfig struct { - APIKey string `yaml:"api_key"` - Model string `yaml:"model"` - Host string `yaml:"host"` - PatternBasedRedactionEnabled *bool `yaml:"pattern_based_redaction_enabled"` - RedactEntropy *EntropyConfig `yaml:"redact_entropy"` +// IsEnabled returns true if enabled is unset (default on) or explicitly true. +func (c *PatternRedactionConfig) IsEnabled() bool { + if c == nil || c.Enabled == nil { + return true + } + return *c.Enabled } -// RedactionEnabled returns whether pattern-based redaction is enabled for this provider. -// Defaults to true if not explicitly set. -func (p *ProviderConfig) RedactionEnabled() bool { - if p == nil || p.PatternBasedRedactionEnabled == nil { - return true +// GetPatterns returns the configured patterns, or nil if none. +func (c *PatternRedactionConfig) GetPatterns() []string { + if c == nil { + return nil } - return *p.PatternBasedRedactionEnabled + return c.Patterns +} + +// EntropyRedactionConfig holds Shannon entropy-based redaction settings. +type EntropyRedactionConfig struct { + Enabled *bool `yaml:"enabled"` + Threshold float64 `yaml:"threshold"` + MinLength int `yaml:"min_length"` } -// EntropyRedactionEnabled returns whether entropy-based redaction is enabled for this provider. -// Defaults to false if not explicitly set. -func (p *ProviderConfig) EntropyRedactionEnabled() bool { - if p == nil || p.RedactEntropy == nil || p.RedactEntropy.Enabled == nil { +// IsEnabled returns true only if explicitly set to true (default off). +func (c *EntropyRedactionConfig) IsEnabled() bool { + if c == nil || c.Enabled == nil { return false } - return *p.RedactEntropy.Enabled + return *c.Enabled } -// GetEntropyThreshold returns the entropy threshold for this provider, defaulting to 4.5. -func (p *ProviderConfig) GetEntropyThreshold() float64 { - if p == nil || p.RedactEntropy == nil || p.RedactEntropy.Threshold <= 0 { +// GetThreshold returns the entropy threshold, defaulting to 4.5 bits/char. +func (c *EntropyRedactionConfig) GetThreshold() float64 { + if c == nil || c.Threshold <= 0 { return 4.5 } - return p.RedactEntropy.Threshold + return c.Threshold } -// GetEntropyMinLength returns the minimum token length for entropy redaction, defaulting to 20. -func (p *ProviderConfig) GetEntropyMinLength() int { - if p == nil || p.RedactEntropy == nil || p.RedactEntropy.MinLength <= 0 { +// GetMinLength returns the minimum token length, defaulting to 20. +func (c *EntropyRedactionConfig) GetMinLength() int { + if c == nil || c.MinLength <= 0 { return 20 } - return p.RedactEntropy.MinLength + return c.MinLength +} + +// RedactionConfig holds all redaction settings. Used both at global level and per-provider. +type RedactionConfig struct { + PatternBased *PatternRedactionConfig `yaml:"pattern_based"` + ShannonEntropyBased *EntropyRedactionConfig `yaml:"shannon_entropy_based"` +} + +type ProviderConfig struct { + APIKey string `yaml:"api_key"` + Model string `yaml:"model"` + Host string `yaml:"host"` + Redaction *RedactionConfig `yaml:"redaction"` +} + +// EffectivePatternRedaction returns the effective pattern config for this provider, +// merging per-provider overrides on top of the global config. +func (p *ProviderConfig) EffectivePatternRedaction(global *PatternRedactionConfig) *PatternRedactionConfig { + if p == nil || p.Redaction == nil || p.Redaction.PatternBased == nil { + return global + } + pb := p.Redaction.PatternBased + result := &PatternRedactionConfig{} + if pb.Enabled != nil { + result.Enabled = pb.Enabled + } else if global != nil { + result.Enabled = global.Enabled + } + if len(pb.Patterns) > 0 { + result.Patterns = pb.Patterns + } else if global != nil { + result.Patterns = global.Patterns + } + return result +} + +// EffectiveEntropyRedaction returns the effective entropy config for this provider, +// merging per-provider overrides on top of the global config. +func (p *ProviderConfig) EffectiveEntropyRedaction(global *EntropyRedactionConfig) *EntropyRedactionConfig { + if p == nil || p.Redaction == nil || p.Redaction.ShannonEntropyBased == nil { + return global + } + eb := p.Redaction.ShannonEntropyBased + result := &EntropyRedactionConfig{} + if eb.Enabled != nil { + result.Enabled = eb.Enabled + } else if global != nil { + result.Enabled = global.Enabled + } + if eb.Threshold > 0 { + result.Threshold = eb.Threshold + } else if global != nil { + result.Threshold = global.Threshold + } + if eb.MinLength > 0 { + result.MinLength = eb.MinLength + } else if global != nil { + result.MinLength = global.MinLength + } + return result } type Config struct { @@ -66,7 +130,7 @@ type Config struct { CacheEnabled *bool `yaml:"cache_enabled"` CacheMaxSize int `yaml:"cache_max_size"` CommandWhitelist []string `yaml:"command_whitelist"` - RedactPatterns []string `yaml:"redact_patterns"` + Redaction RedactionConfig `yaml:"redaction"` } func (c *Config) Get(name string) (*ProviderConfig, error) { @@ -414,16 +478,31 @@ func DefaultModel(provider string) string { } } -// UpdateProviderRedaction sets pattern_based_redaction_enabled for a specific provider. -func UpdateProviderRedaction(name string, enabled bool) error { +// UpdateProviderPatternRedaction sets redaction.pattern_based.enabled for a specific provider. +func UpdateProviderPatternRedaction(name string, enabled bool) error { + return updateProviderRedactionEnabled(name, "pattern_based", enabled) +} + +// UpdateProviderEntropyRedaction sets redaction.shannon_entropy_based.enabled for a specific provider. +// If the block does not exist it is created with default threshold and min_length. +func UpdateProviderEntropyRedaction(name string, enabled bool) error { + return updateProviderRedactionEnabled(name, "shannon_entropy_based", enabled) +} + +// updateProviderRedactionEnabled writes the enabled flag for a redaction type under +// providers.NAME.redaction.TYPE, creating intermediate blocks as needed. +// +// Provider block indent: 2sp (name) / 4sp (fields) +// redaction block: 4sp (header) / 6sp (type headers) / 8sp (type fields) +func updateProviderRedactionEnabled(name, redactionType string, enabled bool) error { cfgPath := filepath.Join(Dir(), "config.yaml") data, err := os.ReadFile(cfgPath) if err != nil { return fmt.Errorf("config not found at %s", cfgPath) } lines := strings.Split(string(data), "\n") - start, end := findProviderBlock(lines, name) - if start < 0 { + pStart, pEnd := findProviderBlock(lines, name) + if pStart < 0 { return fmt.Errorf("provider %q not found in config", name) } @@ -431,79 +510,83 @@ func UpdateProviderRedaction(name string, enabled bool) error { if !enabled { val = "false" } - newLine := " pattern_based_redaction_enabled: " + val - // Update existing field if present within the block - for i := start + 1; i < end; i++ { - if strings.HasPrefix(strings.TrimSpace(lines[i]), "pattern_based_redaction_enabled:") { - lines[i] = newLine - return os.WriteFile(cfgPath, []byte(strings.Join(lines, "\n")), 0600) + // Find " redaction:" within provider block + redactionLine := -1 + for i := pStart + 1; i < pEnd; i++ { + if lines[i] == " redaction:" { + redactionLine = i + break } } - // Insert at end of block - result := make([]string, 0, len(lines)+1) - result = append(result, lines[:end]...) - result = append(result, newLine) - result = append(result, lines[end:]...) - return os.WriteFile(cfgPath, []byte(strings.Join(result, "\n")), 0600) -} - -// UpdateProviderEntropyRedaction sets redact_entropy.enabled for a specific provider. -// If the redact_entropy block does not exist, it is created with default threshold and min_length. -func UpdateProviderEntropyRedaction(name string, enabled bool) error { - cfgPath := filepath.Join(Dir(), "config.yaml") - data, err := os.ReadFile(cfgPath) - if err != nil { - return fmt.Errorf("config not found at %s", cfgPath) - } - lines := strings.Split(string(data), "\n") - start, end := findProviderBlock(lines, name) - if start < 0 { - return fmt.Errorf("provider %q not found in config", name) - } + if redactionLine >= 0 { + // End of redaction block: first line without 6-space prefix + redactionEnd := redactionLine + 1 + for redactionEnd < pEnd && strings.HasPrefix(lines[redactionEnd], " ") { + redactionEnd++ + } - val := "true" - if !enabled { - val = "false" - } + // Find " TYPE:" within redaction block + typeLine := -1 + for i := redactionLine + 1; i < redactionEnd; i++ { + if lines[i] == " "+redactionType+":" { + typeLine = i + break + } + } - // Look for existing redact_entropy: block within the provider block - for i := start + 1; i < end; i++ { - if strings.TrimSpace(lines[i]) == "redact_entropy:" { - // Find enabled: within the sub-block (6-space indent) - for j := i + 1; j < end; j++ { - if !strings.HasPrefix(lines[j], " ") { - break - } - if strings.HasPrefix(strings.TrimSpace(lines[j]), "enabled:") { - lines[j] = " enabled: " + val + if typeLine >= 0 { + // End of type sub-block: first line without 8-space prefix + typeEnd := typeLine + 1 + for typeEnd < redactionEnd && strings.HasPrefix(lines[typeEnd], " ") { + typeEnd++ + } + // Find " enabled:" + for i := typeLine + 1; i < typeEnd; i++ { + if strings.HasPrefix(strings.TrimSpace(lines[i]), "enabled:") { + lines[i] = " enabled: " + val return os.WriteFile(cfgPath, []byte(strings.Join(lines, "\n")), 0600) } } - // enabled: not found — insert right after redact_entropy: - result := make([]string, 0, len(lines)+1) - result = append(result, lines[:i+1]...) - result = append(result, " enabled: "+val) - result = append(result, lines[i+1:]...) + // Not found — insert after type header + result := insertLines(lines, typeLine+1, []string{" enabled: " + val}) return os.WriteFile(cfgPath, []byte(strings.Join(result, "\n")), 0600) } + + // Type block not found — insert at start of redaction block + result := insertLines(lines, redactionLine+1, newTypeBlock(redactionType, val)) + return os.WriteFile(cfgPath, []byte(strings.Join(result, "\n")), 0600) } - // No redact_entropy block — append it before end of provider block - newBlock := []string{ - " redact_entropy:", - " enabled: " + val, - " threshold: 4.5", - " min_length: 20", - } - result := make([]string, 0, len(lines)+len(newBlock)) - result = append(result, lines[:end]...) - result = append(result, newBlock...) - result = append(result, lines[end:]...) + // No redaction block — insert before end of provider block + newLines := append([]string{" redaction:"}, newTypeBlock(redactionType, val)...) + result := insertLines(lines, pEnd, newLines) return os.WriteFile(cfgPath, []byte(strings.Join(result, "\n")), 0600) } +func newTypeBlock(redactionType, enabledVal string) []string { + block := []string{ + " " + redactionType + ":", + " enabled: " + enabledVal, + } + if redactionType == "shannon_entropy_based" { + block = append(block, + " threshold: 4.5", + " min_length: 20", + ) + } + return block +} + +func insertLines(lines []string, at int, insert []string) []string { + result := make([]string, 0, len(lines)+len(insert)) + result = append(result, lines[:at]...) + result = append(result, insert...) + result = append(result, lines[at:]...) + return result +} + func DefaultHost(provider string) string { switch provider { case "ollama": diff --git a/internal/llm/multi.go b/internal/llm/multi.go index d1e8b09..d1cbf9a 100644 --- a/internal/llm/multi.go +++ b/internal/llm/multi.go @@ -23,20 +23,18 @@ const ( type Multi struct { providers []guard.Provider names []string - prompts map[string]string // per-provider prompt overrides - redactor *redact.Redactor - redactEnabled map[string]bool // per-provider pattern redaction toggle + prompts map[string]string // per-provider prompt overrides + patternRedactors map[string]*redact.Redactor // per-provider pattern redactors entropyRedactors map[string]*redact.EntropyRedactor // per-provider entropy redactors debug io.Writer } -func NewMulti(names []string, providers []guard.Provider, prompts map[string]string, redactor *redact.Redactor, redactEnabled map[string]bool, entropyRedactors map[string]*redact.EntropyRedactor, debug io.Writer) *Multi { +func NewMulti(names []string, providers []guard.Provider, prompts map[string]string, patternRedactors map[string]*redact.Redactor, entropyRedactors map[string]*redact.EntropyRedactor, debug io.Writer) *Multi { return &Multi{ names: names, providers: providers, prompts: prompts, - redactor: redactor, - redactEnabled: redactEnabled, + patternRedactors: patternRedactors, entropyRedactors: entropyRedactors, debug: debug, } @@ -50,8 +48,8 @@ func (m *Multi) Query(ctx context.Context, systemPrompt, command string) (string prompt = override } cmd := command - if m.redactor != nil && m.redactEnabled[name] { - cmd = m.redactor.Redact(cmd) + if pr, ok := m.patternRedactors[name]; ok && pr != nil { + cmd = pr.Redact(cmd) if m.debug != nil && cmd != command { fmt.Fprintf(m.debug, " %sredact (pattern)%s %s→ %q%s\n", dbgDim, dbgReset, dbgDim, cmd, dbgReset) } diff --git a/internal/llm/multi_test.go b/internal/llm/multi_test.go index 62c7675..af5bdab 100644 --- a/internal/llm/multi_test.go +++ b/internal/llm/multi_test.go @@ -25,7 +25,7 @@ func (m *mockProvider) Query(_ context.Context, _, cmd string) (string, error) { func TestMulti_FirstProviderSucceeds(t *testing.T) { p1 := &mockProvider{response: "OK"} p2 := &mockProvider{response: "fallback"} - m := NewMulti([]string{"p1", "p2"}, []guard.Provider{p1, p2}, nil, nil, nil, nil, nil) + m := NewMulti([]string{"p1", "p2"}, []guard.Provider{p1, p2}, nil, nil, nil, nil) result, err := m.Query(context.Background(), "", "ls") if err != nil { @@ -45,7 +45,7 @@ func TestMulti_FirstProviderSucceeds(t *testing.T) { func TestMulti_FallbackOnError(t *testing.T) { p1 := &mockProvider{err: errors.New("rate limit")} p2 := &mockProvider{response: "Deletes everything"} - m := NewMulti([]string{"p1", "p2"}, []guard.Provider{p1, p2}, nil, nil, nil, nil, nil) + m := NewMulti([]string{"p1", "p2"}, []guard.Provider{p1, p2}, nil, nil, nil, nil) result, err := m.Query(context.Background(), "", "rm -rf /") if err != nil { @@ -65,7 +65,7 @@ func TestMulti_FallbackOnError(t *testing.T) { func TestMulti_AllFail(t *testing.T) { p1 := &mockProvider{err: errors.New("error 1")} p2 := &mockProvider{err: errors.New("error 2")} - m := NewMulti([]string{"p1", "p2"}, []guard.Provider{p1, p2}, nil, nil, nil, nil, nil) + m := NewMulti([]string{"p1", "p2"}, []guard.Provider{p1, p2}, nil, nil, nil, nil) _, err := m.Query(context.Background(), "", "rm -rf /") if err == nil { @@ -77,21 +77,21 @@ func TestMulti_AllFail(t *testing.T) { } func TestMulti_EmptyProviders(t *testing.T) { - m := NewMulti(nil, nil, nil, nil, nil, nil, nil) + m := NewMulti(nil, nil, nil, nil, nil, nil) _, err := m.Query(context.Background(), "", "ls") if err == nil { t.Error("expected error with no providers, got nil") } } -func TestMulti_RedactionApplied(t *testing.T) { +func TestMulti_PatternRedactionApplied(t *testing.T) { p := &mockProvider{response: "OK"} r, err := redact.New([]string{`(?i)password=\S+`}) if err != nil { t.Fatal(err) } - redactEnabled := map[string]bool{"p1": true} - m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, r, redactEnabled, nil, nil) + patternRedactors := map[string]*redact.Redactor{"p1": r} + m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, patternRedactors, nil, nil) _, err = m.Query(context.Background(), "", "mysql password=secret") if err != nil { @@ -102,25 +102,21 @@ func TestMulti_RedactionApplied(t *testing.T) { } } -func TestMulti_RedactionDisabledForProvider(t *testing.T) { +func TestMulti_PatternRedactionDisabledForProvider(t *testing.T) { p := &mockProvider{response: "OK"} - r, err := redact.New([]string{`(?i)password=\S+`}) - if err != nil { - t.Fatal(err) - } - redactEnabled := map[string]bool{"p1": false} - m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, r, redactEnabled, nil, nil) + // p1 has no entry in patternRedactors → disabled + m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, nil, nil, nil) - _, err = m.Query(context.Background(), "", "mysql password=secret") + _, err := m.Query(context.Background(), "", "mysql password=secret") if err != nil { t.Fatal(err) } if p.lastCommand != "mysql password=secret" { - t.Errorf("expected original command when redaction disabled, got %q", p.lastCommand) + t.Errorf("expected original command when pattern redaction disabled, got %q", p.lastCommand) } } -func TestMulti_RedactionPerProvider(t *testing.T) { +func TestMulti_PatternRedactionPerProvider(t *testing.T) { p1 := &mockProvider{response: "OK"} p2 := &mockProvider{err: errors.New("fail")} p3 := &mockProvider{response: "OK"} @@ -128,8 +124,9 @@ func TestMulti_RedactionPerProvider(t *testing.T) { if err != nil { t.Fatal(err) } - redactEnabled := map[string]bool{"p1": true, "p2": false, "p3": false} - m := NewMulti([]string{"p1", "p2", "p3"}, []guard.Provider{p1, p2, p3}, nil, r, redactEnabled, nil, nil) + // only p1 has pattern redaction + patternRedactors := map[string]*redact.Redactor{"p1": r} + m := NewMulti([]string{"p1", "p2", "p3"}, []guard.Provider{p1, p2, p3}, nil, patternRedactors, nil, nil) m.Query(context.Background(), "", "mysql password=secret") @@ -142,7 +139,7 @@ func TestMulti_EntropyRedactionApplied(t *testing.T) { p := &mockProvider{response: "OK"} er := redact.NewEntropyRedactor(4.5, 20) entropyRedactors := map[string]*redact.EntropyRedactor{"p1": er} - m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, nil, nil, entropyRedactors, nil) + m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, nil, entropyRedactors, nil) secret := "ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456" _, err := m.Query(context.Background(), "", "curl -H Authorization:"+secret) @@ -156,8 +153,7 @@ func TestMulti_EntropyRedactionApplied(t *testing.T) { func TestMulti_EntropyRedactionDisabledForProvider(t *testing.T) { p := &mockProvider{response: "OK"} - // p1 has no entry in entropyRedactors → disabled - m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, nil, nil, nil, nil) + m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, nil, nil, nil) input := "ls -la /tmp" _, err := m.Query(context.Background(), "", input) @@ -176,9 +172,9 @@ func TestMulti_PatternAndEntropyRedactionBothApplied(t *testing.T) { t.Fatal(err) } er := redact.NewEntropyRedactor(4.5, 20) - redactEnabled := map[string]bool{"p1": true} + patternRedactors := map[string]*redact.Redactor{"p1": r} entropyRedactors := map[string]*redact.EntropyRedactor{"p1": er} - m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, r, redactEnabled, entropyRedactors, nil) + m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, patternRedactors, entropyRedactors, nil) secret := "ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456" input := "curl password=foo token:" + secret diff --git a/main.go b/main.go index 5963d54..5dd7de8 100644 --- a/main.go +++ b/main.go @@ -108,6 +108,7 @@ func runStatus(args []string) { } fmt.Printf("\n %sproviders%s\n", bold, reset) + globalRedaction := cfg.Redaction for i, name := range cfg.ProviderOrder { p := cfg.Providers[name] model := "" @@ -117,27 +118,31 @@ func runStatus(args []string) { if model == "" { model = config.DefaultModel(name) } - redactBadge := "" - if p.RedactionEnabled() { - redactBadge = green + "● on" + reset - } else { - redactBadge = dim + "○ off" + reset + + effectivePattern := p.EffectivePatternRedaction(globalRedaction.PatternBased) + patternBadge := dim + "○ off" + reset + if effectivePattern.IsEnabled() { + patternBadge = green + "● on" + reset } - fmt.Printf(" %s%d%s %s%s%s\n", dim, i+1, reset, cyan, name, reset) - fmt.Printf(" %s %smodel%s %s%s%s\n", dim+" "+reset, dim, reset, dim, model, reset) + + effectiveEntropy := p.EffectiveEntropyRedaction(globalRedaction.ShannonEntropyBased) entropyBadge := dim + "○ off" + reset entropyDetail := "" - if p.EntropyRedactionEnabled() { + if effectiveEntropy.IsEnabled() { entropyBadge = green + "● on" + reset - entropyDetail = fmt.Sprintf("%s (threshold %.1f, min length %d)%s", dim, p.GetEntropyThreshold(), p.GetEntropyMinLength(), reset) + entropyDetail = fmt.Sprintf("%s (threshold %.1f, min length %d)%s", dim, effectiveEntropy.GetThreshold(), effectiveEntropy.GetMinLength(), reset) } - fmt.Printf(" %s %spattern based redaction%s %s\n", dim+" "+reset, dim, reset, redactBadge) - fmt.Printf(" %s %sentropy based redaction%s %s%s\n\n", dim+" "+reset, dim, reset, entropyBadge, entropyDetail) + + fmt.Printf(" %s%d%s %s%s%s\n", dim, i+1, reset, cyan, name, reset) + fmt.Printf(" %s %smodel%s %s%s%s\n", dim+" "+reset, dim, reset, dim, model, reset) + fmt.Printf(" %s %spattern redaction%s %s\n", dim+" "+reset, dim, reset, patternBadge) + fmt.Printf(" %s %sentropy redaction%s %s%s\n\n", dim+" "+reset, dim, reset, entropyBadge, entropyDetail) } - if len(cfg.RedactPatterns) > 0 { + globalPatterns := globalRedaction.PatternBased.GetPatterns() + if len(globalPatterns) > 0 { fmt.Printf("\n %sredaction%s\n", bold, reset) - fmt.Printf(" %s %s%d patterns active%s\n", label("patterns"), dim, len(cfg.RedactPatterns), reset) + fmt.Printf(" %s %s%d global patterns%s\n", label("patterns"), dim, len(globalPatterns), reset) } if len(cfg.CommandWhitelist) > 0 { @@ -395,30 +400,33 @@ func main() { } } - var redactor *redact.Redactor - if len(cfg.RedactPatterns) > 0 { - r, rerr := redact.New(cfg.RedactPatterns) - if rerr != nil { - fmt.Fprintf(os.Stderr, "guard-sh: %v\n", rerr) - os.Exit(0) // fail open - } - redactor = r - } - - redactEnabled := make(map[string]bool, len(names)) - for _, name := range names { - redactEnabled[name] = cfg.Providers[name].RedactionEnabled() - } - + globalRedact := cfg.Redaction + patternRedactors := make(map[string]*redact.Redactor, len(names)) entropyRedactors := make(map[string]*redact.EntropyRedactor, len(names)) + for _, name := range names { p := cfg.Providers[name] - if p.EntropyRedactionEnabled() { - entropyRedactors[name] = redact.NewEntropyRedactor(p.GetEntropyThreshold(), p.GetEntropyMinLength()) + + effectivePattern := p.EffectivePatternRedaction(globalRedact.PatternBased) + if effectivePattern.IsEnabled() { + patterns := effectivePattern.GetPatterns() + if len(patterns) > 0 { + r, rerr := redact.New(patterns) + if rerr != nil { + fmt.Fprintf(os.Stderr, "guard-sh: %v\n", rerr) + os.Exit(0) // fail open + } + patternRedactors[name] = r + } + } + + effectiveEntropy := p.EffectiveEntropyRedaction(globalRedact.ShannonEntropyBased) + if effectiveEntropy.IsEnabled() { + entropyRedactors[name] = redact.NewEntropyRedactor(effectiveEntropy.GetThreshold(), effectiveEntropy.GetMinLength()) } } - g := guard.New(llm.NewMulti(names, providers, providerPrompts, redactor, redactEnabled, entropyRedactors, debugOut), defaultPrompt, config.Dir(), cfg.CommandWhitelist, cacheMaxSize, debugOut) + g := guard.New(llm.NewMulti(names, providers, providerPrompts, patternRedactors, entropyRedactors, debugOut), defaultPrompt, config.Dir(), cfg.CommandWhitelist, cacheMaxSize, debugOut) timeout := cfg.TimeoutSeconds if timeout <= 0 { diff --git a/redact_cmd.go b/redact_cmd.go index 6ea3413..d14e09b 100644 --- a/redact_cmd.go +++ b/redact_cmd.go @@ -77,7 +77,8 @@ func runRedactEntropySet(enable bool) { for i, name := range cfg.ProviderOrder { p := cfg.Providers[name] - badge := statusBadge(map[bool]string{true: "on", false: "off"}[p.EntropyRedactionEnabled()]) + effective := p.EffectiveEntropyRedaction(cfg.Redaction.ShannonEntropyBased) + badge := statusBadge(map[bool]string{true: "on", false: "off"}[effective.IsEnabled()]) fmt.Printf(" %s%d%s %s%-10s%s %s\n", dim, i+1, reset, cyan, name, reset, badge) } @@ -121,7 +122,8 @@ func runRedactPatternSet(enable bool) { for i, name := range cfg.ProviderOrder { p := cfg.Providers[name] - badge := statusBadge(map[bool]string{true: "on", false: "off"}[p.RedactionEnabled()]) + effective := p.EffectivePatternRedaction(cfg.Redaction.PatternBased) + badge := statusBadge(map[bool]string{true: "on", false: "off"}[effective.IsEnabled()]) fmt.Printf(" %s%d%s %s%-10s%s %s\n", dim, i+1, reset, cyan, name, reset, badge) } @@ -134,7 +136,7 @@ func runRedactPatternSet(enable bool) { } name := cfg.ProviderOrder[idx-1] - if err := config.UpdateProviderRedaction(name, enable); err != nil { + if err := config.UpdateProviderPatternRedaction(name, enable); err != nil { fmt.Fprintf(os.Stderr, "guard-sh: %v\n", err) os.Exit(1) } From 9267067c93bbcf0975c4f8f1e0cd84632046b503 Mon Sep 17 00:00:00 2001 From: claude-developer-1 Date: Sun, 15 Mar 2026 20:18:51 +0300 Subject: [PATCH 04/14] fix: prevent duplicate shell integration entries on repeated install install.sh was checking for the exact source path, so running it from a different directory would always add a new entry. Now it detects any existing guard-sh integration in the rc file and skips if found. Also copies the shell script to the config dir so the sourced path is stable. Co-Authored-By: Claude Sonnet 4.6 --- install.sh | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/install.sh b/install.sh index af4da6e..2b8fd9a 100755 --- a/install.sh +++ b/install.sh @@ -72,16 +72,12 @@ if [[ $WITH_SHELL -eq 1 ]]; then ;; esac - SOURCE_LINE="source \"$SHELL_SCRIPT\"" + DEST_SCRIPT="$CONFIG_DIR/guard.$SHELL_NAME" + cp "$SHELL_SCRIPT" "$DEST_SCRIPT" + SOURCE_LINE="source \"$DEST_SCRIPT\"" ON_LINE="guard-sh on" - if grep -qF "$SOURCE_LINE" "$RC_FILE" 2>/dev/null; then - if ! grep -qF "$ON_LINE" "$RC_FILE" 2>/dev/null; then - echo "$ON_LINE" >> "$RC_FILE" - echo "Shell integration updated in $RC_FILE (added guard-sh on)" - echo "Restart your shell or run: source $RC_FILE" - else - echo "Shell integration already present in $RC_FILE" - fi + if grep -q "guard-sh" "$RC_FILE" 2>/dev/null; then + echo "Shell integration already present in $RC_FILE" else printf '\n# guard-sh\n%s\n%s\n' "$SOURCE_LINE" "$ON_LINE" >> "$RC_FILE" echo "Shell integration added to $RC_FILE" From e96bd0d31938b5e5498644d19444ce15c9650cf7 Mon Sep 17 00:00:00 2001 From: claude-developer-1 Date: Sun, 15 Mar 2026 20:21:56 +0300 Subject: [PATCH 05/14] fix: skip config overwrite if file already exists on reinstall Co-Authored-By: Claude Sonnet 4.6 --- install.sh | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/install.sh b/install.sh index 2b8fd9a..dc9b89d 100755 --- a/install.sh +++ b/install.sh @@ -43,10 +43,14 @@ echo "" # --- Config --- mkdir -p "$CONFIG_DIR" -cp "$INSTALL_DIR/config.default.yaml" "$CONFIG_FILE" -chmod 600 "$CONFIG_FILE" -echo "Config created: $CONFIG_FILE" -echo "Edit it and set your api_key before using guard-sh." +if [[ -f "$CONFIG_FILE" ]]; then + echo "Config already exists, skipping: $CONFIG_FILE" +else + cp "$INSTALL_DIR/config.default.yaml" "$CONFIG_FILE" + chmod 600 "$CONFIG_FILE" + echo "Config created: $CONFIG_FILE" + echo "Edit it and set your api_key before using guard-sh." +fi cp "$INSTALL_DIR/prompt.txt" "$CONFIG_DIR/prompt.txt" echo "Prompt copied: $CONFIG_DIR/prompt.txt (edit to customize)" From 1c88207024e6757de73e631cd2bdaad6035f79f4 Mon Sep 17 00:00:00 2001 From: claude-developer-1 Date: Sun, 15 Mar 2026 20:27:02 +0300 Subject: [PATCH 06/14] feat: always show per-provider redaction status in debug mode Debug output now shows pattern/entropy on|off for every provider, even when no redaction occurs. Redacted command is shown beneath the provider line when the command was modified. Co-Authored-By: Claude Sonnet 4.6 --- internal/llm/multi.go | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/internal/llm/multi.go b/internal/llm/multi.go index d1cbf9a..27484d4 100644 --- a/internal/llm/multi.go +++ b/internal/llm/multi.go @@ -50,31 +50,37 @@ func (m *Multi) Query(ctx context.Context, systemPrompt, command string) (string cmd := command if pr, ok := m.patternRedactors[name]; ok && pr != nil { cmd = pr.Redact(cmd) - if m.debug != nil && cmd != command { - fmt.Fprintf(m.debug, " %sredact (pattern)%s %s→ %q%s\n", dbgDim, dbgReset, dbgDim, cmd, dbgReset) - } } if er, ok := m.entropyRedactors[name]; ok && er != nil { - before := cmd cmd = er.Redact(cmd) - if m.debug != nil && cmd != before { - fmt.Fprintf(m.debug, " %sredact (entropy)%s %s→ %q%s\n", dbgDim, dbgReset, dbgDim, cmd, dbgReset) - } } + if m.debug != nil { - fmt.Fprintf(m.debug, " %s%-10s%s", dbgCyan, name, dbgReset) + patStatus := fmt.Sprintf("%s○ off%s", dbgDim, dbgReset) + if _, ok := m.patternRedactors[name]; ok { + patStatus = fmt.Sprintf("%s● on%s", dbgGreen, dbgReset) + } + entStatus := fmt.Sprintf("%s○ off%s", dbgDim, dbgReset) + if _, ok := m.entropyRedactors[name]; ok { + entStatus = fmt.Sprintf("%s● on%s", dbgGreen, dbgReset) + } + fmt.Fprintf(m.debug, " %s%-10s%s pattern %s entropy %s\n", dbgCyan, name, dbgReset, patStatus, entStatus) + if cmd != command { + fmt.Fprintf(m.debug, " %s → %q%s\n", dbgDim, cmd, dbgReset) + } } + start := time.Now() result, err := p.Query(ctx, prompt, cmd) elapsed := time.Since(start).Milliseconds() if err == nil { if m.debug != nil { - fmt.Fprintf(m.debug, " %s✓ ok%s %s(%dms)%s\n", dbgGreen, dbgReset, dbgDim, elapsed, dbgReset) + fmt.Fprintf(m.debug, " %s ✓ ok %s(%dms)%s\n", dbgGreen, dbgDim, elapsed, dbgReset) } return result, nil } if m.debug != nil { - fmt.Fprintf(m.debug, " %s✗ %s%s %s(%dms), trying next%s\n", dbgRed, err.Error(), dbgReset, dbgDim, elapsed, dbgReset) + fmt.Fprintf(m.debug, " %s ✗ %s %s(%dms), trying next%s\n", dbgRed, err.Error(), dbgDim, elapsed, dbgReset) } } return "", errors.New("all providers failed") From aff1e366b61021b33d39b11dfd2552b4500eb5ec Mon Sep 17 00:00:00 2001 From: claude-developer-1 Date: Sun, 15 Mar 2026 20:34:29 +0300 Subject: [PATCH 07/14] feat: show per-stage redaction result in debug output Each provider now shows the command state after pattern redaction and after entropy redaction separately, with "unchanged" when the stage produced no modifications. Co-Authored-By: Claude Sonnet 4.6 --- internal/llm/multi.go | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/internal/llm/multi.go b/internal/llm/multi.go index 27484d4..3cafcbe 100644 --- a/internal/llm/multi.go +++ b/internal/llm/multi.go @@ -48,25 +48,31 @@ func (m *Multi) Query(ctx context.Context, systemPrompt, command string) (string prompt = override } cmd := command + + patStatus := fmt.Sprintf("%s○ off%s", dbgDim, dbgReset) if pr, ok := m.patternRedactors[name]; ok && pr != nil { + patStatus = fmt.Sprintf("%s● on%s", dbgGreen, dbgReset) cmd = pr.Redact(cmd) } + if m.debug != nil { + if cmd != command { + fmt.Fprintf(m.debug, " %s%-10s%s pattern %s %s→ %q%s\n", dbgCyan, name, dbgReset, patStatus, dbgDim, cmd, dbgReset) + } else { + fmt.Fprintf(m.debug, " %s%-10s%s pattern %s %sunchanged%s\n", dbgCyan, name, dbgReset, patStatus, dbgDim, dbgReset) + } + } + + afterPattern := cmd + entStatus := fmt.Sprintf("%s○ off%s", dbgDim, dbgReset) if er, ok := m.entropyRedactors[name]; ok && er != nil { + entStatus = fmt.Sprintf("%s● on%s", dbgGreen, dbgReset) cmd = er.Redact(cmd) } - if m.debug != nil { - patStatus := fmt.Sprintf("%s○ off%s", dbgDim, dbgReset) - if _, ok := m.patternRedactors[name]; ok { - patStatus = fmt.Sprintf("%s● on%s", dbgGreen, dbgReset) - } - entStatus := fmt.Sprintf("%s○ off%s", dbgDim, dbgReset) - if _, ok := m.entropyRedactors[name]; ok { - entStatus = fmt.Sprintf("%s● on%s", dbgGreen, dbgReset) - } - fmt.Fprintf(m.debug, " %s%-10s%s pattern %s entropy %s\n", dbgCyan, name, dbgReset, patStatus, entStatus) - if cmd != command { - fmt.Fprintf(m.debug, " %s → %q%s\n", dbgDim, cmd, dbgReset) + if cmd != afterPattern { + fmt.Fprintf(m.debug, " %s %s entropy %s %s→ %q%s\n", dbgCyan, dbgReset, entStatus, dbgDim, cmd, dbgReset) + } else { + fmt.Fprintf(m.debug, " %s %s entropy %s %sunchanged%s\n", dbgCyan, dbgReset, entStatus, dbgDim, dbgReset) } } From bfc703c645fe1c87c0f3ddf37b2cac6066205954 Mon Sep 17 00:00:00 2001 From: claude-developer-1 Date: Sun, 15 Mar 2026 23:02:01 +0300 Subject: [PATCH 08/14] fix: preserve custom prompt.txt on reinstall install.sh was unconditionally overwriting ~/.config/guard-sh/prompt.txt on every run. Now it skips the copy if the file already exists, matching the existing behavior for config.yaml. Closes #14 Co-Authored-By: Claude Sonnet 4.6 --- install.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/install.sh b/install.sh index dc9b89d..c8a20b4 100755 --- a/install.sh +++ b/install.sh @@ -52,8 +52,12 @@ else echo "Edit it and set your api_key before using guard-sh." fi -cp "$INSTALL_DIR/prompt.txt" "$CONFIG_DIR/prompt.txt" -echo "Prompt copied: $CONFIG_DIR/prompt.txt (edit to customize)" +if [[ -f "$CONFIG_DIR/prompt.txt" ]]; then + echo "Prompt already exists, skipping: $CONFIG_DIR/prompt.txt" +else + cp "$INSTALL_DIR/prompt.txt" "$CONFIG_DIR/prompt.txt" + echo "Prompt created: $CONFIG_DIR/prompt.txt (edit to customize)" +fi echo "" From 001ea7bfed446d6ae38f350e2f8a4b85f7f0cb8c Mon Sep 17 00:00:00 2001 From: claude-developer-1 Date: Sun, 15 Mar 2026 23:07:01 +0300 Subject: [PATCH 09/14] feat: per-provider cache keys to prevent cross-provider cache hits Cache is moved from guard.Guard into llm.Multi. Each provider now has its own cache entry keyed as "providerName:command". This ensures: - Provider 1 failing and Provider 2 answering does not cause Provider 1 to be skipped on the next run. - Reordering or removing a provider does not serve stale responses. Closes #13 Co-Authored-By: Claude Sonnet 4.6 --- internal/guard/guard.go | 23 ++----------- internal/guard/guard_test.go | 47 ++++--------------------- internal/llm/multi.go | 23 ++++++++++++- internal/llm/multi_test.go | 67 +++++++++++++++++++++++++++++------- main.go | 7 ++-- 5 files changed, 89 insertions(+), 78 deletions(-) diff --git a/internal/guard/guard.go b/internal/guard/guard.go index 9e930da..06d1465 100644 --- a/internal/guard/guard.go +++ b/internal/guard/guard.go @@ -7,8 +7,6 @@ import ( "os" "path/filepath" "strings" - - "github.com/Berdan/guard-sh/internal/cache" ) const ( @@ -30,13 +28,12 @@ type Guard struct { provider Provider prompt string whitelist map[string]bool - cache *cache.Cache debug io.Writer } // New creates a Guard. If a custom prompt.txt exists in configDir it takes // precedence over the compiled-in defaultPrompt. -func New(provider Provider, defaultPrompt, configDir string, whitelist []string, cacheMaxSize int, debug io.Writer) *Guard { +func New(provider Provider, defaultPrompt, configDir string, whitelist []string, debug io.Writer) *Guard { prompt := defaultPrompt if data, err := os.ReadFile(filepath.Join(configDir, "prompt.txt")); err == nil { prompt = string(data) @@ -45,7 +42,7 @@ func New(provider Provider, defaultPrompt, configDir string, whitelist []string, for _, cmd := range whitelist { wl[strings.TrimSpace(cmd)] = true } - return &Guard{provider: provider, prompt: prompt, whitelist: wl, cache: cache.Load(configDir, cacheMaxSize), debug: debug} + return &Guard{provider: provider, prompt: prompt, whitelist: wl, debug: debug} } func (g *Guard) dbg(format string, args ...any) { @@ -71,19 +68,6 @@ func (g *Guard) Check(ctx context.Context, cmd string) (safe bool, warning strin g.dbg(" %swhitelist%s %sempty%s\n", dbgDim, dbgReset, dbgDim, dbgReset) } - if g.cache != nil { - if cached, ok := g.cache.Get(cmd); ok { - g.dbg(" %scache%s %s● hit%s %s→ %q%s\n\n", dbgDim, dbgReset, dbgGreen, dbgReset, dbgDim, cached, dbgReset) - if cached == "OK" || cached == "" { - return true, "" - } - return false, cached - } - g.dbg(" %scache%s %s○ miss%s\n", dbgDim, dbgReset, dbgDim, dbgReset) - } else { - g.dbg(" %scache%s %sdisabled%s\n", dbgDim, dbgReset, dbgDim, dbgReset) - } - g.dbg("\n %sproviders%s\n", dbgBold, dbgReset) response, err := g.provider.Query(ctx, g.prompt, cmd) @@ -93,9 +77,6 @@ func (g *Guard) Check(ctx context.Context, cmd string) (safe bool, warning strin } response = strings.TrimSpace(response) - if g.cache != nil { - g.cache.Set(cmd, response) - } if response == "OK" || response == "" { g.dbg("\n %sresult%s %s● safe%s\n\n", dbgDim, dbgReset, dbgGreen, dbgReset) diff --git a/internal/guard/guard_test.go b/internal/guard/guard_test.go index 94ee457..d0a3d70 100644 --- a/internal/guard/guard_test.go +++ b/internal/guard/guard_test.go @@ -47,7 +47,7 @@ func TestWhitelist(t *testing.T) { } for _, tt := range tests { - g := New(&mockProvider{}, "", "", tt.wl, 0, nil) + g := New(&mockProvider{}, "", "", tt.wl, nil) safe, _ := g.Check(context.Background(), tt.cmd) if safe != tt.wantSafe { t.Errorf("cmd=%q whitelist=%v: got safe=%v, want %v", tt.cmd, tt.wl, safe, tt.wantSafe) @@ -86,7 +86,7 @@ func TestExtractBaseCommands(t *testing.T) { } func TestCheck_SafeResponse(t *testing.T) { - g := New(&mockProvider{response: "OK"}, "", "", nil, 0, nil) + g := New(&mockProvider{response: "OK"}, "", "", nil, nil) safe, warning := g.Check(context.Background(), "rm -rf /") if !safe { t.Errorf("expected safe=true, got false (warning=%q)", warning) @@ -94,7 +94,7 @@ func TestCheck_SafeResponse(t *testing.T) { } func TestCheck_UnsafeResponse(t *testing.T) { - g := New(&mockProvider{response: "Deletes everything"}, "", "", nil, 0, nil) + g := New(&mockProvider{response: "Deletes everything"}, "", "", nil, nil) safe, warning := g.Check(context.Background(), "rm -rf /") if safe { t.Error("expected safe=false, got true") @@ -105,39 +105,13 @@ func TestCheck_UnsafeResponse(t *testing.T) { } func TestCheck_ProviderError_FailsOpen(t *testing.T) { - g := New(&mockProvider{err: errors.New("network error")}, "", "", nil, 0, nil) + g := New(&mockProvider{err: errors.New("network error")}, "", "", nil, nil) safe, _ := g.Check(context.Background(), "rm -rf /") if safe { t.Error("expected safe=false when provider errors (fail open still prompts)") } } -func TestCheck_CacheHit(t *testing.T) { - dir := t.TempDir() - called := 0 - p := &countingProvider{response: "OK", onCall: func() { called++ }} - g := New(p, "", dir, nil, 100, nil) - - g.Check(context.Background(), "ls -la") - g.Check(context.Background(), "ls -la") - - if called != 1 { - t.Errorf("expected provider called once (cache hit on second), got %d", called) - } -} - -func TestCheck_CacheDisabled(t *testing.T) { - called := 0 - p := &countingProvider{response: "OK", onCall: func() { called++ }} - g := New(p, "", "", nil, 0, nil) // cacheMaxSize=0 disables cache - - g.Check(context.Background(), "ls -la") - g.Check(context.Background(), "ls -la") - - if called != 2 { - t.Errorf("expected provider called twice (no cache), got %d", called) - } -} func TestCheck_CustomPrompt(t *testing.T) { dir := t.TempDir() @@ -146,7 +120,7 @@ func TestCheck_CustomPrompt(t *testing.T) { } var receivedPrompt string p := &capturingProvider{onQuery: func(prompt, _ string) { receivedPrompt = prompt }} - g := New(p, "default prompt", dir, nil, 0, nil) + g := New(p, "default prompt", dir, nil, nil) g.Check(context.Background(), "ls") if receivedPrompt != "custom prompt" { t.Errorf("expected custom prompt, got %q", receivedPrompt) @@ -156,22 +130,13 @@ func TestCheck_CustomPrompt(t *testing.T) { func TestCheck_DefaultPrompt(t *testing.T) { var receivedPrompt string p := &capturingProvider{onQuery: func(prompt, _ string) { receivedPrompt = prompt }} - g := New(p, "default prompt", t.TempDir(), nil, 0, nil) + g := New(p, "default prompt", t.TempDir(), nil, nil) g.Check(context.Background(), "ls") if receivedPrompt != "default prompt" { t.Errorf("expected default prompt, got %q", receivedPrompt) } } -type countingProvider struct { - response string - onCall func() -} - -func (p *countingProvider) Query(_ context.Context, _, _ string) (string, error) { - p.onCall() - return p.response, nil -} type capturingProvider struct { onQuery func(prompt, cmd string) diff --git a/internal/llm/multi.go b/internal/llm/multi.go index 3cafcbe..5fb845a 100644 --- a/internal/llm/multi.go +++ b/internal/llm/multi.go @@ -5,8 +5,10 @@ import ( "errors" "fmt" "io" + "strings" "time" + "github.com/Berdan/guard-sh/internal/cache" "github.com/Berdan/guard-sh/internal/guard" "github.com/Berdan/guard-sh/internal/redact" ) @@ -20,22 +22,25 @@ const ( ) // Multi tries each provider in order, falling back to the next on error. +// Cache is keyed per-provider: "providerName:command". type Multi struct { providers []guard.Provider names []string prompts map[string]string // per-provider prompt overrides patternRedactors map[string]*redact.Redactor // per-provider pattern redactors entropyRedactors map[string]*redact.EntropyRedactor // per-provider entropy redactors + cache *cache.Cache debug io.Writer } -func NewMulti(names []string, providers []guard.Provider, prompts map[string]string, patternRedactors map[string]*redact.Redactor, entropyRedactors map[string]*redact.EntropyRedactor, debug io.Writer) *Multi { +func NewMulti(names []string, providers []guard.Provider, prompts map[string]string, patternRedactors map[string]*redact.Redactor, entropyRedactors map[string]*redact.EntropyRedactor, c *cache.Cache, debug io.Writer) *Multi { return &Multi{ names: names, providers: providers, prompts: prompts, patternRedactors: patternRedactors, entropyRedactors: entropyRedactors, + cache: c, debug: debug, } } @@ -43,6 +48,18 @@ func NewMulti(names []string, providers []guard.Provider, prompts map[string]str func (m *Multi) Query(ctx context.Context, systemPrompt, command string) (string, error) { for i, p := range m.providers { name := m.names[i] + cacheKey := name + ":" + command + + // Per-provider cache check + if m.cache != nil { + if cached, ok := m.cache.Get(cacheKey); ok { + if m.debug != nil { + fmt.Fprintf(m.debug, " %s%-10s%s %scache ● hit%s %s→ %q%s\n", dbgCyan, name, dbgReset, dbgGreen, dbgReset, dbgDim, cached, dbgReset) + } + return cached, nil + } + } + prompt := systemPrompt if override, ok := m.prompts[name]; ok { prompt = override @@ -80,6 +97,10 @@ func (m *Multi) Query(ctx context.Context, systemPrompt, command string) (string result, err := p.Query(ctx, prompt, cmd) elapsed := time.Since(start).Milliseconds() if err == nil { + result = strings.TrimSpace(result) + if m.cache != nil { + m.cache.Set(cacheKey, result) + } if m.debug != nil { fmt.Fprintf(m.debug, " %s ✓ ok %s(%dms)%s\n", dbgGreen, dbgDim, elapsed, dbgReset) } diff --git a/internal/llm/multi_test.go b/internal/llm/multi_test.go index af5bdab..22d3377 100644 --- a/internal/llm/multi_test.go +++ b/internal/llm/multi_test.go @@ -5,6 +5,7 @@ import ( "errors" "testing" + "github.com/Berdan/guard-sh/internal/cache" "github.com/Berdan/guard-sh/internal/guard" "github.com/Berdan/guard-sh/internal/redact" ) @@ -25,7 +26,7 @@ func (m *mockProvider) Query(_ context.Context, _, cmd string) (string, error) { func TestMulti_FirstProviderSucceeds(t *testing.T) { p1 := &mockProvider{response: "OK"} p2 := &mockProvider{response: "fallback"} - m := NewMulti([]string{"p1", "p2"}, []guard.Provider{p1, p2}, nil, nil, nil, nil) + m := NewMulti([]string{"p1", "p2"}, []guard.Provider{p1, p2}, nil, nil, nil, nil, nil) result, err := m.Query(context.Background(), "", "ls") if err != nil { @@ -45,7 +46,7 @@ func TestMulti_FirstProviderSucceeds(t *testing.T) { func TestMulti_FallbackOnError(t *testing.T) { p1 := &mockProvider{err: errors.New("rate limit")} p2 := &mockProvider{response: "Deletes everything"} - m := NewMulti([]string{"p1", "p2"}, []guard.Provider{p1, p2}, nil, nil, nil, nil) + m := NewMulti([]string{"p1", "p2"}, []guard.Provider{p1, p2}, nil, nil, nil, nil, nil) result, err := m.Query(context.Background(), "", "rm -rf /") if err != nil { @@ -65,7 +66,7 @@ func TestMulti_FallbackOnError(t *testing.T) { func TestMulti_AllFail(t *testing.T) { p1 := &mockProvider{err: errors.New("error 1")} p2 := &mockProvider{err: errors.New("error 2")} - m := NewMulti([]string{"p1", "p2"}, []guard.Provider{p1, p2}, nil, nil, nil, nil) + m := NewMulti([]string{"p1", "p2"}, []guard.Provider{p1, p2}, nil, nil, nil, nil, nil) _, err := m.Query(context.Background(), "", "rm -rf /") if err == nil { @@ -77,7 +78,7 @@ func TestMulti_AllFail(t *testing.T) { } func TestMulti_EmptyProviders(t *testing.T) { - m := NewMulti(nil, nil, nil, nil, nil, nil) + m := NewMulti(nil, nil, nil, nil, nil, nil, nil) _, err := m.Query(context.Background(), "", "ls") if err == nil { t.Error("expected error with no providers, got nil") @@ -91,7 +92,7 @@ func TestMulti_PatternRedactionApplied(t *testing.T) { t.Fatal(err) } patternRedactors := map[string]*redact.Redactor{"p1": r} - m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, patternRedactors, nil, nil) + m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, patternRedactors, nil, nil, nil) _, err = m.Query(context.Background(), "", "mysql password=secret") if err != nil { @@ -104,8 +105,7 @@ func TestMulti_PatternRedactionApplied(t *testing.T) { func TestMulti_PatternRedactionDisabledForProvider(t *testing.T) { p := &mockProvider{response: "OK"} - // p1 has no entry in patternRedactors → disabled - m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, nil, nil, nil) + m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, nil, nil, nil, nil) _, err := m.Query(context.Background(), "", "mysql password=secret") if err != nil { @@ -124,9 +124,8 @@ func TestMulti_PatternRedactionPerProvider(t *testing.T) { if err != nil { t.Fatal(err) } - // only p1 has pattern redaction patternRedactors := map[string]*redact.Redactor{"p1": r} - m := NewMulti([]string{"p1", "p2", "p3"}, []guard.Provider{p1, p2, p3}, nil, patternRedactors, nil, nil) + m := NewMulti([]string{"p1", "p2", "p3"}, []guard.Provider{p1, p2, p3}, nil, patternRedactors, nil, nil, nil) m.Query(context.Background(), "", "mysql password=secret") @@ -139,7 +138,7 @@ func TestMulti_EntropyRedactionApplied(t *testing.T) { p := &mockProvider{response: "OK"} er := redact.NewEntropyRedactor(4.5, 20) entropyRedactors := map[string]*redact.EntropyRedactor{"p1": er} - m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, nil, entropyRedactors, nil) + m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, nil, entropyRedactors, nil, nil) secret := "ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456" _, err := m.Query(context.Background(), "", "curl -H Authorization:"+secret) @@ -153,7 +152,7 @@ func TestMulti_EntropyRedactionApplied(t *testing.T) { func TestMulti_EntropyRedactionDisabledForProvider(t *testing.T) { p := &mockProvider{response: "OK"} - m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, nil, nil, nil) + m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, nil, nil, nil, nil) input := "ls -la /tmp" _, err := m.Query(context.Background(), "", input) @@ -174,7 +173,7 @@ func TestMulti_PatternAndEntropyRedactionBothApplied(t *testing.T) { er := redact.NewEntropyRedactor(4.5, 20) patternRedactors := map[string]*redact.Redactor{"p1": r} entropyRedactors := map[string]*redact.EntropyRedactor{"p1": er} - m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, patternRedactors, entropyRedactors, nil) + m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, patternRedactors, entropyRedactors, nil, nil) secret := "ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456" input := "curl password=foo token:" + secret @@ -186,3 +185,47 @@ func TestMulti_PatternAndEntropyRedactionBothApplied(t *testing.T) { t.Errorf("expected redaction to occur, got unchanged: %q", p.lastCommand) } } + +func TestMulti_CacheHit(t *testing.T) { + p := &mockProvider{response: "OK"} + c := cache.Load(t.TempDir(), 100) + m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, nil, nil, c, nil) + + m.Query(context.Background(), "", "ls -la") + m.Query(context.Background(), "", "ls -la") + + if p.called != 1 { + t.Errorf("expected provider called once (cache hit on second), got %d", p.called) + } +} + +func TestMulti_CacheKeyIsPerProvider(t *testing.T) { + p1 := &mockProvider{err: errors.New("fail")} + p2 := &mockProvider{response: "Risky"} + c := cache.Load(t.TempDir(), 100) + m := NewMulti([]string{"p1", "p2"}, []guard.Provider{p1, p2}, nil, nil, nil, c, nil) + + // First call: p1 fails, p2 responds + m.Query(context.Background(), "", "rm -rf /") + + // Second call: p1 should still be tried (its cache entry is empty), p2 cache should not bypass p1 + p1.err = nil + p1.response = "OK" + m.Query(context.Background(), "", "rm -rf /") + + if p1.called != 2 { + t.Errorf("expected p1 tried on second call too (its own cache was empty), got p1.called=%d", p1.called) + } +} + +func TestMulti_CacheDisabled(t *testing.T) { + p := &mockProvider{response: "OK"} + m := NewMulti([]string{"p1"}, []guard.Provider{p}, nil, nil, nil, nil, nil) + + m.Query(context.Background(), "", "ls -la") + m.Query(context.Background(), "", "ls -la") + + if p.called != 2 { + t.Errorf("expected provider called twice (no cache), got %d", p.called) + } +} diff --git a/main.go b/main.go index 5dd7de8..fc38124 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/Berdan/guard-sh/internal/cache" "github.com/Berdan/guard-sh/internal/config" "github.com/Berdan/guard-sh/internal/guard" "github.com/Berdan/guard-sh/internal/llm" @@ -388,9 +389,9 @@ func main() { providers = append(providers, provider) } - cacheMaxSize := 0 // disabled + var providerCache *cache.Cache if cfg.CacheEnabled == nil || *cfg.CacheEnabled { - cacheMaxSize = cfg.CacheMaxSize + providerCache = cache.Load(config.Dir(), cfg.CacheMaxSize) } providerPrompts := make(map[string]string) for _, name := range names { @@ -426,7 +427,7 @@ func main() { } } - g := guard.New(llm.NewMulti(names, providers, providerPrompts, patternRedactors, entropyRedactors, debugOut), defaultPrompt, config.Dir(), cfg.CommandWhitelist, cacheMaxSize, debugOut) + g := guard.New(llm.NewMulti(names, providers, providerPrompts, patternRedactors, entropyRedactors, providerCache, debugOut), defaultPrompt, config.Dir(), cfg.CommandWhitelist, debugOut) timeout := cfg.TimeoutSeconds if timeout <= 0 { From dc7e5955350434881610c8987a14b08c2069c748 Mon Sep 17 00:00:00 2001 From: claude-developer-1 Date: Sun, 15 Mar 2026 23:09:57 +0300 Subject: [PATCH 10/14] feat: include working directory in LLM evaluation and cache key When send_working_directory is true (default), guard-sh now sends: Working directory: /etc/nginx Command: chmod 777 nginx.conf to the LLM instead of the bare command. The cache key includes the working directory so identical commands in different directories are evaluated independently. Set send_working_directory: false in config to revert to command-only behaviour. Closes #16 Co-Authored-By: Claude Sonnet 4.6 --- config.default.yaml | 6 ++++++ internal/config/config.go | 23 ++++++++++++++++------- internal/guard/guard.go | 24 +++++++++++++++--------- internal/guard/guard_test.go | 20 ++++++++++---------- main.go | 9 ++++++++- prompt.txt | 15 ++++++++++++--- 6 files changed, 67 insertions(+), 30 deletions(-) diff --git a/config.default.yaml b/config.default.yaml index c084b9e..70421fd 100644 --- a/config.default.yaml +++ b/config.default.yaml @@ -14,6 +14,12 @@ cache_enabled: true # Maximum number of cached responses to keep. Oldest entries are evicted when full. cache_max_size: 1000 +# Include the working directory in the command sent to the LLM. +# This allows the LLM to assess risk based on context (e.g. running chmod in /etc vs ~/projects). +# Cache keys include the working directory when this is enabled. +# Set to false to send only the command string. +send_working_directory: true + # Redaction: sensitive values are replaced with [REDACTED] before sending to LLM providers. # Per-provider overrides can be added under providers.NAME.redaction (optional). # Toggle per-provider with: guard-sh redact pattern on/off diff --git a/internal/config/config.go b/internal/config/config.go index 74e1944..41edd53 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -124,13 +124,22 @@ func (p *ProviderConfig) EffectiveEntropyRedaction(global *EntropyRedactionConfi } type Config struct { - ProviderOrder []string `yaml:"provider_order"` - Providers map[string]*ProviderConfig `yaml:"providers"` - TimeoutSeconds int `yaml:"timeout_seconds"` - CacheEnabled *bool `yaml:"cache_enabled"` - CacheMaxSize int `yaml:"cache_max_size"` - CommandWhitelist []string `yaml:"command_whitelist"` - Redaction RedactionConfig `yaml:"redaction"` + ProviderOrder []string `yaml:"provider_order"` + Providers map[string]*ProviderConfig `yaml:"providers"` + TimeoutSeconds int `yaml:"timeout_seconds"` + CacheEnabled *bool `yaml:"cache_enabled"` + CacheMaxSize int `yaml:"cache_max_size"` + SendWorkingDirectory *bool `yaml:"send_working_directory"` + CommandWhitelist []string `yaml:"command_whitelist"` + Redaction RedactionConfig `yaml:"redaction"` +} + +// IsSendWorkingDirectoryEnabled returns true unless explicitly set to false. +func (c *Config) IsSendWorkingDirectoryEnabled() bool { + if c.SendWorkingDirectory == nil { + return true + } + return *c.SendWorkingDirectory } func (c *Config) Get(name string) (*ProviderConfig, error) { diff --git a/internal/guard/guard.go b/internal/guard/guard.go index 9e930da..b65e9a3 100644 --- a/internal/guard/guard.go +++ b/internal/guard/guard.go @@ -54,14 +54,20 @@ func (g *Guard) dbg(format string, args ...any) { } } -// Check queries the LLM. Returns (safe=true, warning="") if OK, -// or (safe=false, warning="...") if the command needs confirmation. -// On any error it fails open. -func (g *Guard) Check(ctx context.Context, cmd string) (safe bool, warning string) { - g.dbg("\n %scommand%s %s%q%s\n\n", dbgDim, dbgReset, dbgCyan, cmd, dbgReset) +// Check queries the LLM. rawCmd is the original shell command (used for whitelist +// and cache key). query is what is sent to the LLM — when working directory is +// enabled it includes a "Working directory: ...\nCommand: ..." prefix; otherwise +// it equals rawCmd. +// Returns (safe=true, warning="") if OK, or (safe=false, warning="...") if the +// command needs confirmation. On any error it fails open. +func (g *Guard) Check(ctx context.Context, rawCmd, query string) (safe bool, warning string) { + g.dbg("\n %scommand%s %s%q%s\n\n", dbgDim, dbgReset, dbgCyan, rawCmd, dbgReset) + if query != rawCmd { + g.dbg(" %squery%s %s%q%s\n\n", dbgDim, dbgReset, dbgCyan, query, dbgReset) + } if len(g.whitelist) > 0 { - bases := extractBaseCommands(cmd) + bases := extractBaseCommands(rawCmd) if len(bases) > 0 && g.allWhitelisted(bases) { g.dbg(" %swhitelist%s %s● hit%s %s(%s)%s\n\n", dbgDim, dbgReset, dbgGreen, dbgReset, dbgDim, strings.Join(bases, ", "), dbgReset) return true, "" @@ -72,7 +78,7 @@ func (g *Guard) Check(ctx context.Context, cmd string) (safe bool, warning strin } if g.cache != nil { - if cached, ok := g.cache.Get(cmd); ok { + if cached, ok := g.cache.Get(query); ok { g.dbg(" %scache%s %s● hit%s %s→ %q%s\n\n", dbgDim, dbgReset, dbgGreen, dbgReset, dbgDim, cached, dbgReset) if cached == "OK" || cached == "" { return true, "" @@ -86,7 +92,7 @@ func (g *Guard) Check(ctx context.Context, cmd string) (safe bool, warning strin g.dbg("\n %sproviders%s\n", dbgBold, dbgReset) - response, err := g.provider.Query(ctx, g.prompt, cmd) + response, err := g.provider.Query(ctx, g.prompt, query) if err != nil { g.dbg("\n %s✗ all providers failed%s\n\n", dbgRed, dbgReset) return false, "Could not reach any provider. Proceed anyway?" @@ -94,7 +100,7 @@ func (g *Guard) Check(ctx context.Context, cmd string) (safe bool, warning strin response = strings.TrimSpace(response) if g.cache != nil { - g.cache.Set(cmd, response) + g.cache.Set(query, response) } if response == "OK" || response == "" { diff --git a/internal/guard/guard_test.go b/internal/guard/guard_test.go index 94ee457..75749bf 100644 --- a/internal/guard/guard_test.go +++ b/internal/guard/guard_test.go @@ -48,7 +48,7 @@ func TestWhitelist(t *testing.T) { for _, tt := range tests { g := New(&mockProvider{}, "", "", tt.wl, 0, nil) - safe, _ := g.Check(context.Background(), tt.cmd) + safe, _ := g.Check(context.Background(), tt.cmd, tt.cmd) if safe != tt.wantSafe { t.Errorf("cmd=%q whitelist=%v: got safe=%v, want %v", tt.cmd, tt.wl, safe, tt.wantSafe) } @@ -87,7 +87,7 @@ func TestExtractBaseCommands(t *testing.T) { func TestCheck_SafeResponse(t *testing.T) { g := New(&mockProvider{response: "OK"}, "", "", nil, 0, nil) - safe, warning := g.Check(context.Background(), "rm -rf /") + safe, warning := g.Check(context.Background(), "rm -rf /", "rm -rf /") if !safe { t.Errorf("expected safe=true, got false (warning=%q)", warning) } @@ -95,7 +95,7 @@ func TestCheck_SafeResponse(t *testing.T) { func TestCheck_UnsafeResponse(t *testing.T) { g := New(&mockProvider{response: "Deletes everything"}, "", "", nil, 0, nil) - safe, warning := g.Check(context.Background(), "rm -rf /") + safe, warning := g.Check(context.Background(), "rm -rf /", "rm -rf /") if safe { t.Error("expected safe=false, got true") } @@ -106,7 +106,7 @@ func TestCheck_UnsafeResponse(t *testing.T) { func TestCheck_ProviderError_FailsOpen(t *testing.T) { g := New(&mockProvider{err: errors.New("network error")}, "", "", nil, 0, nil) - safe, _ := g.Check(context.Background(), "rm -rf /") + safe, _ := g.Check(context.Background(), "rm -rf /", "rm -rf /") if safe { t.Error("expected safe=false when provider errors (fail open still prompts)") } @@ -118,8 +118,8 @@ func TestCheck_CacheHit(t *testing.T) { p := &countingProvider{response: "OK", onCall: func() { called++ }} g := New(p, "", dir, nil, 100, nil) - g.Check(context.Background(), "ls -la") - g.Check(context.Background(), "ls -la") + g.Check(context.Background(), "ls -la", "ls -la") + g.Check(context.Background(), "ls -la", "ls -la") if called != 1 { t.Errorf("expected provider called once (cache hit on second), got %d", called) @@ -131,8 +131,8 @@ func TestCheck_CacheDisabled(t *testing.T) { p := &countingProvider{response: "OK", onCall: func() { called++ }} g := New(p, "", "", nil, 0, nil) // cacheMaxSize=0 disables cache - g.Check(context.Background(), "ls -la") - g.Check(context.Background(), "ls -la") + g.Check(context.Background(), "ls -la", "ls -la") + g.Check(context.Background(), "ls -la", "ls -la") if called != 2 { t.Errorf("expected provider called twice (no cache), got %d", called) @@ -147,7 +147,7 @@ func TestCheck_CustomPrompt(t *testing.T) { var receivedPrompt string p := &capturingProvider{onQuery: func(prompt, _ string) { receivedPrompt = prompt }} g := New(p, "default prompt", dir, nil, 0, nil) - g.Check(context.Background(), "ls") + g.Check(context.Background(), "ls", "ls") if receivedPrompt != "custom prompt" { t.Errorf("expected custom prompt, got %q", receivedPrompt) } @@ -157,7 +157,7 @@ func TestCheck_DefaultPrompt(t *testing.T) { var receivedPrompt string p := &capturingProvider{onQuery: func(prompt, _ string) { receivedPrompt = prompt }} g := New(p, "default prompt", t.TempDir(), nil, 0, nil) - g.Check(context.Background(), "ls") + g.Check(context.Background(), "ls", "ls") if receivedPrompt != "default prompt" { t.Errorf("expected default prompt, got %q", receivedPrompt) } diff --git a/main.go b/main.go index 5dd7de8..eab47df 100644 --- a/main.go +++ b/main.go @@ -435,7 +435,14 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() - safe, warning := g.Check(ctx, cmd) + query := cmd + if cfg.IsSendWorkingDirectoryEnabled() { + if wd, err := os.Getwd(); err == nil { + query = "Working directory: " + wd + "\nCommand: " + cmd + } + } + + safe, warning := g.Check(ctx, cmd, query) if safe { os.Exit(0) } diff --git a/prompt.txt b/prompt.txt index 8c28333..0ccaaf7 100644 --- a/prompt.txt +++ b/prompt.txt @@ -1,14 +1,23 @@ You are a shell command risk auditor. -The user will send you various shell commands. -Respond to each command with exactly one of these two formats: +The user will send you a shell command, optionally accompanied by the directory it will be run in: + +Working directory: +Command: + +Or just: + +Command: + +Respond with exactly one of these two formats: 1) OK 2) . Are you sure? Rules: +- If a working directory is provided, take it into account when assessing risk. The same command can be significantly more dangerous in system directories (e.g. /etc, /usr, /) than in a home or project directory. - Only warn for commands that are clearly valid, runnable, and carry significant risk. -- Don't be overly cautious. For example, "rm file.txt" should normally be OK. +- Don't be overly cautious. For example, "rm file.txt" in a home directory should normally be OK. - Keep the description to 1-2 sentences maximum. - Do not explain individual flags or sub-parts of the command. - If the command has obvious syntax errors or clearly won't run, do not warn; just say OK. From f30cc56db80deb77505a6361c21a3b17fcd9ea74 Mon Sep 17 00:00:00 2001 From: claude-developer-1 Date: Sun, 15 Mar 2026 23:12:34 +0300 Subject: [PATCH 11/14] fix: handle multiline commands and history expansion in zsh integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two edge cases were untested and broken: 1. Multiline commands — the widget was firing on the first line before the full command was assembled. Now checks $CONTEXT: if it is 'cont' (zsh waiting for more input due to open quotes, backslash, braces, if/for/while bodies etc.), the Enter is passed through unchanged. guard-sh intercepts only when $CONTEXT is 'start' (complete input). 2. History expansion — !! / !$ / !cmd tokens were passed raw to guard-sh, so it evaluated a different command than what would actually run. Now calls `zle .expand-history` before reading $BUFFER, so the expanded form is checked. Closes #15 Co-Authored-By: Claude Sonnet 4.6 --- shell/guard.zsh | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/shell/guard.zsh b/shell/guard.zsh index 0d3c5ac..67d1a1a 100644 --- a/shell/guard.zsh +++ b/shell/guard.zsh @@ -3,6 +3,19 @@ _GUARD_SCRIPT_PATH="${${(%):-%x}:A}" _guard_zsh_accept_line() { + # $CONTEXT is 'cont' when zsh is waiting for more input (open quotes, + # backslash continuation, open braces, if/for/while bodies, etc.). + # Pass through so zsh can collect the rest of the command; we will + # intercept the final Enter when $CONTEXT returns to 'start'. + if [[ "$CONTEXT" == "cont" ]]; then + zle .accept-line + return + fi + + # Expand history references (!! !$ !cmd etc.) before checking, so + # guard-sh evaluates the command that will actually run, not the token. + zle .expand-history + local cmd="$BUFFER" if [[ -z "${cmd//[[:space:]]/}" ]]; then From dc4a60bfc61686467acd07a41fd50022e7421425 Mon Sep 17 00:00:00 2001 From: claude-developer-1 Date: Sun, 15 Mar 2026 23:16:37 +0300 Subject: [PATCH 12/14] feat: add fish shell integration (#17) - Add shell/guard.fish with full fish integration (bind \n/\r, commandline widget, guard-sh on/off/--global) - Embed guard.fish in binary via //go:embed - Update setup.go to write guard.fish and handle fish RC file ($XDG_CONFIG_HOME/fish/config.fish) - Update install.sh with fish case - Add TestSetup_FreshInstall_Fish; update UnsupportedShell test to use tcsh Co-Authored-By: Claude Sonnet 4.6 --- install.sh | 6 ++- main.go | 3 ++ setup.go | 17 +++++++++ setup_test.go | 30 ++++++++++++++- shell/guard.fish | 98 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 shell/guard.fish diff --git a/install.sh b/install.sh index dc9b89d..7e2c2de 100755 --- a/install.sh +++ b/install.sh @@ -69,8 +69,12 @@ if [[ $WITH_SHELL -eq 1 ]]; then RC_FILE="$HOME/.bashrc" SHELL_SCRIPT="$INSTALL_DIR/shell/guard.bash" ;; + fish) + RC_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/fish/config.fish" + SHELL_SCRIPT="$INSTALL_DIR/shell/guard.fish" + ;; *) - echo "Unsupported shell: $SHELL_NAME (supported: zsh, bash)" + echo "Unsupported shell: $SHELL_NAME (supported: bash, zsh, fish)" echo "Manually source the appropriate file from $INSTALL_DIR/shell/" exit 0 ;; diff --git a/main.go b/main.go index 5dd7de8..3bc2d56 100644 --- a/main.go +++ b/main.go @@ -34,6 +34,9 @@ var shellBash string //go:embed shell/guard.zsh var shellZsh string +//go:embed shell/guard.fish +var shellFish string + var version = "dev" const ( diff --git a/setup.go b/setup.go index b8bb76a..2ef7228 100644 --- a/setup.go +++ b/setup.go @@ -54,6 +54,12 @@ func runSetup() { } fmt.Printf(" shell %s%s%s\n", dim, zshScript, reset) + fishScript := filepath.Join(dir, "guard.fish") + if err := os.WriteFile(fishScript, []byte(shellFish), 0644); err != nil { + fatalf("could not write guard.fish: %v", err) + } + fmt.Printf(" shell %s%s%s\n", dim, fishScript, reset) + // --- Shell integration --- fmt.Println() shellName := filepath.Base(os.Getenv("SHELL")) @@ -65,6 +71,13 @@ func runSetup() { case "bash": rcFile = filepath.Join(os.Getenv("HOME"), ".bashrc") scriptPath = bashScript + case "fish": + xdgConfig := os.Getenv("XDG_CONFIG_HOME") + if xdgConfig == "" { + xdgConfig = filepath.Join(os.Getenv("HOME"), ".config") + } + rcFile = filepath.Join(xdgConfig, "fish", "config.fish") + scriptPath = fishScript default: fmt.Printf(" %sUnsupported shell: %s%s\n", dim, shellName, reset) fmt.Printf(" %sManually source the appropriate file from %s%s\n", dim, dir, reset) @@ -75,6 +88,10 @@ func runSetup() { sourceLine := `source "` + scriptPath + `"` onLine := "guard-sh on" + if err := os.MkdirAll(filepath.Dir(rcFile), 0755); err != nil { + fatalf("could not create rc dir: %v", err) + } + rcData, _ := os.ReadFile(rcFile) rc := string(rcData) diff --git a/setup_test.go b/setup_test.go index 25c7c39..7db13a8 100644 --- a/setup_test.go +++ b/setup_test.go @@ -149,8 +149,36 @@ func TestSetup_ShellIntegration_SourcePresent_OnMissing(t *testing.T) { } } -func TestSetup_UnsupportedShell_NoRcWritten(t *testing.T) { +func TestSetup_FreshInstall_Fish(t *testing.T) { xdg, home := setupEnv(t, "/bin/fish") + dir := xdg + "/guard-sh" + + runSetup() + + if _, err := os.Stat(filepath.Join(dir, "guard.fish")); err != nil { + t.Errorf("guard.fish not created: %v", err) + } + + rc := readFile(t, filepath.Join(xdg, "fish", "config.fish")) + fishScript := filepath.Join(dir, "guard.fish") + if !strings.Contains(rc, `source "`+fishScript+`"`) { + t.Error("config.fish missing correct source line") + } + if !strings.Contains(rc, "guard-sh on") { + t.Error("config.fish missing guard-sh on") + } + + // Other rc files should not be touched + for _, name := range []string{".bashrc", ".zshrc"} { + path := filepath.Join(home, name) + if _, err := os.Stat(path); err == nil { + t.Errorf("%s should not have been created for fish shell", name) + } + } +} + +func TestSetup_UnsupportedShell_NoRcWritten(t *testing.T) { + xdg, home := setupEnv(t, "/bin/tcsh") runSetup() diff --git a/shell/guard.fish b/shell/guard.fish new file mode 100644 index 0000000..6ff909b --- /dev/null +++ b/shell/guard.fish @@ -0,0 +1,98 @@ +# guard-sh fish integration + +set -g _GUARD_SCRIPT_PATH (status filename) + +function _guard_execute + set -l cmd (commandline) + + # Empty buffer — pass through + if test -z (string trim -- $cmd) + commandline -f execute + return + end + + if not command -v guard-sh &>/dev/null + commandline -f execute + return + end + + set -l warning (command guard-sh check $cmd 2>/dev/null) + set -l exit_code $status + + if test $exit_code -eq 0 + commandline -f execute + return + end + + printf 'guard-sh: %s [Y/n] ' $warning + set -l confirm (read --nchars 1 --silent) + printf '\n' + + if string match -qr '^[Yy]$' -- $confirm + commandline -f execute + else + commandline -f repaint + end +end + +function _guard_enable + bind \n _guard_execute + bind \r _guard_execute + echo "guard-sh: enabled" +end + +function _guard_disable + bind --erase \n + bind --erase \r + echo "guard-sh: disabled" +end + +function _guard_global_on + set -l rc "$HOME/.config/fish/config.fish" + set -l source_line "source $_GUARD_SCRIPT_PATH" + set -l on_line "guard-sh on" + + if not grep -qF $source_line $rc 2>/dev/null + printf '\n# guard-sh\n%s\n%s\n' $source_line $on_line >> $rc + echo "guard-sh: enabled globally in $rc" + return + end + + if grep -qF $on_line $rc 2>/dev/null + echo "guard-sh: already enabled globally in $rc" + else + echo $on_line >> $rc + echo "guard-sh: enabled globally in $rc" + end +end + +function _guard_global_off + set -l rc "$HOME/.config/fish/config.fish" + set -l on_line "guard-sh on" + + if grep -qF $on_line $rc 2>/dev/null + grep -vF $on_line $rc > $rc.guardtmp && mv $rc.guardtmp $rc + echo "guard-sh: disabled globally in $rc" + else + echo "guard-sh: not enabled globally in $rc" + end +end + +function guard-sh + switch $argv[1] + case on + if test "$argv[2]" = "--global" + _guard_global_on + else + _guard_enable + end + case off + if test "$argv[2]" = "--global" + _guard_global_off + else + _guard_disable + end + case '*' + command guard-sh $argv + end +end From 8cb80f086dbc95af62b547e3b221c3f0491c9a3b Mon Sep 17 00:00:00 2001 From: claude-developer-1 Date: Sun, 15 Mar 2026 23:26:03 +0300 Subject: [PATCH 13/14] docs: update status, README, CLAUDE.md and add WD tests for #16 - status command now shows work dir on/off - README.org: add send_working_directory to config example and add dedicated Working directory context section with usage and example - CLAUDE.md: update request flow, shell integration, config sections - guard_test.go: add TestCheck_QuerySentToLLM and TestCheck_WhitelistUsesRawCmd Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 25 ++++++++++++++++++------- README.org | 28 ++++++++++++++++++++++++++++ internal/guard/guard_test.go | 27 +++++++++++++++++++++++++++ main.go | 3 +++ 4 files changed, 76 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 39ecc85..a72be64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ git push https://x-access-token:${TOKEN}@github.com/${GITHUB_REPO}.git HEAD ## What This Project Does -**guard-sh** is a shell safety layer. It hooks into bash/zsh to intercept commands before execution, queries an LLM to assess risk, and prompts the user for confirmation if the command is deemed risky. Safe commands are whitelisted or cached to skip the LLM call. +**guard-sh** is a shell safety layer. It hooks into bash/zsh/fish to intercept commands before execution, queries an LLM to assess risk, and prompts the user for confirmation if the command is deemed risky. Safe commands are whitelisted or cached to skip the LLM call. ## Commands @@ -76,7 +76,7 @@ There is no Makefile. ``` guard-sh on / off enable/disable for current session (handled by shell hook) guard-sh on --global / off --global auto-enable/disable in every new terminal -guard-sh status show session/global state, config, timeout, cache, providers, whitelist +guard-sh status show session/global state, config, timeout, work dir, cache, providers, whitelist guard-sh check "" core check — exit 0 if safe, exit 1 with warning if risky guard-sh check "" --debug trace whitelist hit, cache hit, provider attempts, LLM response guard-sh healthcheck validate API keys, models, latency, shell integration @@ -102,9 +102,10 @@ guard-sh version print version ``` shell command typed - → shell hook (shell/guard.bash or shell/guard.zsh) + → shell hook (shell/guard.bash or shell/guard.zsh or shell/guard.fish) → guard-sh check - → internal/guard: whitelist check → cache check → LLM query + → main.go: prepend "Working directory: \nCommand: " if send_working_directory is enabled + → internal/guard: whitelist check (rawCmd) → cache check → LLM query (with WD prefix) → internal/llm/multi.go: try each provider in order, first success wins → response printed; shell prompts [Y/n] if not "OK" ``` @@ -121,8 +122,9 @@ shell command typed ### Shell integration - **Bash**: `DEBUG` trap with `extdebug` — intercepts before execution -- **Zsh**: custom widget bound to `^M`/`^J` (Enter key) -- Both call `guard-sh check` and check exit code: 0 = allow, 1 = block +- **Zsh**: custom widget bound to `^M`/`^J` (Enter key); handles multiline (`$CONTEXT == "cont"`) and history expansion +- **Fish**: `bind \n`/`bind \r` mapped to `_guard_execute`; uses `commandline` widget +- All call `guard-sh check` and check exit code: 0 = allow, 1 = block ### System prompt @@ -132,4 +134,13 @@ Provider-specific prompts can be placed at `~/.config/guard-sh/prompt_PROVIDERNA ### Runtime config location -`~/.config/guard-sh/config.yaml` — providers, API keys, whitelist, cache settings, timeout. See `config.default.yaml` (embedded in binary) for all options. +`~/.config/guard-sh/config.yaml` — providers, API keys, whitelist, cache settings, timeout, `send_working_directory`. See `config.default.yaml` (embedded in binary) for all options. + +### Working directory context + +When `send_working_directory: true` (default), `main.go` reads `os.Getwd()` and builds: +``` +Working directory: /path/to/cwd +Command: +``` +This is passed as the `query` to `g.Check(ctx, rawCmd, query)`. The `rawCmd` (bare command) is used for whitelist matching only. diff --git a/README.org b/README.org index 1c8b1ed..5f77792 100644 --- a/README.org +++ b/README.org @@ -132,6 +132,12 @@ command_whitelist: # How long to wait for an LLM response, in seconds. Defaults to 10 if not set. timeout_seconds: 10 +# Include the working directory in the command sent to the LLM. +# Allows the LLM to assess risk based on context (e.g. chmod in /etc vs ~/projects). +# Cache keys include the working directory when this is enabled. +# Set to false to send only the command string. +send_working_directory: true + # Cache LLM responses to avoid repeated API calls for the same command. cache_enabled: true cache_max_size: 1000 @@ -141,6 +147,27 @@ cache_max_size: 1000 The LLM prompt is stored at =~/.config/guard-sh/prompt.txt=. Edit it to adjust what guard-sh considers risky, change the output format, or add your own rules. Changes take effect immediately — no rebuild needed. +** Working directory context + +By default, guard-sh includes the current working directory when querying the LLM. This gives the LLM context to make better decisions — for example, =chmod 777 config.yaml= in =/etc= is riskier than in =~/projects/=. + +The LLM receives a structured two-line query: + +#+begin_example +Working directory: /etc +Command: chmod 777 config.yaml +#+end_example + +The working directory is also included in cache keys, so the same command in different directories is evaluated independently. + +To disable: + +#+begin_src yaml +send_working_directory: false +#+end_src + +Or via config file: set =send_working_directory: false= under the root level. + ** Redaction :PROPERTIES: :CUSTOM_ID: redaction @@ -317,6 +344,7 @@ Shows session and global state, prompt path, timeout, cache stats, active provid config ~/.config/guard-sh/config.yaml prompt ~/.config/guard-sh/prompt.txt timeout 10s + work dir ● on cache ● on 1000 max entries 42 entries, 8.3 KB diff --git a/internal/guard/guard_test.go b/internal/guard/guard_test.go index 75749bf..c3ebadb 100644 --- a/internal/guard/guard_test.go +++ b/internal/guard/guard_test.go @@ -163,6 +163,33 @@ func TestCheck_DefaultPrompt(t *testing.T) { } } +func TestCheck_QuerySentToLLM(t *testing.T) { + var receivedCmd string + p := &capturingProvider{onQuery: func(_, cmd string) { receivedCmd = cmd }} + g := New(p, "", t.TempDir(), nil, 0, nil) + + rawCmd := "rm -rf /" + query := "Working directory: /etc\nCommand: rm -rf /" + g.Check(context.Background(), rawCmd, query) + + if receivedCmd != query { + t.Errorf("expected LLM to receive query %q, got %q", query, receivedCmd) + } +} + +func TestCheck_WhitelistUsesRawCmd(t *testing.T) { + called := false + p := &countingProvider{response: "OK", onCall: func() { called = true }} + g := New(p, "", t.TempDir(), []string{"ls"}, 0, nil) + + // rawCmd is whitelisted but query includes WD prefix — should still be whitelisted + g.Check(context.Background(), "ls", "Working directory: /home/user\nCommand: ls") + + if called { + t.Error("expected provider not called for whitelisted rawCmd") + } +} + type countingProvider struct { response string onCall func() diff --git a/main.go b/main.go index eab47df..373bfca 100644 --- a/main.go +++ b/main.go @@ -89,6 +89,9 @@ func runStatus(args []string) { } fmt.Printf(" %s %s%ds%s\n", label("timeout "), dim, timeout, reset) + wdEnabled := cfg.IsSendWorkingDirectoryEnabled() + fmt.Printf(" %s %s\n", label("work dir"), statusBadge(map[bool]string{true: "on", false: "off"}[wdEnabled])) + cacheEnabled := cfg.CacheEnabled == nil || *cfg.CacheEnabled cacheMaxSize := cfg.CacheMaxSize if cacheMaxSize <= 0 { From 91baa3c53c6c20ecbd8fad67d07cc6d2fb0b5bc3 Mon Sep 17 00:00:00 2001 From: claude-developer-1 Date: Sun, 15 Mar 2026 23:28:12 +0300 Subject: [PATCH 14/14] docs: update healthcheck, README, CLAUDE.md for fish integration (#17) - healthcheck now checks fish RC file (~/.config/fish/config.fish) - README.org: add fish to requirements, install descriptions, shell support table, healthcheck example, and setup description - CLAUDE.md: update project description, request flow, and shell integration section to include fish Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 9 +++++---- README.org | 14 ++++++++------ healthcheck.go | 5 +++++ 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 39ecc85..c10d45d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ git push https://x-access-token:${TOKEN}@github.com/${GITHUB_REPO}.git HEAD ## What This Project Does -**guard-sh** is a shell safety layer. It hooks into bash/zsh to intercept commands before execution, queries an LLM to assess risk, and prompts the user for confirmation if the command is deemed risky. Safe commands are whitelisted or cached to skip the LLM call. +**guard-sh** is a shell safety layer. It hooks into bash/zsh/fish to intercept commands before execution, queries an LLM to assess risk, and prompts the user for confirmation if the command is deemed risky. Safe commands are whitelisted or cached to skip the LLM call. ## Commands @@ -102,7 +102,7 @@ guard-sh version print version ``` shell command typed - → shell hook (shell/guard.bash or shell/guard.zsh) + → shell hook (shell/guard.bash or shell/guard.zsh or shell/guard.fish) → guard-sh check → internal/guard: whitelist check → cache check → LLM query → internal/llm/multi.go: try each provider in order, first success wins @@ -121,8 +121,9 @@ shell command typed ### Shell integration - **Bash**: `DEBUG` trap with `extdebug` — intercepts before execution -- **Zsh**: custom widget bound to `^M`/`^J` (Enter key) -- Both call `guard-sh check` and check exit code: 0 = allow, 1 = block +- **Zsh**: custom widget bound to `^M`/`^J` (Enter key); handles multiline (`$CONTEXT == "cont"`) and history expansion +- **Fish**: `bind \n`/`bind \r` mapped to `_guard_execute`; uses `commandline` widget +- All call `guard-sh check` and check exit code: 0 = allow, 1 = block ### System prompt diff --git a/README.org b/README.org index 1c8b1ed..0f1b631 100644 --- a/README.org +++ b/README.org @@ -38,7 +38,7 @@ Commands in your *whitelist* skip the LLM entirely. :END: - Go 1.22+ -- bash or zsh +- bash, zsh, or fish - API key(s) for one or more of the cloud LLMs listed above, *or* a running [[https://ollama.com][Ollama]] instance for fully local operation. * Installation @@ -58,7 +58,7 @@ This will: - Build the ~guard-sh~ binary and install it to =~/.local/bin/= - Copy the default config to =~/.config/guard-sh/config.yaml= - Copy the default prompt to =~/.config/guard-sh/prompt.txt= -- Add shell integration to your =.bashrc= or =.zshrc= +- Add shell integration to your =.bashrc=, =.zshrc=, or =~/.config/fish/config.fish= ** From binary @@ -69,7 +69,7 @@ chmod +x ~/.local/bin/guard-sh guard-sh setup #+end_src -This will create the config dir, write the shell integration scripts, and add them to your =.bashrc= / =.zshrc=. +This will create the config dir, write the shell integration scripts, and add them to your =.bashrc=, =.zshrc=, or =~/.config/fish/config.fish= depending on your current shell. ** Install without shell integration @@ -297,8 +297,9 @@ Checks all configured providers without spending any tokens. Reports: claude claude-haiku-4-5-20251001 ● ok (510ms) shell - bash ~/.bashrc ● present - zsh ~/.zshrc ○ not found + bash ~/.bashrc ● present + zsh ~/.zshrc ○ not found + fish ~/.config/fish/config.fish ○ not found #+end_example ** Status @@ -390,7 +391,7 @@ If a provider fails, guard-sh moves on to the next and shows why: guard-sh setup #+end_src -Creates =~/.config/guard-sh/= with the default config and prompt, writes shell integration scripts, and adds them to your =.bashrc= / =.zshrc=. Existing files are not overwritten. This is the only step needed after downloading a pre-built binary. +Creates =~/.config/guard-sh/= with the default config and prompt, writes shell integration scripts (bash, zsh, fish), and adds them to your rc file based on your current shell. Existing files are not overwritten. This is the only step needed after downloading a pre-built binary. ** Version @@ -474,6 +475,7 @@ guard-sh cache clear # delete all cached responses |-------+------------------------------------| | bash | ~shopt -s extdebug~ + ~DEBUG~ trap | | zsh | ZLE widget bound to Enter | +| fish | ~bind \n~/~\r~ mapped to custom function | * License :PROPERTIES: diff --git a/healthcheck.go b/healthcheck.go index 4413f1b..d30de21 100644 --- a/healthcheck.go +++ b/healthcheck.go @@ -110,9 +110,14 @@ func runHealthcheck() { // --- Shell integration --- fmt.Printf("\n %sshell%s\n", bold, reset) home, _ := os.UserHomeDir() + xdgConfig := os.Getenv("XDG_CONFIG_HOME") + if xdgConfig == "" { + xdgConfig = filepath.Join(home, ".config") + } shellChecks := []struct{ shell, rc, marker string }{ {"bash", filepath.Join(home, ".bashrc"), "guard.bash"}, {"zsh", filepath.Join(home, ".zshrc"), "guard.zsh"}, + {"fish", filepath.Join(xdgConfig, "fish", "config.fish"), "guard.fish"}, } for _, s := range shellChecks { shellCol := fmt.Sprintf("%-6s", s.shell)