From 97b065596d4abcc56ab59439d8c6da2a2158bfdb Mon Sep 17 00:00:00 2001 From: vikingowl Date: Fri, 3 Apr 2026 16:15:41 +0200 Subject: [PATCH] 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 , --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. --- cmd/gnoma/main.go | 38 +++++++++++++++++++++++++--------- internal/engine/engine.go | 6 ++++-- internal/engine/loop.go | 19 +++++++++++++++++ internal/permission/checker.go | 5 +++++ internal/stream/event.go | 8 ++++++- 5 files changed, 63 insertions(+), 13 deletions(-) diff --git a/cmd/gnoma/main.go b/cmd/gnoma/main.go index f45e666..9c67f36 100644 --- a/cmd/gnoma/main.go +++ b/cmd/gnoma/main.go @@ -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() diff --git a/internal/engine/engine.go b/internal/engine/engine.go index d42eb46..a0f0372 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -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 diff --git a/internal/engine/loop.go b/internal/engine/loop.go index 57bef82..c536493 100644 --- a/internal/engine/loop.go +++ b/internal/engine/loop.go @@ -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) diff --git a/internal/permission/checker.go b/internal/permission/checker.go index f69273f..92bd2da 100644 --- a/internal/permission/checker.go +++ b/internal/permission/checker.go @@ -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 diff --git a/internal/stream/event.go b/internal/stream/event.go index 7bfd788..a87daa9 100644 --- a/internal/stream/event.go +++ b/internal/stream/event.go @@ -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