| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1 | package bashkit |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "strings" |
| banksean | 19a32ea | 2025-07-18 18:29:31 +0000 | [diff] [blame^] | 6 | "sync" |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 7 | |
| 8 | "mvdan.cc/sh/v3/syntax" |
| 9 | ) |
| 10 | |
| 11 | var checks = []func(*syntax.CallExpr) error{ |
| 12 | noGitConfigUsernameEmailChanges, |
| Josh Bleecher Snyder | dbfd36a | 2025-05-23 20:57:50 +0000 | [diff] [blame] | 13 | noBlindGitAdd, |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 14 | } |
| 15 | |
| banksean | 19a32ea | 2025-07-18 18:29:31 +0000 | [diff] [blame^] | 16 | // Process-level checks that track state across calls |
| 17 | var processAwareChecks = []func(*syntax.CallExpr) error{ |
| 18 | noSketchWipBranchChangesOnce, |
| 19 | } |
| 20 | |
| 21 | // Track whether sketch-wip branch warning has been shown in this process |
| 22 | var ( |
| 23 | sketchWipWarningMu sync.Mutex |
| 24 | sketchWipWarningShown bool |
| 25 | ) |
| 26 | |
| 27 | // ResetSketchWipWarning resets the warning state for testing purposes |
| 28 | func ResetSketchWipWarning() { |
| 29 | sketchWipWarningMu.Lock() |
| 30 | sketchWipWarningShown = false |
| 31 | sketchWipWarningMu.Unlock() |
| 32 | } |
| 33 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 34 | // Check inspects bashScript and returns an error if it ought not be executed. |
| 35 | // Check DOES NOT PROVIDE SECURITY against malicious actors. |
| 36 | // It is intended to catch straightforward mistakes in which a model |
| 37 | // does things despite having been instructed not to do them. |
| 38 | func Check(bashScript string) error { |
| 39 | r := strings.NewReader(bashScript) |
| 40 | parser := syntax.NewParser() |
| 41 | file, err := parser.Parse(r, "") |
| 42 | if err != nil { |
| 43 | // Execution will fail, but we'll get a better error message from bash. |
| 44 | // Note that if this were security load bearing, this would be a terrible idea: |
| 45 | // You could smuggle stuff past Check by exploiting differences in what is considered syntactically valid. |
| 46 | // But it is not. |
| 47 | return nil |
| 48 | } |
| 49 | |
| 50 | syntax.Walk(file, func(node syntax.Node) bool { |
| 51 | if err != nil { |
| 52 | return false |
| 53 | } |
| 54 | callExpr, ok := node.(*syntax.CallExpr) |
| 55 | if !ok { |
| 56 | return true |
| 57 | } |
| banksean | 19a32ea | 2025-07-18 18:29:31 +0000 | [diff] [blame^] | 58 | // Run regular checks |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 59 | for _, check := range checks { |
| 60 | err = check(callExpr) |
| 61 | if err != nil { |
| 62 | return false |
| 63 | } |
| 64 | } |
| banksean | 19a32ea | 2025-07-18 18:29:31 +0000 | [diff] [blame^] | 65 | // Run process-aware checks |
| 66 | for _, check := range processAwareChecks { |
| 67 | err = check(callExpr) |
| 68 | if err != nil { |
| 69 | return false |
| 70 | } |
| 71 | } |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 72 | return true |
| 73 | }) |
| 74 | |
| 75 | return err |
| 76 | } |
| 77 | |
| 78 | // noGitConfigUsernameEmailChanges checks for git config username/email changes. |
| 79 | // It uses simple heuristics, and has both false positives and false negatives. |
| 80 | func noGitConfigUsernameEmailChanges(cmd *syntax.CallExpr) error { |
| 81 | if hasGitConfigUsernameEmailChanges(cmd) { |
| 82 | return fmt.Errorf("permission denied: changing git config username/email is not allowed, use env vars instead") |
| 83 | } |
| 84 | return nil |
| 85 | } |
| 86 | |
| 87 | func hasGitConfigUsernameEmailChanges(cmd *syntax.CallExpr) bool { |
| 88 | if len(cmd.Args) < 3 { |
| 89 | return false |
| 90 | } |
| 91 | if cmd.Args[0].Lit() != "git" { |
| 92 | return false |
| 93 | } |
| 94 | |
| 95 | configIndex := -1 |
| 96 | for i, arg := range cmd.Args { |
| 97 | if arg.Lit() == "config" { |
| 98 | configIndex = i |
| 99 | break |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | if configIndex < 0 || configIndex == len(cmd.Args)-1 { |
| 104 | return false |
| 105 | } |
| 106 | |
| 107 | // check for user.name or user.email |
| 108 | keyIndex := -1 |
| 109 | for i, arg := range cmd.Args { |
| 110 | if i < configIndex { |
| 111 | continue |
| 112 | } |
| 113 | if arg.Lit() == "user.name" || arg.Lit() == "user.email" { |
| 114 | keyIndex = i |
| 115 | break |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | if keyIndex < 0 || keyIndex == len(cmd.Args)-1 { |
| 120 | return false |
| 121 | } |
| 122 | |
| 123 | // user.name/user.email is followed by a value |
| 124 | return true |
| 125 | } |
| Josh Bleecher Snyder | dae1907 | 2025-04-30 01:08:57 +0000 | [diff] [blame] | 126 | |
| 127 | // WillRunGitCommit checks if the provided bash script will run 'git commit'. |
| 128 | // It returns true if any command in the script is a git commit command. |
| 129 | func WillRunGitCommit(bashScript string) (bool, error) { |
| 130 | r := strings.NewReader(bashScript) |
| 131 | parser := syntax.NewParser() |
| 132 | file, err := parser.Parse(r, "") |
| 133 | if err != nil { |
| 134 | // Parsing failed, but let's not consider this an error for the same reasons as in Check |
| 135 | return false, nil |
| 136 | } |
| 137 | |
| 138 | willCommit := false |
| 139 | |
| 140 | syntax.Walk(file, func(node syntax.Node) bool { |
| 141 | callExpr, ok := node.(*syntax.CallExpr) |
| 142 | if !ok { |
| 143 | return true |
| 144 | } |
| 145 | if isGitCommitCommand(callExpr) { |
| 146 | willCommit = true |
| 147 | return false |
| 148 | } |
| 149 | return true |
| 150 | }) |
| 151 | |
| 152 | return willCommit, nil |
| 153 | } |
| 154 | |
| Josh Bleecher Snyder | dbfd36a | 2025-05-23 20:57:50 +0000 | [diff] [blame] | 155 | // noBlindGitAdd checks for git add commands that blindly add all files. |
| 156 | // It rejects patterns like 'git add -A', 'git add .', 'git add --all', 'git add *'. |
| 157 | func noBlindGitAdd(cmd *syntax.CallExpr) error { |
| 158 | if hasBlindGitAdd(cmd) { |
| 159 | return fmt.Errorf("permission denied: blind git add commands (git add -A, git add ., git add --all, git add *) are not allowed, specify files explicitly") |
| 160 | } |
| 161 | return nil |
| 162 | } |
| 163 | |
| 164 | func hasBlindGitAdd(cmd *syntax.CallExpr) bool { |
| 165 | if len(cmd.Args) < 2 { |
| 166 | return false |
| 167 | } |
| 168 | if cmd.Args[0].Lit() != "git" { |
| 169 | return false |
| 170 | } |
| 171 | |
| 172 | // Find the 'add' subcommand |
| 173 | addIndex := -1 |
| 174 | for i, arg := range cmd.Args { |
| 175 | if arg.Lit() == "add" { |
| 176 | addIndex = i |
| 177 | break |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | if addIndex < 0 { |
| 182 | return false |
| 183 | } |
| 184 | |
| 185 | // Check arguments after 'add' for blind patterns |
| 186 | for i := addIndex + 1; i < len(cmd.Args); i++ { |
| 187 | arg := cmd.Args[i].Lit() |
| 188 | // Check for blind add patterns |
| 189 | if arg == "-A" || arg == "--all" || arg == "." || arg == "*" { |
| 190 | return true |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | return false |
| 195 | } |
| 196 | |
| Josh Bleecher Snyder | dae1907 | 2025-04-30 01:08:57 +0000 | [diff] [blame] | 197 | // isGitCommitCommand checks if a command is 'git commit'. |
| 198 | func isGitCommitCommand(cmd *syntax.CallExpr) bool { |
| 199 | if len(cmd.Args) < 2 { |
| 200 | return false |
| 201 | } |
| 202 | |
| 203 | // First argument must be 'git' |
| 204 | if cmd.Args[0].Lit() != "git" { |
| 205 | return false |
| 206 | } |
| 207 | |
| 208 | // Look for 'commit' in any position after 'git' |
| 209 | for i := 1; i < len(cmd.Args); i++ { |
| 210 | if cmd.Args[i].Lit() == "commit" { |
| 211 | return true |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | return false |
| 216 | } |
| banksean | 19a32ea | 2025-07-18 18:29:31 +0000 | [diff] [blame^] | 217 | |
| 218 | // noSketchWipBranchChangesOnce checks for git commands that would change the sketch-wip branch. |
| 219 | // It rejects commands that would rename the sketch-wip branch or switch away from it. |
| 220 | // This check only shows the warning once per process. |
| 221 | func noSketchWipBranchChangesOnce(cmd *syntax.CallExpr) error { |
| 222 | if hasSketchWipBranchChanges(cmd) { |
| 223 | // Check if we've already warned in this process |
| 224 | sketchWipWarningMu.Lock() |
| 225 | alreadyWarned := sketchWipWarningShown |
| 226 | if !alreadyWarned { |
| 227 | sketchWipWarningShown = true |
| 228 | } |
| 229 | sketchWipWarningMu.Unlock() |
| 230 | |
| 231 | if !alreadyWarned { |
| 232 | return fmt.Errorf("permission denied: changing the 'sketch-wip' branch is not allowed. The outie needs this branch name to detect and push your changes to GitHub. If you want to change the external GitHub branch name, use the 'set-slug' tool instead. This warning is shown once per session - you can repeat the command if you really need to do this") |
| 233 | } |
| 234 | } |
| 235 | return nil |
| 236 | } |
| 237 | |
| 238 | // hasSketchWipBranchChanges checks if a git command would change the sketch-wip branch. |
| 239 | func hasSketchWipBranchChanges(cmd *syntax.CallExpr) bool { |
| 240 | if len(cmd.Args) < 2 { |
| 241 | return false |
| 242 | } |
| 243 | if cmd.Args[0].Lit() != "git" { |
| 244 | return false |
| 245 | } |
| 246 | |
| 247 | // Look for subcommands that could change the sketch-wip branch |
| 248 | for i := 1; i < len(cmd.Args); i++ { |
| 249 | arg := cmd.Args[i].Lit() |
| 250 | switch arg { |
| 251 | case "branch": |
| 252 | // Check for branch rename: git branch -m sketch-wip newname or git branch -M sketch-wip newname |
| 253 | if i+2 < len(cmd.Args) { |
| 254 | // Look for -m or -M flag |
| 255 | for j := i + 1; j < len(cmd.Args)-1; j++ { |
| 256 | flag := cmd.Args[j].Lit() |
| 257 | if flag == "-m" || flag == "-M" { |
| 258 | // Check if sketch-wip is the source branch |
| 259 | if cmd.Args[j+1].Lit() == "sketch-wip" { |
| 260 | return true |
| 261 | } |
| 262 | } |
| 263 | } |
| 264 | } |
| 265 | case "checkout": |
| 266 | // Check for branch switching: git checkout otherbranch |
| 267 | // But allow git checkout files/paths |
| 268 | if i+1 < len(cmd.Args) { |
| 269 | nextArg := cmd.Args[i+1].Lit() |
| 270 | // Skip if it's a flag |
| 271 | if !strings.HasPrefix(nextArg, "-") { |
| 272 | // This might be a branch checkout - we'll be conservative and warn |
| 273 | // unless it looks like a file path |
| 274 | if !strings.Contains(nextArg, "/") && !strings.Contains(nextArg, ".") { |
| 275 | return true |
| 276 | } |
| 277 | } |
| 278 | } |
| 279 | case "switch": |
| 280 | // Check for branch switching: git switch otherbranch |
| 281 | if i+1 < len(cmd.Args) { |
| 282 | nextArg := cmd.Args[i+1].Lit() |
| 283 | // Skip if it's a flag |
| 284 | if !strings.HasPrefix(nextArg, "-") { |
| 285 | return true |
| 286 | } |
| 287 | } |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | return false |
| 292 | } |