feat(config): add command pattern detection tool in exec settings (#1971)
* 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. * Only process deny patterns when enableDenyPatterns is true
This commit is contained in:
parent
8da0638ee3
commit
adf1a5749d
5 changed files with 343 additions and 0 deletions
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"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("GET /api/config", h.handleGetConfig)
|
||||||
mux.HandleFunc("PUT /api/config", h.handleUpdateConfig)
|
mux.HandleFunc("PUT /api/config", h.handleUpdateConfig)
|
||||||
mux.HandleFunc("PATCH /api/config", h.handlePatchConfig)
|
mux.HandleFunc("PATCH /api/config", h.handlePatchConfig)
|
||||||
|
mux.HandleFunc("POST /api/config/test-command-patterns", h.handleTestCommandPatterns)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGetConfig returns the complete system configuration.
|
// 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"})
|
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.
|
// validateConfig checks the config for common errors before saving.
|
||||||
// Returns a list of human-readable error strings; empty means valid.
|
// Returns a list of human-readable error strings; empty means valid.
|
||||||
func validateConfig(cfg *config.Config) []string {
|
func validateConfig(cfg *config.Config) []string {
|
||||||
|
|
|
||||||
|
|
@ -282,3 +282,170 @@ func TestHandlePatchConfig_AllowsInvalidDenyRegexPatternsWhenDenyPatternsDisable
|
||||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useState } from "react"
|
||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
|
@ -7,6 +8,7 @@ import {
|
||||||
type LauncherForm,
|
type LauncherForm,
|
||||||
} from "@/components/config/form-model"
|
} from "@/components/config/form-model"
|
||||||
import { Field, SwitchCardField } from "@/components/shared-form"
|
import { Field, SwitchCardField } from "@/components/shared-form"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
|
@ -201,6 +203,56 @@ interface ExecSectionProps {
|
||||||
|
|
||||||
export function ExecSection({ form, onFieldChange }: ExecSectionProps) {
|
export function ExecSection({ form, onFieldChange }: ExecSectionProps) {
|
||||||
const { t } = useTranslation()
|
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 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 {
|
||||||
|
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 (
|
return (
|
||||||
<ConfigSectionCard title={t("pages.config.sections.exec")}>
|
<ConfigSectionCard title={t("pages.config.sections.exec")}>
|
||||||
|
|
@ -266,6 +318,50 @@ export function ExecSection({ form, onFieldChange }: ExecSectionProps) {
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("pages.config.pattern_detector_title")}
|
||||||
|
hint={t("pages.config.pattern_detector_hint")}
|
||||||
|
layout="setting-row"
|
||||||
|
controlClassName="md:max-w-md"
|
||||||
|
>
|
||||||
|
<div className="flex w-full flex-col gap-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={testCommand}
|
||||||
|
placeholder={t(
|
||||||
|
"pages.config.pattern_detector_input_placeholder",
|
||||||
|
)}
|
||||||
|
onChange={(e) => setTestCommand(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
testPatterns()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button onClick={testPatterns} disabled={isLoading}>
|
||||||
|
{t("pages.config.pattern_detector_test_button")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{testResult && (
|
||||||
|
<div
|
||||||
|
className={`rounded-md p-2 text-sm ${
|
||||||
|
testResult.allowed
|
||||||
|
? "bg-green-500/10 text-green-600"
|
||||||
|
: testResult.blocked
|
||||||
|
? "bg-red-500/10 text-red-600"
|
||||||
|
: "bg-muted text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{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")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={t("pages.config.exec_timeout_seconds")}
|
label={t("pages.config.exec_timeout_seconds")}
|
||||||
hint={t("pages.config.exec_timeout_seconds_hint")}
|
hint={t("pages.config.exec_timeout_seconds_hint")}
|
||||||
|
|
|
||||||
|
|
@ -433,6 +433,13 @@
|
||||||
"custom_allow_patterns": "Command Whitelist",
|
"custom_allow_patterns": "Command Whitelist",
|
||||||
"custom_allow_patterns_hint": "Add extra command-allow rules, one regular expression per line. A command matching any rule here skips blacklist matching, but other safety limits still apply.",
|
"custom_allow_patterns_hint": "Add extra command-allow rules, one regular expression per line. A command matching any rule here skips blacklist matching, but other safety limits still apply.",
|
||||||
"custom_patterns_placeholder": "^rm\\s+-rf\\b\n^git\\s+push\\b",
|
"custom_patterns_placeholder": "^rm\\s+-rf\\b\n^git\\s+push\\b",
|
||||||
|
"pattern_detector_title": "Pattern Detection Tool",
|
||||||
|
"pattern_detector_hint": "Enter a command to test if it matches any blacklist or whitelist patterns.",
|
||||||
|
"pattern_detector_input_placeholder": "Enter a command to test, e.g., rm -rf /tmp",
|
||||||
|
"pattern_detector_test_button": "Test",
|
||||||
|
"pattern_detector_result_allowed": "Allowed (matches whitelist)",
|
||||||
|
"pattern_detector_result_blocked": "Blocked (matches blacklist)",
|
||||||
|
"pattern_detector_result_no_match": "No match (will use default rules)",
|
||||||
"allow_shell_execution": "Allow Scheduled Commands",
|
"allow_shell_execution": "Allow Scheduled Commands",
|
||||||
"allow_shell_execution_hint": "Allow scheduled tasks to run commands by default. When disabled, users must pass command_confirm=true to schedule a command task.",
|
"allow_shell_execution_hint": "Allow scheduled tasks to run commands by default. When disabled, users must pass command_confirm=true to schedule a command task.",
|
||||||
"cron_exec_timeout": "Scheduled Command Timeout (minutes)",
|
"cron_exec_timeout": "Scheduled Command Timeout (minutes)",
|
||||||
|
|
|
||||||
|
|
@ -433,6 +433,13 @@
|
||||||
"custom_allow_patterns": "命令白名单",
|
"custom_allow_patterns": "命令白名单",
|
||||||
"custom_allow_patterns_hint": "用于补充额外的命令放行规则,每行一个正则表达式。命中任意一条规则的命令会跳过黑名单检查,但仍受其他安全限制约束。",
|
"custom_allow_patterns_hint": "用于补充额外的命令放行规则,每行一个正则表达式。命中任意一条规则的命令会跳过黑名单检查,但仍受其他安全限制约束。",
|
||||||
"custom_patterns_placeholder": "^rm\\s+-rf\\b\n^git\\s+push\\b",
|
"custom_patterns_placeholder": "^rm\\s+-rf\\b\n^git\\s+push\\b",
|
||||||
|
"pattern_detector_title": "规则检测工具",
|
||||||
|
"pattern_detector_hint": "输入命令以检测其是否匹配黑名单或白名单规则。",
|
||||||
|
"pattern_detector_input_placeholder": "输入要检测的命令,例如 rm -rf /tmp",
|
||||||
|
"pattern_detector_test_button": "检测",
|
||||||
|
"pattern_detector_result_allowed": "允许(匹配白名单)",
|
||||||
|
"pattern_detector_result_blocked": "阻止(匹配黑名单)",
|
||||||
|
"pattern_detector_result_no_match": "无匹配(将使用默认规则)",
|
||||||
"allow_shell_execution": "允许定时任务运行命令",
|
"allow_shell_execution": "允许定时任务运行命令",
|
||||||
"allow_shell_execution_hint": "开启后,定时任务默认允许运行命令。关闭后,必须显式传入 command_confirm=true 才能创建运行命令的定时任务。",
|
"allow_shell_execution_hint": "开启后,定时任务默认允许运行命令。关闭后,必须显式传入 command_confirm=true 才能创建运行命令的定时任务。",
|
||||||
"cron_exec_timeout": "定时命令超时(分钟)",
|
"cron_exec_timeout": "定时命令超时(分钟)",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue