Unified Agent + reusable Go agent core.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add bash command rewrite prefix

Lyric 9dc72d52 8956c26f

+205 -83
+5
assets/config/config.example.yaml
··· 270 270 # Extra environment variable names to inject into bash execution. 271 271 # These are added on top of the built-in safe env allowlist. 272 272 injected_env_vars: [] # e.g. ["OPENAI_API_BASE", "HTTP_TIMEOUT"] 273 + # Optional command prefix before execution, for tools such as RTK. 274 + # Example: binary "rtk" turns `git status` into `rtk git status`. 275 + rewrite: 276 + enabled: false 277 + binary: "" 273 278 # Dangerous: enables local PowerShell execution (Windows). 274 279 powershell: 275 280 # Enable the powershell tool.
+84 -76
cmd/mistermorph/registry.go
··· 17 17 ) 18 18 19 19 type registryConfig struct { 20 - UserAgent string 21 - SecretsAllowProfiles []string 22 - AuthProfiles map[string]secrets.AuthProfile 23 - FileCacheDir string 24 - FileStateDir string 25 - ToolsReadFileMaxBytes int64 26 - ToolsReadFileDenyPaths []string 27 - ToolsWriteFileEnabled bool 28 - ToolsWriteFileMaxBytes int 29 - ToolsBashEnabled bool 30 - ToolsBashTimeout time.Duration 31 - ToolsBashMaxOutputBytes int 32 - ToolsBashDenyPaths []string 33 - ToolsBashInjectedEnvVars []string 34 - ToolsPowerShellEnabled bool 35 - ToolsPowerShellTimeout time.Duration 36 - ToolsPowerShellMaxOutputBytes int 37 - ToolsPowerShellDenyPaths []string 20 + UserAgent string 21 + SecretsAllowProfiles []string 22 + AuthProfiles map[string]secrets.AuthProfile 23 + FileCacheDir string 24 + FileStateDir string 25 + ToolsReadFileMaxBytes int64 26 + ToolsReadFileDenyPaths []string 27 + ToolsWriteFileEnabled bool 28 + ToolsWriteFileMaxBytes int 29 + ToolsBashEnabled bool 30 + ToolsBashTimeout time.Duration 31 + ToolsBashMaxOutputBytes int 32 + ToolsBashDenyPaths []string 33 + ToolsBashInjectedEnvVars []string 34 + ToolsBashRewriteEnabled bool 35 + ToolsBashRewriteBinary string 36 + ToolsPowerShellEnabled bool 37 + ToolsPowerShellTimeout time.Duration 38 + ToolsPowerShellMaxOutputBytes int 39 + ToolsPowerShellDenyPaths []string 38 40 ToolsPowerShellInjectedEnvVars []string 39 - ToolsURLFetchEnabled bool 40 - ToolsURLFetchTimeout time.Duration 41 - ToolsURLFetchMaxBytes int64 42 - ToolsURLFetchMaxBytesDownload int64 43 - ToolsWebSearchEnabled bool 44 - ToolsWebSearchTimeout time.Duration 45 - ToolsWebSearchMaxResults int 46 - ToolsWebSearchBaseURL string 47 - ToolsContactsSendEnabled bool 48 - ContactsDir string 49 - TelegramBotToken string 50 - TelegramBaseURL string 51 - SlackBotToken string 52 - SlackBaseURL string 53 - LineChannelAccessToken string 54 - LineBaseURL string 55 - LarkAppID string 56 - LarkAppSecret string 57 - LarkBaseURL string 58 - ContactsFailureCooldown time.Duration 41 + ToolsURLFetchEnabled bool 42 + ToolsURLFetchTimeout time.Duration 43 + ToolsURLFetchMaxBytes int64 44 + ToolsURLFetchMaxBytesDownload int64 45 + ToolsWebSearchEnabled bool 46 + ToolsWebSearchTimeout time.Duration 47 + ToolsWebSearchMaxResults int 48 + ToolsWebSearchBaseURL string 49 + ToolsContactsSendEnabled bool 50 + ContactsDir string 51 + TelegramBotToken string 52 + TelegramBaseURL string 53 + SlackBotToken string 54 + SlackBaseURL string 55 + LineChannelAccessToken string 56 + LineBaseURL string 57 + LarkAppID string 58 + LarkAppSecret string 59 + LarkBaseURL string 60 + ContactsFailureCooldown time.Duration 59 61 } 60 62 61 63 func loadRegistryConfigFromViper() registryConfig { ··· 71 73 fileStateDir := strings.TrimSpace(viper.GetString("file_state_dir")) 72 74 73 75 return registryConfig{ 74 - UserAgent: strings.TrimSpace(viper.GetString("user_agent")), 75 - SecretsAllowProfiles: append([]string(nil), viper.GetStringSlice("secrets.allow_profiles")...), 76 - AuthProfiles: copyAuthProfilesMap(authProfiles), 77 - FileCacheDir: strings.TrimSpace(viper.GetString("file_cache_dir")), 78 - FileStateDir: fileStateDir, 79 - ToolsReadFileMaxBytes: int64(viper.GetInt("tools.read_file.max_bytes")), 80 - ToolsReadFileDenyPaths: append([]string(nil), viper.GetStringSlice("tools.read_file.deny_paths")...), 81 - ToolsWriteFileEnabled: viper.GetBool("tools.write_file.enabled"), 82 - ToolsWriteFileMaxBytes: viper.GetInt("tools.write_file.max_bytes"), 83 - ToolsBashEnabled: viper.GetBool("tools.bash.enabled"), 84 - ToolsBashTimeout: viper.GetDuration("tools.bash.timeout"), 85 - ToolsBashMaxOutputBytes: viper.GetInt("tools.bash.max_output_bytes"), 86 - ToolsBashDenyPaths: append([]string(nil), viper.GetStringSlice("tools.bash.deny_paths")...), 87 - ToolsBashInjectedEnvVars: append([]string(nil), viper.GetStringSlice("tools.bash.injected_env_vars")...), 88 - ToolsPowerShellEnabled: viper.GetBool("tools.powershell.enabled"), 89 - ToolsPowerShellTimeout: viper.GetDuration("tools.powershell.timeout"), 90 - ToolsPowerShellMaxOutputBytes: viper.GetInt("tools.powershell.max_output_bytes"), 91 - ToolsPowerShellDenyPaths: append([]string(nil), viper.GetStringSlice("tools.powershell.deny_paths")...), 76 + UserAgent: strings.TrimSpace(viper.GetString("user_agent")), 77 + SecretsAllowProfiles: append([]string(nil), viper.GetStringSlice("secrets.allow_profiles")...), 78 + AuthProfiles: copyAuthProfilesMap(authProfiles), 79 + FileCacheDir: strings.TrimSpace(viper.GetString("file_cache_dir")), 80 + FileStateDir: fileStateDir, 81 + ToolsReadFileMaxBytes: int64(viper.GetInt("tools.read_file.max_bytes")), 82 + ToolsReadFileDenyPaths: append([]string(nil), viper.GetStringSlice("tools.read_file.deny_paths")...), 83 + ToolsWriteFileEnabled: viper.GetBool("tools.write_file.enabled"), 84 + ToolsWriteFileMaxBytes: viper.GetInt("tools.write_file.max_bytes"), 85 + ToolsBashEnabled: viper.GetBool("tools.bash.enabled"), 86 + ToolsBashTimeout: viper.GetDuration("tools.bash.timeout"), 87 + ToolsBashMaxOutputBytes: viper.GetInt("tools.bash.max_output_bytes"), 88 + ToolsBashDenyPaths: append([]string(nil), viper.GetStringSlice("tools.bash.deny_paths")...), 89 + ToolsBashInjectedEnvVars: append([]string(nil), viper.GetStringSlice("tools.bash.injected_env_vars")...), 90 + ToolsBashRewriteEnabled: viper.GetBool("tools.bash.rewrite.enabled"), 91 + ToolsBashRewriteBinary: strings.TrimSpace(viper.GetString("tools.bash.rewrite.binary")), 92 + ToolsPowerShellEnabled: viper.GetBool("tools.powershell.enabled"), 93 + ToolsPowerShellTimeout: viper.GetDuration("tools.powershell.timeout"), 94 + ToolsPowerShellMaxOutputBytes: viper.GetInt("tools.powershell.max_output_bytes"), 95 + ToolsPowerShellDenyPaths: append([]string(nil), viper.GetStringSlice("tools.powershell.deny_paths")...), 92 96 ToolsPowerShellInjectedEnvVars: append([]string(nil), viper.GetStringSlice("tools.powershell.injected_env_vars")...), 93 - ToolsURLFetchEnabled: viper.GetBool("tools.url_fetch.enabled"), 94 - ToolsURLFetchTimeout: viper.GetDuration("tools.url_fetch.timeout"), 95 - ToolsURLFetchMaxBytes: viper.GetInt64("tools.url_fetch.max_bytes"), 96 - ToolsURLFetchMaxBytesDownload: viper.GetInt64("tools.url_fetch.max_bytes_download"), 97 - ToolsWebSearchEnabled: viper.GetBool("tools.web_search.enabled"), 98 - ToolsWebSearchTimeout: viper.GetDuration("tools.web_search.timeout"), 99 - ToolsWebSearchMaxResults: viper.GetInt("tools.web_search.max_results"), 100 - ToolsWebSearchBaseURL: strings.TrimSpace(viper.GetString("tools.web_search.base_url")), 101 - ToolsContactsSendEnabled: viper.GetBool("tools.contacts_send.enabled"), 102 - ContactsDir: pathutil.ResolveStateChildDir(fileStateDir, strings.TrimSpace(viper.GetString("contacts.dir_name")), "contacts"), 103 - TelegramBotToken: strings.TrimSpace(viper.GetString("telegram.bot_token")), 104 - TelegramBaseURL: "https://api.telegram.org", 105 - SlackBotToken: strings.TrimSpace(viper.GetString("slack.bot_token")), 106 - SlackBaseURL: strings.TrimSpace(viper.GetString("slack.base_url")), 107 - LineChannelAccessToken: strings.TrimSpace(viper.GetString("line.channel_access_token")), 108 - LineBaseURL: strings.TrimSpace(viper.GetString("line.base_url")), 109 - LarkAppID: strings.TrimSpace(viper.GetString("lark.app_id")), 110 - LarkAppSecret: strings.TrimSpace(viper.GetString("lark.app_secret")), 111 - LarkBaseURL: strings.TrimSpace(viper.GetString("lark.base_url")), 112 - ContactsFailureCooldown: contactsFailureCooldownFromViper(), 97 + ToolsURLFetchEnabled: viper.GetBool("tools.url_fetch.enabled"), 98 + ToolsURLFetchTimeout: viper.GetDuration("tools.url_fetch.timeout"), 99 + ToolsURLFetchMaxBytes: viper.GetInt64("tools.url_fetch.max_bytes"), 100 + ToolsURLFetchMaxBytesDownload: viper.GetInt64("tools.url_fetch.max_bytes_download"), 101 + ToolsWebSearchEnabled: viper.GetBool("tools.web_search.enabled"), 102 + ToolsWebSearchTimeout: viper.GetDuration("tools.web_search.timeout"), 103 + ToolsWebSearchMaxResults: viper.GetInt("tools.web_search.max_results"), 104 + ToolsWebSearchBaseURL: strings.TrimSpace(viper.GetString("tools.web_search.base_url")), 105 + ToolsContactsSendEnabled: viper.GetBool("tools.contacts_send.enabled"), 106 + ContactsDir: pathutil.ResolveStateChildDir(fileStateDir, strings.TrimSpace(viper.GetString("contacts.dir_name")), "contacts"), 107 + TelegramBotToken: strings.TrimSpace(viper.GetString("telegram.bot_token")), 108 + TelegramBaseURL: "https://api.telegram.org", 109 + SlackBotToken: strings.TrimSpace(viper.GetString("slack.bot_token")), 110 + SlackBaseURL: strings.TrimSpace(viper.GetString("slack.base_url")), 111 + LineChannelAccessToken: strings.TrimSpace(viper.GetString("line.channel_access_token")), 112 + LineBaseURL: strings.TrimSpace(viper.GetString("line.base_url")), 113 + LarkAppID: strings.TrimSpace(viper.GetString("lark.app_id")), 114 + LarkAppSecret: strings.TrimSpace(viper.GetString("lark.app_secret")), 115 + LarkBaseURL: strings.TrimSpace(viper.GetString("lark.base_url")), 116 + ContactsFailureCooldown: contactsFailureCooldownFromViper(), 113 117 } 114 118 } 115 119 ··· 167 171 MaxOutputBytes: cfg.ToolsBashMaxOutputBytes, 168 172 DenyPaths: append([]string(nil), cfg.ToolsBashDenyPaths...), 169 173 InjectedEnvVars: append([]string(nil), cfg.ToolsBashInjectedEnvVars...), 174 + Rewrite: builtin.BashRewriteConfig{ 175 + Enabled: cfg.ToolsBashRewriteEnabled, 176 + Binary: cfg.ToolsBashRewriteBinary, 177 + }, 170 178 }, 171 179 PowerShell: toolsutil.StaticPowerShellConfig{ 172 180 Enabled: cfg.ToolsPowerShellEnabled,
+1
docs/tools.md
··· 145 145 - Restricted by `tools.bash.deny_paths` and internal deny-token rules. 146 146 - Runs with an allowlisted environment instead of inheriting the full parent process environment. 147 147 - Extra environment variables can be injected explicitly via `tools.bash.injected_env_vars`. 148 + - `tools.bash.rewrite.enabled` optionally prefixes commands before execution with `tools.bash.rewrite.binary`. For example, binary `rtk` turns `git status` into `rtk git status`. 148 149 149 150 ## `powershell` 150 151
+2
internal/configdefaults/defaults.go
··· 179 179 v.SetDefault("tools.bash.max_output_bytes", 256*1024) 180 180 v.SetDefault("tools.bash.deny_paths", []string{"config.yaml"}) 181 181 v.SetDefault("tools.bash.injected_env_vars", []string{}) 182 + v.SetDefault("tools.bash.rewrite.enabled", false) 183 + v.SetDefault("tools.bash.rewrite.binary", "") 182 184 183 185 v.SetDefault("tools.powershell.timeout", 30*time.Second) 184 186 v.SetDefault("tools.powershell.max_output_bytes", 256*1024)
+2
internal/toolsutil/static_register.go
··· 70 70 MaxOutputBytes int 71 71 DenyPaths []string 72 72 InjectedEnvVars []string 73 + Rewrite builtin.BashRewriteConfig 73 74 } 74 75 75 76 type StaticPowerShellConfig struct { ··· 152 153 ) 153 154 bt.DenyPaths = append([]string(nil), cfg.Bash.DenyPaths...) 154 155 bt.InjectedEnvVars = append([]string(nil), cfg.Bash.InjectedEnvVars...) 156 + bt.Rewrite = cfg.Bash.Rewrite 155 157 if cfg.Common.AuthenticatedHTTPConfigured { 156 158 // Safety default: allow bash for local automation, but deny curl when authenticated HTTP is configured. 157 159 bt.DenyTokens = append(bt.DenyTokens, "curl")
+30
tools/builtin/bash.go
··· 26 26 DenyPaths []string 27 27 DenyTokens []string 28 28 InjectedEnvVars []string 29 + Rewrite BashRewriteConfig 29 30 } 30 31 31 32 type bashExecutionPayload = shellExecutionPayload 33 + 34 + type BashRewriteConfig struct { 35 + Enabled bool 36 + Binary string 37 + } 32 38 33 39 func NewBashTool(enabled bool, defaultTimeout time.Duration, maxOutputBytes int, roots pathroots.PathRoots) *BashTool { 34 40 if defaultTimeout <= 0 { ··· 88 94 if err != nil { 89 95 return "", err 90 96 } 97 + inv, err = t.rewriteInvocation(inv) 98 + if err != nil { 99 + return "", err 100 + } 91 101 runInSubtask, _ := asBool(params["run_in_subtask"]) 92 102 if runInSubtask && agent.SubtaskDepthFromContext(ctx) == 0 { 93 103 return t.executeInSubtask(ctx, inv.Command, inv.CWD, inv.Timeout) ··· 127 137 ReturnObservationOnTimeout: true, 128 138 ReturnObservationOnExecError: true, 129 139 } 140 + } 141 + 142 + func (t *BashTool) rewriteInvocation(inv shellInvocation) (shellInvocation, error) { 143 + if !t.Rewrite.Enabled { 144 + return inv, nil 145 + } 146 + binary := strings.TrimSpace(t.Rewrite.Binary) 147 + if binary == "" { 148 + return inv, nil 149 + } 150 + rewritten := shellQuote(binary) + " " + inv.Command 151 + if err := validateShellCommandAllowed(rewritten, t.commonConfig(), t.runnerSpec()); err != nil { 152 + return shellInvocation{}, err 153 + } 154 + inv.Command = rewritten 155 + return inv, nil 156 + } 157 + 158 + func shellQuote(s string) string { 159 + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" 130 160 } 131 161 132 162 func (t *BashTool) emitChunk(ctx context.Context, stream, text string) {
+67
tools/builtin/bash_test.go
··· 60 60 return n, os.ErrClosed 61 61 } 62 62 63 + func installFakeRTK(t *testing.T, content string) { 64 + t.Helper() 65 + dir := t.TempDir() 66 + path := filepath.Join(dir, "rtk") 67 + if err := os.WriteFile(path, []byte(content), 0o700); err != nil { 68 + t.Fatalf("write test executable: %v", err) 69 + } 70 + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) 71 + } 72 + 63 73 func TestContainsTokenBoundary(t *testing.T) { 64 74 cases := []struct { 65 75 name string ··· 230 240 } 231 241 if strings.Contains(out, "CUSTOM_HTTP_TIMEOUT=15s") { 232 242 t.Fatalf("unexpected non-allowed env var leaked: %q", out) 243 + } 244 + } 245 + 246 + func TestBashTool_Execute_RewritesCommand(t *testing.T) { 247 + installFakeRTK(t, `#!/bin/sh 248 + if [ "$1" = "printf" ] && [ "$2" = "raw" ]; then 249 + printf '%s\n' "printf rewritten" 250 + exit 0 251 + fi 252 + exit 1 253 + `) 254 + 255 + tool := NewBashTool(true, 5*time.Second, 4096, pathroots.PathRoots{}) 256 + tool.Rewrite = BashRewriteConfig{Enabled: true, Binary: "rtk"} 257 + 258 + out, err := tool.Execute(context.Background(), map[string]any{ 259 + "cmd": "printf raw", 260 + }) 261 + if err != nil { 262 + t.Fatalf("Execute() error = %v (out=%q)", err, out) 263 + } 264 + if !strings.Contains(out, "rewritten") { 265 + t.Fatalf("expected rewritten command output, got %q", out) 266 + } 267 + if strings.Contains(out, "raw") { 268 + t.Fatalf("expected original command not to run, got %q", out) 269 + } 270 + } 271 + 272 + func TestBashTool_Execute_RewriteBinaryUsesDenyRules(t *testing.T) { 273 + tool := NewBashTool(true, 5*time.Second, 4096, pathroots.PathRoots{}) 274 + tool.DenyTokens = []string{"curl"} 275 + tool.Rewrite = BashRewriteConfig{Enabled: true, Binary: "curl"} 276 + 277 + out, err := tool.Execute(context.Background(), map[string]any{ 278 + "cmd": "printf ok", 279 + }) 280 + if err == nil { 281 + t.Fatalf("expected deny error, got nil (out=%q)", out) 282 + } 283 + if !strings.Contains(err.Error(), "denied token") { 284 + t.Fatalf("error = %v, want denied token", err) 285 + } 286 + } 287 + 288 + func TestBashTool_Execute_RewriteEnabledEmptyBinaryDoesNothing(t *testing.T) { 289 + tool := NewBashTool(true, 5*time.Second, 4096, pathroots.PathRoots{}) 290 + tool.Rewrite = BashRewriteConfig{Enabled: true} 291 + 292 + out, err := tool.Execute(context.Background(), map[string]any{ 293 + "cmd": "printf raw", 294 + }) 295 + if err != nil { 296 + t.Fatalf("Execute() error = %v (out=%q)", err, out) 297 + } 298 + if !strings.Contains(out, "raw") { 299 + t.Fatalf("expected original command output, got %q", out) 233 300 } 234 301 } 235 302
+14 -7
tools/builtin/shell_runner.go
··· 75 75 return shellInvocation{}, err 76 76 } 77 77 78 - if spec.MatchDeniedPath != nil { 79 - if offending, ok := spec.MatchDeniedPath(cmdStr, common.DenyPaths); ok { 80 - return shellInvocation{}, fmt.Errorf("%s command references denied path %q (configure via tools.%s.deny_paths)", common.ToolName, offending, common.ToolName) 81 - } 82 - } 83 - if offending, ok := bashCommandDeniedTokens(cmdStr, common.DenyTokens); ok { 84 - return shellInvocation{}, fmt.Errorf("%s command references denied token %q", common.ToolName, offending) 78 + if err := validateShellCommandAllowed(cmdStr, common, spec); err != nil { 79 + return shellInvocation{}, err 85 80 } 86 81 87 82 cwd, _ := params["cwd"].(string) ··· 102 97 CWD: cwd, 103 98 Timeout: timeout, 104 99 }, nil 100 + } 101 + 102 + func validateShellCommandAllowed(cmdStr string, common shellToolCommon, spec shellRunnerSpec) error { 103 + if spec.MatchDeniedPath != nil { 104 + if offending, ok := spec.MatchDeniedPath(cmdStr, common.DenyPaths); ok { 105 + return fmt.Errorf("%s command references denied path %q (configure via tools.%s.deny_paths)", common.ToolName, offending, common.ToolName) 106 + } 107 + } 108 + if offending, ok := bashCommandDeniedTokens(cmdStr, common.DenyTokens); ok { 109 + return fmt.Errorf("%s command references denied token %q", common.ToolName, offending) 110 + } 111 + return nil 105 112 } 106 113 107 114 func executeShellCommand(ctx context.Context, params map[string]any, common shellToolCommon, spec shellRunnerSpec) (string, error) {