Files
zero/internal/sandbox/normalize.go
KRATOSandGnanam 69810d5ab0 Mid-run model escalation: escalate_model tool + --allow-escalation (#146)
* modelregistry: add UpgradeTargetID and Registry.UpgradeTarget escalation lookup

* modelregistry: seed escalation chains in catalog decoration

* modelregistry: verification gate for escalation upgrade map

* agent: add ModelSwitcher option and ToolResult.RequestedModel field

* agent: lift escalate_to_model meta into ToolResult.RequestedModel

* agent: perform at-most-one mid-run model switch in the turn loop

* tools: add SideEffectNone for control-only tools

* tools: add escalate_model control tool (metadata + args)

* cli: parse --allow-escalation exec flag

* cli: document --allow-escalation in exec help

* cli: register escalate_model tool under --allow-escalation

* cli: wire model switcher for exec escalation

* cli: guard byte-identical exec run without escalation flag

* cli: attribute post-switch usage to the escalated model

* tools: accept an explicit empty escalate_model reason

reason is an optional, informational-only arg, but the previous arg
parsing rejected an explicit reason:"" (non-empty-string check), failing
the whole escalation. Switch to stringArgWithEmpty so an absent OR empty
reason is accepted while a non-string value is still rejected. Add a test
covering both the empty and absent forms.

* agent: test (nil,nil) escalation switcher keeps original provider

When a ModelSwitcher returns (nil, nil) — no error, no replacement — the
loop's `else if newProvider != nil` guard already keeps the active
provider, so the run continues on the original model. Add an agent-layer
test that proves this (it panics if the nil guard is dropped), closing the
gap the adversarial review flagged.

Also note the deferred compaction limitation near the switch: the
compactor's context-window budget is fixed at run start and not updated on
a mid-run switch (needs a ModelSwitcher contract change to fix).

* cli: gate usage model attribution on the escalation flag and harden tests

FIX A (back-compat): the OnUsage closure wrote a "model" key into every
persisted EventUsage payload unconditionally, diverging a non-escalation
run from the byte-identical origin/main baseline. The model can only
change mid-run under --allow-escalation, so include the "model" key only
then. A new flag-OFF test asserts the persisted usage payload carries no
"model" key (mutation-proven).

Test hardening for the CLI escalation boundary:
- Wire test: each newProvider build returns a DISTINCT provider with its
  own turn counter; assert the source provider served exactly the
  escalation turn and the escalated provider served exactly the
  post-switch answer. Fails if `provider = newProvider` is dropped.
- Mixed usage attribution: emit usage pre- and post-switch and assert the
  first attributes to the original model, the second to the escalated
  target (the loop's ordering guarantee).
- Switch-error path: a failed rebuild is non-fatal and post-error usage
  stays attributed to the original model (currentModel unchanged).
- Top-tier decline end-to-end: a flag-on run that escalates while already
  top-tier performs no switch (newProvider built exactly once) and
  succeeds.

* modelregistry: validate UpgradeTargetID resolves at registry construction

* cli: update currentModel only when the switched provider is non-nil

* sandbox: make 'none' a first-class side effect (control-only tools)

---------

Co-authored-by: Gnanam <[email protected]>
2026-06-08 20:50:32 +05:30

84 lines
2.4 KiB
Go

package sandbox
import (
"fmt"
"strings"
)
var autonomyRank = map[Autonomy]int{
AutonomyLow: 0,
AutonomyMedium: 1,
AutonomyHigh: 2,
}
func NormalizeAutonomy(value Autonomy) (Autonomy, error) {
normalized := Autonomy(strings.ToLower(strings.TrimSpace(string(value))))
switch normalized {
case "", AutonomyLow:
return AutonomyLow, nil
case AutonomyMedium, AutonomyHigh:
return normalized, nil
default:
return "", fmt.Errorf("invalid sandbox autonomy %q. Expected low, medium, or high", value)
}
}
func NormalizePermissionMode(value PermissionMode) PermissionMode {
normalized := PermissionMode(strings.ToLower(strings.TrimSpace(string(value))))
switch normalized {
case PermissionModeAsk:
return PermissionModeAsk
case PermissionUnsafe:
return PermissionUnsafe
default:
return PermissionModeAuto
}
}
func NormalizePermission(value Permission) Permission {
normalized := Permission(strings.ToLower(strings.TrimSpace(string(value))))
switch normalized {
case PermissionAllow, PermissionDeny:
return normalized
default:
return PermissionPrompt
}
}
func NormalizeSideEffect(value SideEffect) SideEffect {
normalized := SideEffect(strings.ToLower(strings.TrimSpace(string(value))))
switch normalized {
case SideEffectRead, SideEffectWrite, SideEffectShell, SideEffectNetwork, SideEffectOutOfWorkspace, SideEffectNone:
return normalized
default:
return SideEffectOutOfWorkspace
}
}
func NormalizeGrantDecision(value GrantDecision) (GrantDecision, error) {
normalized := GrantDecision(strings.ToLower(strings.TrimSpace(string(value))))
switch normalized {
case GrantAllow, GrantDeny:
return normalized, nil
default:
return "", fmt.Errorf("invalid sandbox grant decision %q. Expected allow or deny", value)
}
}
func autonomyAllowed(requested Autonomy, max Autonomy) bool {
requestedNorm, requestedErr := NormalizeAutonomy(requested)
maxNorm, maxErr := NormalizeAutonomy(max)
// An unknown requested tier ranks above every valid tier so it is never
// allowed (fail-closed), even against a High ceiling. An unknown ceiling
// ranks below every valid tier so it admits nothing above Low (fail-closed).
requestedScore := autonomyRank[requestedNorm]
if requestedErr != nil {
requestedScore = autonomyRank[AutonomyHigh] + 1
}
maxScore := autonomyRank[maxNorm]
if maxErr != nil {
maxScore = autonomyRank[AutonomyLow] - 1
}
return requestedScore <= maxScore
}