mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 05:19:33 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1367abf17e |
No files matched your search
@@ -2159,6 +2159,18 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
|
||||
}
|
||||
}
|
||||
|
||||
// A command that explicitly requests additional sandbox permissions
|
||||
// (sandbox_permissions: with_additional_permissions) is an ELEVATION the user
|
||||
// must consent to, even when the base command's sandbox decision was Allow.
|
||||
// shouldRequestPermission blocks on OnPermissionRequest for exactly this case,
|
||||
// so the event MUST be a prompt — otherwise it carried Action=allow while the
|
||||
// loop waited on a decision, and a UI that renders only prompts (the TUI)
|
||||
// dropped it, deadlocking the run. Keep this consistent with
|
||||
// shouldRequestPermission's own condition.
|
||||
if action == PermissionActionAllow && !permissionGranted && shellCommandAdditionalPermissionsRequested(args) {
|
||||
action = PermissionActionPrompt
|
||||
}
|
||||
|
||||
if safety.Permission == tools.PermissionAllow && action == PermissionActionAllow && !grantMatched && block == nil {
|
||||
return PermissionEvent{}, false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// promptShellTool is a minimal shell-style tool whose Safety requires a prompt,
|
||||
// standing in for bash so buildPermissionEvent can be exercised directly.
|
||||
type promptShellTool struct{}
|
||||
|
||||
func (promptShellTool) Name() string { return "bash" }
|
||||
func (promptShellTool) Description() string { return "run a shell command" }
|
||||
func (promptShellTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", AdditionalProperties: false}
|
||||
}
|
||||
func (promptShellTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs a shell command"}
|
||||
}
|
||||
func (promptShellTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
|
||||
// A command whose sandbox decision is Allow but that explicitly requests
|
||||
// additional sandbox permissions is an ELEVATION: it must be surfaced as a
|
||||
// prompt, never Action=allow. Previously it carried allow while the loop still
|
||||
// blocked on OnPermissionRequest, and the TUI (which renders only prompts)
|
||||
// dropped it → the run deadlocked forever. This is the exact gpt-5.5
|
||||
// folder-creation shape captured in the reported hang.
|
||||
func TestBuildPermissionEventPromptsForAdditionalPermissions(t *testing.T) {
|
||||
call := ToolCall{ID: "call_1", Name: "bash", Arguments: `{}`}
|
||||
args := map[string]any{
|
||||
"command": "mkdir foo && cd foo && go mod init foo",
|
||||
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||
}
|
||||
// Base command allowed by the sandbox…
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionAllow}
|
||||
|
||||
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
|
||||
if !ok {
|
||||
t.Fatal("buildPermissionEvent returned ok=false; the elevation request must produce an event")
|
||||
}
|
||||
if event.Action != PermissionActionPrompt {
|
||||
t.Fatalf("Action = %q, want prompt — an additional-permissions elevation must ask the user, not auto-allow", event.Action)
|
||||
}
|
||||
// Sanity: shouldRequestPermission agrees the loop blocks on this.
|
||||
if !shouldRequestPermission(promptShellTool{}, args, false, decision) {
|
||||
t.Fatal("shouldRequestPermission must be true for an additional-permissions request")
|
||||
}
|
||||
}
|
||||
|
||||
// Without the additional-permissions flag, a sandbox-allowed command is NOT a
|
||||
// prompt (shouldRequestPermission is false; it just runs) — the override must
|
||||
// not over-reach and prompt for ordinary allowed commands.
|
||||
func TestBuildPermissionEventKeepsAllowForOrdinaryAllowedCommand(t *testing.T) {
|
||||
call := ToolCall{ID: "call_2", Name: "bash", Arguments: `{}`}
|
||||
args := map[string]any{"command": "ls"}
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionAllow}
|
||||
|
||||
event, _ := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
|
||||
if event.Action == PermissionActionPrompt {
|
||||
t.Fatalf("Action = prompt for an ordinary allowed command; want allow (no spurious prompt)")
|
||||
}
|
||||
if shouldRequestPermission(promptShellTool{}, args, false, decision) {
|
||||
t.Fatal("shouldRequestPermission must be false for an ordinary sandbox-allowed command")
|
||||
}
|
||||
}
|
||||
+41
-5
@@ -1669,17 +1669,38 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// when the loop is already running or there is nothing to animate.
|
||||
return m, m.ensureSpinnerTick()
|
||||
case permissionRequestMsg:
|
||||
// The agent goroutine that raised this request is BLOCKED waiting on the
|
||||
// decision callback, so every branch below must resolve it exactly once —
|
||||
// or store it in pendingPermission, which resolves it on the user's reply.
|
||||
// Dropping a request without resolving parks the run forever (the reported
|
||||
// "stuck for 33 minutes" deadlock): the agent waits on a decision channel
|
||||
// nothing will ever signal, with no visible prompt and no network activity.
|
||||
if msg.runID != m.activeRunID {
|
||||
// A superseded/stale run: unblock its parked goroutine now rather than
|
||||
// relying on that run's context cancel to fire first.
|
||||
if msg.decide != nil {
|
||||
msg.decide(agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "run superseded"})
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if msg.request.Action != agent.PermissionActionPrompt {
|
||||
// Not a user-facing prompt (e.g. a sandbox-allowed command that still
|
||||
// blocked because it requested additional permissions). The UI has
|
||||
// nothing to ask, so resolve immediately and FAIL CLOSED — never
|
||||
// silently grant access that was never surfaced to the user. (The agent
|
||||
// now marks such elevation requests as prompts, so in practice this is a
|
||||
// defensive backstop; matches the ACP handler's fail-closed contract.)
|
||||
if msg.decide != nil {
|
||||
msg.decide(autoResolvedPermissionDecision(msg.request.Action))
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
promptRow := permissionTranscriptRow(permissionEventFromRequest(msg.request))
|
||||
promptRow.runID = msg.runID
|
||||
m.transcript = appendTranscriptRow(m.transcript, promptRow)
|
||||
if msg.request.Action == agent.PermissionActionPrompt {
|
||||
m.pendingPermission = &pendingPermissionPrompt{
|
||||
request: msg.request,
|
||||
decide: msg.decide,
|
||||
}
|
||||
m.pendingPermission = &pendingPermissionPrompt{
|
||||
request: msg.request,
|
||||
decide: msg.decide,
|
||||
}
|
||||
return m, nil
|
||||
case askUserRequestMsg:
|
||||
@@ -4525,6 +4546,21 @@ func (m model) sendPermissionRequest(runID int, request agent.PermissionRequest,
|
||||
m.runtimeMessageSink(permissionRequestMsg{runID: runID, request: request, decide: decide})
|
||||
}
|
||||
|
||||
// autoResolvedPermissionDecision resolves a permission request the TUI cannot
|
||||
// turn into a user prompt (Action != prompt). The agent is blocked awaiting a
|
||||
// decision, so one must ALWAYS be produced. Only an explicit Cancel is honored
|
||||
// as such; every other non-prompt action — including allow — is DENIED, so the
|
||||
// UI never silently grants access it did not surface for approval.
|
||||
func autoResolvedPermissionDecision(action agent.PermissionAction) agent.PermissionDecision {
|
||||
if action == agent.PermissionActionCancel {
|
||||
return agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "run cancelled"}
|
||||
}
|
||||
return agent.PermissionDecision{
|
||||
Action: agent.PermissionDecisionDeny,
|
||||
Reason: "permission request could not be surfaced for approval",
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) sendAskUserRequest(runID int, request agent.AskUserRequest, answer func([]string)) {
|
||||
if m.runtimeMessageSink == nil {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
)
|
||||
|
||||
func permissionModel(t *testing.T) model {
|
||||
t.Helper()
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.pending = true
|
||||
m.activeRunID = 7
|
||||
m.width = 96
|
||||
return m
|
||||
}
|
||||
|
||||
// The deadlock: a permission request whose Action is NOT prompt (e.g. a
|
||||
// sandbox-allowed command that still blocked to request additional permissions)
|
||||
// must be resolved immediately — the agent goroutine is parked awaiting a
|
||||
// decision. Before the fix the handler dropped it, and the run hung forever
|
||||
// (the reported "stuck for 33 minutes"). Fail closed: a non-prompt allow denies.
|
||||
func TestPermissionRequestNonPromptResolvesFailClosed(t *testing.T) {
|
||||
m := permissionModel(t)
|
||||
var got *agent.PermissionDecision
|
||||
decide := func(d agent.PermissionDecision) { got = &d }
|
||||
|
||||
updated, _ := m.Update(permissionRequestMsg{
|
||||
runID: 7,
|
||||
request: agent.PermissionRequest{ToolCallID: "c1", ToolName: "bash", Action: agent.PermissionActionAllow},
|
||||
decide: decide,
|
||||
})
|
||||
next := updated.(model)
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("DEADLOCK: decide was never called for a non-prompt request — the agent goroutine would park forever")
|
||||
}
|
||||
if got.Action != agent.PermissionDecisionDeny {
|
||||
t.Fatalf("decision = %q, want deny (fail closed — never silently grant an unsurfaced permission)", got.Action)
|
||||
}
|
||||
if next.pendingPermission != nil {
|
||||
t.Fatal("a non-prompt request must not open a pending prompt")
|
||||
}
|
||||
}
|
||||
|
||||
// A superseded/stale run's request (runID mismatch) must also be resolved
|
||||
// (cancelled) so its parked goroutine unblocks instead of depending on
|
||||
// context-cancel ordering.
|
||||
func TestPermissionRequestStaleRunResolvesCancel(t *testing.T) {
|
||||
m := permissionModel(t) // activeRunID = 7
|
||||
var got *agent.PermissionDecision
|
||||
decide := func(d agent.PermissionDecision) { got = &d }
|
||||
|
||||
m.Update(permissionRequestMsg{
|
||||
runID: 3, // != activeRunID
|
||||
request: agent.PermissionRequest{ToolCallID: "c1", ToolName: "bash", Action: agent.PermissionActionPrompt},
|
||||
decide: decide,
|
||||
})
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("stale-run request was dropped without resolving — its agent goroutine would park")
|
||||
}
|
||||
if got.Action != agent.PermissionDecisionCancel {
|
||||
t.Fatalf("decision = %q, want cancel for a superseded run", got.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit-cancel action is honored as a cancel (not denied).
|
||||
func TestPermissionRequestCancelActionResolvesCancel(t *testing.T) {
|
||||
m := permissionModel(t)
|
||||
var got *agent.PermissionDecision
|
||||
decide := func(d agent.PermissionDecision) { got = &d }
|
||||
|
||||
m.Update(permissionRequestMsg{
|
||||
runID: 7,
|
||||
request: agent.PermissionRequest{ToolCallID: "c1", ToolName: "bash", Action: agent.PermissionActionCancel},
|
||||
decide: decide,
|
||||
})
|
||||
if got == nil || got.Action != agent.PermissionDecisionCancel {
|
||||
t.Fatalf("cancel action must resolve as cancel, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A genuine prompt request stores the pending prompt and does NOT resolve yet —
|
||||
// the decision comes from the user's reply. (Regression guard: the fix must not
|
||||
// accidentally auto-resolve real prompts.)
|
||||
func TestPermissionRequestPromptDefersToUser(t *testing.T) {
|
||||
m := permissionModel(t)
|
||||
resolved := false
|
||||
decide := func(agent.PermissionDecision) { resolved = true }
|
||||
|
||||
updated, _ := m.Update(permissionRequestMsg{
|
||||
runID: 7,
|
||||
request: agent.PermissionRequest{
|
||||
ToolCallID: "c1", ToolName: "bash", Action: agent.PermissionActionPrompt,
|
||||
Reason: "runs a shell command", AvailableDecisions: []agent.PermissionDecisionAction{agent.PermissionDecisionAllow},
|
||||
},
|
||||
decide: decide,
|
||||
})
|
||||
next := updated.(model)
|
||||
|
||||
if resolved {
|
||||
t.Fatal("a real prompt must not be auto-resolved; it awaits the user")
|
||||
}
|
||||
if next.pendingPermission == nil {
|
||||
t.Fatal("a prompt request must open a pending permission prompt")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user