Files
picoclaw/pkg/config/security.go
T
2026-03-29 14:06:19 +02:00

412 lines
9.5 KiB
Go

// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package config
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"sync"
"gopkg.in/yaml.v3"
"github.com/sipeed/picoclaw/pkg/credential"
"github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/logger"
)
const (
SecurityConfigFile = ".security.yml"
)
// securityPath returns the path to security.yml relative to the config file
func securityPath(configPath string) string {
configDir := filepath.Dir(configPath)
return filepath.Join(configDir, SecurityConfigFile)
}
// loadSecurityConfig loads the security configuration from security.yml
// Returns an empty SecurityConfig if the file doesn't exist
func loadSecurityConfig(cfg *Config, securityPath string) error {
if cfg == nil {
return fmt.Errorf("config is nil")
}
data, err := os.ReadFile(securityPath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("failed to read security config: %w", err)
}
if err := yaml.Unmarshal(data, cfg); err != nil {
return fmt.Errorf("failed to parse security config: %w", err)
}
return nil
}
// saveSecurityConfig saves the security configuration to security.yml
func saveSecurityConfig(securityPath string, sec *Config) error {
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
err := enc.Encode(sec)
if err != nil {
return fmt.Errorf("failed to marshal security config: %w", err)
}
return fileutil.WriteFileAtomic(securityPath, buf.Bytes(), 0o600)
}
// SensitiveDataCache caches the compiled regex for filtering sensitive data.
// SensitiveDataCache caches the strings.Replacer for filtering sensitive data.
// Computed once on first access via sync.Once.
type SensitiveDataCache struct {
replacer *strings.Replacer
once sync.Once
}
// SensitiveDataReplacer returns the strings.Replacer for filtering sensitive data.
// It is computed once on first access via sync.Once.
func (sec *Config) SensitiveDataReplacer() *strings.Replacer {
sec.initSensitiveCache()
return sec.sensitiveCache.replacer
}
// initSensitiveCache initializes the sensitive data cache if not already done.
func (sec *Config) initSensitiveCache() {
if sec.sensitiveCache == nil {
sec.sensitiveCache = &SensitiveDataCache{}
}
sec.sensitiveCache.once.Do(func() {
values := sec.collectSensitiveValues()
if len(values) == 0 {
sec.sensitiveCache.replacer = strings.NewReplacer()
return
}
// Build old/new pairs for strings.Replacer
var pairs []string
for _, v := range values {
if len(v) > 3 {
pairs = append(pairs, v, "[FILTERED]")
}
}
if len(pairs) == 0 {
sec.sensitiveCache.replacer = strings.NewReplacer()
return
}
sec.sensitiveCache.replacer = strings.NewReplacer(pairs...)
})
}
// collectSensitiveValues collects all sensitive strings from SecurityConfig using reflection.
func (sec *Config) collectSensitiveValues() []string {
var values []string
collectSensitive(reflect.ValueOf(sec), &values)
return values
}
// collectSensitive recursively traverses the value and collects SecureString/SecureStrings values.
func collectSensitive(v reflect.Value, values *[]string) {
for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface {
if v.IsNil() {
return
}
v = v.Elem()
}
t := v.Type()
// SecureString: collect via String() method (defined on *SecureString)
if t == reflect.TypeOf(SecureString{}) {
result := v.Addr().MethodByName("String").Call(nil)
if len(result) > 0 {
if s := result[0].String(); s != "" {
*values = append(*values, s)
}
}
return
}
// SecureStrings ([]*SecureString): iterate and collect each element
if t == reflect.TypeOf(SecureStrings{}) {
for i := 0; i < v.Len(); i++ {
elem := v.Index(i)
for elem.Kind() == reflect.Ptr || elem.Kind() == reflect.Interface {
if elem.IsNil() {
elem = reflect.Value{}
break
}
elem = elem.Elem()
}
if elem.IsValid() && elem.Type() == reflect.TypeOf(SecureString{}) {
result := elem.Addr().MethodByName("String").Call(nil)
if len(result) > 0 {
if s := result[0].String(); s != "" {
*values = append(*values, s)
}
}
}
}
return
}
switch v.Kind() {
case reflect.Struct:
for i := 0; i < v.NumField(); i++ {
if !t.Field(i).IsExported() {
continue
}
collectSensitive(v.Field(i), values)
}
case reflect.Slice:
for i := 0; i < v.Len(); i++ {
collectSensitive(v.Index(i), values)
}
case reflect.Map:
for _, key := range v.MapKeys() {
collectSensitive(v.MapIndex(key), values)
}
}
}
const (
notHere = `"[NOT_HERE]"`
)
// SecureStrings is a slice of SecureString
type SecureStrings []*SecureString
// Values returns the decrypted/resolved values
func (s *SecureStrings) Values() []string {
if s == nil {
return nil
}
keys := make([]string, len(*s))
for i, k := range *s {
keys[i] = k.String()
}
return unique(keys)
}
func SimpleSecureStrings(val ...string) SecureStrings {
val = unique(val)
vv := make(SecureStrings, len(val))
for i, s := range val {
vv[i] = NewSecureString(s)
}
return vv
}
// unique returns a new slice with duplicate elements removed.
func unique[T comparable](input []T) []T {
m := make(map[T]struct{})
var result []T
for _, v := range input {
if _, ok := m[v]; !ok {
m[v] = struct{}{}
result = append(result, v)
}
}
return result
}
func (s SecureStrings) MarshalJSON() ([]byte, error) {
return []byte(notHere), nil
}
func (s *SecureStrings) UnmarshalJSON(value []byte) error {
if string(value) == notHere {
return nil
}
var v []*SecureString
err := json.Unmarshal(value, &v)
if err != nil {
return err
}
*s = v
return nil
}
// SecureString the string value that can be decrypted or resolved
//
//nolint:recvcheck
type SecureString struct {
resolved string // Decrypted/resolved value returned by String()
raw string // Persisted raw value (enc://, file://, or plaintext)
}
func callerFromYaml() bool {
_, file, _, ok := runtime.Caller(2)
if ok {
d := filepath.Dir(file)
// check the caller is from yaml.v
if !strings.Contains(d, "yaml.v") {
return true
}
}
return false
}
// IsZero returns true if the SecureString is empty
// if caller not yaml, just return true for prevent marshal this field
func (s SecureString) IsZero() bool {
if callerFromYaml() {
return true
}
return s.resolved == ""
}
func NewSecureString(value string) *SecureString {
s := &SecureString{}
if err := s.fromRaw(value); err != nil {
logger.Warn(fmt.Sprintf("NewSecureString.fromRaw error: %s", err))
}
return s
}
func (s *SecureString) String() string {
if s == nil {
return ""
}
return s.resolved
}
func (s *SecureString) Set(value string) *SecureString {
s.resolved = value
s.raw = ""
return s
}
func (s SecureString) MarshalJSON() ([]byte, error) {
return []byte(notHere), nil
}
func (s *SecureString) UnmarshalJSON(value []byte) error {
if string(value) == notHere {
return nil
}
var v string
if err := json.Unmarshal(value, &v); err != nil {
return err
}
return s.fromRaw(v)
}
func (s SecureString) MarshalYAML() (any, error) {
// Preserve raw value if it is already a reference (enc:// or file://)
if strings.HasPrefix(s.raw, credential.EncScheme) || strings.HasPrefix(s.raw, credential.FileScheme) {
return s.raw, nil
}
// If resolved is a reference format (e.g. set via Set), copy back to raw
if strings.HasPrefix(s.resolved, credential.EncScheme) || strings.HasPrefix(s.resolved, credential.FileScheme) {
s.raw = s.resolved
return s.raw, nil
}
// Try to encrypt the resolved value
if passphrase := credential.PassphraseProvider(); passphrase != "" {
encrypted, err := credential.Encrypt(passphrase, "", s.resolved)
if err != nil {
logger.Errorf("Encrypt error: %v", err)
return nil, err
}
s.raw = encrypted
} else {
s.raw = s.resolved
}
return s.raw, nil
}
func (s *SecureString) UnmarshalYAML(value *yaml.Node) error {
return s.fromRaw(value.Value)
}
func (s *SecureString) fromRaw(v string) error {
s.raw = v
vv, err := resolveKey(v)
if err != nil {
return err
}
s.resolved = vv
return nil
}
var (
secResolverMu sync.RWMutex
secResolver *credential.Resolver
)
func updateResolver(path string) {
secResolverMu.Lock()
defer secResolverMu.Unlock()
secResolver = credential.NewResolver(path)
}
func resolveKey(v string) (string, error) {
secResolverMu.RLock()
resolver := secResolver
secResolverMu.RUnlock()
if resolver == nil {
resolver = credential.NewResolver("")
}
if strings.HasPrefix(v, "enc://") || strings.HasPrefix(v, "file://") {
decrypted, err := resolver.Resolve(v)
if err != nil {
logger.Errorf("Resolve error: %v", err)
return "", err
}
return decrypted, nil
}
return v, nil
}
func (s *SecureString) UnmarshalText(text []byte) error {
v := string(text)
return s.fromRaw(v)
}
type SecureModelList []*ModelConfig
func (v *SecureModelList) UnmarshalYAML(value *yaml.Node) error {
mm := make(map[string]*ModelConfig)
if err := value.Decode(&mm); err != nil {
logger.Errorf("Decode error: %v", err)
return err
}
nameList := toNameIndex(*v)
for i, m := range *v {
sec := mm[nameList[i]]
if sec == nil {
sec = mm[m.ModelName]
}
if sec != nil {
m.APIKeys = sec.APIKeys
}
}
return nil
}
func (v SecureModelList) MarshalYAML() (any, error) {
type onlySecureData struct {
APIKeys SecureStrings `yaml:"api_keys,omitempty"`
}
mm := make(map[string]onlySecureData)
nameList := toNameIndex(v)
for i, m := range v {
mm[nameList[i]] = onlySecureData{
APIKeys: m.APIKeys,
}
}
return mm, nil
}