nub/internal/permission/policy.go
Tom a97013d876 initial commit
- v 0.1.0 siehe CHANGELOG.md
2026-07-25 11:08:01 +02:00

104 lines
2.7 KiB
Go

// Package permission entscheidet pro Tool-Aufruf, ob er automatisch laufen
// darf, eine Rückfrage braucht, oder abgelehnt wird (E-11). Bewusst
// config-unabhängig (nur Strings/Maps) — der Aufrufer übersetzt
// config.PermissionsConfig in eine Policy, damit dieses Paket ohne
// Config-Kopplung testbar bleibt.
package permission
import (
"encoding/json"
"strings"
"github.com/bmatcuk/doublestar/v4"
)
type Mode string
const (
ModeAuto Mode = "auto"
ModeAsk Mode = "ask"
ModeDeny Mode = "deny"
)
// Policy ist die ausgewertete Permissions-Config für eine Session.
type Policy struct {
// Modes bildet Tool-Namen auf einen Modus ab. Tools ohne Eintrag (z.B.
// MCP-Tools, todo, read_skill) gelten als ModeAuto.
Modes map[string]Mode
// DenyPaths/DenyBash sind zusätzliche Sperren, die "auto" und "ask"
// immer überstimmen — eine explizite Deny-Regel gewinnt.
DenyPaths []string
DenyBash []string
}
// ModeFor liefert den konfigurierten Modus für ein Tool, ohne Pfad-/Bash-
// Denylist zu prüfen. Unbekannte Tools und ein nil-Policy ergeben ModeAuto.
func (p *Policy) ModeFor(toolName string) Mode {
if p == nil {
return ModeAuto
}
if m, ok := p.Modes[toolName]; ok && m != "" {
return m
}
return ModeAuto
}
// Check entscheidet den tatsächlichen Modus für einen konkreten Aufruf
// inklusive Pfad-/Bash-Denylist. Eine Deny-Regel überstimmt immer, auch
// wenn das Tool selbst auf "auto" oder "ask" steht.
func (p *Policy) Check(toolName string, input json.RawMessage) Mode {
mode := p.ModeFor(toolName)
if p == nil {
return mode
}
if path := extractPath(input); path != "" && matchesAny(p.DenyPaths, path) {
return ModeDeny
}
if toolName == "bash" {
if cmd := extractCommand(input); cmd != "" && matchesAny(p.DenyBash, cmd) {
return ModeDeny
}
}
return mode
}
func extractPath(input json.RawMessage) string {
var v struct {
Path string `json:"path"`
}
if err := json.Unmarshal(input, &v); err != nil {
return ""
}
return v.Path
}
func extractCommand(input json.RawMessage) string {
var v struct {
Command string `json:"command"`
}
if err := json.Unmarshal(input, &v); err != nil {
return ""
}
return v.Command
}
// matchesAny prüft ein Glob-Match (deny_paths: ".git/**", "**/id_rsa*") und
// zusätzlich ein Substring-Match, weil deny_bash-Beispiele im Konzept
// literale Fragmente ohne Wildcard sind ("rm -rf /") — ein reines
// Vollstring-Glob-Match würde "cd /tmp && rm -rf /" nicht fangen. Für eine
// Denylist ist Über-Treffen die sicherere Richtung als Unter-Treffen.
func matchesAny(patterns []string, s string) bool {
for _, p := range patterns {
if p == "" {
continue
}
if ok, _ := doublestar.Match(p, s); ok {
return true
}
if strings.Contains(s, p) {
return true
}
}
return false
}