Skip to content
27 changes: 20 additions & 7 deletions internal/acp/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,10 +367,16 @@ func (a *Agent) handleSetMode(_ context.Context, params json.RawMessage) (any, e
defer sess.turnMu.Unlock()
mode := agent.PermissionMode(p.ModeID)
switch mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
case agent.PermissionModeAsk, agent.PermissionModeAuto, agent.PermissionModeWorkspaceAuto, agent.PermissionModePlan:
sess.setMode(mode)
(&notifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
return SetSessionModeResult{}, nil
case agent.PermissionModeAutoClassifier:
// Auto-classifier hands each low-risk decision to an LLM that may approve it
// with no prompt. The TUI gates enabling it behind an explicit one-time user
// acknowledgement; ACP has no way to represent that confirmation, so an
// editor client must not be able to turn it on over the wire.
return nil, RPCError(codeInvalidParams, "mode requires an in-app confirmation and cannot be enabled over ACP: "+p.ModeID)
case agent.PermissionModeUnsafe:
// Unsafe = run every tool with no prompt. The TUI gates this behind an
// explicit --skip-permissions-unsafe operator flag; an editor client must
Expand Down Expand Up @@ -459,16 +465,23 @@ func (a *Agent) handleCancel(_ context.Context, params json.RawMessage) {
// ---- advertising helpers ----

func (a *Agent) modeState(s *acpSession) *SessionModeState {
// auto/ask/plan are offered over ACP; Unsafe is gated to the operator (see
// handleSetMode) so a client can't grant itself no-prompt host access. Plan
// only narrows what a client can do (read-only, no write/shell tools), so
// Only prompt-respecting modes are offered over ACP; Unsafe is gated to the
// operator (see handleSetMode) so a client can't grant itself no-prompt host
// access. Plan only narrows what a client can do (read-only, no write/shell tools), so
// unlike Unsafe there is no elevation risk in letting a client select it.
// Auto-classifier is intentionally NOT advertised: enabling it needs an in-app
// confirmation the ACP protocol cannot represent, and handleSetMode rejects it.
ask := agent.PermissionModeInfoFor(agent.PermissionModeAsk)
auto := agent.PermissionModeInfoFor(agent.PermissionModeAuto)
workspaceAuto := agent.PermissionModeInfoFor(agent.PermissionModeWorkspaceAuto)
plan := agent.PermissionModeInfoFor(agent.PermissionModePlan)
return &SessionModeState{
CurrentModeID: string(s.currentMode()),
AvailableModes: []SessionMode{
{ID: string(agent.PermissionModeAuto), Name: "Auto", Description: "Run safe tools automatically; ask before risky ones."},
{ID: string(agent.PermissionModeAsk), Name: "Ask", Description: "Ask before every tool that changes state."},
{ID: string(agent.PermissionModePlan), Name: "Plan", Description: "Read-only planning; write and shell tools are hidden."},
{ID: string(ask.ID), Name: ask.Label, Description: ask.Description},
{ID: string(auto.ID), Name: auto.Label, Description: auto.Description},
{ID: string(workspaceAuto.ID), Name: workspaceAuto.Label, Description: workspaceAuto.Description},
{ID: string(plan.ID), Name: plan.Label, Description: plan.Description},
},
}
}
Expand Down
7 changes: 6 additions & 1 deletion internal/acp/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ func TestACPSetModeUpdatesSession(t *testing.T) {
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
t.Fatalf("session/new: %v", err)
}
// auto/ask are accepted.
// Prompt-respecting modes are accepted.
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeAsk)}, &SetSessionModeResult{}); err != nil {
t.Fatalf("set_mode ask: %v", err)
}
Expand Down Expand Up @@ -423,6 +423,11 @@ func TestACPSetModeUpdatesSession(t *testing.T) {
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeUnsafe)}, &SetSessionModeResult{}); err == nil {
t.Fatal("expected Unsafe mode to be rejected over ACP")
}
// Auto-classifier requires an in-app confirmation the ACP protocol cannot
// represent, so a client must not be able to enable it over the wire.
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeAutoClassifier)}, &SetSessionModeResult{}); err == nil {
t.Fatal("expected auto-classifier mode to be rejected over ACP")
}
// An unknown mode must be rejected.
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: "bogus"}, &SetSessionModeResult{}); err == nil {
t.Fatal("expected error for unknown mode")
Expand Down
116 changes: 103 additions & 13 deletions internal/acp/permission.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import (
"encoding/json"
"strings"

"github.com/Gitlawb/zero/internal/agent"
)
Expand All @@ -22,6 +23,21 @@
if kind == "" {
continue // skip actions that have no clean ACP option (e.g. cancel)
}
// A command-prefix grant can be offered at several breadths (e.g. `git`,
// `git push`, `git push origin`). Expand each into its own ACP option so the
// client can pick how wide the grant is; the chosen breadth is encoded into
// the option id and validated on the way back. Without this, ACP clients can
// only ever grant the exact default prefix.
if isPrefixAction(action) && len(req.CommandPrefixOptions) > 1 {
for _, prefix := range req.CommandPrefixOptions {
options = append(options, PermissionOption{
OptionID: encodeOptionID(action, prefix),
Name: name + ": " + strings.Join(prefix, " "),
Kind: kind,
})
}
continue
}
options = append(options, PermissionOption{
OptionID: string(action),
Name: name,
Expand All @@ -31,6 +47,52 @@
return options
}

// isPrefixAction reports whether an action grants a command prefix, which is the
// only decision family that carries a breadth choice (CommandPrefixOptions).
func isPrefixAction(action agent.PermissionDecisionAction) bool {
switch action {
case agent.PermissionDecisionAllowPrefix,
agent.PermissionDecisionAllowPrefixProject,
agent.PermissionDecisionAlwaysAllowPrefix:
return true
default:
return false
}
}

// encodeOptionID packs the ZERO action plus the chosen command-prefix breadth into
// the ACP option id so the selection round-trips exactly. An action with no
// breadth keeps the bare action string, so non-prefix options stay backward
// compatible on the wire.
func encodeOptionID(action agent.PermissionDecisionAction, prefix []string) string {
if len(prefix) == 0 {
return string(action)
}
payload, err := json.Marshal(optionIDPayload{Action: string(action), Prefix: prefix})
if err != nil {
return string(action)
}
return string(payload)
}

// decodeOptionID reverses encodeOptionID. A JSON payload yields the action and its
// breadth; anything else is treated as a bare action string (no breadth).
func decodeOptionID(id string) (agent.PermissionDecisionAction, []string) {
trimmed := strings.TrimSpace(id)
if strings.HasPrefix(trimmed, "{") {
var payload optionIDPayload
if err := json.Unmarshal([]byte(trimmed), &payload); err == nil && payload.Action != "" {
return agent.PermissionDecisionAction(payload.Action), payload.Prefix
}
}
return agent.PermissionDecisionAction(id), nil
}

type optionIDPayload struct {
Action string `json:"action"`
Prefix []string `json:"prefix"`
}

// offeredDecisions resolves what this request actually offers the client.
//
// ONE RESOLVER, USED BY BOTH SIDES, and that is the entire point of it existing.
Expand Down Expand Up @@ -82,6 +144,8 @@
return PermAllowAlways, "Allow for this session"
case agent.PermissionDecisionAllowPrefix:
return PermAllowAlways, withSandboxEscalationNote("Allow this command for the session", escalates)
case agent.PermissionDecisionAllowPrefixProject:
return PermAllowAlways, "Allow this command for this project"
case agent.PermissionDecisionAlwaysAllow:
return PermAllowAlways, "Always allow"
case agent.PermissionDecisionAlwaysAllowPrefix:
Expand Down Expand Up @@ -120,10 +184,15 @@
// Bind to what was actually offered for THIS call: a client must not be able
// to return a broader grant (always_allow / allow_for_session) that wasn't
// presented. Anything not offered fails closed to deny.
if actionOffered(outcome.OptionID, offered) {
return agent.PermissionDecision{Action: agent.PermissionDecisionAction(outcome.OptionID)}
if !actionOffered(outcome.OptionID, offered) {
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "permission option was not offered"}
}
action, prefix := decodeOptionID(outcome.OptionID)
decision := agent.PermissionDecision{Action: action}
if len(prefix) > 0 {
decision.CommandPrefix = append([]string(nil), prefix...)
}
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "permission option was not offered"}
return decision
default:
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "no permission outcome"}
}
Expand All @@ -138,6 +207,37 @@
return false
}

// prefixOffered reports whether prefix exactly matches one of the offered breadth
// rungs, so a selected grant can only be one the request actually presented.
func prefixOffered(prefix []string, offered [][]string) bool {

Check failure on line 212 in internal/acp/permission.go

View workflow job for this annotation

GitHub Actions / Smoke (windows-latest)

unreachable func: prefixOffered

Check failure on line 212 in internal/acp/permission.go

View workflow job for this annotation

GitHub Actions / Security & code health

unreachable func: prefixOffered
for _, candidate := range offered {
if len(candidate) != len(prefix) {
continue
}
match := true
for i := range candidate {
if candidate[i] != prefix[i] {
match = false
break
}
}
if match {
return true
}
}
return false
}

// withSandboxEscalationNote appends the escalation disclosure to a prefix
// option's label. Kept parallel to the TUI's wording so the same consequence is
// not described two different ways depending on which client the user runs.
func withSandboxEscalationNote(name string, escalates bool) string {
if !escalates {
return name
}
return name + " (runs outside the sandbox)"
}

// permissionToolCall builds the ToolCall descriptor embedded in a
// session/request_permission request from a ZERO permission request.
func permissionToolCall(req agent.PermissionRequest) ToolCallUpdate {
Expand Down Expand Up @@ -168,13 +268,3 @@
}
return json.RawMessage(data)
}

// withSandboxEscalationNote appends the escalation disclosure to a prefix
// option's label. Kept parallel to the TUI's wording so the same consequence is
// not described two different ways depending on which client the user runs.
func withSandboxEscalationNote(name string, escalates bool) string {
if !escalates {
return name
}
return name + " (runs outside the sandbox)"
}
101 changes: 97 additions & 4 deletions internal/acp/permission_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package acp

import (
"strings"
"testing"

"github.com/Gitlawb/zero/internal/agent"
Expand All @@ -13,21 +14,35 @@ func TestBuildPermissionOptions(t *testing.T) {
AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionAllowForSession,
agent.PermissionDecisionAllowPrefix,
agent.PermissionDecisionAllowPrefixProject,
agent.PermissionDecisionAlwaysAllow,
agent.PermissionDecisionAlwaysAllowPrefix,
agent.PermissionDecisionDeny,
agent.PermissionDecisionCancel, // must be dropped (expressed as outcome)
},
}
opts := buildPermissionOptions(req)
if len(opts) != 4 {
t.Fatalf("expected 4 options (cancel dropped), got %d: %+v", len(opts), opts)
// 7 options: allow, allow_for_session, allow_prefix, allow_prefix_for_project,
// always_allow, always_allow_prefix, deny (cancel dropped). No breadth expansion
// because CommandPrefixOptions is empty.
if len(opts) != 7 {
t.Fatalf("expected 7 options (cancel dropped), got %d: %+v", len(opts), opts)
}
// optionId must carry the ZERO action verbatim for a clean round trip.
if opts[0].OptionID != string(agent.PermissionDecisionAllow) || opts[0].Kind != PermAllowOnce {
t.Errorf("allow option = %+v", opts[0])
}
if opts[1].Kind != PermAllowAlways || opts[3].Kind != PermRejectOnce {
t.Errorf("kinds = %q, %q", opts[1].Kind, opts[3].Kind)
// The project-scoped prefix grant must be present (regression: it was silently
// dropped because optionKindFor had no case for it).
if opts[3].OptionID != string(agent.PermissionDecisionAllowPrefixProject) || opts[3].Kind != PermAllowAlways {
t.Errorf("project prefix option = %+v", opts[3])
}
if opts[1].Kind != PermAllowAlways || opts[2].Kind != PermAllowAlways || opts[4].Kind != PermAllowAlways || opts[5].Kind != PermAllowAlways {
t.Errorf("allow kinds = %q, %q, %q, %q", opts[1].Kind, opts[2].Kind, opts[4].Kind, opts[5].Kind)
}
if opts[6].OptionID != string(agent.PermissionDecisionDeny) || opts[6].Kind != PermRejectOnce {
t.Errorf("deny option = %+v", opts[6])
}
}

Expand Down Expand Up @@ -66,6 +81,84 @@ func TestDecisionFromOutcome(t *testing.T) {
}
}

func TestBuildPermissionOptionsExpandsPrefixBreadths(t *testing.T) {
req := agent.PermissionRequest{
ToolName: "bash",
CommandPrefix: []string{"git", "push", "origin"},
CommandPrefixOptions: [][]string{
{"git", "push"},
{"git", "push", "origin"},
},
AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionAllowPrefix,
agent.PermissionDecisionAllowPrefixProject,
agent.PermissionDecisionAlwaysAllowPrefix,
agent.PermissionDecisionDeny,
},
}
opts := buildPermissionOptions(req)
// allow + deny (1 each) + 3 prefix actions × 2 breadths = 8.
if len(opts) != 8 {
t.Fatalf("expected 8 options (2 breadths per prefix action), got %d: %+v", len(opts), opts)
}
// Each prefix breadth must round-trip its action AND its exact prefix.
seen := map[string]bool{}
for _, opt := range opts {
action, prefix := decodeOptionID(opt.OptionID)
if !isPrefixAction(action) {
continue
}
if !prefixOffered(prefix, req.CommandPrefixOptions) {
t.Errorf("expanded option %q decoded to a non-offered prefix %v", opt.Name, prefix)
}
seen[string(action)+"|"+strings.Join(prefix, " ")] = true
}
for _, action := range []agent.PermissionDecisionAction{
agent.PermissionDecisionAllowPrefix,
agent.PermissionDecisionAllowPrefixProject,
agent.PermissionDecisionAlwaysAllowPrefix,
} {
for _, prefix := range req.CommandPrefixOptions {
if !seen[string(action)+"|"+strings.Join(prefix, " ")] {
t.Errorf("missing expanded option for %s / %v", action, prefix)
}
}
}
}

func TestDecisionFromOutcomePrefixBreadthRoundTrip(t *testing.T) {
req := agent.PermissionRequest{
CommandPrefix: []string{"git", "push", "origin"},
CommandPrefixOptions: [][]string{
{"git", "push"},
{"git", "push", "origin"},
},
AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllowPrefixProject,
agent.PermissionDecisionDeny,
},
}
offered := buildPermissionOptions(req)

// Selecting the broader `git push` breadth for the project scope must carry
// both the action and the chosen prefix back to ZERO.
id := encodeOptionID(agent.PermissionDecisionAllowPrefixProject, []string{"git", "push"})
d := decisionFromOutcome(RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: id}, offered)
if d.Action != agent.PermissionDecisionAllowPrefixProject {
t.Fatalf("action = %q, want allow_prefix_for_project", d.Action)
}
if strings.Join(d.CommandPrefix, " ") != "git push" {
t.Fatalf("prefix = %v, want [git push]", d.CommandPrefix)
}

// A prefix that was never offered fails closed to deny (no silent widening).
tampered := encodeOptionID(agent.PermissionDecisionAllowPrefixProject, []string{"git"})
if d := decisionFromOutcome(RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: tampered}, offered); d.Action != agent.PermissionDecisionDeny {
t.Fatalf("non-offered prefix -> %q, want deny", d.Action)
}
}

func TestPermissionToolCall(t *testing.T) {
tc := permissionToolCall(agent.PermissionRequest{
ToolCallID: "tc9",
Expand Down
Loading
Loading