Why: Nested unchanged credential masks left empty parents that looked like managed subtree overwrites. Shared members also hit an owner-only upload guard, while upload ingestion cards could disclose private filenames and parser metadata. Evidence: SELF independent nested-mask probe, HTTP nested/mixed writeback, member upload, project card privacy and authorization-change regressions failed before each fix and passed after it. Affected settings/server suite passed; exact final-object build and independent replay remain delivery gates owned by the task coordinator. Impact: Prune only parents emptied by ignored masks; explicit empty/real managed writes stay rejected. Registered uploads check live work and trusted project existence, keep originals owned by the actor, and recheck before persistence. Suppress only upload project cards when no private stream exists; explicit structure cards and unregistered upload behavior remain available. Refs: Dev project-member-access plan r1 Task 1/R5; independent nested mask P2; 2026-10-13 composed member-upload and private project-stream acceptance findings
109 lines
2.9 KiB
Go
109 lines
2.9 KiB
Go
package settings
|
|
|
|
import (
|
|
core "agent-system-core"
|
|
"strings"
|
|
)
|
|
|
|
// MaskedProjectMetadata returns a detached credential-safe view without changing
|
|
// stored values or dotted service/configuration key shapes. The builtin and
|
|
// plugin secret schema plus the existing secret-leaf fallback cover legacy data.
|
|
func (s *Service) MaskedProjectMetadata(values map[string]any) map[string]any {
|
|
if values == nil {
|
|
return map[string]any{}
|
|
}
|
|
schema := Schema()
|
|
if s != nil {
|
|
schema = append(schema, s.extraSchema...)
|
|
}
|
|
secrets := map[string]bool{}
|
|
for _, d := range schema {
|
|
if d.Type == core.SettingSecret || d.Secret {
|
|
secrets[d.Key] = true
|
|
}
|
|
}
|
|
return maskedProjectValue(values, "", secrets).(map[string]any)
|
|
}
|
|
|
|
func maskedProjectValue(value any, path string, secrets map[string]bool) any {
|
|
leaf := path
|
|
if i := strings.LastIndexByte(leaf, '.'); i >= 0 {
|
|
leaf = leaf[i+1:]
|
|
}
|
|
if secrets[path] || isSecretKey(leaf) {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
if text, ok := value.(string); ok && text == "" {
|
|
return text
|
|
}
|
|
return MaskPlaceholder
|
|
}
|
|
switch v := value.(type) {
|
|
case map[string]any:
|
|
if v == nil {
|
|
return v
|
|
}
|
|
out := make(map[string]any, len(v))
|
|
for key, item := range v {
|
|
child := key
|
|
if path != "" {
|
|
child = path + "." + key
|
|
}
|
|
out[key] = maskedProjectValue(item, child, secrets)
|
|
}
|
|
return out
|
|
case []any:
|
|
if v == nil {
|
|
return v
|
|
}
|
|
out := make([]any, len(v))
|
|
for i, item := range v {
|
|
out[i] = maskedProjectValue(item, path, secrets)
|
|
}
|
|
return out
|
|
default:
|
|
return value
|
|
}
|
|
}
|
|
|
|
// PrepareProjectMetadataWrite applies the same managed-path and mask-placeholder
|
|
// rules as project settings, while preserving the metadata store's key shapes.
|
|
// Project metadata lives in projects.settings, not the settings scope table.
|
|
func (s *Service) PrepareProjectMetadataWrite(values map[string]any) (map[string]any, error) {
|
|
incoming := dropProjectMaskPlaceholders(values)
|
|
if err := s.checkManagedWrite(normalizeKeys(incoming)); err != nil {
|
|
return nil, err
|
|
}
|
|
return incoming, nil
|
|
}
|
|
|
|
// dropProjectMaskPlaceholders prunes a parent only when a previously nonempty
|
|
// object became empty through mask removal. Explicit empty objects still express
|
|
// an overwrite and must reach the managed-path guard unchanged.
|
|
func dropProjectMaskPlaceholders(values map[string]any) map[string]any {
|
|
out := make(map[string]any, len(values))
|
|
for key, value := range values {
|
|
if child, ok := value.(map[string]any); ok {
|
|
prepared := dropProjectMaskPlaceholders(child)
|
|
if len(child) > 0 && len(prepared) == 0 {
|
|
continue
|
|
}
|
|
out[key] = prepared
|
|
continue
|
|
}
|
|
if text, ok := value.(string); ok && text == MaskPlaceholder {
|
|
continue
|
|
}
|
|
out[key] = value
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Service) checkManagedWrite(incoming map[string]any) error {
|
|
if hits := core.TouchesManagedSettings(incoming, s.ManagedSettingPaths()); len(hits) > 0 {
|
|
return &ErrManagedPath{Paths: hits}
|
|
}
|
|
return nil
|
|
}
|