feat: add exec admin support for unrestricted shell command execution

- Add exec_admins config option to specify admin identities
- Add ExecuteUnrestricted method to ExecTool for admin override
- Update agent loop to check sender identity for exec admin permission
- Exec admins bypass all command guards including dangerous command checks
This commit is contained in:
seagochen 2026-03-11 15:39:53 +08:00
parent 1f2802906f
commit e3416662dd
5 changed files with 68 additions and 24 deletions

View file

@ -424,8 +424,9 @@
"^unzip\\b", "^unzip\\b",
"^tar\\b", "^tar\\b",
"^git\\b", "^git\\b",
"^rm\\s+(?!.*-[rR])\\S" "^rm\\s+[^-]"
] ],
"exec_admins": []
}, },
"skills": { "skills": {
"enabled": true, "enabled": true,

View file

@ -26,6 +26,7 @@ import (
"github.com/sipeed/picoclaw/pkg/commands" "github.com/sipeed/picoclaw/pkg/commands"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/mcp" "github.com/sipeed/picoclaw/pkg/mcp"
"github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/media"
@ -64,16 +65,17 @@ type AgentLoop struct {
// processOptions configures how a message is processed // processOptions configures how a message is processed
type processOptions struct { type processOptions struct {
SessionKey string // Session identifier for history/context SessionKey string // Session identifier for history/context
Channel string // Target channel for tool execution Channel string // Target channel for tool execution
ChatID string // Target chat ID for tool execution ChatID string // Target chat ID for tool execution
UserMessage string // User message content (may include prefix) UserMessage string // User message content (may include prefix)
Media []string // media:// refs from inbound message Media []string // media:// refs from inbound message
DefaultResponse string // Response when LLM returns empty DefaultResponse string // Response when LLM returns empty
EnableSummary bool // Whether to trigger summarization EnableSummary bool // Whether to trigger summarization
SendResponse bool // Whether to send response via bus SendResponse bool // Whether to send response via bus
NoHistory bool // If true, don't load session history (for heartbeat) NoHistory bool // If true, don't load session history (for heartbeat)
WorkingDir string // Current working directory override (for hipico from cmd mode) WorkingDir string // Current working directory override (for hipico from cmd mode)
Sender bus.SenderInfo // Sender identity for per-user permission checks
} }
const ( const (
@ -722,6 +724,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
EnableSummary: true, EnableSummary: true,
SendResponse: false, SendResponse: false,
WorkingDir: msg.Metadata["work_dir"], WorkingDir: msg.Metadata["work_dir"],
Sender: msg.Sender,
} }
// In cmd mode, rewrite bare text as /exec so all shell execution flows // In cmd mode, rewrite bare text as /exec so all shell execution flows
@ -1962,7 +1965,8 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
if agent == nil { if agent == nil {
return "", fmt.Errorf("no agent available") return "", fmt.Errorf("no agent available")
} }
return al.executeCmdMode(ctx, agent, command, opts.SessionKey, opts.Channel, opts.ChatID) isAdmin := al.isExecAdmin(opts.Sender)
return al.executeCmdMode(ctx, agent, command, opts.SessionKey, opts.Channel, opts.ChatID, isAdmin)
}, },
// One-shot AI query for /hipico (stays in modeCmd, separate session key) // One-shot AI query for /hipico (stays in modeCmd, separate session key)
@ -2025,12 +2029,26 @@ func mapCommandError(result commands.ExecuteResult) string {
return fmt.Sprintf("Failed to execute /%s: %v", result.Command, result.Err) return fmt.Sprintf("Failed to execute /%s: %v", result.Command, result.Err)
} }
// isExecAdmin returns true if the sender is listed in tools.exec.exec_admins,
// using the same identity matching as channel allow_from.
func (al *AgentLoop) isExecAdmin(sender bus.SenderInfo) bool {
admins := al.cfg.Tools.Exec.ExecAdmins
for _, admin := range admins {
if identity.MatchAllowed(sender, admin) {
return true
}
}
return false
}
// executeCmdMode executes a shell command in command mode via ExecTool. // executeCmdMode executes a shell command in command mode via ExecTool.
// Output is formatted as a console code block for channel display. // Output is formatted as a console code block for channel display.
// isAdmin bypasses all command guards for verified exec admins.
func (al *AgentLoop) executeCmdMode( func (al *AgentLoop) executeCmdMode(
ctx context.Context, ctx context.Context,
agent *AgentInstance, agent *AgentInstance,
content, sessionKey, channel, chatID string, content, sessionKey, channel, chatID string,
isAdmin bool,
) (string, error) { ) (string, error) {
content = strings.TrimSpace(content) content = strings.TrimSpace(content)
if content == "" { if content == "" {
@ -2053,11 +2071,23 @@ func (al *AgentLoop) executeCmdMode(
workDir = agent.Workspace workDir = agent.Workspace
} }
// Execute via ExecTool execArgs := map[string]any{
result := agent.Tools.ExecuteWithContext(ctx, "exec", map[string]any{
"command": content, "command": content,
"working_dir": workDir, "working_dir": workDir,
}, channel, chatID, nil) }
// Execute via ExecTool — admins bypass command guards
var result *tools.ToolResult
if isAdmin {
if t, ok := agent.Tools.Get("exec"); ok {
if et, ok := t.(*tools.ExecTool); ok {
result = et.ExecuteUnrestricted(ctx, execArgs)
}
}
}
if result == nil {
result = agent.Tools.ExecuteWithContext(ctx, "exec", execArgs, channel, chatID, nil)
}
displayDir := shortenHomePath(workDir) displayDir := shortenHomePath(workDir)
output := result.ForLLM output := result.ForLLM

View file

@ -678,6 +678,9 @@ type ExecConfig struct {
// When non-empty and DevMode is false, only commands matching at least one pattern are permitted; // When non-empty and DevMode is false, only commands matching at least one pattern are permitted;
// deny patterns are bypassed — the whitelist is the sole access control. // deny patterns are bypassed — the whitelist is the sole access control.
AllowedCommands []string `env:"PICOCLAW_TOOLS_EXEC_ALLOWED_COMMANDS" json:"allowed_commands"` AllowedCommands []string `env:"PICOCLAW_TOOLS_EXEC_ALLOWED_COMMANDS" json:"allowed_commands"`
// ExecAdmins lists sender identities (same format as channel allow_from) that are granted
// unrestricted shell execution regardless of DevMode or AllowedCommands.
ExecAdmins []string `env:"PICOCLAW_TOOLS_EXEC_ADMINS" json:"exec_admins"`
} }
type SkillsToolsConfig struct { type SkillsToolsConfig struct {

View file

@ -179,7 +179,17 @@ func (t *ExecTool) Parameters() map[string]any {
} }
} }
// ExecuteUnrestricted executes a command bypassing all command guards.
// Use only after the caller has verified the sender is an exec admin.
func (t *ExecTool) ExecuteUnrestricted(ctx context.Context, args map[string]any) *ToolResult {
return t.execute(ctx, args, true)
}
func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult { func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
return t.execute(ctx, args, false)
}
func (t *ExecTool) execute(ctx context.Context, args map[string]any, adminOverride bool) *ToolResult {
command, ok := args["command"].(string) command, ok := args["command"].(string)
if !ok { if !ok {
return ErrorResult("command is required") return ErrorResult("command is required")
@ -205,7 +215,7 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
} }
} }
if guardError := t.guardCommand(command, cwd); guardError != "" { if guardError := t.guardCommand(command, cwd, adminOverride); guardError != "" {
return ErrorResult(guardError) return ErrorResult(guardError)
} }
@ -310,9 +320,9 @@ func sanitizeCommand(cmd string) string {
return cmd return cmd
} }
func (t *ExecTool) guardCommand(command, cwd string) string { func (t *ExecTool) guardCommand(command, cwd string, adminOverride bool) string {
// Dev mode: no restrictions at all. // Dev mode or exec admin: no restrictions at all.
if t.devMode { if t.devMode || adminOverride {
return "" return ""
} }

View file

@ -369,7 +369,7 @@ func TestGuardCommand_URLEncodedTraversal(t *testing.T) {
} }
tool.SetRestrictToWorkspace(true) tool.SetRestrictToWorkspace(true)
msg := tool.guardCommand("cat %2e%2e%2f%2e%2e%2fetc/passwd", tmpDir) msg := tool.guardCommand("cat %2e%2e%2f%2e%2e%2fetc/passwd", tmpDir, false)
if msg == "" { if msg == "" {
t.Error("Expected URL-encoded path traversal to be blocked") t.Error("Expected URL-encoded path traversal to be blocked")
} }
@ -385,7 +385,7 @@ func TestGuardCommand_NullByte(t *testing.T) {
} }
tool.SetRestrictToWorkspace(true) tool.SetRestrictToWorkspace(true)
msg := tool.guardCommand("cat foo\x00../../etc/passwd", tmpDir) msg := tool.guardCommand("cat foo\x00../../etc/passwd", tmpDir, false)
if msg == "" { if msg == "" {
t.Error("Expected null-byte traversal to be blocked") t.Error("Expected null-byte traversal to be blocked")
} }
@ -402,7 +402,7 @@ func TestGuardCommand_SuBlocked(t *testing.T) {
cases := []string{"su", "su -", "su root", "doas ls", "pkexec /bin/bash"} cases := []string{"su", "su -", "su root", "doas ls", "pkexec /bin/bash"}
for _, cmd := range cases { for _, cmd := range cases {
msg := tool.guardCommand(cmd, tmpDir) msg := tool.guardCommand(cmd, tmpDir, false)
if msg == "" { if msg == "" {
t.Errorf("Expected %q to be blocked", cmd) t.Errorf("Expected %q to be blocked", cmd)
} }
@ -420,7 +420,7 @@ func TestGuardCommand_SuNoFalsePositive(t *testing.T) {
cases := []string{"echo surplus", "cat summary.txt", "ls result/"} cases := []string{"echo surplus", "cat summary.txt", "ls result/"}
for _, cmd := range cases { for _, cmd := range cases {
msg := tool.guardCommand(cmd, tmpDir) msg := tool.guardCommand(cmd, tmpDir, false)
if msg != "" { if msg != "" {
t.Errorf("Expected %q to NOT be blocked, got: %s", cmd, msg) t.Errorf("Expected %q to NOT be blocked, got: %s", cmd, msg)
} }