feat: wire permission checker into engine tool execution

Tools now go through permission.Checker before executing:
- plan mode: denies all writes (fs.write, bash), allows reads
- bypass mode: allows all (deny rules still enforced)
- default mode: prompts user (pipe: stdin prompt, TUI: auto-approve for now)
- accept_edits: auto-allows file ops, prompts for bash
- deny mode: denies all without allow rules

CLI flags: --permission <mode>, --incognito
Pipe mode: console Y/N prompt on stderr
TUI mode: auto-approve (proper overlay TODO)

Verified: plan mode correctly blocks fs.write, model sees error.
This commit is contained in:
2026-04-03 16:15:41 +02:00
parent 8e95f97cd5
commit 97b065596d
5 changed files with 63 additions and 13 deletions

View File

@@ -11,6 +11,7 @@ import (
"strings"
"somegit.dev/Owlibou/gnoma/internal/engine"
"encoding/json"
"somegit.dev/Owlibou/gnoma/internal/permission"
"somegit.dev/Owlibou/gnoma/internal/provider"
"somegit.dev/Owlibou/gnoma/internal/router"
@@ -131,8 +132,14 @@ func main() {
logger.Debug("incognito mode enabled")
}
// Permission checker
_ = permission.NewChecker(permission.Mode(*permMode), nil, nil)
// Permission checker with console prompt for pipe mode
pipePromptFn := func(ctx context.Context, toolName string, args json.RawMessage) (bool, error) {
fmt.Fprintf(os.Stderr, "⚠ Tool %s wants to execute. Allow? [y/N] ", toolName)
var response string
fmt.Scanln(&response)
return strings.ToLower(response) == "y" || strings.ToLower(response) == "yes", nil
}
permChecker := permission.NewChecker(permission.Mode(*permMode), nil, pipePromptFn)
// Build system prompt with compact inventory summary
systemPrompt := *system
@@ -142,13 +149,14 @@ func main() {
// Create engine
eng, err := engine.New(engine.Config{
Provider: prov,
Router: rtr,
Tools: reg,
Firewall: fw,
System: systemPrompt,
Model: *model,
MaxTurns: *maxTurns,
Provider: prov,
Router: rtr,
Tools: reg,
Firewall: fw,
Permissions: permChecker,
System: systemPrompt,
Model: *model,
MaxTurns: *maxTurns,
Logger: logger,
})
if err != nil {
@@ -191,7 +199,17 @@ func main() {
os.Exit(1)
}
} else {
// TUI mode: interactive terminal
// TUI mode: replace permission prompt with channel-based one
permChecker.SetPromptFunc(func(ctx context.Context, toolName string, args json.RawMessage) (bool, error) {
// Send permission request through a channel, block until TUI responds
respCh := make(chan bool, 1)
// The engine callback will emit this as an event
// For now, auto-approve in TUI (proper overlay is M5+)
// TODO: wire to TUI overlay
respCh <- true
return <-respCh, nil
})
armModel := *model
if armModel == "" {
armModel = prov.DefaultModel()

View File

@@ -6,6 +6,7 @@ import (
"log/slog"
"somegit.dev/Owlibou/gnoma/internal/message"
"somegit.dev/Owlibou/gnoma/internal/permission"
"somegit.dev/Owlibou/gnoma/internal/provider"
"somegit.dev/Owlibou/gnoma/internal/router"
"somegit.dev/Owlibou/gnoma/internal/security"
@@ -17,8 +18,9 @@ type Config struct {
Provider provider.Provider // direct provider (used if Router is nil)
Router *router.Router // nil = use Provider directly
Tools *tool.Registry
Firewall *security.Firewall // nil = no scanning
System string // system prompt
Firewall *security.Firewall // nil = no scanning
Permissions *permission.Checker // nil = allow all
System string // system prompt
Model string // override model (empty = provider default)
MaxTurns int // safety limit on tool loops (0 = unlimited)
Logger *slog.Logger

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"somegit.dev/Owlibou/gnoma/internal/message"
"somegit.dev/Owlibou/gnoma/internal/permission"
"somegit.dev/Owlibou/gnoma/internal/provider"
"somegit.dev/Owlibou/gnoma/internal/router"
"somegit.dev/Owlibou/gnoma/internal/stream"
@@ -199,6 +200,24 @@ func (e *Engine) executeTools(ctx context.Context, calls []message.ToolCall, cb
continue
}
// Permission check
if e.cfg.Permissions != nil {
info := permission.ToolInfo{
Name: call.Name,
IsReadOnly: t.IsReadOnly(),
IsDestructive: t.IsDestructive(),
}
if err := e.cfg.Permissions.Check(ctx, info, call.Arguments); err != nil {
e.logger.Info("tool permission denied", "name", call.Name, "error", err)
results = append(results, message.ToolResult{
ToolCallID: call.ID,
Content: fmt.Sprintf("permission denied: %v", err),
IsError: true,
})
continue
}
}
e.logger.Debug("executing tool", "name", call.Name, "id", call.ID)
result, err := t.Execute(ctx, call.Arguments)

View File

@@ -51,6 +51,11 @@ func NewChecker(mode Mode, rules []Rule, promptFn PromptFunc) *Checker {
}
}
// SetPromptFunc replaces the prompt function (e.g., switching from pipe to TUI prompt).
func (c *Checker) SetPromptFunc(fn PromptFunc) {
c.promptFn = fn
}
// SetMode changes the active permission mode.
func (c *Checker) SetMode(mode Mode) {
c.mode = mode

View File

@@ -16,7 +16,8 @@ const (
EventToolCallStart
EventToolCallDelta
EventToolCallDone
EventToolResult // tool execution output
EventToolResult // tool execution output
EventPermissionReq // permission prompt needed
EventUsage
EventError
)
@@ -35,6 +36,8 @@ func (et EventType) String() string {
return "tool_call_done"
case EventToolResult:
return "tool_result"
case EventPermissionReq:
return "permission_req"
case EventUsage:
return "usage"
case EventError:
@@ -63,6 +66,9 @@ type Event struct {
ToolName string
ToolOutput string
// PermissionReq: tool requesting permission, response channel
PermissionResponse chan bool
// Usage
Usage *message.Usage