blob: 5bdabf51502fd7db400b9caf7a74827f90dd5287 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package bashkit
2
3import (
4 "fmt"
5 "strings"
banksean19a32ea2025-07-18 18:29:31 +00006 "sync"
Earl Lee2e463fb2025-04-17 11:22:22 -07007
8 "mvdan.cc/sh/v3/syntax"
9)
10
11var checks = []func(*syntax.CallExpr) error{
12 noGitConfigUsernameEmailChanges,
Josh Bleecher Snyderdbfd36a2025-05-23 20:57:50 +000013 noBlindGitAdd,
Earl Lee2e463fb2025-04-17 11:22:22 -070014}
15
banksean19a32ea2025-07-18 18:29:31 +000016// Process-level checks that track state across calls
17var processAwareChecks = []func(*syntax.CallExpr) error{
18 noSketchWipBranchChangesOnce,
19}
20
21// Track whether sketch-wip branch warning has been shown in this process
22var (
23 sketchWipWarningMu sync.Mutex
24 sketchWipWarningShown bool
25)
26
27// ResetSketchWipWarning resets the warning state for testing purposes
28func ResetSketchWipWarning() {
29 sketchWipWarningMu.Lock()
30 sketchWipWarningShown = false
31 sketchWipWarningMu.Unlock()
32}
33
Earl Lee2e463fb2025-04-17 11:22:22 -070034// 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.
38func 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 }
banksean19a32ea2025-07-18 18:29:31 +000058 // Run regular checks
Earl Lee2e463fb2025-04-17 11:22:22 -070059 for _, check := range checks {
60 err = check(callExpr)
61 if err != nil {
62 return false
63 }
64 }
banksean19a32ea2025-07-18 18:29:31 +000065 // Run process-aware checks
66 for _, check := range processAwareChecks {
67 err = check(callExpr)
68 if err != nil {
69 return false
70 }
71 }
Earl Lee2e463fb2025-04-17 11:22:22 -070072 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.
80func 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
87func 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 Snyderdae19072025-04-30 01:08:57 +0000126
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.
129func 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 Snyderdbfd36a2025-05-23 20:57:50 +0000155// 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 *'.
157func 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
164func 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 Snyderdae19072025-04-30 01:08:57 +0000197// isGitCommitCommand checks if a command is 'git commit'.
198func 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}
banksean19a32ea2025-07-18 18:29:31 +0000217
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.
221func 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.
239func 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}