Initial commit
diff --git a/claudetool/bashkit/bashkit.go b/claudetool/bashkit/bashkit.go
new file mode 100644
index 0000000..a56eef0
--- /dev/null
+++ b/claudetool/bashkit/bashkit.go
@@ -0,0 +1,97 @@
+package bashkit
+
+import (
+ "fmt"
+ "strings"
+
+ "mvdan.cc/sh/v3/syntax"
+)
+
+var checks = []func(*syntax.CallExpr) error{
+ noGitConfigUsernameEmailChanges,
+}
+
+// Check inspects bashScript and returns an error if it ought not be executed.
+// Check DOES NOT PROVIDE SECURITY against malicious actors.
+// It is intended to catch straightforward mistakes in which a model
+// does things despite having been instructed not to do them.
+func Check(bashScript string) error {
+ r := strings.NewReader(bashScript)
+ parser := syntax.NewParser()
+ file, err := parser.Parse(r, "")
+ if err != nil {
+ // Execution will fail, but we'll get a better error message from bash.
+ // Note that if this were security load bearing, this would be a terrible idea:
+ // You could smuggle stuff past Check by exploiting differences in what is considered syntactically valid.
+ // But it is not.
+ return nil
+ }
+
+ syntax.Walk(file, func(node syntax.Node) bool {
+ if err != nil {
+ return false
+ }
+ callExpr, ok := node.(*syntax.CallExpr)
+ if !ok {
+ return true
+ }
+ for _, check := range checks {
+ err = check(callExpr)
+ if err != nil {
+ return false
+ }
+ }
+ return true
+ })
+
+ return err
+}
+
+// noGitConfigUsernameEmailChanges checks for git config username/email changes.
+// It uses simple heuristics, and has both false positives and false negatives.
+func noGitConfigUsernameEmailChanges(cmd *syntax.CallExpr) error {
+ if hasGitConfigUsernameEmailChanges(cmd) {
+ return fmt.Errorf("permission denied: changing git config username/email is not allowed, use env vars instead")
+ }
+ return nil
+}
+
+func hasGitConfigUsernameEmailChanges(cmd *syntax.CallExpr) bool {
+ if len(cmd.Args) < 3 {
+ return false
+ }
+ if cmd.Args[0].Lit() != "git" {
+ return false
+ }
+
+ configIndex := -1
+ for i, arg := range cmd.Args {
+ if arg.Lit() == "config" {
+ configIndex = i
+ break
+ }
+ }
+
+ if configIndex < 0 || configIndex == len(cmd.Args)-1 {
+ return false
+ }
+
+ // check for user.name or user.email
+ keyIndex := -1
+ for i, arg := range cmd.Args {
+ if i < configIndex {
+ continue
+ }
+ if arg.Lit() == "user.name" || arg.Lit() == "user.email" {
+ keyIndex = i
+ break
+ }
+ }
+
+ if keyIndex < 0 || keyIndex == len(cmd.Args)-1 {
+ return false
+ }
+
+ // user.name/user.email is followed by a value
+ return true
+}
diff --git a/claudetool/bashkit/bashkit_test.go b/claudetool/bashkit/bashkit_test.go
new file mode 100644
index 0000000..8bcdd8f
--- /dev/null
+++ b/claudetool/bashkit/bashkit_test.go
@@ -0,0 +1,109 @@
+package bashkit
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestCheck(t *testing.T) {
+ tests := []struct {
+ name string
+ script string
+ wantErr bool
+ errMatch string // string to match in error message, if wantErr is true
+ }{
+ {
+ name: "valid script",
+ script: "echo hello world",
+ wantErr: false,
+ errMatch: "",
+ },
+ {
+ name: "invalid syntax",
+ script: "echo 'unterminated string",
+ wantErr: false, // As per implementation, syntax errors are not flagged
+ errMatch: "",
+ },
+ {
+ name: "git config user.name",
+ script: "git config user.name 'John Doe'",
+ wantErr: true,
+ errMatch: "changing git config username/email is not allowed",
+ },
+ {
+ name: "git config user.email",
+ script: "git config user.email 'john@example.com'",
+ wantErr: true,
+ errMatch: "changing git config username/email is not allowed",
+ },
+ {
+ name: "git config with flag user.name",
+ script: "git config --global user.name 'John Doe'",
+ wantErr: true,
+ errMatch: "changing git config username/email is not allowed",
+ },
+ {
+ name: "git config with other setting",
+ script: "git config core.editor vim",
+ wantErr: false,
+ errMatch: "",
+ },
+ {
+ name: "git without config",
+ script: "git commit -m 'Add feature'",
+ wantErr: false,
+ errMatch: "",
+ },
+ {
+ name: "multiline script with proper escaped newlines",
+ script: "echo 'Setting up git...' && git config user.name 'John Doe' && echo 'Done!'",
+ wantErr: true,
+ errMatch: "changing git config username/email is not allowed",
+ },
+ {
+ name: "multiline script with backticks",
+ script: `echo 'Setting up git...'
+git config user.name 'John Doe'
+echo 'Done!'`,
+ wantErr: true,
+ errMatch: "changing git config username/email is not allowed",
+ },
+ {
+ name: "git config with variable",
+ script: "NAME='John Doe'\ngit config user.name $NAME",
+ wantErr: true,
+ errMatch: "changing git config username/email is not allowed",
+ },
+ {
+ name: "only git command",
+ script: "git",
+ wantErr: false,
+ errMatch: "",
+ },
+ {
+ name: "read git config",
+ script: "git config user.name",
+ wantErr: false,
+ errMatch: "",
+ },
+ {
+ name: "commented git config",
+ script: "# git config user.name 'John Doe'",
+ wantErr: false,
+ errMatch: "",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ err := Check(tc.script)
+ if (err != nil) != tc.wantErr {
+ t.Errorf("Check() error = %v, wantErr %v", err, tc.wantErr)
+ return
+ }
+ if tc.wantErr && err != nil && !strings.Contains(err.Error(), tc.errMatch) {
+ t.Errorf("Check() error message = %v, want containing %v", err, tc.errMatch)
+ }
+ })
+ }
+}