Files
Agent-System-Backend/internal/settings/scope.go
T
weichen f965c5877f Enforce settings ownership and versioned shared configuration
Why: Personal project rows and credentials could override system policy and split shared business configuration by actor.

Evidence: SELF receipts /tmp/project-settings-backend-full-20261014-973d52c1.json and /tmp/rdm-settings-backend-final-green-20261014-03.json preserve full commands, object snapshots and output. PostgreSQL and current-head CI remain NOT_RUN.

Impact: Add scope-aware APIs, a fail-closed optional project configuration authorizer, CAS updates and explicit atomic migration. Preserve legacy rows and private preferences; protected secrets and runtime model routes obey system ownership.

Refs: P1/P2/P8, docs/records/2026-10-14-rdm-settings-completion-contract.md
2026-10-14 02:28:41 +00:00

296 lines
8.3 KiB
Go

package settings
import (
"fmt"
"math"
"sort"
"strings"
core "agent-system-core"
)
const (
ScopeSharedProjectPrefix = "shared-project:"
PersonalProjectScope core.SettingScope = "project-personal"
UserSettingScope core.SettingScope = "user"
ProjectSettingScope core.SettingScope = "project"
)
func builtinScope(d core.SettingDesc) core.SettingScope {
if d.Scope == core.SettingScopeSystem || strings.HasPrefix(d.Key, "runtime.") || d.Key == "plugins.dir" || d.Key == "tools.mcp.overrides" {
return core.SettingScopeSystem
}
if strings.HasPrefix(d.Key, "deliverables.") {
return ProjectSettingScope
}
if d.Key == "crossSession.read" {
return PersonalProjectScope
}
return UserSettingScope
}
// Descriptors protects builtin declarations from plugin collisions. Legacy blank
// plugin scope means a personal user/project preference, never shared authority.
func (s *Service) Descriptors() []core.SettingDesc {
out := Schema()
known := map[string]bool{}
for _, d := range out {
known[d.Key] = true
}
if s != nil {
s.schemaMu.RLock()
defer s.schemaMu.RUnlock()
for _, d := range s.extraSchema {
// A plugin leaf must not replace a builtin namespace/object.
overlaps := false
for key := range known {
if strings.HasPrefix(key, d.Key+".") || strings.HasPrefix(d.Key, key+".") {
overlaps = true
break
}
}
if !known[d.Key] && !overlaps && validSettingPath(d.Key) {
if strings.HasPrefix(d.Key, "models.") || strings.HasPrefix(d.Key, "runtime.") || strings.HasPrefix(d.Key, "tools.tavily.") || strings.HasPrefix(d.Key, "tools.mcp.") {
d.Scope = core.SettingScopeSystem
}
out = append(out, d)
known[d.Key] = true
}
}
}
return out
}
func (s *Service) declaredDefaults() map[string]any {
out := map[string]any{}
for _, d := range s.Descriptors() {
if d.Default != nil && validateSettingType(d, d.Default) == nil {
out = core.MergeSettings(out, expandDotted(d.Key, d.Default))
}
}
return core.MergeSettings(out, SystemDefaults())
}
// EffectiveSettings getters use the JSON numeric representation. Code/plugin
// defaults must have the same float64 form as values read back from the DB.
func effectiveNumbers(values map[string]any) map[string]any {
out := make(map[string]any, len(values))
for key, value := range values {
switch v := value.(type) {
case map[string]any:
out[key] = effectiveNumbers(v)
case int:
out[key] = float64(v)
case int64:
out[key] = float64(v)
default:
out[key] = value
}
}
return out
}
func scopeAllows(d core.SettingDesc, scope core.SettingScope) bool {
if d.Scope == "" {
return scope == UserSettingScope || scope == PersonalProjectScope
}
return d.Scope == scope || (scope == PersonalProjectScope && d.Scope == UserSettingScope)
}
func (s *Service) SchemaFor(scope core.SettingScope) []core.SettingDesc {
out := []core.SettingDesc{}
for _, d := range s.Descriptors() {
if scopeAllows(d, scope) {
out = append(out, d)
}
}
return out
}
// ProjectValues contains only known leaves for the given authority scope. Old
// user/project copies of shared or managed configuration stay in storage but
// cannot re-enter the effective settings or HTTP responses.
func (s *Service) ProjectValues(values map[string]any, scope core.SettingScope) map[string]any {
values = normalizeKeys(values)
out := map[string]any{}
for _, d := range s.SchemaFor(scope) {
if v, ok := valueAtPath(values, d.Key); ok && validateSettingType(d, v) == nil {
out = core.MergeSettings(out, expandDotted(d.Key, normalizeValue(v)))
}
}
return out
}
func (s *Service) DefaultsFor(scope core.SettingScope) map[string]any {
out := map[string]any{}
for _, d := range s.SchemaFor(scope) {
out = core.MergeSettings(out, expandDotted(d.Key, d.Default))
}
return core.MergeSettings(out, s.ProjectValues(SystemDefaults(), scope))
}
type ErrInvalidSetting struct {
Path string
Reason string
}
func (e *ErrInvalidSetting) Error() string { return fmt.Sprintf("设置 %s: %s", e.Path, e.Reason) }
func validSettingPath(path string) bool {
if path == "" {
return false
}
root := strings.SplitN(path, ".", 2)[0]
if root == "_policy" || root == "_revision" {
return false
}
for _, segment := range strings.Split(path, ".") {
if segment == "" || strings.TrimSpace(segment) != segment || strings.ContainsAny(segment, "/\\\x00") {
return false
}
}
return true
}
// ValidateWrite validates raw structure before normalization, then known leaves
// and ownership. This prevents dotted/nested ambiguity and empty/scalar parents
// from silently erasing managed subtrees.
func (s *Service) ValidateWrite(values map[string]any, scope core.SettingScope) error {
if values == nil {
return &ErrInvalidSetting{Reason: "需要设置对象"}
}
if err := checkRawSettingPaths(values, ""); err != nil {
return err
}
if err := checkAmbiguousPaths(values, "", map[string]bool{}); err != nil {
return err
}
incoming := prepareWrite(values)
if scope != core.SettingScopeSystem {
if err := s.checkManagedWrite(incoming); err != nil {
return err
}
}
desc := map[string]core.SettingDesc{}
for _, d := range s.Descriptors() {
desc[d.Key] = d
}
var walk func(map[string]any, string) error
walk = func(m map[string]any, prefix string) error {
keys := make([]string, 0, len(m))
for key := range m {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
path := key
if prefix != "" {
path = prefix + "." + key
}
value := m[key]
if d, known := desc[path]; known {
if !scopeAllows(d, scope) {
return &ErrInvalidSetting{Path: path, Reason: "不属于当前设置范围"}
}
if value == nil && (scope == UserSettingScope || scope == PersonalProjectScope) {
leaf := path[strings.LastIndex(path, ".")+1:]
if d.Secret || d.Type == core.SettingSecret || isSecretKey(leaf) {
return &ErrInvalidSetting{Path: path, Reason: "撤销密钥请明确保存空值,不能删除后回退旧凭据"}
}
continue
}
if err := validateSettingType(d, value); err != nil {
return err
}
continue
}
child, object := value.(map[string]any)
parent := false
for candidate := range desc {
if strings.HasPrefix(candidate, path+".") {
parent = true
break
}
}
if !object || !parent || len(child) == 0 {
return &ErrInvalidSetting{Path: path, Reason: "未知设置项或无效对象"}
}
if err := walk(child, path); err != nil {
return err
}
}
return nil
}
return walk(normalizeKeys(values), "")
}
func checkAmbiguousPaths(values map[string]any, prefix string, seen map[string]bool) error {
for key, value := range values {
path := key
if prefix != "" {
path = prefix + "." + key
}
if child, ok := value.(map[string]any); ok && len(child) > 0 {
if err := checkAmbiguousPaths(child, path, seen); err != nil {
return err
}
continue
}
for previous := range seen {
if previous == path || strings.HasPrefix(previous, path+".") || strings.HasPrefix(path, previous+".") {
return &ErrInvalidSetting{Path: path, Reason: "嵌套和点路径配置重复或冲突"}
}
}
seen[path] = true
}
return nil
}
func checkRawSettingPaths(values map[string]any, prefix string) error {
for key, value := range values {
path := key
if prefix != "" {
path = prefix + "." + key
}
if !validSettingPath(key) {
return &ErrInvalidSetting{Path: path, Reason: "路径格式无效"}
}
if object, ok := value.(map[string]any); ok {
if err := checkRawSettingPaths(object, path); err != nil {
return err
}
}
}
return nil
}
func validateSettingType(d core.SettingDesc, value any) error {
valid := false
switch d.Type {
case core.SettingBool:
_, valid = value.(bool)
case core.SettingString, core.SettingSecret:
_, valid = value.(string)
case core.SettingEnum:
if text, ok := value.(string); ok {
for _, option := range d.Options {
if text == option {
valid = true
break
}
}
}
case core.SettingNumber:
switch number := value.(type) {
case float64:
valid = !math.IsNaN(number) && !math.IsInf(number, 0) && number >= 0 && number <= 1e9 && number == math.Trunc(number)
case int:
valid = number >= 0 && number <= 1e9
}
}
if !valid {
return &ErrInvalidSetting{Path: d.Key, Reason: "值类型或选项无效"}
}
return nil
}