feat(config): add exec controls and gate cron commands on exec settings (#1685)

- add a dedicated exec settings section in the config page
- support timeout and custom allow/deny regex patterns for exec
- validate custom exec regex patterns in the config API
- block cron command scheduling and execution when exec is disabled
- update tests and i18n strings for the new command settings
This commit is contained in:
wenjie
2026-03-17 18:56:52 +08:00
committed by GitHub
parent 8a44410e37
commit 7b9fdaec32
9 changed files with 379 additions and 28 deletions
+22
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"regexp"
"github.com/sipeed/picoclaw/pkg/config"
)
@@ -188,6 +189,27 @@ func validateConfig(cfg *config.Config) []string {
errs = append(errs, "channels.discord.token is required when discord channel is enabled")
}
if cfg.Tools.Exec.Enabled {
if cfg.Tools.Exec.EnableDenyPatterns {
errs = append(
errs,
validateRegexPatterns("tools.exec.custom_deny_patterns", cfg.Tools.Exec.CustomDenyPatterns)...)
}
errs = append(
errs,
validateRegexPatterns("tools.exec.custom_allow_patterns", cfg.Tools.Exec.CustomAllowPatterns)...)
}
return errs
}
func validateRegexPatterns(field string, patterns []string) []string {
var errs []string
for index, pattern := range patterns {
if _, err := regexp.Compile(pattern); err != nil {
errs = append(errs, fmt.Sprintf("%s[%d] is not a valid regular expression: %v", field, index, err))
}
}
return errs
}
+79
View File
@@ -86,3 +86,82 @@ func TestHandleUpdateConfig_DoesNotInheritDefaultModelFields(t *testing.T) {
t.Fatalf("model_list[0].api_base = %q, want empty string", got)
}
}
func TestHandlePatchConfig_RejectsInvalidExecRegexPatterns(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
"tools": {
"exec": {
"custom_deny_patterns": ["("]
}
}
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte("custom_deny_patterns")) {
t.Fatalf("expected validation error mentioning custom_deny_patterns, body=%s", rec.Body.String())
}
}
func TestHandlePatchConfig_AllowsInvalidExecRegexPatternsWhenExecDisabled(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
"tools": {
"exec": {
"enabled": false,
"custom_deny_patterns": ["("],
"custom_allow_patterns": ["("]
}
}
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
}
func TestHandlePatchConfig_AllowsInvalidDenyRegexPatternsWhenDenyPatternsDisabled(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
"tools": {
"exec": {
"enabled": true,
"enable_deny_patterns": false,
"custom_deny_patterns": ["("]
}
}
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
}