diff --git a/internal/permission/config_policy.go b/internal/permission/config_policy.go index d9b223091..360bb256d 100644 --- a/internal/permission/config_policy.go +++ b/internal/permission/config_policy.go @@ -364,7 +364,13 @@ func switchModeEscalates(input json.RawMessage) bool { Mode string `json:"mode"` } _ = json.Unmarshal(input, &a) - m := strings.TrimSpace(a.Mode) + // #4260: the tool side parses the mode case-INSENSITIVELY + // (switch_mode.go lowercases before ParsePermissionMode), and the + // permission Check sees the raw JSON args BEFORE CoerceEnumValues + // normalizes them. A case-sensitive compare here let {"mode":"Bypass"} + // skip the escalation Ask entirely while still switching into bypass - + // normalize the same way the parser does. + m := strings.ToLower(strings.TrimSpace(a.Mode)) return m == "bypass" || m == "autopilot" } diff --git a/internal/permission/zz_issue4260_test.go b/internal/permission/zz_issue4260_test.go new file mode 100644 index 000000000..037716e4e --- /dev/null +++ b/internal/permission/zz_issue4260_test.go @@ -0,0 +1,28 @@ +package permission + +import ( + "encoding/json" + "testing" +) + +// #4260: the escalation gate must match the parser's case-insensitive +// semantics - every casing of bypass/autopilot escalates (Ask), mixed-case +// non-escalating targets stay Allow. +func TestIssue4260_EscalationGateCaseInsensitive(t *testing.T) { + for _, raw := range []string{ + `{"mode":"Bypass"}`, `{"mode":"BYPASS"}`, `{"mode":"ByPass"}`, + `{"mode":"Autopilot"}`, `{"mode":"AUTOPILOT"}`, `{"mode":"autopilot"}`, + `{"mode":" bypass "}`, + } { + if !switchModeEscalates(json.RawMessage(raw)) { + t.Fatalf("%s must escalate (Ask gate), got false - privilege-bypass regression", raw) + } + } + for _, raw := range []string{ + `{"mode":"supervised"}`, `{"mode":"Supervised"}`, `{"mode":"auto"}`, `{"mode":"Auto"}`, `{"mode":"plan"}`, + } { + if switchModeEscalates(json.RawMessage(raw)) { + t.Fatalf("%s must NOT escalate, got true", raw) + } + } +}