feat(tools): add command keyword allowlist

Closes #42
This commit is contained in:
Duy /zuey/ authored and GitHub committed 2026-05-24 13:52:23 +07:00
1 parent 5ae5815738
commit 61fdcdcb81
14 files changed
+600 -97

No files matched your search

+5 -1
View File
@@ -30,6 +30,10 @@ func subscribeShellDenyGroupsReload(msgBus *bus.MessageBus, toolsReg *tools.Regi
return
}
et.SetGlobalShellDenyGroups(updatedCfg.Tools.ShellDenyGroups)
slog.Info("shell deny groups reloaded via pub/sub", "groups", len(updatedCfg.Tools.ShellDenyGroups))
et.SetCommandKeywordAllowlist(updatedCfg.Tools.CommandKeywordAllowlist)
slog.Info("shell deny groups reloaded via pub/sub",
"groups", len(updatedCfg.Tools.ShellDenyGroups),
"command_keyword_allowlist_rules", len(updatedCfg.Tools.CommandKeywordAllowlist),
)
})
}
@@ -35,6 +35,37 @@ func TestShellDenyGroupsConfigReload_UpdatesGlobal(t *testing.T) {
if v, ok := got["package_install"]; !ok || v != true {
t.Fatalf("expected pub/sub to set global package_install=true, got %v", got)
}
rules := execTool.CommandKeywordAllowlistForTest()
if len(rules) != 0 {
t.Fatalf("expected empty command keyword allowlist, got %v", rules)
}
}
func TestShellDenyGroupsConfigReload_UpdatesCommandKeywordAllowlist(t *testing.T) {
msgBus := bus.New()
defer msgBus.Unsubscribe("shell-deny-groups-config-reload")
toolsReg := tools.NewRegistry()
execTool := tools.NewExecTool("/tmp", false)
toolsReg.Register(execTool)
subscribeShellDenyGroupsReload(msgBus, toolsReg)
msgBus.Broadcast(bus.Event{
Name: bus.TopicConfigChanged,
Payload: &config.Config{
Tools: config.ToolsConfig{
CommandKeywordAllowlist: []config.CommandKeywordAllowlistRule{
{ID: "github-content", Command: "gh", Args: []string{"--body"}, Keywords: []string{"secret"}},
},
},
},
})
rules := execTool.CommandKeywordAllowlistForTest()
if len(rules) != 1 || rules[0].ID != "github-content" {
t.Fatalf("expected command keyword allowlist to reload, got %v", rules)
}
}
// TestShellDenyGroupsConfigReload_IgnoresOtherEvents: subscriber must guard
+2 -2
View File
@@ -14,11 +14,11 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/edition"
mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
"github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/skills"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
@@ -214,6 +214,7 @@ func setupToolRegistry(
// Apply global shell deny-group toggles before any request can arrive.
// Per-agent overrides via store.WithShellDenyGroups still win per-key.
et.SetGlobalShellDenyGroups(cfg.Tools.ShellDenyGroups)
et.SetCommandKeywordAllowlist(cfg.Tools.CommandKeywordAllowlist)
et.DenyPaths(dataDir, ".goclaw/")
// Allow skills execution: master-tenant skills-store + all tenant-scoped skills-store dirs.
et.AllowPathExemptions(
@@ -603,4 +604,3 @@ func setupSkillsSystem(
return skillsLoader, skillSearchTool, globalSkillsDir, bundledSkillsDir, builtinSkillsDir
}
+27 -1
View File
@@ -246,6 +246,32 @@ User-facing parameter schemas for the most commonly configured tools.
```
Available presets: `gh`, `gcloud`, `aws`, `kubectl`, `terraform`.
### Credentialed CLI keyword allowlist
`config.tools.commandKeywordAllowlist` lets operators allow specific product or security vocabulary inside selected credentialed CLI content arguments without disabling `deny_args`.
Example:
```json
{
"tools": {
"commandKeywordAllowlist": [
{
"id": "github-content",
"command": "gh",
"subcommands": ["issue create", "issue edit", "pr create", "pr comment"],
"args": ["--body", "--title"],
"argPositions": [],
"keywords": ["secret", "secrets", "token", "credential"],
"reason": "Allow security vocabulary in GitHub issue and PR prose"
}
]
}
}
```
The rule above allows `gh issue create --body "secret rotation notes"` but still blocks command paths like `gh secret set TOKEN`. `argPositions` are 0-based after the matched subcommand. The scanner evaluates command arguments only; it does not read the contents of files passed through arguments such as `--body-file`.
---
## 6. Interception Layer
@@ -406,7 +432,7 @@ Current adopters: `web_search`, `web_fetch`, `tts`, `create_image`, `read_image`
- Per-key: agent value takes precedence over global value
- Multi-tenant invariant: each tenant's config is isolated
**Live reload:** Changes to `config.tools.shellDenyGroups` propagate via `bus.TopicConfigChanged` pub/sub. Next agent turn automatically applies new toggles.
**Live reload:** Changes to `config.tools.shellDenyGroups` and `config.tools.commandKeywordAllowlist` propagate via `bus.TopicConfigChanged` pub/sub. Next agent turn automatically applies new toggles.
**Deny-group classes** (from `internal/tools/shell_deny_groups.go` — all denied by default):
+15
View File
@@ -6,6 +6,21 @@ Significant changes, features, and fixes in reverse chronological order.
## 2026-05-24
### Command keyword allowlist
**Features**
- Added scoped credentialed CLI keyword allowlist config for content arguments and positional arguments, with runtime reload and web config editing.
**Fixes**
- Kept credentialed CLI `deny_args` active for command paths such as `gh secret set` while allowing approved GitHub issue/PR prose to mention security vocabulary.
- Added security audit logging for real allowlisted pass-throughs without logging full argument values.
**Tests**
- Added regression coverage for scoped keyword masking, disabled rules, unsafe positional rules, config reload, and race-safe policy snapshots.
### Browser cookie sync and config UI
**Features**
+89 -75
View File
@@ -24,25 +24,25 @@ type ChannelsConfig struct {
}
type TelegramConfig struct {
Enabled bool `json:"enabled"`
Token string `json:"token"`
Proxy string `json:"proxy,omitempty"`
APIServer string `json:"api_server,omitempty"` // custom Telegram Bot API server URL (e.g. "http://localhost:8081")
AllowFrom FlexibleStringSlice `json:"allow_from"`
DMPolicy string `json:"dm_policy,omitempty"` // "pairing" (default), "allowlist", "open", "disabled"
GroupPolicy string `json:"group_policy,omitempty"` // "open" (default), "allowlist", "disabled"
RequireMention *bool `json:"require_mention,omitempty"` // require @bot mention in groups (default true)
MentionMode string `json:"mention_mode,omitempty"` // "strict" (default) = only respond when mentioned; "yield" = respond unless another bot is mentioned
HistoryLimit int `json:"history_limit,omitempty"` // max pending group messages for context (default 50, 0=disabled)
DMStream *bool `json:"dm_stream,omitempty"` // enable streaming for DMs (default false) — edits placeholder progressively
GroupStream *bool `json:"group_stream,omitempty"` // enable streaming for groups (default false) — sends new message, edits progressively
DraftTransport *bool `json:"draft_transport,omitempty"` // use sendMessageDraft for DM streaming (default true) — stealth preview, no notifications per edit
ReasoningStream *bool `json:"reasoning_stream,omitempty"` // show reasoning as separate message when provider emits thinking events (default true)
ReactionLevel string `json:"reaction_level,omitempty"` // "off" (default), "minimal", "full" — status emoji reactions
MediaMaxBytes int64 `json:"media_max_bytes,omitempty"` // max media download size in bytes (default 20MB)
LinkPreview *bool `json:"link_preview,omitempty"` // enable URL previews in messages (default true)
BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit)
ForceIPv4 bool `json:"force_ipv4,omitempty"` // force IPv4 for all Telegram API requests (use when IPv6 routing is broken)
Enabled bool `json:"enabled"`
Token string `json:"token"`
Proxy string `json:"proxy,omitempty"`
APIServer string `json:"api_server,omitempty"` // custom Telegram Bot API server URL (e.g. "http://localhost:8081")
AllowFrom FlexibleStringSlice `json:"allow_from"`
DMPolicy string `json:"dm_policy,omitempty"` // "pairing" (default), "allowlist", "open", "disabled"
GroupPolicy string `json:"group_policy,omitempty"` // "open" (default), "allowlist", "disabled"
RequireMention *bool `json:"require_mention,omitempty"` // require @bot mention in groups (default true)
MentionMode string `json:"mention_mode,omitempty"` // "strict" (default) = only respond when mentioned; "yield" = respond unless another bot is mentioned
HistoryLimit int `json:"history_limit,omitempty"` // max pending group messages for context (default 50, 0=disabled)
DMStream *bool `json:"dm_stream,omitempty"` // enable streaming for DMs (default false) — edits placeholder progressively
GroupStream *bool `json:"group_stream,omitempty"` // enable streaming for groups (default false) — sends new message, edits progressively
DraftTransport *bool `json:"draft_transport,omitempty"` // use sendMessageDraft for DM streaming (default true) — stealth preview, no notifications per edit
ReasoningStream *bool `json:"reasoning_stream,omitempty"` // show reasoning as separate message when provider emits thinking events (default true)
ReactionLevel string `json:"reaction_level,omitempty"` // "off" (default), "minimal", "full" — status emoji reactions
MediaMaxBytes int64 `json:"media_max_bytes,omitempty"` // max media download size in bytes (default 20MB)
LinkPreview *bool `json:"link_preview,omitempty"` // enable URL previews in messages (default true)
BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit)
ForceIPv4 bool `json:"force_ipv4,omitempty"` // force IPv4 for all Telegram API requests (use when IPv6 routing is broken)
// Optional STT (Speech-to-Text) pipeline for voice/audio inbound messages.
// When stt_proxy_url is set, audio/voice messages are transcribed before being forwarded to the agent.
@@ -133,7 +133,7 @@ type SlackConfig struct {
type WhatsAppConfig struct {
Enabled bool `json:"enabled"`
AuthDir string `json:"auth_dir,omitempty"` // optional: SQLite auth dir override (desktop)
AuthDir string `json:"auth_dir,omitempty"` // optional: SQLite auth dir override (desktop)
AllowFrom FlexibleStringSlice `json:"allow_from"`
DMPolicy string `json:"dm_policy,omitempty"` // "pairing" (default for DB instances), "open", "allowlist", "disabled"
GroupPolicy string `json:"group_policy,omitempty"` // "pairing" (default for DB instances), "open" (default for config), "allowlist", "disabled"
@@ -196,25 +196,25 @@ type FeishuConfig struct {
// ProvidersConfig maps provider name to its config.
type ProvidersConfig struct {
Anthropic ProviderConfig `json:"anthropic"`
OpenAI ProviderConfig `json:"openai"`
OpenRouter ProviderConfig `json:"openrouter"`
Groq ProviderConfig `json:"groq"`
Gemini ProviderConfig `json:"gemini"`
DeepSeek ProviderConfig `json:"deepseek"`
Mistral ProviderConfig `json:"mistral"`
XAI ProviderConfig `json:"xai"`
MiniMax ProviderConfig `json:"minimax"`
Cohere ProviderConfig `json:"cohere"`
Perplexity ProviderConfig `json:"perplexity"`
DashScope ProviderConfig `json:"dashscope"`
Bailian ProviderConfig `json:"bailian"`
Zai ProviderConfig `json:"zai"`
ZaiCoding ProviderConfig `json:"zai_coding"`
Ollama OllamaConfig `json:"ollama"` // local Ollama instance (no API key needed)
OllamaCloud ProviderConfig `json:"ollama_cloud"` // Ollama Cloud (API key required)
ClaudeCLI ClaudeCLIConfig `json:"claude_cli"`
ACP ACPConfig `json:"acp"`
Anthropic ProviderConfig `json:"anthropic"`
OpenAI ProviderConfig `json:"openai"`
OpenRouter ProviderConfig `json:"openrouter"`
Groq ProviderConfig `json:"groq"`
Gemini ProviderConfig `json:"gemini"`
DeepSeek ProviderConfig `json:"deepseek"`
Mistral ProviderConfig `json:"mistral"`
XAI ProviderConfig `json:"xai"`
MiniMax ProviderConfig `json:"minimax"`
Cohere ProviderConfig `json:"cohere"`
Perplexity ProviderConfig `json:"perplexity"`
DashScope ProviderConfig `json:"dashscope"`
Bailian ProviderConfig `json:"bailian"`
Zai ProviderConfig `json:"zai"`
ZaiCoding ProviderConfig `json:"zai_coding"`
Ollama OllamaConfig `json:"ollama"` // local Ollama instance (no API key needed)
OllamaCloud ProviderConfig `json:"ollama_cloud"` // Ollama Cloud (API key required)
ClaudeCLI ClaudeCLIConfig `json:"claude_cli"`
ACP ACPConfig `json:"acp"`
Novita ProviderConfig `json:"novita"` // Novita AI (OpenAI-compatible endpoint)
BytePlus ProviderConfig `json:"byteplus"` // BytePlus ModelArk (Seed 2.0)
BytePlusCoding ProviderConfig `json:"byteplus_coding"` // BytePlus ModelArk Coding Plan
@@ -362,16 +362,16 @@ type QuotaConfig struct {
// GatewayConfig controls the gateway server.
type GatewayConfig struct {
Host string `json:"host"`
Port int `json:"port"`
Token string `json:"token,omitempty"` // bearer token for WS/HTTP auth
OwnerIDs []string `json:"owner_ids,omitempty"` // sender IDs considered "owner"
AllowedOrigins []string `json:"allowed_origins,omitempty"` // WebSocket CORS whitelist (empty = allow all)
MaxMessageChars int `json:"max_message_chars,omitempty"` // max user message characters (default 32000)
RateLimitRPM int `json:"rate_limit_rpm,omitempty"` // rate limit: requests per minute per user (default 20, 0 = disabled)
InjectionAction string `json:"injection_action,omitempty"` // prompt injection action: "log", "warn" (default), "block", "off"
InboundDebounceMs int `json:"inbound_debounce_ms,omitempty"` // merge rapid channel/Web Chat messages from same sender/session (0 = no wait)
Quota *QuotaConfig `json:"quota,omitempty"` // per-user/group request quotas
Host string `json:"host"`
Port int `json:"port"`
Token string `json:"token,omitempty"` // bearer token for WS/HTTP auth
OwnerIDs []string `json:"owner_ids,omitempty"` // sender IDs considered "owner"
AllowedOrigins []string `json:"allowed_origins,omitempty"` // WebSocket CORS whitelist (empty = allow all)
MaxMessageChars int `json:"max_message_chars,omitempty"` // max user message characters (default 32000)
RateLimitRPM int `json:"rate_limit_rpm,omitempty"` // rate limit: requests per minute per user (default 20, 0 = disabled)
InjectionAction string `json:"injection_action,omitempty"` // prompt injection action: "log", "warn" (default), "block", "off"
InboundDebounceMs int `json:"inbound_debounce_ms,omitempty"` // merge rapid channel/Web Chat messages from same sender/session (0 = no wait)
Quota *QuotaConfig `json:"quota,omitempty"` // per-user/group request quotas
BlockReply *bool `json:"block_reply,omitempty"` // deliver intermediate text during tool iterations (default false)
ToolStatus *bool `json:"tool_status,omitempty"` // show tool name in streaming preview during tool execution (default true)
TaskRecoveryIntervalSec int `json:"task_recovery_interval_sec,omitempty"` // team task recovery ticker interval in seconds (default 300 = 5min)
@@ -381,18 +381,32 @@ type GatewayConfig struct {
// ToolsConfig controls tool availability, policy, and web search.
type ToolsConfig struct {
Profile string `json:"profile,omitempty"` // global profile: "minimal", "coding", "messaging", "full"
Allow []string `json:"allow,omitempty"` // global allow list (tool names or "group:xxx")
Deny []string `json:"deny,omitempty"` // global deny list
AlsoAllow []string `json:"alsoAllow,omitempty"` // additive: adds without removing existing
ByProvider map[string]*ToolPolicySpec `json:"byProvider,omitempty"` // per-provider overrides
ShellDenyGroups map[string]bool `json:"shellDenyGroups,omitempty"` // global shell deny-group toggles (group name -> denied); per-agent overrides win per-key
ExecApproval ExecApprovalCfg `json:"execApproval"` // exec command approval settings
WebFetch WebFetchPolicyConfig `json:"web_fetch"` // domain policy for URL fetching
Browser BrowserToolConfig `json:"browser"`
RateLimitPerHour int `json:"rate_limit_per_hour,omitempty"` // max tool executions per hour per session (0 = disabled)
ScrubCredentials *bool `json:"scrub_credentials,omitempty"` // auto-redact API keys/tokens in tool output (default true)
McpServers map[string]*MCPServerConfig `json:"mcp_servers,omitempty"` // external MCP server connections
Profile string `json:"profile,omitempty"` // global profile: "minimal", "coding", "messaging", "full"
Allow []string `json:"allow,omitempty"` // global allow list (tool names or "group:xxx")
Deny []string `json:"deny,omitempty"` // global deny list
AlsoAllow []string `json:"alsoAllow,omitempty"` // additive: adds without removing existing
ByProvider map[string]*ToolPolicySpec `json:"byProvider,omitempty"` // per-provider overrides
ShellDenyGroups map[string]bool `json:"shellDenyGroups,omitempty"` // global shell deny-group toggles (group name -> denied); per-agent overrides win per-key
CommandKeywordAllowlist []CommandKeywordAllowlistRule `json:"commandKeywordAllowlist,omitempty"` // scoped bypass for credentialed CLI content keywords
ExecApproval ExecApprovalCfg `json:"execApproval"` // exec command approval settings
WebFetch WebFetchPolicyConfig `json:"web_fetch"` // domain policy for URL fetching
Browser BrowserToolConfig `json:"browser"`
RateLimitPerHour int `json:"rate_limit_per_hour,omitempty"` // max tool executions per hour per session (0 = disabled)
ScrubCredentials *bool `json:"scrub_credentials,omitempty"` // auto-redact API keys/tokens in tool output (default true)
McpServers map[string]*MCPServerConfig `json:"mcp_servers,omitempty"` // external MCP server connections
}
// CommandKeywordAllowlistRule scopes product/security vocabulary that may appear
// in credentialed CLI content args without disabling command-path deny patterns.
type CommandKeywordAllowlistRule struct {
ID string `json:"id,omitempty"`
Command string `json:"command"`
Subcommands []string `json:"subcommands,omitempty"`
Args []string `json:"args,omitempty"`
ArgPositions []int `json:"argPositions,omitempty"` // 0-based after matched subcommand
Keywords []string `json:"keywords,omitempty"`
Reason string `json:"reason,omitempty"`
Enabled *bool `json:"enabled,omitempty"` // nil means enabled
}
// MCPServerConfig configures a single external MCP server connection.
@@ -429,24 +443,24 @@ type WebFetchPolicyConfig struct {
// BrowserToolConfig controls the browser automation tool.
type BrowserToolConfig struct {
Enabled bool `json:"enabled"` // enable the browser tool (default false)
Headless bool `json:"headless,omitempty"` // run Chrome in headless mode (ignored when RemoteURL is set)
RemoteURL string `json:"remote_url,omitempty"` // CDP endpoint for remote Chrome sidecar, e.g. "ws://chrome:9222"
ActionTimeoutMs int `json:"action_timeout_ms,omitempty"` // per-action timeout in ms (default 30000)
IdleTimeoutMs int `json:"idle_timeout_ms,omitempty"` // idle page auto-close in ms (default 600000, 0=disabled)
MaxPages int `json:"max_pages,omitempty"` // max open pages per tenant (default 5)
CookieSyncEnabled bool `json:"cookie_sync_enabled"` // apply selected synced cookies to scoped browser sessions
Enabled bool `json:"enabled"` // enable the browser tool (default false)
Headless bool `json:"headless,omitempty"` // run Chrome in headless mode (ignored when RemoteURL is set)
RemoteURL string `json:"remote_url,omitempty"` // CDP endpoint for remote Chrome sidecar, e.g. "ws://chrome:9222"
ActionTimeoutMs int `json:"action_timeout_ms,omitempty"` // per-action timeout in ms (default 30000)
IdleTimeoutMs int `json:"idle_timeout_ms,omitempty"` // idle page auto-close in ms (default 600000, 0=disabled)
MaxPages int `json:"max_pages,omitempty"` // max open pages per tenant (default 5)
CookieSyncEnabled bool `json:"cookie_sync_enabled"` // apply selected synced cookies to scoped browser sessions
}
// ToolPolicySpec defines a tool policy at any level (global, per-agent, per-provider).
type ToolPolicySpec struct {
Profile string `json:"profile,omitempty"`
Allow []string `json:"allow,omitempty"`
Deny []string `json:"deny,omitempty"`
AlsoAllow []string `json:"alsoAllow,omitempty"`
ByProvider map[string]*ToolPolicySpec `json:"byProvider,omitempty"`
Wait *WaitToolPolicy `json:"wait,omitempty"`
ToolCallPrefix string `json:"toolCallPrefix,omitempty"` // prefix to strip from model's tool call names before registry lookup
Profile string `json:"profile,omitempty"`
Allow []string `json:"allow,omitempty"`
Deny []string `json:"deny,omitempty"`
AlsoAllow []string `json:"alsoAllow,omitempty"`
ByProvider map[string]*ToolPolicySpec `json:"byProvider,omitempty"`
Wait *WaitToolPolicy `json:"wait,omitempty"`
ToolCallPrefix string `json:"toolCallPrefix,omitempty"` // prefix to strip from model's tool call names before registry lookup
}
// WaitToolPolicy configures per-agent safety bounds for the wait tool.
+187
View File
@@ -0,0 +1,187 @@
package tools
import (
"regexp"
"strconv"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/config"
)
type commandKeywordAllowAudit struct {
RuleID string
Command string
Subcommand string
Arg string
Keyword string
Reason string
}
func applyCommandKeywordAllowlist(command string, args []string, rules []config.CommandKeywordAllowlistRule) ([]string, []commandKeywordAllowAudit) {
if len(args) == 0 || len(rules) == 0 {
return args, nil
}
var out []string
var audits []commandKeywordAllowAudit
for _, rule := range rules {
if !commandKeywordAllowlistRuleEnabled(rule) {
continue
}
if normalizeBinaryName(rule.Command) != normalizeBinaryName(command) {
continue
}
subcommand, argStart, ok := matchCommandKeywordSubcommand(args, rule.Subcommands)
if !ok {
continue
}
argNames := commandKeywordSet(rule.Args)
argPositions := commandKeywordPositionSet(rule.ArgPositions)
keywords := commandKeywordSet(rule.Keywords)
if (len(argNames) == 0 && len(argPositions) == 0) || len(keywords) == 0 {
continue
}
if len(argPositions) > 0 && subcommand == "" {
continue
}
if out == nil {
out = slicesClone(args)
}
for i := argStart; i < len(out); i++ {
argName, valueIndex, inlineValue, ok := commandKeywordArgValue(out, i, argNames)
if !ok {
if _, positional := argPositions[i-argStart]; positional {
argName = "argPositions[" + strconv.Itoa(i-argStart) + "]"
valueIndex = i
inlineValue = out[i]
ok = true
}
}
if !ok {
continue
}
value := inlineValue
if valueIndex != i {
value = out[valueIndex]
}
masked, hits := maskCommandKeywords(value, keywords)
if len(hits) == 0 {
continue
}
if valueIndex == i {
prefix := out[i][:strings.Index(out[i], "=")+1]
out[i] = prefix + masked
} else {
out[valueIndex] = masked
i = valueIndex
}
for _, keyword := range hits {
audits = append(audits, commandKeywordAllowAudit{
RuleID: rule.ID,
Command: normalizeBinaryName(command),
Subcommand: subcommand,
Arg: argName,
Keyword: keyword,
Reason: rule.Reason,
})
}
}
}
if out == nil {
return args, audits
}
return out, audits
}
func commandKeywordAllowlistRuleEnabled(rule config.CommandKeywordAllowlistRule) bool {
return rule.Enabled == nil || *rule.Enabled
}
func matchCommandKeywordSubcommand(args []string, subcommands []string) (string, int, bool) {
if len(subcommands) == 0 {
return "", 0, true
}
for _, subcommand := range subcommands {
parts := strings.Fields(strings.ToLower(strings.TrimSpace(subcommand)))
if len(parts) == 0 || len(parts) > len(args) {
continue
}
matched := true
for i, part := range parts {
if strings.ToLower(args[i]) != part {
matched = false
break
}
}
if matched {
return strings.Join(parts, " "), len(parts), true
}
}
return "", 0, false
}
func commandKeywordSet(values []string) map[string]struct{} {
if len(values) == 0 {
return nil
}
set := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.ToLower(strings.TrimSpace(value))
if value != "" {
set[value] = struct{}{}
}
}
return set
}
func commandKeywordPositionSet(values []int) map[int]struct{} {
if len(values) == 0 {
return nil
}
set := make(map[int]struct{}, len(values))
for _, value := range values {
if value >= 0 {
set[value] = struct{}{}
}
}
return set
}
func commandKeywordArgValue(args []string, i int, argNames map[string]struct{}) (string, int, string, bool) {
arg := args[i]
if eq := strings.Index(arg, "="); eq > 0 {
name := strings.ToLower(arg[:eq])
if _, ok := argNames[name]; ok {
return name, i, arg[eq+1:], true
}
return "", 0, "", false
}
name := strings.ToLower(arg)
if _, ok := argNames[name]; !ok || i+1 >= len(args) {
return "", 0, "", false
}
return name, i + 1, "", true
}
func maskCommandKeywords(value string, keywords map[string]struct{}) (string, []string) {
if value == "" || len(keywords) == 0 {
return value, nil
}
var hits []string
masked := value
for keyword := range keywords {
re := regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(keyword) + `\b`)
if !re.MatchString(masked) {
continue
}
hits = append(hits, keyword)
masked = re.ReplaceAllString(masked, "__allowlisted_keyword__")
}
return masked, hits
}
func slicesClone[T any](src []T) []T {
dst := make([]T, len(src))
copy(dst, src)
return dst
}
+16 -1
View File
@@ -381,7 +381,8 @@ func (t *ExecTool) executeCredentialed(ctx context.Context, cred *store.SecureCL
}
// Step 3: Per-binary deny check (deny_args)
if p := matchesBinaryDeny(args, cred.DenyArgs); p != "" {
denyArgs, allowAudits := applyCommandKeywordAllowlist(binary, args, t.commandKeywordAllowlistSnapshot())
if p := matchesBinaryDeny(denyArgs, cred.DenyArgs); p != "" {
return credentialedDenyError(binary, args, p)
}
// Per-binary verbose deny check (deny_verbose) — per-arg start-anchored match
@@ -389,6 +390,20 @@ func (t *ExecTool) executeCredentialed(ctx context.Context, cred *store.SecureCL
if p := matchesBinaryVerbose(args, cred.DenyVerbose); p != "" {
return credentialedDenyError(binary, args, p)
}
for _, audit := range allowAudits {
slog.Info("security.command_keyword_allowlist",
"binary", audit.Command,
"subcommand", audit.Subcommand,
"arg", audit.Arg,
"keyword", audit.Keyword,
"rule_id", audit.RuleID,
"reason", audit.Reason,
"agent_id", store.AgentIDFromContext(ctx),
"user_id", store.UserIDFromContext(ctx),
"credential_user_id", store.CredentialUserIDFromContext(ctx),
"tenant_id", store.TenantIDFromContext(ctx),
)
}
// Step 4: Decrypt env vars from store (already decrypted by store layer).
// Per-user env overrides take priority over binary/grant env.
+109
View File
@@ -5,6 +5,8 @@ import (
"os"
"path/filepath"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/config"
)
func TestDetectShellOperators(t *testing.T) {
@@ -208,6 +210,113 @@ func TestMatchesBinaryDenyJoinedArgs(t *testing.T) {
}
}
func TestApplyCommandKeywordAllowlistScopesContentArgs(t *testing.T) {
ghPatterns, _ := json.Marshal([]string{`auth\s+`, `repo\s+delete`, `secret\s+`, `token\s+`})
rules := []config.CommandKeywordAllowlistRule{
{
ID: "github-content",
Command: "gh",
Subcommands: []string{"issue create", "pr create"},
Args: []string{"--body", "--title"},
ArgPositions: []int{0},
Keywords: []string{"secret", "token"},
Reason: "GitHub issue and PR prose may discuss security terms.",
},
}
tests := []struct {
name string
args []string
wantHit bool
wantAudit int
}{
{
name: "issue body content allowed",
args: []string{"issue", "create", "--body", "secret rotation details"},
wantHit: false,
wantAudit: 1,
},
{
name: "pr title content allowed",
args: []string{"pr", "create", "--title=token handling notes"},
wantHit: false,
wantAudit: 1,
},
{
name: "positional content allowed",
args: []string{"issue", "create", "token handling notes"},
wantHit: false,
wantAudit: 1,
},
{
name: "command path stays blocked",
args: []string{"secret", "set", "TOKEN"},
wantHit: true,
},
{
name: "non-allowlisted arg stays blocked",
args: []string{"issue", "create", "--label", "secret incident"},
wantHit: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sanitized, audits := applyCommandKeywordAllowlist("gh", tt.args, rules)
got := matchesBinaryDeny(sanitized, ghPatterns)
if (got != "") != tt.wantHit {
t.Fatalf("matchesBinaryDeny(%v) after allowlist = %q, wantHit=%v", sanitized, got, tt.wantHit)
}
if len(audits) != tt.wantAudit {
t.Fatalf("audit count = %d, want %d", len(audits), tt.wantAudit)
}
})
}
}
func TestApplyCommandKeywordAllowlistIgnoresDisabledRules(t *testing.T) {
ghPatterns, _ := json.Marshal([]string{`secret\s+`})
enabled := false
rules := []config.CommandKeywordAllowlistRule{
{
ID: "disabled-github-content",
Command: "gh",
Subcommands: []string{"issue create"},
Args: []string{"--body"},
Keywords: []string{"secret"},
Enabled: &enabled,
},
}
sanitized, audits := applyCommandKeywordAllowlist("gh", []string{"issue", "create", "--body", "secret notes"}, rules)
if got := matchesBinaryDeny(sanitized, ghPatterns); got == "" {
t.Fatalf("disabled rule bypassed deny_args; sanitized args = %v", sanitized)
}
if len(audits) != 0 {
t.Fatalf("disabled rule emitted audit records: %v", audits)
}
}
func TestApplyCommandKeywordAllowlistRequiresSubcommandForPositions(t *testing.T) {
ghPatterns, _ := json.Marshal([]string{`secret\s+`})
rules := []config.CommandKeywordAllowlistRule{
{
ID: "unsafe-position",
Command: "gh",
ArgPositions: []int{0},
Keywords: []string{"secret"},
},
}
sanitized, audits := applyCommandKeywordAllowlist("gh", []string{"secret", "set", "TOKEN"}, rules)
if got := matchesBinaryDeny(sanitized, ghPatterns); got == "" {
t.Fatalf("position rule without subcommand bypassed command-path deny; sanitized args = %v", sanitized)
}
if len(audits) != 0 {
t.Fatalf("position rule without subcommand emitted audit records: %v", audits)
}
}
func TestResolveAndMatchBinaryUsesConfiguredExecutablePath(t *testing.T) {
t.Setenv("PATH", "/usr/bin")
binDir := t.TempDir()
+51 -14
View File
@@ -13,8 +13,10 @@ import (
"regexp"
"runtime"
"strings"
"sync"
"time"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
"github.com/nextlevelbuilder/goclaw/internal/store"
"golang.org/x/text/unicode/norm"
@@ -35,27 +37,56 @@ func DefaultDenyPatterns() []*regexp.Regexp {
// ExecTool executes shell commands, optionally inside a sandbox container.
type ExecTool struct {
workspace string
timeout time.Duration
pathDenyPatterns []*regexp.Regexp // always-on path-based denials (DenyPaths)
pathDenyRoots []string // raw deny roots for nested workspace exemptions
denyExemptions []string // substrings that exempt a command from deny
restrict bool
sandboxMgr sandbox.Manager // nil = no sandbox, execute on host
approvalMgr *ExecApprovalManager // nil = no approval needed
agentID string // for approval request context
secureCLIStore store.SecureCLIStore // nil = no credentialed exec
workspace string
timeout time.Duration
pathDenyPatterns []*regexp.Regexp // always-on path-based denials (DenyPaths)
pathDenyRoots []string // raw deny roots for nested workspace exemptions
denyExemptions []string // substrings that exempt a command from deny
restrict bool
sandboxMgr sandbox.Manager // nil = no sandbox, execute on host
approvalMgr *ExecApprovalManager // nil = no approval needed
agentID string // for approval request context
secureCLIStore store.SecureCLIStore // nil = no credentialed exec
policyMu sync.RWMutex
commandKeywordAllowlist []config.CommandKeywordAllowlistRule
// globalDenyGroups holds global shell deny-group toggles from config.tools.
// Per-agent overrides from context (store.WithShellDenyGroups) win per-key.
// Updated at startup and via TopicConfigChanged pub/sub for runtime reload.
globalDenyGroups map[string]bool
}
// SetCommandKeywordAllowlist replaces the scoped credentialed CLI keyword
// allowlist. The slice is defensively copied so config reload callers cannot
// mutate the running tool policy after assignment.
func (t *ExecTool) SetCommandKeywordAllowlist(rules []config.CommandKeywordAllowlistRule) {
t.policyMu.Lock()
defer t.policyMu.Unlock()
if len(rules) == 0 {
t.commandKeywordAllowlist = nil
return
}
t.commandKeywordAllowlist = slicesClone(rules)
}
// CommandKeywordAllowlistForTest exposes the effective global allowlist for
// cross-package config reload tests. Not for production callers.
func (t *ExecTool) CommandKeywordAllowlistForTest() []config.CommandKeywordAllowlistRule {
return t.commandKeywordAllowlistSnapshot()
}
func (t *ExecTool) commandKeywordAllowlistSnapshot() []config.CommandKeywordAllowlistRule {
t.policyMu.RLock()
defer t.policyMu.RUnlock()
return slicesClone(t.commandKeywordAllowlist)
}
// SetGlobalShellDenyGroups replaces the global shell deny-group toggles. The
// caller's map is defensively copied so later mutations cannot leak into the
// tool's internal state. Passing nil or an empty map clears the global config
// (per-agent context overrides, if any, still apply on their own).
func (t *ExecTool) SetGlobalShellDenyGroups(groups map[string]bool) {
t.policyMu.Lock()
defer t.policyMu.Unlock()
if len(groups) == 0 {
t.globalDenyGroups = nil
return
@@ -70,14 +101,20 @@ func (t *ExecTool) SetGlobalShellDenyGroups(groups map[string]bool) {
// empty, the other is returned directly (no allocation).
func (t *ExecTool) effectiveDenyGroups(ctx context.Context) map[string]bool {
agent := store.ShellDenyGroupsFromContext(ctx)
if len(t.globalDenyGroups) == 0 {
t.policyMu.RLock()
global := t.globalDenyGroups
if len(global) > 0 {
global = maps.Clone(global)
}
t.policyMu.RUnlock()
if len(global) == 0 {
return agent
}
if len(agent) == 0 {
return t.globalDenyGroups
return global
}
merged := make(map[string]bool, len(t.globalDenyGroups)+len(agent))
maps.Copy(merged, t.globalDenyGroups)
merged := make(map[string]bool, len(global)+len(agent))
maps.Copy(merged, global)
// agent wins per-key
maps.Copy(merged, agent)
return merged
+4
View File
@@ -208,6 +208,10 @@
"tools.execAskMode": "Ask Mode",
"tools.execAskModeTip": "When to prompt the user for approval before executing a command.",
"tools.execAllowlistLabel": "Allowed Commands (one pattern per line)",
"tools.commandKeywordAllowlist": "Command Keyword Allowlist",
"tools.commandKeywordAllowlistTip": "JSON rules that allow configured keywords only inside selected credentialed CLI content arguments. Command-path deny rules still apply.",
"tools.commandKeywordAllowlistJsonError": "Invalid JSON.",
"tools.commandKeywordAllowlistArrayError": "Use a JSON array of rules.",
"tools.scrubCredentials": "Scrub Credentials",
"browser.title": "Browser Automation",
+4
View File
@@ -208,6 +208,10 @@
"tools.execAskMode": "Chế độ hỏi",
"tools.execAskModeTip": "Khi nào nhắc người dùng phê duyệt trước khi thực thi lệnh.",
"tools.execAllowlistLabel": "Lệnh được phép (mỗi dòng một mẫu)",
"tools.commandKeywordAllowlist": "Allowlist từ khóa theo lệnh",
"tools.commandKeywordAllowlistTip": "Rule JSON cho phép từ khóa đã cấu hình chỉ trong argument nội dung của CLI có credential. Deny rule theo command path vẫn áp dụng.",
"tools.commandKeywordAllowlistJsonError": "JSON không hợp lệ.",
"tools.commandKeywordAllowlistArrayError": "Dùng mảng JSON các rule.",
"tools.scrubCredentials": "Ẩn thông tin xác thực",
"browser.title": "Tự động hóa trình duyệt",
+4
View File
@@ -208,6 +208,10 @@
"tools.execAskMode": "询问模式",
"tools.execAskModeTip": "在执行命令之前何时提示用户审批。",
"tools.execAllowlistLabel": "允许的命令(每行一个模式)",
"tools.commandKeywordAllowlist": "命令关键词允许列表",
"tools.commandKeywordAllowlistTip": "JSON 规则只允许配置的关键词出现在指定的凭证 CLI 内容参数中。命令路径拒绝规则仍然生效。",
"tools.commandKeywordAllowlistJsonError": "JSON 无效。",
"tools.commandKeywordAllowlistArrayError": "请使用规则 JSON 数组。",
"tools.scrubCredentials": "擦除凭证",
"browser.title": "浏览器自动化",
@@ -19,7 +19,6 @@ import {
} from "@/components/ui/select";
import { InfoLabel } from "@/components/shared/info-label";
type ToolsData = Record<string, any>;
interface Props {
@@ -31,10 +30,14 @@ interface Props {
export function ToolsExecSection({ data, onSave, saving }: Props) {
const { t } = useTranslation("config");
const [draft, setDraft] = useState<ToolsData>(data ?? {});
const [allowlistText, setAllowlistText] = useState("");
const [allowlistError, setAllowlistError] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
useEffect(() => {
setDraft(data ?? {});
setAllowlistText(JSON.stringify(data?.commandKeywordAllowlist ?? [], null, 2));
setAllowlistError(null);
setDirty(false);
}, [data]);
@@ -46,6 +49,30 @@ export function ToolsExecSection({ data, onSave, saving }: Props) {
setDirty(true);
};
const updateCommandKeywordAllowlist = (value: string) => {
setAllowlistText(value);
setDirty(true);
const trimmed = value.trim();
if (!trimmed) {
setAllowlistError(null);
setDraft((prev) => ({ ...prev, commandKeywordAllowlist: [] }));
return;
}
try {
const parsed = JSON.parse(trimmed);
if (!Array.isArray(parsed)) {
setAllowlistError(t("tools.commandKeywordAllowlistArrayError"));
return;
}
setAllowlistError(null);
setDraft((prev) => ({ ...prev, commandKeywordAllowlist: parsed }));
} catch {
setAllowlistError(t("tools.commandKeywordAllowlistJsonError"));
}
};
if (!data) return null;
const exec = draft.execApproval ?? {};
@@ -91,15 +118,41 @@ export function ToolsExecSection({ data, onSave, saving }: Props) {
allowlist: e.target.value.split("\n").filter(Boolean),
})
}
className="min-h-[80px] font-mono text-xs"
className="min-h-[80px] font-mono text-base md:text-sm"
placeholder="git *&#10;npm *&#10;ls *"
/>
</div>
)}
<div className="grid gap-1.5">
<InfoLabel tip={t("tools.commandKeywordAllowlistTip")}>
{t("tools.commandKeywordAllowlist")}
</InfoLabel>
<Textarea
value={allowlistText}
onChange={(e) => updateCommandKeywordAllowlist(e.target.value)}
className="min-h-[180px] font-mono text-base md:text-sm"
spellCheck={false}
placeholder={`[
{
"id": "github-content",
"command": "gh",
"subcommands": ["issue create", "issue edit", "pr create", "pr comment"],
"args": ["--body", "--title"],
"argPositions": [],
"keywords": ["secret", "secrets", "token", "credential"],
"reason": "Allow security vocabulary in GitHub prose"
}
]`}
/>
{allowlistError && (
<p className="text-xs text-destructive">{allowlistError}</p>
)}
</div>
{dirty && (
<div className="flex justify-end pt-2">
<Button size="sm" onClick={() => onSave(draft)} disabled={saving} className="gap-1.5">
<Button size="sm" onClick={() => onSave(draft)} disabled={saving || !!allowlistError} className="gap-1.5">
<Save className="h-3.5 w-3.5" /> {saving ? t("saving") : t("save")}
</Button>
</div>