Files
vikingowl bb7892c0c2 chore(audit): polish remaining audit findings (M2, H1, H3)
- M2: stop echoing the matched pattern name in the user-visible
  [BLOCKED: ...] message returned by the firewall. The pattern (and
  the matched secret class) still appear in the operator log, but the
  string sent back into the prompt is now generic.

- H1: document Rule.Pattern semantics on the Rule type and pin them
  with a regression test. Pattern is a case-sensitive, exact substring
  match against the JSON-serialised tool arguments — not a glob,
  regex, or whitespace-insensitive match. The new test exercises both
  matches and the documented gotchas (double-space, case drift, tab).

- H3: every code path in CommandExecutor.Execute that converts a hook
  failure into Allow via FailOpen now emits a WARN naming the hook
  and the failure mode (timeout / launch_error / parse_error), so
  chronic hook failure or abuse is visible in operator logs.

Also tightens errcheck on permission/rule.go (Printer.Print on a
strings.Builder cannot error in practice; make the intent explicit).
2026-05-19 17:05:39 +02:00

100 lines
2.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.
//
// Pattern matching semantics — important caveats:
//
// - Tool is matched as a filepath.Match glob ("bash", "fs.*", "*").
// - Pattern, if set, is a case-sensitive substring match against the
// JSON-serialized tool arguments. It is NOT a glob, regex, or
// structured-field match.
//
// Concretely, given `Pattern = "rm -rf"`, these match:
// - `{"command":"rm -rf /tmp"}`
// - `{"prefix":"echo rm -rf","other":"x"}` (substring appears anywhere)
//
// And these DO NOT match:
// - `{"command":"rm -rf /"}` (double space — substring is exact)
// - `{"command":"RM -RF /"}` (case differs)
// - `{"command":"rm\t-rf /"}` (tab vs space)
//
// Rule authors: write patterns that are unambiguous when serialized as JSON
// (no leading/trailing whitespace, no surrounding quotes) and remember that
// deny rules are bypass-immune — they fire before any mode check.
type Rule struct {
Tool string `toml:"tool"` // glob pattern: "bash", "fs.*", "*"
Pattern string `toml:"pattern"` // optional: case-sensitive substring on JSON args (see godoc)
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.
// Printer errors on a strings.Builder are unreachable (Builder.Write never errors).
var b strings.Builder
_ = printer.Print(&b, node)
if s := strings.TrimSpace(b.String()); s != "" {
*out = append(*out, s)
}
}