Files
gnoma/internal/permission/permission_test.go
vikingowl cb2d63d06f feat: Ollama/gemma4 compat — /init flow, stream filter, safety fixes
provider/openai:
- Fix doubled tool call args (argsComplete flag): Ollama sends complete
  args in the first streaming chunk then repeats them as delta, causing
  doubled JSON and 400 errors in elfs
- Handle fs: prefix (gemma4 uses fs:grep instead of fs.grep)
- Add Reasoning field support for Ollama thinking output

cmd/gnoma:
- Early TTY detection so logger is created with correct destination
  before any component gets a reference to it (fixes slog WARN bleed
  into TUI textarea)

permission:
- Exempt spawn_elfs and agent tools from safety scanner: elf prompt
  text may legitimately mention .env/.ssh/credentials patterns and
  should not be blocked

tui/app:
- /init retry chain: no-tool-calls → spawn_elfs nudge → write nudge
  (ask for plain text output) → TUI fallback write from streamBuf
- looksLikeAgentsMD + extractMarkdownDoc: validate and clean fallback
  content before writing (reject refusals, strip narrative preambles)
- Collapse thinking output to 3 lines; ctrl+o to expand (live stream
  and committed messages)
- Stream-level filter for model pseudo-tool-call blocks: suppresses
  <<tool_code>>...</tool_code>> and <<function_call>>...<tool_call|>
  from entering streamBuf across chunk boundaries
- sanitizeAssistantText regex covers both block formats
- Reset streamFilterClose at every turn start
2026-04-05 19:24:51 +02:00

328 lines
10 KiB
Go

package permission
import (
"context"
"encoding/json"
"errors"
"testing"
)
func TestMode_Valid(t *testing.T) {
valid := []Mode{ModeDefault, ModeAcceptEdits, ModeBypass, ModeDeny, ModePlan, ModeAuto}
for _, m := range valid {
if !m.Valid() {
t.Errorf("mode %q should be valid", m)
}
}
if Mode("bogus").Valid() {
t.Error("bogus mode should be invalid")
}
}
func TestChecker_BypassMode(t *testing.T) {
c := NewChecker(ModeBypass, nil, nil)
err := c.Check(context.Background(), ToolInfo{Name: "bash", IsDestructive: true}, json.RawMessage(`{"command":"rm -rf /"}`))
if err != nil {
t.Errorf("bypass mode should allow everything, got: %v", err)
}
}
func TestChecker_BypassDenyRuleImmune(t *testing.T) {
rules := []Rule{{Tool: "bash", Pattern: "rm -rf", Action: ActionDeny}}
c := NewChecker(ModeBypass, rules, nil)
err := c.Check(context.Background(), ToolInfo{Name: "bash"}, json.RawMessage(`{"command":"rm -rf /"}`))
if err == nil {
t.Error("deny rules should override bypass mode")
}
}
func TestChecker_DenyMode(t *testing.T) {
c := NewChecker(ModeDeny, nil, nil)
err := c.Check(context.Background(), ToolInfo{Name: "bash"}, json.RawMessage(`{}`))
if !errors.Is(err, ErrDenied) {
t.Error("deny mode should deny without allow rules")
}
}
func TestChecker_DenyModeWithAllowRule(t *testing.T) {
rules := []Rule{{Tool: "fs.*", Action: ActionAllow}}
c := NewChecker(ModeDeny, rules, nil)
// Allowed by rule
err := c.Check(context.Background(), ToolInfo{Name: "fs.read", IsReadOnly: true}, json.RawMessage(`{}`))
if err != nil {
t.Errorf("should allow fs.read via rule: %v", err)
}
// Not allowed — no matching rule
err = c.Check(context.Background(), ToolInfo{Name: "bash"}, json.RawMessage(`{}`))
if !errors.Is(err, ErrDenied) {
t.Error("bash should be denied without allow rule")
}
}
func TestChecker_PlanMode(t *testing.T) {
c := NewChecker(ModePlan, nil, nil)
// Read-only allowed
err := c.Check(context.Background(), ToolInfo{Name: "fs.read", IsReadOnly: true}, json.RawMessage(`{}`))
if err != nil {
t.Errorf("plan mode should allow read-only: %v", err)
}
// Write denied
err = c.Check(context.Background(), ToolInfo{Name: "fs.write"}, json.RawMessage(`{}`))
if !errors.Is(err, ErrDenied) {
t.Error("plan mode should deny writes")
}
// Bash denied
err = c.Check(context.Background(), ToolInfo{Name: "bash"}, json.RawMessage(`{}`))
if !errors.Is(err, ErrDenied) {
t.Error("plan mode should deny bash")
}
}
func TestChecker_AcceptEditsMode(t *testing.T) {
c := NewChecker(ModeAcceptEdits, nil, func(_ context.Context, _ string, _ json.RawMessage) (bool, error) {
return false, nil // deny prompt
})
// Read-only allowed
err := c.Check(context.Background(), ToolInfo{Name: "fs.read", IsReadOnly: true}, json.RawMessage(`{}`))
if err != nil {
t.Errorf("should allow read-only: %v", err)
}
// File edits allowed
err = c.Check(context.Background(), ToolInfo{Name: "fs.write"}, json.RawMessage(`{}`))
if err != nil {
t.Errorf("should allow fs.write in acceptEdits: %v", err)
}
// Bash requires prompt — denied since our prompt returns false
err = c.Check(context.Background(), ToolInfo{Name: "bash"}, json.RawMessage(`{}`))
if !errors.Is(err, ErrDenied) {
t.Error("bash should go through prompt in acceptEdits mode")
}
}
func TestChecker_ElfNilPrompt_FsWriteAllowed(t *testing.T) {
// Elfs use WithDenyPrompt (nil promptFn). Non-destructive fs ops must still
// be allowed so elfs can write files in auto/acceptEdits modes.
c := NewChecker(ModeAuto, nil, nil) // nil promptFn simulates elf checker
// Non-destructive fs.write: allowed
err := c.Check(context.Background(), ToolInfo{Name: "fs.write"}, json.RawMessage(`{"path":"AGENTS.md"}`))
if err != nil {
t.Errorf("elf should be able to write files: %v", err)
}
// Destructive fs op: denied
err = c.Check(context.Background(), ToolInfo{Name: "fs.delete", IsDestructive: true}, json.RawMessage(`{"path":"foo"}`))
if !errors.Is(err, ErrDenied) {
t.Error("destructive fs op should be denied without prompt handler")
}
// bash: denied
err = c.Check(context.Background(), ToolInfo{Name: "bash"}, json.RawMessage(`{"command":"echo hi"}`))
if !errors.Is(err, ErrDenied) {
t.Error("bash should be denied without prompt handler")
}
}
func TestChecker_AutoMode(t *testing.T) {
c := NewChecker(ModeAuto, nil, func(_ context.Context, _ string, _ json.RawMessage) (bool, error) {
return true, nil // approve prompt
})
// Read-only auto-allowed
err := c.Check(context.Background(), ToolInfo{Name: "fs.grep", IsReadOnly: true}, json.RawMessage(`{}`))
if err != nil {
t.Errorf("auto mode should auto-allow read-only: %v", err)
}
// Write goes to prompt — approved
err = c.Check(context.Background(), ToolInfo{Name: "bash"}, json.RawMessage(`{}`))
if err != nil {
t.Errorf("auto mode should prompt for write, prompt approved: %v", err)
}
}
func TestChecker_DefaultMode_Prompts(t *testing.T) {
prompted := false
c := NewChecker(ModeDefault, nil, func(_ context.Context, name string, _ json.RawMessage) (bool, error) {
prompted = true
return true, nil
})
err := c.Check(context.Background(), ToolInfo{Name: "fs.read", IsReadOnly: true}, json.RawMessage(`{}`))
if err != nil {
t.Errorf("should allow after prompt: %v", err)
}
if !prompted {
t.Error("default mode should always prompt")
}
}
func TestChecker_SafetyCheck(t *testing.T) {
// Safety checks are bypass-immune
c := NewChecker(ModeBypass, nil, nil)
blocked := []struct {
name string
toolName string
args string
}{
{"env file", "fs.read", `{"path":".env"}`},
{"git dir", "fs.read", `{"path":".git/config"}`},
{"ssh key", "fs.read", `{"path":"id_rsa"}`},
{"aws creds", "fs.read", `{"path":".aws/credentials"}`},
{"bash env", "bash", `{"command":"cat .env"}`},
}
for _, tt := range blocked {
t.Run(tt.name, func(t *testing.T) {
err := c.Check(context.Background(), ToolInfo{Name: tt.toolName}, json.RawMessage(tt.args))
if !errors.Is(err, ErrDenied) {
t.Errorf("safety check should block: %v", err)
}
})
}
// Writing a file whose *content* mentions .env (e.g. AGENTS.md docs) must not be blocked.
t.Run("env mention in content not blocked", func(t *testing.T) {
args := json.RawMessage(`{"path":"AGENTS.md","content":"Copy .env.example to .env and fill in the values."}`)
err := c.Check(context.Background(), ToolInfo{Name: "fs.write"}, args)
if err != nil {
t.Errorf("fs.write to safe path should not be blocked by content mention: %v", err)
}
})
}
func TestChecker_SafetyCheck_OrchestrationToolsExempt(t *testing.T) {
// spawn_elfs and agent carry elf PROMPT TEXT as args — arbitrary instruction
// text that may legitimately mention .env, credentials, etc.
// Security is enforced inside each spawned elf, not at the orchestration layer.
c := NewChecker(ModeBypass, nil, nil)
cases := []struct {
name string
toolName string
args string
}{
{"spawn_elfs with .env mention", "spawn_elfs", `{"tasks":[{"task":"check .env config","elf":"worker"}]}`},
{"spawn_elfs with credentials mention", "spawn_elfs", `{"tasks":[{"task":"read credentials file","elf":"worker"}]}`},
{"agent with .env mention", "agent", `{"prompt":"verify .env is configured correctly"}`},
{"agent with ssh mention", "agent", `{"prompt":"check .ssh/config for proxy settings"}`},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
err := c.Check(context.Background(), ToolInfo{Name: tt.toolName}, json.RawMessage(tt.args))
if err != nil {
t.Errorf("orchestration tool %q should not be blocked by safety check: %v", tt.toolName, err)
}
})
}
// Non-orchestration tools with the same patterns are still blocked.
t.Run("bash with .env still blocked", func(t *testing.T) {
err := c.Check(context.Background(), ToolInfo{Name: "bash"}, json.RawMessage(`{"command":"cat .env"}`))
if !errors.Is(err, ErrDenied) {
t.Errorf("bash accessing .env should still be blocked: %v", err)
}
})
}
func TestChecker_CompoundCommand(t *testing.T) {
rules := []Rule{{Tool: "bash", Pattern: "rm", Action: ActionDeny}}
c := NewChecker(ModeBypass, rules, nil)
// Single safe command — allowed
err := c.Check(context.Background(), ToolInfo{Name: "bash"}, json.RawMessage(`{"command":"echo hello"}`))
if err != nil {
t.Errorf("single safe command should be allowed: %v", err)
}
// Compound with denied subcommand
err = c.Check(context.Background(), ToolInfo{Name: "bash"}, json.RawMessage(`{"command":"echo hello && rm -rf /"}`))
if !errors.Is(err, ErrDenied) {
t.Error("compound with denied subcommand should be denied")
}
}
func TestSplitCompoundCommand(t *testing.T) {
tests := []struct {
cmd string
want int
}{
{"echo hello", 1},
{"echo hello && echo world", 2},
{"echo a; echo b; echo c", 3},
{"echo hello | grep h", 1}, // pipe is one statement
{"cd src && make && make test", 3},
}
for _, tt := range tests {
parts := SplitCompoundCommand(tt.cmd)
if len(parts) != tt.want {
t.Errorf("SplitCompoundCommand(%q) = %d parts %v, want %d", tt.cmd, len(parts), parts, tt.want)
}
}
}
func TestRule_Matches(t *testing.T) {
tests := []struct {
rule Rule
tool string
want bool
}{
{Rule{Tool: "bash"}, "bash", true},
{Rule{Tool: "bash"}, "fs.read", false},
{Rule{Tool: "fs.*"}, "fs.read", true},
{Rule{Tool: "fs.*"}, "fs.write", true},
{Rule{Tool: "fs.*"}, "bash", false},
{Rule{Tool: "*"}, "anything", true},
}
for _, tt := range tests {
if got := tt.rule.Matches(tt.tool); got != tt.want {
t.Errorf("Rule{%q}.Matches(%q) = %v, want %v", tt.rule.Tool, tt.tool, got, tt.want)
}
}
}
func TestChecker_SetMode(t *testing.T) {
c := NewChecker(ModeDefault, nil, nil)
if c.Mode() != ModeDefault {
t.Errorf("initial mode should be default")
}
c.SetMode(ModePlan)
if c.Mode() != ModePlan {
t.Errorf("mode should be plan after SetMode")
}
}
func TestChecker_ConcurrentSetModeAndCheck(t *testing.T) {
// Verifies no data race between SetMode (TUI goroutine) and Check (engine goroutine).
// Run with: go test -race ./internal/permission/...
c := NewChecker(ModeDefault, nil, nil)
ctx := context.Background()
info := ToolInfo{Name: "bash", IsReadOnly: true}
args := json.RawMessage(`{}`)
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < 1000; i++ {
c.SetMode(ModeAuto)
c.SetMode(ModeDefault)
}
}()
for i := 0; i < 1000; i++ {
c.Check(ctx, info, args) //nolint:errcheck
}
<-done
}