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 ` 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/ 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 ` 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(:)" entry, // dropping the trailing ":". 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) }