Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1bcb2bf
feat(tui): put unsafe mode in the shift+tab cycle behind a confirm key
Vasanthdev2004 Aug 8, 2026
eda9f30
fix(sandbox): stop classifying local dev servers as network egress
Vasanthdev2004 Aug 8, 2026
dbdd281
refactor: rename the unsafe permission mode to full-auto
Vasanthdev2004 Aug 8, 2026
f608752
fix: keep the old permission-mode constant names as deprecated aliases
Vasanthdev2004 Aug 8, 2026
874db9f
fix: make the full-auto rename reach the values that travel as data
Vasanthdev2004 Aug 9, 2026
30459e1
test(tui): move the plan-mode guard onto advancePermissionMode
Vasanthdev2004 Aug 10, 2026
54613e4
fix(sandbox,cli,tui): keep serving commands gated, and accept the can…
Vasanthdev2004 Aug 11, 2026
714ce64
fix(tui): cancel the full-auto offer when the terminal loses focus
Vasanthdev2004 Aug 19, 2026
1cc95f3
fix(sandbox,tui,cli): close the unparseable network gap and the defer…
Vasanthdev2004 Aug 20, 2026
b75e7cb
fix(tui,sandbox): cancel the offer on a streaming partial, and derive…
Vasanthdev2004 Aug 21, 2026
677b3f0
fix(sandbox): a flagged invocation classifies like its unflagged self…
gnanam1990 Aug 24, 2026
32c3527
fix(sandbox): keep framework builds under the network gate
Vasanthdev2004 Sep 12, 2026
f87a0af
Merge remote-tracking branch 'origin/main' into split/permission-mode…
Vasanthdev2004 Sep 12, 2026
3b52175
fix(tui): keep the full-auto offer visible before ctrl+g can accept it
Vasanthdev2004 Sep 13, 2026
c9d8ac9
Merge origin/main into split/permission-mode-and-classifier
Vasanthdev2004 Sep 15, 2026
89eab02
fix(tui): deliver peer callbacks to the event loop through an ordered…
Vasanthdev2004 Sep 15, 2026
6f228bf
test(cli): isolate user state in the full-auto parity helper and pin it
Vasanthdev2004 Sep 15, 2026
7be8920
fix(tui): only offer full-auto where the footer can show the confirma…
Vasanthdev2004 Sep 15, 2026
9f427ba
Merge remote-tracking branch 'origin/main' into split/permission-mode…
Vasanthdev2004 Sep 21, 2026
8608699
fix(tui): end a full-auto offer on cross-session peer traffic
Vasanthdev2004 Sep 21, 2026
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
17 changes: 13 additions & 4 deletions internal/acp/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -646,13 +646,19 @@ func (a *Agent) handleSetMode(_ context.Context, params json.RawMessage) (any, e
}
sess.turnMu.Lock()
defer sess.turnMu.Unlock()
mode := agent.PermissionMode(p.ModeID)
// Normalized before the switch, for the same reason the TUI normalizes at its
// own boundary: the mode arrives as data, so the accepted legacy "unsafe"
// spelling reaches this switch unrewritten and falls through to default. The
// client that asks for the disallowed mode by its documented old name is then
// told it does not exist, which reads as "try another spelling" rather than
// "this door is closed over ACP".
mode := agent.NormalizePermissionMode(agent.PermissionMode(p.ModeID))
switch mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
sess.setMode(mode)
(&notifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
return SetSessionModeResult{}, nil
case agent.PermissionModeUnsafe:
case agent.PermissionModeFullAuto:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Unsafe = run every tool with no prompt. The TUI gates this behind an
// explicit --skip-permissions-unsafe operator flag; an editor client must
// not be able to grant itself unconfined, no-prompt access over the wire.
Expand Down Expand Up @@ -682,12 +688,15 @@ func (a *Agent) handleSetConfigOption(_ context.Context, params json.RawMessage)
// set_mode and set_config_option) serialize mode flips consistently.
sess.turnMu.Lock()
defer sess.turnMu.Unlock()
mode := agent.PermissionMode(p.Value)
// Normalized like handleSetMode above: this is the second advertised mode
// door, and an alias that only one of them rewrites is a difference
// between two paths that are meant to be the same contract.
mode := agent.NormalizePermissionMode(agent.PermissionMode(p.Value))
switch mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
sess.setMode(mode)
(&notifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
case agent.PermissionModeUnsafe:
case agent.PermissionModeFullAuto:
return nil, RPCError(codeInvalidParams, "mode not permitted over ACP: "+p.Value)
default:
return nil, RPCError(codeInvalidParams, "unknown mode: "+p.Value)
Expand Down
88 changes: 84 additions & 4 deletions internal/acp/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,8 @@ func TestACPEndToEndPrompt(t *testing.T) {
if len(newRes.ConfigOptions) != 2 || newRes.ConfigOptions[0].ID != configIDModel || newRes.ConfigOptions[0].CurrentValue != "fake-model" {
t.Fatalf("model config option = %+v, want fake-model fallback", newRes.ConfigOptions)
}
if newRes.ConfigOptions[1].ID != configIDMode || newRes.ConfigOptions[1].CurrentValue != string(agent.PermissionModeAuto) {
t.Fatalf("mode config option = %+v", newRes.ConfigOptions[1])
if mode := configOptionByID(t, newRes.ConfigOptions, configIDMode); mode.CurrentValue != string(agent.PermissionModeAuto) {
t.Fatalf("mode config option = %+v", mode)
}

// session/prompt
Expand Down Expand Up @@ -453,7 +453,7 @@ func TestACPModelConfigOptionsCatalogSelectionAndLoad(t *testing.T) {
if len(loaded.ConfigOptions) != 2 || loaded.ConfigOptions[0].CurrentValue != "gpt-5.4-mini" {
t.Fatalf("load model option = %+v", loaded.ConfigOptions)
}
if loaded.ConfigOptions[1].CurrentValue != string(agent.PermissionModeAuto) {
if mode := configOptionByID(t, loaded.ConfigOptions, configIDMode); mode.CurrentValue != string(agent.PermissionModeAuto) {
t.Fatalf("load mode option = %+v", loaded.ConfigOptions[1])
}
}
Expand Down Expand Up @@ -652,7 +652,7 @@ func TestACPSetModeUpdatesSession(t *testing.T) {
t.Fatalf("config mode options missing plan: %#v", planConfigured.ConfigOptions[1].Options)
}
// Unsafe must be rejected over ACP — a client can't self-grant no-prompt host access.
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeUnsafe)}, &SetSessionModeResult{}); err == nil {
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeFullAuto)}, &SetSessionModeResult{}); err == nil {
t.Fatal("expected Unsafe mode to be rejected over ACP")
}
Comment thread
Vasanthdev2004 marked this conversation as resolved.
// An unknown mode must be rejected.
Expand All @@ -661,6 +661,70 @@ func TestACPSetModeUpdatesSession(t *testing.T) {
}
}

// The legacy "unsafe" spelling has to reach the full-auto arm of both mode
// doors, not fall through to "unknown mode".
//
// It is an accepted alias everywhere else in the tree, so a client that sends it
// is naming the mode ACP deliberately refuses. Unnormalized, both handlers
// answered "unknown mode: unsafe" — the mode was still refused, so nothing
// escalated, but the client was told the wrong thing about why: that the mode
// does not exist, rather than that this transport will not grant it. That
// invites retrying under another spelling instead of stopping.
//
// Both doors are checked because they are two entry points onto one contract,
// and the previous round of this bug was exactly one layer normalizing while
// another did not.
func TestACPRejectsLegacyUnsafeAliasAsDisallowedNotUnknown(t *testing.T) {
h := newHarness(t, testDeps(t))
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

var newRes NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
t.Fatalf("session/new: %v", err)
}

assertDisallowed := func(door string, err error) {
t.Helper()
if err == nil {
t.Fatalf("%s: legacy unsafe alias was accepted over ACP", door)
}
if !strings.Contains(err.Error(), "mode not permitted over ACP") {
t.Errorf("%s: error = %q, want the disallowed-mode message", door, err)
}
if strings.Contains(err.Error(), "unknown mode") {
t.Errorf("%s: error = %q, want the alias resolved instead of reported as unknown", door, err)
}
}

// Spelled literally on purpose: this is the value that travels over the wire,
// and the Go alias for it is the canonical string, not the legacy one.
const legacyModeID = "unsafe"
assertDisallowed("set_mode", h.client.Call(ctx, MethodSessionSetMode,
SetSessionModeParams{SessionID: newRes.SessionID, ModeID: legacyModeID}, &SetSessionModeResult{}))
assertDisallowed("set_config_option", h.client.Call(ctx, MethodSessionSetConfigOption,
SetSessionConfigOptionParams{SessionID: newRes.SessionID, ConfigID: configIDMode, Value: legacyModeID}, &SetSessionConfigOptionResult{}))

// Refusing must leave the session where it was, not in a half-applied state.
var configured SetSessionConfigOptionResult
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
SessionID: newRes.SessionID, ConfigID: configIDMode, Value: string(agent.PermissionModeAsk),
}, &configured); err != nil {
t.Fatalf("set_config_option ask: %v", err)
}
if got := configured.ConfigOptions[1].CurrentValue; got != string(agent.PermissionModeAsk) {
t.Fatalf("mode after the refusals = %q, want ask", got)
}

// A genuinely unknown mode must still say so, or the assertions above would
// hold for a handler that answered "not permitted" to everything.
err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: "bogus"}, &SetSessionModeResult{})
if err == nil || !strings.Contains(err.Error(), "unknown mode") {
t.Fatalf("set_mode bogus = %v, want an unknown-mode error", err)
}
}

// TestACPPlanModeWiresPermissionModeIntoAgentOptions confirms selecting "plan"
// over ACP actually reaches agent.Options.PermissionMode for the next turn —
// the same gap this test's TUI counterpart covers for /plan on.
Expand Down Expand Up @@ -2418,3 +2482,19 @@ func payloadString(payload any, key string) string {
value, _ := decoded[key].(string)
return value
}

// configOptionByID finds an advertised option by its identity rather than by
// position. Asserting on ConfigOptions[1] made every one of these tests depend
// on the ORDER the options are advertised in: reordering them would move the
// assertion onto a different option, or panic, instead of failing with a
// message about the option it means.
func configOptionByID(t *testing.T, options []SessionConfigOption, id string) SessionConfigOption {
t.Helper()
for _, option := range options {
if option.ID == id {
return option
}
}
t.Fatalf("no config option %q was advertised; got %+v", id, options)
return SessionConfigOption{}
}
8 changes: 4 additions & 4 deletions internal/agent/compaction_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ func TestRunProactiveCompactionTriggers(t *testing.T) {

result, err := Run(context.Background(), strings.Repeat("y", 8000), provider, Options{
Registry: registry,
PermissionMode: PermissionModeUnsafe,
PermissionMode: PermissionModeFullAuto,
ContextWindow: 1000, // ~250 token 80% threshold; easily exceeded
CompactionPreserveLast: 2,
})
Expand Down Expand Up @@ -393,7 +393,7 @@ func TestRunNoCompactionWhenContextWindowZero(t *testing.T) {

_, err := Run(context.Background(), strings.Repeat("y", 8000), provider, Options{
Registry: registry,
PermissionMode: PermissionModeUnsafe,
PermissionMode: PermissionModeFullAuto,
ContextWindow: 0, // disabled
})
if err != nil {
Expand Down Expand Up @@ -534,7 +534,7 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
// only the reactive path can save the run.
result, err := Run(context.Background(), strings.Repeat("z", 6000), provider, Options{
Registry: registry,
PermissionMode: PermissionModeUnsafe,
PermissionMode: PermissionModeFullAuto,
ContextWindow: 10_000_000,
CompactionPreserveLast: 2,
Trace: recorder,
Expand Down Expand Up @@ -620,7 +620,7 @@ func TestRunReactiveRetryDoesNotDoubleEmitText(t *testing.T) {
var deltas []string
result, err := Run(context.Background(), strings.Repeat("z", 6000), provider, Options{
Registry: registry,
PermissionMode: PermissionModeUnsafe,
PermissionMode: PermissionModeFullAuto,
ContextWindow: 10_000_000,
CompactionPreserveLast: 2,
OnText: func(delta string) { deltas = append(deltas, delta) },
Expand Down
16 changes: 10 additions & 6 deletions internal/agent/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
registry = tools.NewRegistry()
}

permissionMode := options.PermissionMode
// Normalized once, here, because this is the single place the run's mode is
// read off Options; every comparison downstream takes it as a parameter from
// this local. Normalizing at the individual comparison sites instead would
// leave the next one added to be found by whoever it breaks.
permissionMode := NormalizePermissionMode(options.PermissionMode)
if permissionMode == "" {
permissionMode = PermissionModeAuto
}
Expand Down Expand Up @@ -1229,7 +1233,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
return executeRequestPermissions(ctx, call, args, permissionMode, options)
}

permissionGranted := permissionMode == PermissionModeUnsafe
permissionGranted := permissionMode == PermissionModeFullAuto
if toolFound && effectivePermission(tool, args) == tools.PermissionAllow {
permissionGranted = true
}
Expand Down Expand Up @@ -1551,10 +1555,10 @@ func maybeRetryUnsandboxedAfterSandboxRestriction(ctx context.Context, registry
}
requestEvent := sandboxRestrictionRetryEvent(call, tool, args, permissionMode, options, result)
request := permissionRequestFromEvent(requestEvent, args, options)
if permissionMode == PermissionModeUnsafe {
if permissionMode == PermissionModeFullAuto {
retryArgs := unsandboxedRetryArgs(args)
retry := runToolForUnsandboxedRetry(ctx, registry, call.Name, call.ID, retryArgs, permissionMode, options, progressCallback)
return retry, nil, true, PermissionDecisionAllow, "unsafe permission mode permits unsandboxed retry", nil, nil
return retry, nil, true, PermissionDecisionAllow, "full-auto permission mode permits unsandboxed retry", nil, nil
}
decision, err := requestPermission(ctx, request, options)
if err != nil {
Expand Down Expand Up @@ -1605,9 +1609,9 @@ func maybeRetryWithNetworkAfterSandboxDenial(ctx context.Context, registry *tool
}
requestEvent := sandboxDeniedNetworkRetryEvent(call, tool, args, permissionMode, options, result)
request := permissionRequestFromEvent(requestEvent, args, options)
if permissionMode == PermissionModeUnsafe {
if permissionMode == PermissionModeFullAuto {
retry := runToolForNetworkRetry(ctx, registry, call.Name, call.ID, args, permissionMode, options, progressCallback)
return retry, nil, true, PermissionDecisionAllow, "unsafe permission mode permits sandbox network retry", nil
return retry, nil, true, PermissionDecisionAllow, "full-auto permission mode permits sandbox network retry", nil
}
decision, err := requestPermission(ctx, request, options)
if err != nil {
Expand Down
6 changes: 3 additions & 3 deletions internal/agent/loop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2737,7 +2737,7 @@ func TestRunGrantsPromptToolInUnsafeMode(t *testing.T) {

result, err := Run(context.Background(), "write notes", provider, Options{
Registry: registry,
PermissionMode: PermissionModeUnsafe,
PermissionMode: PermissionModeFullAuto,
OnPermission: func(event PermissionEvent) {
permissionEvents = append(permissionEvents, event)
},
Expand All @@ -2763,7 +2763,7 @@ func TestRunGrantsPromptToolInUnsafeMode(t *testing.T) {
if event.Action != PermissionActionAllow || !event.PermissionGranted {
t.Fatalf("expected unsafe approval permission event, got %#v", event)
}
if event.ToolName != "write_file" || event.PermissionMode != PermissionModeUnsafe {
if event.ToolName != "write_file" || event.PermissionMode != PermissionModeFullAuto {
t.Fatalf("unexpected unsafe approval metadata: %#v", event)
}
}
Expand Down Expand Up @@ -2968,7 +2968,7 @@ func TestRunAppliesSandboxEvenInUnsafeMode(t *testing.T) {

result, err := Run(context.Background(), "write outside", provider, Options{
Registry: registry,
PermissionMode: PermissionModeUnsafe,
PermissionMode: PermissionModeFullAuto,
Autonomy: "high",
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Expand Down
44 changes: 40 additions & 4 deletions internal/agent/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package agent

import (
"context"
"strings"

"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
Expand All @@ -22,10 +23,24 @@ type PermissionAction string
type PermissionDecisionAction string

const (
PermissionModeAuto PermissionMode = "auto"
PermissionModeAsk PermissionMode = "ask"
PermissionModeUnsafe PermissionMode = "unsafe"
PermissionModeSpecDraft PermissionMode = "spec-draft"
PermissionModeAuto PermissionMode = "auto"
PermissionModeAsk PermissionMode = "ask"
PermissionModeFullAuto PermissionMode = "full-auto"
// PermissionModeUnsafe is the former name of PermissionModeFullAuto.
//
// Kept as an alias rather than deleted because this constant is referenced
// across packages and by code that lands independently of this branch, so
// removing it turns an ordinary merge into a compile failure for whoever
// merges second. It is the same value, so behaviour is identical either way.
//
// Deprecated: use PermissionModeFullAuto.
PermissionModeUnsafe = PermissionModeFullAuto
// legacyFullAutoPermissionMode is the raw string full-auto used to be. The
// Go alias above keeps SOURCE compatible, but a value that arrives as data
// rather than as an identifier is unaffected by it, so it still needs
// mapping. See NormalizePermissionMode.
legacyFullAutoPermissionMode PermissionMode = "unsafe"
PermissionModeSpecDraft PermissionMode = "spec-draft"
// PermissionModePlan is an interactive, read-only planning mode. It applies
// to the CURRENT session (unlike spec-draft, which drafts in a separate
// session): the agent may inspect the workspace and shape the plan with
Expand Down Expand Up @@ -513,3 +528,24 @@ func (result Result) TruncationNotice() string {
return "Response ended early (" + result.FinishReason + ") and may be incomplete."
}
}

// NormalizePermissionMode maps a permission mode that arrived as data onto its
// canonical value.
//
// full-auto was renamed from "unsafe", and a Go alias only covers callers that
// name the constant. A value that travels as a string does not go through the
// alias: it comes off a command line, out of a swarm member spec, or across a
// protocol, and after the rename "unsafe" stops matching the comparisons the
// loop makes against PermissionModeFullAuto. The mode then reads as unrecognized
// and the run silently behaves as though full-auto was never requested.
//
// Only the legacy spelling is rewritten. Unknown values are returned unchanged
// rather than folded to a default, because this package has modes the sandbox
// layer does not know about (spec-draft, member-auto) and quietly rewriting one
// of those would be a worse bug than the one being fixed.
func NormalizePermissionMode(mode PermissionMode) PermissionMode {
if PermissionMode(strings.ToLower(strings.TrimSpace(string(mode)))) == legacyFullAutoPermissionMode {
return PermissionModeFullAuto
}
return mode
}
Loading
Loading