Files
gnoma/internal/permission/rule.go
T
vikingowl ea4996c663 feat: add permission system with 6 modes and compound command parsing
6 permission modes:
- default: prompt for every tool invocation
- accept_edits: auto-allow file ops, prompt for bash/destructive
- bypass: allow everything (deny rules still enforced)
- deny: deny all unless explicit allow rule
- plan: read-only tools only
- auto: auto-allow read-only, prompt for writes

7-step decision flow: deny rules (bypass-immune) → safety checks
(.env, .git, credentials, ssh keys) → bypass → allow rules →
mode-specific → prompt.

Compound bash command decomposition via mvdan.cc/sh AST parser.
Splits on && and || but preserves pipes as single units.
Deny from any subcommand denies the entire compound.

15 permission tests.
2026-04-03 14:58:14 +02:00

79 lines
1.9 KiB
Go

package permission
import (
"path/filepath"
"strings"
"mvdan.cc/sh/v3/syntax"
)
// Action is the decision for a permission rule.
type Action string
const (
ActionAllow Action = "allow"
ActionDeny Action = "deny"
)
// Rule defines a single permission rule.
type Rule struct {
Tool string `toml:"tool"` // glob pattern: "bash", "fs.*", "*"
Pattern string `toml:"pattern"` // optional: argument pattern
Action Action `toml:"action"`
}
// Matches returns true if the rule matches the given tool name.
func (r Rule) Matches(toolName string) bool {
matched, _ := filepath.Match(r.Tool, toolName)
return matched
}
// SplitCompoundCommand decomposes a shell command into individual simple commands
// using a proper POSIX shell parser (mvdan.cc/sh). Recursively walks BinaryCmd
// nodes (&&, ||) and statement lists (;).
func SplitCompoundCommand(cmd string) []string {
reader := strings.NewReader(cmd)
parser := syntax.NewParser(syntax.KeepComments(false))
file, err := parser.Parse(reader, "")
if err != nil {
return []string{cmd}
}
var commands []string
printer := syntax.NewPrinter()
for _, stmt := range file.Stmts {
extractCommands(stmt.Cmd, printer, &commands)
}
if len(commands) == 0 {
return []string{cmd}
}
return commands
}
func extractCommands(node syntax.Command, printer *syntax.Printer, out *[]string) {
if node == nil {
return
}
// Only split on && and || (logical operators), not pipes
if bin, ok := node.(*syntax.BinaryCmd); ok {
if bin.Op == syntax.AndStmt || bin.Op == syntax.OrStmt {
if bin.X != nil {
extractCommands(bin.X.Cmd, printer, out)
}
if bin.Y != nil {
extractCommands(bin.Y.Cmd, printer, out)
}
return
}
}
// Everything else (simple command, pipe, subshell) — print as one unit
var b strings.Builder
printer.Print(&b, node)
if s := strings.TrimSpace(b.String()); s != "" {
*out = append(*out, s)
}
}