From 0abbc1eea717d51d08ec7e1803914af4aae2c3bb Mon Sep 17 00:00:00 2001 From: uiyzzi Date: Tue, 24 Mar 2026 21:18:31 +0800 Subject: [PATCH 1/2] Add command pattern testing endpoint and UI tool Adds a new API endpoint `/api/config/test-command-patterns` that tests a command against configured whitelist and blacklist patterns, along with a frontend UI component to interactively test patterns. --- web/backend/api/config.go | 66 +++++++ web/backend/api/config_test.go | 167 ++++++++++++++++++ .../src/components/config/config-sections.tsx | 94 ++++++++++ web/frontend/src/i18n/locales/en.json | 7 + web/frontend/src/i18n/locales/zh.json | 7 + 5 files changed, 341 insertions(+) diff --git a/web/backend/api/config.go b/web/backend/api/config.go index e67e3e6d7b..dc5fb14420 100644 --- a/web/backend/api/config.go +++ b/web/backend/api/config.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "regexp" + "strings" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" @@ -16,6 +17,7 @@ func (h *Handler) registerConfigRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/config", h.handleGetConfig) mux.HandleFunc("PUT /api/config", h.handleUpdateConfig) mux.HandleFunc("PATCH /api/config", h.handlePatchConfig) + mux.HandleFunc("POST /api/config/test-command-patterns", h.handleTestCommandPatterns) } // handleGetConfig returns the complete system configuration. @@ -179,6 +181,70 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } +// handleTestCommandPatterns tests a command against whitelist and blacklist patterns. +// +// POST /api/config/test-command-patterns +func (h *Handler) handleTestCommandPatterns(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + var req struct { + AllowPatterns []string `json:"allow_patterns"` + DenyPatterns []string `json:"deny_patterns"` + Command string `json:"command"` + } + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + lower := strings.ToLower(strings.TrimSpace(req.Command)) + + type result struct { + Allowed bool `json:"allowed"` + Blocked bool `json:"blocked"` + MatchedWhitelist *string `json:"matched_whitelist,omitempty"` + MatchedBlacklist *string `json:"matched_blacklist,omitempty"` + } + + resp := result{Allowed: false, Blocked: false} + + // Check whitelist first + for _, pattern := range req.AllowPatterns { + re, err := regexp.Compile(pattern) + if err != nil { + continue // skip invalid patterns + } + if re.MatchString(lower) { + resp.Allowed = true + resp.MatchedWhitelist = &pattern + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + return + } + } + + // Check blacklist + for _, pattern := range req.DenyPatterns { + re, err := regexp.Compile(pattern) + if err != nil { + continue + } + if re.MatchString(lower) { + resp.Blocked = true + resp.MatchedBlacklist = &pattern + break + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + // validateConfig checks the config for common errors before saving. // Returns a list of human-readable error strings; empty means valid. func validateConfig(cfg *config.Config) []string { diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go index 9b05546f9f..36acd95b02 100644 --- a/web/backend/api/config_test.go +++ b/web/backend/api/config_test.go @@ -282,3 +282,170 @@ func TestHandlePatchConfig_AllowsInvalidDenyRegexPatternsWhenDenyPatternsDisable t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } } + +// testCommandPatterns is a helper that sets up a handler and sends a test-command-patterns request. +func testCommandPatterns(t *testing.T, configPath string, body string) *httptest.ResponseRecorder { + t.Helper() + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + req := httptest.NewRequest(http.MethodPost, "/api/config/test-command-patterns", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + return rec +} + +func TestHandleTestCommandPatterns_MatchesWhitelist(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + rec := testCommandPatterns(t, configPath, `{ + "allow_patterns": ["^echo\\s+hello"], + "deny_patterns": ["^rm\\s+-rf"], + "command": "echo hello world" + }`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) { + t.Fatalf("expected allowed=true, body=%s", rec.Body.String()) + } + if bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) { + t.Fatalf("expected blocked=false when whitelist matches, body=%s", rec.Body.String()) + } +} + +func TestHandleTestCommandPatterns_MatchesBlacklistNotWhitelist(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + rec := testCommandPatterns(t, configPath, `{ + "allow_patterns": ["^echo\\s+hello"], + "deny_patterns": ["^rm\\s+-rf"], + "command": "rm -rf /tmp" + }`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) { + t.Fatalf("expected blocked=true, body=%s", rec.Body.String()) + } + if bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) { + t.Fatalf("expected allowed=false when blacklist matches but not whitelist, body=%s", rec.Body.String()) + } +} + +func TestHandleTestCommandPatterns_MatchesNeither(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + rec := testCommandPatterns(t, configPath, `{ + "allow_patterns": ["^echo\\s+hello"], + "deny_patterns": ["^rm\\s+-rf"], + "command": "ls -la" + }`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) { + t.Fatalf("expected allowed=false, body=%s", rec.Body.String()) + } + if bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) { + t.Fatalf("expected blocked=false, body=%s", rec.Body.String()) + } +} + +func TestHandleTestCommandPatterns_CaseInsensitiveWithGoFlag(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + rec := testCommandPatterns(t, configPath, `{ + "allow_patterns": ["(?i)^ECHO"], + "deny_patterns": [], + "command": "echo hello" + }`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) { + t.Fatalf("expected allowed=true with Go (?i) flag, body=%s", rec.Body.String()) + } +} + +func TestHandleTestCommandPatterns_EmptyPatterns(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + rec := testCommandPatterns(t, configPath, `{ + "allow_patterns": [], + "deny_patterns": [], + "command": "rm -rf /tmp" + }`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) { + t.Fatalf("expected allowed=false with empty patterns, body=%s", rec.Body.String()) + } + if bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) { + t.Fatalf("expected blocked=false with empty patterns, body=%s", rec.Body.String()) + } +} + +func TestHandleTestCommandPatterns_InvalidRegexSkipped(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + rec := testCommandPatterns(t, configPath, `{ + "allow_patterns": ["([[", "^echo"], + "deny_patterns": [], + "command": "echo hello" + }`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) { + t.Fatalf("expected allowed=true, invalid pattern skipped and valid one matched, body=%s", rec.Body.String()) + } +} + +func TestHandleTestCommandPatterns_ReturnsMatchedPattern(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + rec := testCommandPatterns(t, configPath, `{ + "allow_patterns": [], + "deny_patterns": ["\\$(?i)[a-zA-Z_]*(SECRET|KEY|PASSWORD|TOKEN|AUTH)[a-zA-Z0-9_]*"], + "command": "echo $GITHUB_API_KEY" + }`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) { + t.Fatalf("expected blocked=true, body=%s", rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(`matched_blacklist`)) { + t.Fatalf("expected matched_blacklist field, body=%s", rec.Body.String()) + } +} + +func TestHandleTestCommandPatterns_InvalidJSON(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + req := httptest.NewRequest( + http.MethodPost, + "/api/config/test-command-patterns", + bytes.NewBufferString(`{invalid json}`), + ) + 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()) + } +} diff --git a/web/frontend/src/components/config/config-sections.tsx b/web/frontend/src/components/config/config-sections.tsx index 5482b0a351..d33420b465 100644 --- a/web/frontend/src/components/config/config-sections.tsx +++ b/web/frontend/src/components/config/config-sections.tsx @@ -1,3 +1,4 @@ +import { useState } from "react" import type { ReactNode } from "react" import { useTranslation } from "react-i18next" @@ -7,6 +8,7 @@ import { type LauncherForm, } from "@/components/config/form-model" import { Field, SwitchCardField } from "@/components/shared-form" +import { Button } from "@/components/ui/button" import { Card, CardContent, @@ -201,6 +203,54 @@ interface ExecSectionProps { export function ExecSection({ form, onFieldChange }: ExecSectionProps) { const { t } = useTranslation() + const [testCommand, setTestCommand] = useState("") + const [testResult, setTestResult] = useState<{ + allowed: boolean + blocked: boolean + matchedWhitelist: string | null + matchedBlacklist: string | null + } | null>(null) + const [isLoading, setIsLoading] = useState(false) + + const testPatterns = async () => { + if (!testCommand.trim()) { + setTestResult(null) + return + } + + const denyPatterns = form.customDenyPatternsText + .split("\n") + .map((p) => p.trim()) + .filter((p) => p.length > 0) + const allowPatterns = form.customAllowPatternsText + .split("\n") + .map((p) => p.trim()) + .filter((p) => p.length > 0) + + setIsLoading(true) + try { + const res = await fetch("/api/config/test-command-patterns", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + allow_patterns: allowPatterns, + deny_patterns: denyPatterns, + command: testCommand, + }), + }) + const data = await res.json() + setTestResult({ + allowed: data.allowed, + blocked: data.blocked, + matchedWhitelist: data.matched_whitelist ?? null, + matchedBlacklist: data.matched_blacklist ?? null, + }) + } catch { + setTestResult(null) + } finally { + setIsLoading(false) + } + } return ( @@ -266,6 +316,50 @@ export function ExecSection({ form, onFieldChange }: ExecSectionProps) { /> + +
+
+ setTestCommand(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + testPatterns() + } + }} + /> + +
+ {testResult && ( +
+ {testResult.allowed + ? `${t("pages.config.pattern_detector_result_allowed")}${testResult.matchedWhitelist ? ` (${testResult.matchedWhitelist})` : ""}` + : testResult.blocked + ? `${t("pages.config.pattern_detector_result_blocked")}${testResult.matchedBlacklist ? ` (${testResult.matchedBlacklist})` : ""}` + : t("pages.config.pattern_detector_result_no_match")} +
+ )} +
+
+ Date: Wed, 25 Mar 2026 10:13:41 +0800 Subject: [PATCH 2/2] Only process deny patterns when enableDenyPatterns is true --- web/frontend/src/components/config/config-sections.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/web/frontend/src/components/config/config-sections.tsx b/web/frontend/src/components/config/config-sections.tsx index d33420b465..1f7426d22b 100644 --- a/web/frontend/src/components/config/config-sections.tsx +++ b/web/frontend/src/components/config/config-sections.tsx @@ -218,14 +218,16 @@ export function ExecSection({ form, onFieldChange }: ExecSectionProps) { return } - const denyPatterns = form.customDenyPatternsText - .split("\n") - .map((p) => p.trim()) - .filter((p) => p.length > 0) const allowPatterns = form.customAllowPatternsText .split("\n") .map((p) => p.trim()) .filter((p) => p.length > 0) + const denyPatterns = form.enableDenyPatterns + ? form.customDenyPatternsText + .split("\n") + .map((p) => p.trim()) + .filter((p) => p.length > 0) + : [] setIsLoading(true) try {