// 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 }