Go 87 lines
package cockpit
import "strings"
// defaultForbiddenGitVerbs is the write/mutate git subcommand denylist used
// when a Playbook does not declare its own Intent.ForbiddenGitVerbs. These
// are the second token of a `git <verb>` invocation; any of them appearing
// in a composed allowlist is a hard generation failure (the safety
// invariant — see ScanAllowlistForWriteGitVerbs). Purely read-only
// inspection subcommands (status, log, diff, describe, show) are deliberately
// absent. "branch" is the exception in this list: it is dual-mode — bare /
// -l / --list / --show-current only read, while -d/-D/-m/-M/-f/<name> create,
// rename, or delete a ref — so it is denied here and its read-only forms pass
// via readOnlyGitCompounds, exactly like "stash" / "stash list".
var defaultForbiddenGitVerbs = []string{
"add", "commit", "push", "tag", "reset", "rebase", "merge", "restore", "switch", "checkout", "branch",
"stash", "fetch", "pull", "clone", "mv", "rm", "apply", "cherry-pick", "revert", "am", "gc",
"worktree", "notes", "update-ref", "fast-import", "format-patch", "send-email", "commit-tree", "write-tree",
}
// readOnlyGitCompounds are `git <verb> <subverb>` phrases that inspect
// rather than mutate, even though their first subcommand token is in the
// denylist. "git stash" mutates the stash; "git stash list" / "git stash
// show" only read it. "git branch" creates, renames, or deletes a ref; "git
// branch --show-current" only prints the current branch name. The gate lets
// these compounds through so a Playbook can declare a precise read-only
// capability without tripping the write-verb scan. Keep the denylist tight:
// add another read-only branch form (--list, -l) here only when a playbook
// actually needs it.
var readOnlyGitCompounds = map[string]bool{
"branch --show-current": true,
"stash list": true,
"stash show": true,
}
// ScanAllowlistForWriteGitVerbs reports the forbidden git write verbs found
// in a composed allowlist (the `allowed-tools` entries — "Bash(git
// commit:*)", "Read", …). An empty result means the safety invariant holds.
//
// It keys on the command phrase inside each Bash(...) entry, not on a
// substring: a bare "git stash" (second token in the denylist) is a hit,
// while the explicit read-only compound "git stash list" passes. Non-git
// and non-bash entries are ignored. forbidden is the denylist
// (Intent.forbiddenVerbs supplies it).
func ScanAllowlistForWriteGitVerbs(allowlist, forbidden []string) []string {
deny := make(map[string]bool, len(forbidden))
for _, f := range forbidden {
deny[f] = true
}
var hits []string
for _, entry := range allowlist {
verb := bashVerb(entry)
if verb == "" {
continue
}
fields := strings.Fields(verb)
if len(fields) < 2 || fields[0] != "git" {
continue
}
sub := fields[1]
if !deny[sub] {
continue
}
if len(fields) >= 3 && readOnlyGitCompounds[fields[1]+" "+fields[2]] {
continue
}
hits = append(hits, sub)
}
return hits
}
// bashVerb extracts the command phrase from a "Bash(<verb>:<scope>)" entry,
// dropping the trailing ":<scope>". A non-Bash entry returns "". The scope
// is split off at the last colon so a verb (which carries no colon) is
// preserved intact.
func bashVerb(entry string) string {
inner, ok := strings.CutPrefix(entry, "Bash(")
if !ok {
return ""
}
inner = strings.TrimSuffix(inner, ")")
if i := strings.LastIndex(inner, ":"); i >= 0 {
inner = inner[:i]
}
return strings.TrimSpace(inner)
}