blob: b1f14db1d3cea4e340d91a54fb5128b43d1f9136 [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 {
Josh Bleecher Snyder106f6f52025-07-21 15:22:00 -0700232 return fmt.Errorf("permission denied: cannot leave 'sketch-wip' branch. This branch is designated for change detection and auto-push; work on other branches may be lost. Warning shown once per session. Repeat command if needed for temporary operations (rebase, bisect, etc.) but return to sketch-wip afterward. Note: users can push to any branch via the Push button in the UI")
banksean19a32ea2025-07-18 18:29:31 +0000233 }
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}