ajhahn.de
← eeco
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)
}