Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -753,3 +753,5 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—`
| [083-discovery-profiler](./specs/083-discovery-profiler/) | `drafted` | 0/41 (0%) |
| [084-toon-output](./specs/084-toon-output/) | `in-flight` | 40/43 (93%) |
| [085-compact-router](./specs/085-compact-router/) | `shipped` | 44/46 (96%) |
| [086-tpa-scanner-approval](./specs/086-tpa-scanner-approval/) | — | — |
| [087-tpa-daily-refresh](./specs/087-tpa-daily-refresh/) | — | — |
82 changes: 82 additions & 0 deletions internal/config/auto_approve_tool_changes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,88 @@ func TestNormalizeServerQuarantineFlags(t *testing.T) {
cfg := &Config{Servers: []*ServerConfig{nil}}
assert.NotPanics(t, func() { normalizeServerQuarantineFlags(cfg) })
})

// --- spec 086: trust_mode derivation (pass 2) ---

t.Run("legacy skip_quarantine true converges to trust_mode auto", func(t *testing.T) {
cfg := &Config{Servers: []*ServerConfig{{Name: "legacy", SkipQuarantine: true}}}
normalizeServerQuarantineFlags(cfg)
// skip -> auto_approve -> trust_mode auto (layering skip->auto->trust).
assert.Equal(t, string(TrustModeAuto), cfg.Servers[0].TrustMode)
assert.Equal(t, TrustModeAuto, cfg.Servers[0].EffectiveTrustMode())
})

t.Run("explicit auto_approve_tool_changes false converges to trust_mode manual", func(t *testing.T) {
cfg := &Config{Servers: []*ServerConfig{{Name: "off", AutoApproveToolChanges: boolPtr(false)}}}
normalizeServerQuarantineFlags(cfg)
assert.Equal(t, string(TrustModeManual), cfg.Servers[0].TrustMode, "false must map to manual, NOT auto")
// AutoApproveToolChanges must NOT be clobbered by the trust migration.
require.NotNil(t, cfg.Servers[0].AutoApproveToolChanges)
assert.False(t, *cfg.Servers[0].AutoApproveToolChanges)
assert.Equal(t, TrustModeManual, cfg.Servers[0].EffectiveTrustMode())
})

t.Run("auto_approve_tool_changes true maps to trust_mode auto", func(t *testing.T) {
cfg := &Config{Servers: []*ServerConfig{{Name: "on", AutoApproveToolChanges: boolPtr(true)}}}
normalizeServerQuarantineFlags(cfg)
assert.Equal(t, string(TrustModeAuto), cfg.Servers[0].TrustMode)
})

t.Run("neither legacy field set leaves trust_mode empty (resolves manual)", func(t *testing.T) {
cfg := &Config{Servers: []*ServerConfig{{Name: "none"}}}
normalizeServerQuarantineFlags(cfg)
assert.Equal(t, "", cfg.Servers[0].TrustMode, "nil auto_approve leaves trust_mode empty")
assert.Equal(t, TrustModeManual, cfg.Servers[0].EffectiveTrustMode(), "empty resolves to manual (secure by default)")
})

t.Run("explicit trust_mode scan wins over legacy skip_quarantine true (not clobbered)", func(t *testing.T) {
cfg := &Config{Servers: []*ServerConfig{
{Name: "scan", SkipQuarantine: true, TrustMode: string(TrustModeScan)},
}}
normalizeServerQuarantineFlags(cfg)
assert.Equal(t, string(TrustModeScan), cfg.Servers[0].TrustMode, "explicit trust_mode must survive migration")
assert.Equal(t, TrustModeScan, cfg.Servers[0].EffectiveTrustMode())
})

t.Run("migration is idempotent for trust_mode", func(t *testing.T) {
cfg := &Config{Servers: []*ServerConfig{{Name: "legacy", SkipQuarantine: true}}}
normalizeServerQuarantineFlags(cfg)
normalizeServerQuarantineFlags(cfg)
assert.Equal(t, string(TrustModeAuto), cfg.Servers[0].TrustMode)
})
}

// TestServerConfig_EffectiveTrustMode covers the single resolution point,
// including the in-memory legacy fallback and fail-closed for unknown values.
func TestServerConfig_EffectiveTrustMode(t *testing.T) {
tests := []struct {
name string
config ServerConfig
want TrustMode
}{
{"explicit auto", ServerConfig{TrustMode: "auto"}, TrustModeAuto},
{"explicit scan", ServerConfig{TrustMode: "scan"}, TrustModeScan},
{"explicit manual", ServerConfig{TrustMode: "manual"}, TrustModeManual},
{"empty resolves manual", ServerConfig{}, TrustModeManual},
{"unknown value fails closed to manual", ServerConfig{TrustMode: "off"}, TrustModeManual},
{"case-typo fails closed to manual", ServerConfig{TrustMode: "Scan"}, TrustModeManual},
{"in-memory legacy auto_approve true -> auto", ServerConfig{AutoApproveToolChanges: boolPtr(true)}, TrustModeAuto},
{"in-memory legacy auto_approve false -> manual", ServerConfig{AutoApproveToolChanges: boolPtr(false)}, TrustModeManual},
{"in-memory legacy skip_quarantine -> auto", ServerConfig{SkipQuarantine: true}, TrustModeAuto},
{"explicit trust_mode wins over legacy skip", ServerConfig{SkipQuarantine: true, TrustMode: "manual"}, TrustModeManual},
// A NON-EMPTY but invalid trust_mode must fail closed to manual and must
// NOT consult the legacy flags — otherwise a typo'd mode combined with a
// migrated skip_quarantine/auto_approve would silently resolve to auto and
// disable quarantine (FR-009).
{"typo mode + legacy skip does NOT fall open to auto", ServerConfig{TrustMode: "Scan", SkipQuarantine: true}, TrustModeManual},
{"typo mode + legacy auto_approve true does NOT fall open to auto", ServerConfig{TrustMode: "scnn", AutoApproveToolChanges: boolPtr(true)}, TrustModeManual},
{"unknown mode + legacy skip does NOT fall open to auto", ServerConfig{TrustMode: "off", SkipQuarantine: true}, TrustModeManual},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, tt.config.EffectiveTrustMode())
})
}
}

// TestAutoApproveToolChanges_RoundTrip_SaveLoad verifies the field survives a
Expand Down
123 changes: 114 additions & 9 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,25 @@ type LogConfig struct {
JSONFormat bool `json:"json_format" mapstructure:"json-format"`
}

// TrustMode is a per-server trust tier that governs how tool changes and
// additions are approved (spec 086). It supersedes the legacy skip_quarantine
// and auto_approve_tool_changes flags:
//
// - TrustModeAuto — auto-approve all changes/additions (today's
// skip_quarantine / auto_approve_tool_changes:true behavior).
// - TrustModeScan — auto-approve a change ONLY when a synchronous, offline
// TPA scan of the changed tool returns a green (clean) verdict; otherwise
// the record is held for human review (fail closed).
// - TrustModeManual — never auto-approve; every change/addition is held
// (secure by default).
type TrustMode string

const (
TrustModeAuto TrustMode = "auto"
TrustModeScan TrustMode = "scan"
TrustModeManual TrustMode = "manual"
)

// ServerConfig represents upstream MCP server configuration
type ServerConfig struct {
Name string `json:"name,omitempty" mapstructure:"name"`
Expand Down Expand Up @@ -463,12 +482,19 @@ type ServerConfig struct {
// from legacy skip_quarantine), explicit true/false = honored as-is so an
// explicit auto_approve_tool_changes:false overrides a legacy skip_quarantine:true.
// Read via IsAutoApproveToolChanges().
AutoApproveToolChanges *bool `json:"auto_approve_tool_changes,omitempty" mapstructure:"auto-approve-tool-changes"` // Per-server intent to auto-approve tool changes/additions. Accepted/persisted by MCP-2930; runtime enforcement lands in MCP-2931 (until then SkipQuarantine governs behavior)
Shared bool `json:"shared,omitempty" mapstructure:"shared"` // Server edition: shared with all users
Created time.Time `json:"created" mapstructure:"created"`
Updated time.Time `json:"updated,omitempty" mapstructure:"updated"`
Isolation *IsolationConfig `json:"isolation,omitempty" mapstructure:"isolation"` // Per-server isolation settings
ReconnectOnUse bool `json:"reconnect_on_use,omitempty" mapstructure:"reconnect-on-use"` // Attempt reconnection when a tool call targets a disconnected server
AutoApproveToolChanges *bool `json:"auto_approve_tool_changes,omitempty" mapstructure:"auto-approve-tool-changes"` // Per-server intent to auto-approve tool changes/additions. Accepted/persisted by MCP-2930; runtime enforcement lands in MCP-2931 (until then SkipQuarantine governs behavior)
// TrustMode is the per-server trust tier: auto|scan|manual. Supersedes
// auto_approve_tool_changes (spec 086). An empty value is derived from the
// legacy fields at load via normalizeServerQuarantineFlags; the single
// resolution point is EffectiveTrustMode(), which treats an empty or
// unrecognized value as manual (secure by default). Read via
// EffectiveTrustMode(), never the raw string.
TrustMode string `json:"trust_mode,omitempty" mapstructure:"trust-mode"` // Per-server trust tier: auto|scan|manual. Supersedes auto_approve_tool_changes (086)
Shared bool `json:"shared,omitempty" mapstructure:"shared"` // Server edition: shared with all users
Created time.Time `json:"created" mapstructure:"created"`
Updated time.Time `json:"updated,omitempty" mapstructure:"updated"`
Isolation *IsolationConfig `json:"isolation,omitempty" mapstructure:"isolation"` // Per-server isolation settings
ReconnectOnUse bool `json:"reconnect_on_use,omitempty" mapstructure:"reconnect-on-use"` // Attempt reconnection when a tool call targets a disconnected server

// LauncherWaitTimeout caps how long mcpproxy will wait for a locally-launched
// HTTP/SSE upstream's URL to become reachable after Spawn(). Only consulted
Expand Down Expand Up @@ -1018,10 +1044,27 @@ func normalizeServerQuarantineFlags(cfg *Config) {
if s == nil {
continue
}
// Pass 1 (MCP-2930): legacy skip_quarantine -> auto_approve_tool_changes,
// only when the successor field is unset so an explicit value wins.
if s.SkipQuarantine && s.AutoApproveToolChanges == nil {
migrated := true
s.AutoApproveToolChanges = &migrated
}
// Pass 2 (spec 086): map auto_approve_tool_changes onto trust_mode when
// trust_mode is unset. Layering skip->auto->trust means a legacy
// skip_quarantine:true converges to trust_mode auto, while an explicit
// auto_approve_tool_changes:false converges to manual. An explicit
// trust_mode is never touched (guard on empty). AutoApproveToolChanges is
// left UNTOUCHED here — only trust_mode is written — so the explicit-false
// invariant is preserved. A nil AutoApproveToolChanges leaves trust_mode
// empty; EffectiveTrustMode() resolves empty -> manual.
if s.TrustMode == "" && s.AutoApproveToolChanges != nil {
if *s.AutoApproveToolChanges {
s.TrustMode = string(TrustModeAuto)
} else {
s.TrustMode = string(TrustModeManual)
}
}
}
}

Expand Down Expand Up @@ -1589,9 +1632,71 @@ func (c *Config) DefaultQuarantineForNewServer() bool {
return c.IsQuarantineEnabled()
}

// IsQuarantineSkipped returns whether this server should skip tool-level quarantine.
// QuarantineDefaultForServer resolves the add-time Quarantined default for a
// specific new server from its trust_mode (spec 086 stage 3, FR-011). Secure by
// default: a server is admitted UNQUARANTINED only under trust_mode auto; scan
// and manual are quarantined on add (scan is later auto-unquarantined once its
// baseline scan settles green — see the EventTypeSecurityScanSettled handler).
//
// The global quarantine_enabled=false escape hatch (#370) still wins: when
// quarantine is disabled globally, every new server is admitted unquarantined
// regardless of its trust_mode, preserving DefaultQuarantineForNewServer's
// semantics. A nil server config fails closed to quarantine.
//
// CN-002: the mode is read from the server's config/registry-derived trust_mode,
// never from a request-driven admission override.
func (c *Config) QuarantineDefaultForServer(sc *ServerConfig) bool {
if !c.IsQuarantineEnabled() {
return false
}
if sc == nil {
return true
}
return sc.EffectiveTrustMode() != TrustModeAuto
}

// EffectiveTrustMode is the single resolution point for a server's trust tier
// (spec 086). It returns one of TrustModeAuto/Scan/Manual, defaulting to manual
// (secure by default) for empty OR unrecognized trust_mode values (FR-009 —
// fail closed on a typo like "Scan" or "off").
//
// Because normalizeServerQuarantineFlags populates TrustMode at config load,
// the legacy-field fallback here only matters for in-memory structs that
// bypassed the loader (tests, REST-constructed configs): an explicit
// auto_approve_tool_changes maps true->auto / false->manual, else a legacy
// skip_quarantine:true maps to auto, else manual. This keeps the accessor
// correct even when the migration has not run.
func (sc *ServerConfig) EffectiveTrustMode() TrustMode {
switch TrustMode(sc.TrustMode) {
case TrustModeAuto, TrustModeScan, TrustModeManual:
return TrustMode(sc.TrustMode)
}
// A NON-EMPTY but unrecognized trust_mode (e.g. the typo "Scan" or a bogus
// "off") must fail closed to manual and must NOT consult the legacy fields —
// otherwise a typo'd mode combined with a migrated auto_approve_tool_changes:true
// or skip_quarantine:true would silently resolve to auto and disable quarantine
// (FR-009, fail closed). Only a genuinely EMPTY mode falls through to the
// legacy-field compatibility path below.
if sc.TrustMode != "" {
return TrustModeManual
}
if sc.AutoApproveToolChanges != nil {
if *sc.AutoApproveToolChanges {
return TrustModeAuto
}
return TrustModeManual
}
if sc.SkipQuarantine {
return TrustModeAuto
}
return TrustModeManual
}

// IsQuarantineSkipped returns whether this server should skip tool-level
// quarantine. Thin wrapper over EffectiveTrustMode(): only trust_mode auto
// skips quarantine; scan and manual do not (spec 086).
func (sc *ServerConfig) IsQuarantineSkipped() bool {
return sc.SkipQuarantine
return sc.EffectiveTrustMode() == TrustModeAuto
}

// IsAutoApproveToolChanges reports the configured per-server intent to auto-approve
Expand All @@ -1603,7 +1708,7 @@ func (sc *ServerConfig) IsQuarantineSkipped() bool {
// (see normalizeServerQuarantineFlags) only when it is unset, so an explicit value
// always wins. MCP-2930.
func (sc *ServerConfig) IsAutoApproveToolChanges() bool {
return sc.AutoApproveToolChanges != nil && *sc.AutoApproveToolChanges
return sc.EffectiveTrustMode() == TrustModeAuto
}

// IsToolAllowedByConfig reports whether toolName passes the server's static
Expand Down
70 changes: 70 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,76 @@ func TestConfig_DefaultQuarantineForNewServer(t *testing.T) {
}
}

func TestConfig_QuarantineDefaultForServer(t *testing.T) {
tests := []struct {
name string
config Config
sc *ServerConfig
expected bool
}{
{
name: "quarantine disabled globally: never quarantine (escape hatch preserved)",
config: Config{QuarantineEnabled: boolPtr(false)},
sc: &ServerConfig{TrustMode: string(TrustModeManual)},
expected: false,
},
{
name: "quarantine disabled globally: even scan mode is admitted unquarantined",
config: Config{QuarantineEnabled: boolPtr(false)},
sc: &ServerConfig{TrustMode: string(TrustModeScan)},
expected: false,
},
{
name: "nil server config: quarantine (fail closed)",
config: Config{QuarantineEnabled: nil},
sc: nil,
expected: true,
},
{
name: "auto mode: admit unquarantined",
config: Config{QuarantineEnabled: nil},
sc: &ServerConfig{TrustMode: string(TrustModeAuto)},
expected: false,
},
{
name: "scan mode: quarantine on add",
config: Config{QuarantineEnabled: nil},
sc: &ServerConfig{TrustMode: string(TrustModeScan)},
expected: true,
},
{
name: "manual mode: quarantine on add",
config: Config{QuarantineEnabled: nil},
sc: &ServerConfig{TrustMode: string(TrustModeManual)},
expected: true,
},
{
name: "empty trust mode resolves to manual: quarantine",
config: Config{QuarantineEnabled: nil},
sc: &ServerConfig{},
expected: true,
},
{
name: "unrecognized trust mode fails closed to manual: quarantine",
config: Config{QuarantineEnabled: nil},
sc: &ServerConfig{TrustMode: "Scan"},
expected: true,
},
{
name: "legacy auto_approve_tool_changes=true resolves to auto: admit unquarantined",
config: Config{QuarantineEnabled: nil},
sc: &ServerConfig{AutoApproveToolChanges: boolPtr(true)},
expected: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, tt.config.QuarantineDefaultForServer(tt.sc))
})
}
}

func TestServerConfig_IsQuarantineSkipped(t *testing.T) {
tests := []struct {
name string
Expand Down
11 changes: 11 additions & 0 deletions internal/config/merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,16 @@ func MergeServerConfig(base, patch *ServerConfig, opts MergeOptions) (*ServerCon
merged.WorkingDir = patch.WorkingDir
}

// TrustMode (spec 086): a PATCH that omits trust_mode keeps the base value
// (merged started as CopyServerConfig(base)); a non-empty, differing value
// updates it. Enables REST PATCH /api/v1/servers/{id} to change the trust tier.
if patch.TrustMode != "" && patch.TrustMode != base.TrustMode {
if diff != nil {
diff.Modified["trust_mode"] = FieldChange{Path: "trust_mode", From: base.TrustMode, To: patch.TrustMode}
}
merged.TrustMode = patch.TrustMode
}

// Boolean fields - use reflection to detect if explicitly set
// For booleans, we check if the patch has them set differently from base
// Since Go booleans default to false, we need a different approach for explicit false
Expand Down Expand Up @@ -547,6 +557,7 @@ func CopyServerConfig(src *ServerConfig) *ServerConfig {
Enabled: src.Enabled,
Quarantined: src.Quarantined,
SkipQuarantine: src.SkipQuarantine,
TrustMode: src.TrustMode,
Shared: src.Shared,
Created: src.Created,
Updated: src.Updated,
Expand Down
29 changes: 29 additions & 0 deletions internal/config/merge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,35 @@ func TestMergeServerConfig_ScalarFieldReplacement(t *testing.T) {
}
}

// Spec 086: a PATCH sets trust_mode; omitting it preserves the base value
// (CopyServerConfig carries it forward).
func TestMergeServerConfig_TrustMode(t *testing.T) {
base := &ServerConfig{Name: "srv", TrustMode: string(TrustModeScan)}

t.Run("patch changes trust_mode and captures diff", func(t *testing.T) {
merged, diff, err := MergeServerConfig(base, &ServerConfig{TrustMode: string(TrustModeAuto)}, DefaultMergeOptions())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if merged.TrustMode != string(TrustModeAuto) {
t.Errorf("trust_mode not patched: got %q, want %q", merged.TrustMode, TrustModeAuto)
}
if _, ok := diff.Modified["trust_mode"]; !ok {
t.Error("trust_mode change not captured in diff")
}
})

t.Run("omitted trust_mode preserves base", func(t *testing.T) {
merged, _, err := MergeServerConfig(base, &ServerConfig{Command: "x"}, DefaultMergeOptions())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if merged.TrustMode != string(TrustModeScan) {
t.Errorf("base trust_mode not preserved: got %q, want %q", merged.TrustMode, TrustModeScan)
}
})
}

// T3.2: Test deep merge for map fields
func TestMergeServerConfig_MapFieldDeepMerge(t *testing.T) {
base := &ServerConfig{
Expand Down
Loading
Loading