security(shell): add opt-in environment variable filtering

When filter_env is enabled in config, shell commands run with a minimal
allowlist of environment variables (PATH, HOME, LANG, TERM, etc. plus
PICOCLAW_* prefix), preventing accidental leakage of sensitive env vars
like API keys to spawned processes. Disabled by default for backward compat.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
admin-mf 2026-03-06 00:13:32 -06:00
parent 503bc3f1d2
commit 6ab2d70348
2 changed files with 47 additions and 0 deletions

View file

@ -618,6 +618,7 @@ type ExecConfig struct {
CustomDenyPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS" json:"custom_deny_patterns"` CustomDenyPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS" json:"custom_deny_patterns"`
CustomAllowPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS" json:"custom_allow_patterns"` CustomAllowPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS" json:"custom_allow_patterns"`
TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s) TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s)
FilterEnv bool ` env:"PICOCLAW_TOOLS_EXEC_FILTER_ENV" json:"filter_env"`
} }
type SkillsToolsConfig struct { type SkillsToolsConfig struct {

View file

@ -23,6 +23,7 @@ type ExecTool struct {
allowPatterns []*regexp.Regexp allowPatterns []*regexp.Regexp
customAllowPatterns []*regexp.Regexp customAllowPatterns []*regexp.Regexp
restrictToWorkspace bool restrictToWorkspace bool
filterEnv bool
} }
var ( var (
@ -136,6 +137,11 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf
timeout = time.Duration(config.Tools.Exec.TimeoutSeconds) * time.Second timeout = time.Duration(config.Tools.Exec.TimeoutSeconds) * time.Second
} }
filterEnv := false
if config != nil {
filterEnv = config.Tools.Exec.FilterEnv
}
return &ExecTool{ return &ExecTool{
workingDir: workingDir, workingDir: workingDir,
timeout: timeout, timeout: timeout,
@ -143,6 +149,7 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf
allowPatterns: nil, allowPatterns: nil,
customAllowPatterns: customAllowPatterns, customAllowPatterns: customAllowPatterns,
restrictToWorkspace: restrict, restrictToWorkspace: restrict,
filterEnv: filterEnv,
}, nil }, nil
} }
@ -221,6 +228,10 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
cmd.Dir = cwd cmd.Dir = cwd
} }
if t.filterEnv {
cmd.Env = filterEnvironment(os.Environ())
}
prepareCommandForTermination(cmd) prepareCommandForTermination(cmd)
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
@ -381,3 +392,38 @@ func (t *ExecTool) SetAllowPatterns(patterns []string) error {
} }
return nil return nil
} }
// safeEnvVars is the allowlist of environment variable names preserved when
// filterEnv is enabled. Variables with a PICOCLAW_ prefix are always kept.
var safeEnvVars = map[string]bool{
"PATH": true,
"HOME": true,
"USER": true,
"LANG": true,
"TERM": true,
"SHELL": true,
"TMPDIR": true,
"TMP": true,
"TEMP": true,
"SystemRoot": true,
"COMSPEC": true,
"USERPROFILE": true,
"APPDATA": true,
"LOCALAPPDATA": true,
}
// filterEnvironment returns a filtered copy of environ keeping only safe vars.
func filterEnvironment(environ []string) []string {
filtered := make([]string, 0, len(safeEnvVars)+4)
for _, entry := range environ {
eqIdx := strings.IndexByte(entry, '=')
if eqIdx < 0 {
continue
}
name := entry[:eqIdx]
if safeEnvVars[name] || strings.HasPrefix(name, "PICOCLAW_") {
filtered = append(filtered, entry)
}
}
return filtered
}