Files
gnoma/internal/permission/checker.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

247 lines
6.5 KiB
Go

package permission
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
)
var ErrDenied = errors.New("permission denied")
// PromptFunc asks the user to approve/deny a tool call.
// Returns true if approved.
type PromptFunc func(ctx context.Context, toolName string, args json.RawMessage) (bool, error)
// ToolInfo provides tool metadata for permission decisions.
type ToolInfo struct {
Name string
IsReadOnly bool
IsDestructive bool
}
// Checker evaluates tool permissions using the 7-step decision flow.
//
// Decision flow (from CC, adapted):
// 1. Rule-based deny gates (BEFORE mode — even bypass can't override)
// 2. Tool-specific safety checks (.env, .git, credentials)
// 3. Mode-based bypass
// 4. Rule-based allow
// 5. Mode-specific behavior
// 6. Prompt user if needed
type Checker struct {
mu sync.RWMutex
mode Mode
rules []Rule
promptFn PromptFunc
// Safety patterns — always checked, even in bypass mode
safetyDenyPatterns []string
}
func NewChecker(mode Mode, rules []Rule, promptFn PromptFunc) *Checker {
return &Checker{
mode: mode,
rules: rules,
promptFn: promptFn,
safetyDenyPatterns: []string{
".env", ".git/", "credentials", "id_rsa", "id_ed25519",
".ssh/", ".gnupg/", ".aws/credentials",
},
}
}
// SetPromptFunc replaces the prompt function (e.g., switching from pipe to TUI prompt).
func (c *Checker) SetPromptFunc(fn PromptFunc) {
c.mu.Lock()
defer c.mu.Unlock()
c.promptFn = fn
}
// SetMode changes the active permission mode.
func (c *Checker) SetMode(mode Mode) {
c.mu.Lock()
defer c.mu.Unlock()
c.mode = mode
}
// Mode returns the current permission mode.
func (c *Checker) Mode() Mode {
c.mu.RLock()
defer c.mu.RUnlock()
return c.mode
}
// WithDenyPrompt returns a new Checker with the same mode and rules but a nil prompt
// function. When a tool would normally require prompting, it is auto-denied. Used for
// elf engines where there is no TUI to prompt.
func (c *Checker) WithDenyPrompt() *Checker {
c.mu.RLock()
defer c.mu.RUnlock()
return &Checker{
mode: c.mode,
rules: c.rules,
promptFn: nil,
safetyDenyPatterns: c.safetyDenyPatterns,
}
}
// Check evaluates whether a tool call is permitted.
// Returns nil if allowed, ErrDenied if denied.
func (c *Checker) Check(ctx context.Context, info ToolInfo, args json.RawMessage) error {
c.mu.RLock()
mode := c.mode
promptFn := c.promptFn
c.mu.RUnlock()
// Step 1: Rule-based deny gates (bypass-immune)
if c.matchesRule(info.Name, args, ActionDeny) {
return fmt.Errorf("%w: deny rule matched for %s", ErrDenied, info.Name)
}
// Step 2: Safety checks (bypass-immune)
if err := c.safetyCheck(info.Name, args); err != nil {
return err
}
// For compound bash commands, check each subcommand
if info.Name == "bash" {
if err := c.checkCompoundCommand(ctx, info, args); err != nil {
return err
}
}
// Step 3: Mode-based bypass
if mode == ModeBypass {
return nil
}
// Step 4: Rule-based allow
if c.matchesRule(info.Name, args, ActionAllow) {
return nil
}
// Step 5: Mode-specific behavior
switch mode {
case ModeDeny:
return fmt.Errorf("%w: deny mode, no allow rule for %s", ErrDenied, info.Name)
case ModePlan:
if !info.IsReadOnly {
return fmt.Errorf("%w: plan mode, %s is not read-only", ErrDenied, info.Name)
}
return nil
case ModeAcceptEdits:
// Auto-allow file reads and edits, prompt for bash/destructive
if info.IsReadOnly {
return nil
}
if strings.HasPrefix(info.Name, "fs.") && !info.IsDestructive {
return nil
}
// Fall through to prompt
case ModeAuto:
// Auto-allow read-only tools
if info.IsReadOnly {
return nil
}
// Fall through to prompt for write tools
case ModeDefault:
// Always prompt
}
// Step 6: Prompt user (using snapshot of promptFn taken before lock release)
if promptFn == nil {
// No prompt handler (e.g. elf sub-agent): auto-allow non-destructive fs
// operations so elfs can write files in auto/acceptEdits modes. Deny
// everything else that would normally require human approval.
if strings.HasPrefix(info.Name, "fs.") && !info.IsDestructive {
return nil
}
return fmt.Errorf("%w: no prompt handler for %s", ErrDenied, info.Name)
}
approved, err := promptFn(ctx, info.Name, args)
if err != nil {
return fmt.Errorf("permission prompt: %w", err)
}
if !approved {
return fmt.Errorf("%w: user denied %s", ErrDenied, info.Name)
}
return nil
}
func (c *Checker) matchesRule(toolName string, args json.RawMessage, action Action) bool {
for _, rule := range c.rules {
if rule.Action != action {
continue
}
if !rule.Matches(toolName) {
continue
}
// If rule has a pattern, check it against serialized args
if rule.Pattern != "" {
if !strings.Contains(string(args), rule.Pattern) {
continue
}
}
return true
}
return false
}
func (c *Checker) safetyCheck(toolName string, args json.RawMessage) error {
// Orchestration tools (spawn_elfs, agent) carry elf PROMPTS as args — arbitrary
// instruction text that may legitimately mention .env, credentials, etc.
// Security is enforced inside each spawned elf when it actually accesses files.
if toolName == "spawn_elfs" || toolName == "agent" {
return nil
}
// For fs.* tools, only check the path field — not content being written.
// Prevents false-positives when writing docs that reference .env, .ssh, etc.
checkStr := string(args)
if strings.HasPrefix(toolName, "fs.") {
var parsed struct {
Path string `json:"path"`
}
if err := json.Unmarshal(args, &parsed); err == nil && parsed.Path != "" {
checkStr = parsed.Path
}
}
for _, pattern := range c.safetyDenyPatterns {
if strings.Contains(checkStr, pattern) {
return fmt.Errorf("%w: safety check blocked access to %q via %s", ErrDenied, pattern, toolName)
}
}
return nil
}
func (c *Checker) checkCompoundCommand(ctx context.Context, info ToolInfo, args json.RawMessage) error {
var bashArgs struct {
Command string `json:"command"`
}
if err := json.Unmarshal(args, &bashArgs); err != nil || bashArgs.Command == "" {
return nil
}
subcommands := SplitCompoundCommand(bashArgs.Command)
if len(subcommands) <= 1 {
return nil // single command, handled by main flow
}
// Check each subcommand — deny from any subcommand denies the whole compound
for _, sub := range subcommands {
subArgs, _ := json.Marshal(map[string]string{"command": sub})
if c.matchesRule("bash", subArgs, ActionDeny) {
return fmt.Errorf("%w: deny rule matched subcommand %q", ErrDenied, sub)
}
}
return nil
}