mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 05:19:33 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5f24fe525 |
No files matched your search
@@ -25,7 +25,7 @@ func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
|
||||
}
|
||||
options := make([]PermissionOption, 0, len(actions))
|
||||
for _, action := range actions {
|
||||
kind, name := optionKindFor(action)
|
||||
kind, name := optionKindFor(action, req.PrefixApprovalEscalates)
|
||||
if kind == "" {
|
||||
continue // skip actions that have no clean ACP option (e.g. cancel)
|
||||
}
|
||||
@@ -41,18 +41,24 @@ func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
|
||||
// optionKindFor maps a ZERO decision action to an ACP PermissionOptionKind and a
|
||||
// human label. Returns an empty kind for actions that ACP expresses through the
|
||||
// outcome rather than an option (cancel).
|
||||
func optionKindFor(action agent.PermissionDecisionAction) (kind, name string) {
|
||||
//
|
||||
// escalates reports that approving a command prefix will also run the command
|
||||
// outside the sandbox. The two prefix options say so in their label, since the
|
||||
// label is all an ACP client shows and the option id carries no room for it. An
|
||||
// editor user is deciding on exactly the same terms as a terminal user, so the
|
||||
// disclosure cannot be TUI-only.
|
||||
func optionKindFor(action agent.PermissionDecisionAction, escalates bool) (kind, name string) {
|
||||
switch action {
|
||||
case agent.PermissionDecisionAllow, agent.PermissionDecisionAllowStrict:
|
||||
return PermAllowOnce, "Allow"
|
||||
case agent.PermissionDecisionAllowForSession:
|
||||
return PermAllowAlways, "Allow for this session"
|
||||
case agent.PermissionDecisionAllowPrefix:
|
||||
return PermAllowAlways, "Allow this command for the session"
|
||||
return PermAllowAlways, withSandboxEscalationNote("Allow this command for the session", escalates)
|
||||
case agent.PermissionDecisionAlwaysAllow:
|
||||
return PermAllowAlways, "Always allow"
|
||||
case agent.PermissionDecisionAlwaysAllowPrefix:
|
||||
return PermAllowAlways, "Always allow this command"
|
||||
return PermAllowAlways, withSandboxEscalationNote("Always allow this command", escalates)
|
||||
case agent.PermissionDecisionDeny:
|
||||
return PermRejectOnce, "Reject"
|
||||
case agent.PermissionDecisionCancel:
|
||||
@@ -123,3 +129,13 @@ func rawInputBytes(data []byte) json.RawMessage {
|
||||
}
|
||||
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)"
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
)
|
||||
|
||||
func escalationRequest(escalates bool) agent.PermissionRequest {
|
||||
return agent.PermissionRequest{
|
||||
ToolName: "bash",
|
||||
AvailableDecisions: []agent.PermissionDecisionAction{
|
||||
agent.PermissionDecisionAllow,
|
||||
agent.PermissionDecisionAllowPrefix,
|
||||
agent.PermissionDecisionAlwaysAllowPrefix,
|
||||
agent.PermissionDecisionDeny,
|
||||
},
|
||||
PrefixApprovalEscalates: escalates,
|
||||
}
|
||||
}
|
||||
|
||||
// An ACP client renders only Name, so if the escalation is not in the label the
|
||||
// editor user approves it blind. They face exactly the outcome a terminal user
|
||||
// faces, so the disclosure has to reach both clients or it is not a fix.
|
||||
func TestACPPrefixOptionsDiscloseLeavingTheSandbox(t *testing.T) {
|
||||
found := 0
|
||||
for _, option := range buildPermissionOptions(escalationRequest(true)) {
|
||||
action := agent.PermissionDecisionAction(option.OptionID)
|
||||
if action != agent.PermissionDecisionAllowPrefix && action != agent.PermissionDecisionAlwaysAllowPrefix {
|
||||
if strings.Contains(option.Name, "sandbox") {
|
||||
t.Errorf("option %q carries the disclosure but does not escalate", option.Name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
found++
|
||||
if !strings.Contains(option.Name, "outside the sandbox") {
|
||||
t.Errorf("prefix option %q does not say the command leaves the sandbox", option.Name)
|
||||
}
|
||||
}
|
||||
if found != 2 {
|
||||
t.Fatalf("expected both prefix options, saw %d", found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPPrefixOptionsStaySilentWithoutEscalation(t *testing.T) {
|
||||
for _, option := range buildPermissionOptions(escalationRequest(false)) {
|
||||
if strings.Contains(option.Name, "sandbox") {
|
||||
t.Errorf("option %q warns about the sandbox when approving would not leave it", option.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The disclosure must live in the label only. The option id is the decision
|
||||
// action round-tripped verbatim, and decisionFromOutcome matches it against
|
||||
// what was offered, so decorating it would fail every prefix approval closed.
|
||||
func TestACPDisclosureDoesNotDisturbTheOptionIDRoundTrip(t *testing.T) {
|
||||
request := escalationRequest(true)
|
||||
for _, option := range buildPermissionOptions(request) {
|
||||
decision := decisionFromOutcome(
|
||||
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: option.OptionID},
|
||||
request.AvailableDecisions,
|
||||
)
|
||||
if string(decision.Action) != option.OptionID {
|
||||
t.Errorf("option id %q round-tripped to %q (%s)", option.OptionID, decision.Action, decision.Reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2718,9 +2718,36 @@ func permissionRequestFromEvent(event PermissionEvent, args map[string]any, opti
|
||||
Grant: event.Grant,
|
||||
CommandPrefix: append([]string(nil), event.CommandPrefix...),
|
||||
AvailableDecisions: availablePermissionDecisions(event, args, options),
|
||||
// Computed from the SAME conditions shellExecutionArgsForApproval uses,
|
||||
// so the prompt cannot claim an escalation that will not happen or stay
|
||||
// silent about one that will. Keeping the two in step matters more than
|
||||
// the wording: a label that overstates gets ignored, and one that
|
||||
// understates is the bug being fixed.
|
||||
PrefixApprovalEscalates: shellPrefixApprovalEscalates(event.ToolName, args, options),
|
||||
}
|
||||
}
|
||||
|
||||
// shellPrefixApprovalEscalates reports whether approving a command prefix would
|
||||
// also take this command out of the sandbox.
|
||||
//
|
||||
// Deliberately mirrors shellExecutionArgsForApproval's guards rather than
|
||||
// re-deriving them: same tool test, same sandbox availability test, and the same
|
||||
// two opt-outs for a call that already asked for additional permissions or
|
||||
// escalation. Those already carry their own disclosure, so labelling them again
|
||||
// would be noise.
|
||||
func shellPrefixApprovalEscalates(toolName string, args map[string]any, options Options) bool {
|
||||
if !isShellCommandTool(toolName) {
|
||||
return false
|
||||
}
|
||||
if options.Sandbox == nil || !options.Sandbox.UnsandboxedExecutionAllowed() {
|
||||
return false
|
||||
}
|
||||
if shellCommandAdditionalPermissionsRequested(args) || shellCommandRequiresEscalated(args) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func availablePermissionDecisions(event PermissionEvent, args map[string]any, options Options) []PermissionDecisionAction {
|
||||
if event.Action != PermissionActionPrompt {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// escalatingEngine allows unsandboxed escalation: no denied reads, so
|
||||
// UnsandboxedExecutionAllowed is true and a prefix approval would rewrite the
|
||||
// call to require_escalated.
|
||||
func escalatingEngine(t *testing.T) *sandbox.Engine {
|
||||
t.Helper()
|
||||
policy := sandbox.DefaultPolicy()
|
||||
policy.DenyRead = nil
|
||||
return sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir(), Policy: policy})
|
||||
}
|
||||
|
||||
// nonEscalatingEngine has a denied read, which is exactly the condition under
|
||||
// which escalation is refused, so no disclosure should be made.
|
||||
func nonEscalatingEngine(t *testing.T) *sandbox.Engine {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
policy := sandbox.DefaultPolicy()
|
||||
policy.DenyRead = []string{filepath.Join(root, "secret.txt")}
|
||||
return sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: root, Policy: policy})
|
||||
}
|
||||
|
||||
// THE POINT OF THE FILE. The prompt's disclosure and the args rewrite must
|
||||
// agree on every input, because the defect being fixed is precisely that the
|
||||
// two disagreed: the rewrite escalated out of the sandbox while the prompt said
|
||||
// only "allow command prefix for session".
|
||||
//
|
||||
// Asserting agreement rather than asserting each side's expected value is what
|
||||
// makes this hold up. If someone later widens or narrows the rewrite's guards
|
||||
// and forgets the flag, the two diverge here and this fails, which a pair of
|
||||
// independent expected-value tests would not catch.
|
||||
func TestPrefixEscalationDisclosureMatchesTheArgsRewrite(t *testing.T) {
|
||||
escalating := Options{Sandbox: escalatingEngine(t)}
|
||||
denied := Options{Sandbox: nonEscalatingEngine(t)}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
toolName string
|
||||
args map[string]any
|
||||
options Options
|
||||
}{
|
||||
{"plain bash command", "bash", map[string]any{"command": "go test ./..."}, escalating},
|
||||
{"plain exec_command", "exec_command", map[string]any{"cmd": "go build ./..."}, escalating},
|
||||
{"not a shell tool", "read_file", map[string]any{"path": "go.mod"}, escalating},
|
||||
{"no sandbox configured", "bash", map[string]any{"command": "go test ./..."}, Options{}},
|
||||
{"escalation refused by denied reads", "bash", map[string]any{"command": "go test ./..."}, denied},
|
||||
{
|
||||
"call already asks for escalation",
|
||||
"bash",
|
||||
map[string]any{"command": "go test ./...", "sandbox_permissions": string(tools.SandboxPermissionsRequireEscalated)},
|
||||
escalating,
|
||||
},
|
||||
{
|
||||
"call already asks for additional permissions",
|
||||
"bash",
|
||||
map[string]any{"command": "go test ./...", "sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions)},
|
||||
escalating,
|
||||
},
|
||||
// An unrecognized value is NOT an opt-out: the rewrite overwrites it, so
|
||||
// the disclosure has to fire. Included because the obvious reading of
|
||||
// the guards says otherwise, and a case asserting the wrong thing here
|
||||
// would have passed silently.
|
||||
{
|
||||
"unrecognized sandbox_permissions value",
|
||||
"bash",
|
||||
map[string]any{"command": "go test ./...", "sandbox_permissions": "all"},
|
||||
escalating,
|
||||
},
|
||||
{"empty args", "bash", map[string]any{}, escalating},
|
||||
{"nil args", "bash", nil, escalating},
|
||||
}
|
||||
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
disclosed := shellPrefixApprovalEscalates(testCase.toolName, testCase.args, testCase.options)
|
||||
|
||||
// What the user's approval would actually be turned into.
|
||||
rewritten := shellExecutionArgsForApproval(testCase.toolName, testCase.args, PermissionDecisionAllowPrefix, testCase.options)
|
||||
escalates := shellCommandRequiresEscalated(rewritten) && !shellCommandRequiresEscalated(testCase.args)
|
||||
|
||||
if disclosed != escalates {
|
||||
t.Fatalf("prompt discloses escalation=%v but approving actually escalates=%v; the prompt and the rewrite disagree, which is the consent gap this guards", disclosed, escalates)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The two prefix decisions are the only ones that escalate, so a disclosure
|
||||
// attached to a request offering neither would be a false alarm. Checked
|
||||
// against the same predicate the rewrite consults.
|
||||
func TestOnlyPrefixDecisionsEscalate(t *testing.T) {
|
||||
escalating := map[PermissionDecisionAction]bool{
|
||||
PermissionDecisionAllowPrefix: true,
|
||||
PermissionDecisionAlwaysAllowPrefix: true,
|
||||
}
|
||||
for _, action := range []PermissionDecisionAction{
|
||||
PermissionDecisionAllow,
|
||||
PermissionDecisionAllowStrict,
|
||||
PermissionDecisionAllowForSession,
|
||||
PermissionDecisionAllowPrefix,
|
||||
PermissionDecisionAlwaysAllowPrefix,
|
||||
PermissionDecisionAlwaysAllow,
|
||||
PermissionDecisionDeny,
|
||||
PermissionDecisionCancel,
|
||||
} {
|
||||
if got := shellPrefixApprovalBypassesSandbox(action); got != escalating[action] {
|
||||
t.Errorf("decision %q bypasses sandbox=%v, want %v", action, got, escalating[action])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,6 +176,22 @@ type PermissionRequest struct {
|
||||
Grant *sandbox.Grant `json:"grant,omitempty"`
|
||||
CommandPrefix []string `json:"commandPrefix,omitempty"`
|
||||
AvailableDecisions []PermissionDecisionAction `json:"availableDecisions,omitempty"`
|
||||
// PrefixApprovalEscalates reports that approving a command prefix will also
|
||||
// run the command OUTSIDE the sandbox, not merely stop asking about it.
|
||||
//
|
||||
// It exists because that consequence was real but invisible. Approving a
|
||||
// prefix rewrites the call to sandbox_permissions: require_escalated, which
|
||||
// resolves to a nil engine and genuinely unsandboxed execution, and the
|
||||
// engine's own escalation prompt is then satisfied by the approval just
|
||||
// given for the sandboxed form. So the operator authorized one thing and got
|
||||
// a wider one, having been shown only "allow command prefix for session".
|
||||
//
|
||||
// The escalation itself is deliberate and guarded: proposedCommandPrefix
|
||||
// refuses to offer a prefix while any other segment of the command is not
|
||||
// known-safe, precisely so an unreviewed segment cannot ride out of the
|
||||
// sandbox on it. What was missing was telling the person deciding, so this
|
||||
// surfaces it rather than removing it.
|
||||
PrefixApprovalEscalates bool `json:"prefixApprovalEscalates,omitempty"`
|
||||
}
|
||||
|
||||
type PermissionDecision struct {
|
||||
|
||||
@@ -43,9 +43,9 @@ func permissionOptions(request agent.PermissionRequest) []permissionOption {
|
||||
case agent.PermissionDecisionAllowForSession:
|
||||
options = append(options, permissionOption{label: "allow for session", hotkey: "s", choice: permissionDecisionAllowForSession})
|
||||
case agent.PermissionDecisionAllowPrefix:
|
||||
options = append(options, permissionOption{label: "allow command prefix for session", hotkey: "p", choice: permissionDecisionAllowPrefix})
|
||||
options = append(options, permissionOption{label: prefixApprovalLabel("allow command prefix for session", request), hotkey: "p", choice: permissionDecisionAllowPrefix})
|
||||
case agent.PermissionDecisionAlwaysAllowPrefix:
|
||||
options = append(options, permissionOption{label: "always allow command prefix", hotkey: "y", choice: permissionDecisionAlwaysAllowPrefix})
|
||||
options = append(options, permissionOption{label: prefixApprovalLabel("always allow command prefix", request), hotkey: "y", choice: permissionDecisionAlwaysAllowPrefix})
|
||||
case agent.PermissionDecisionAlwaysAllow:
|
||||
options = append(options, permissionOption{label: "always", hotkey: "y", choice: permissionDecisionAlwaysAllow})
|
||||
case agent.PermissionDecisionDeny:
|
||||
@@ -155,3 +155,21 @@ func (m model) cancelPermissionTyping() (tea.Model, tea.Cmd) {
|
||||
m.pendingPermission.savedDraft = ""
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// prefixApprovalLabel spells out that approving a prefix also takes the command
|
||||
// out of the sandbox.
|
||||
//
|
||||
// Approving a prefix rewrites the call to require_escalated, which resolves to
|
||||
// unsandboxed execution, and the engine's escalation prompt is then satisfied by
|
||||
// the approval just given for the sandboxed form. The operator was shown only
|
||||
// "allow command prefix for session" and got something wider, which is a consent
|
||||
// gap rather than a bug in the escalation itself.
|
||||
//
|
||||
// Only appended when it is true. The backend computes that from the same guards
|
||||
// the rewrite uses, so a prompt that says this always means it.
|
||||
func prefixApprovalLabel(base string, request agent.PermissionRequest) string {
|
||||
if !request.PrefixApprovalEscalates {
|
||||
return base
|
||||
}
|
||||
return base + " (runs outside the sandbox)"
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
)
|
||||
|
||||
func prefixOptionLabels(request agent.PermissionRequest) []string {
|
||||
labels := []string{}
|
||||
for _, option := range permissionOptions(request) {
|
||||
if option.choice == permissionDecisionAllowPrefix || option.choice == permissionDecisionAlwaysAllowPrefix {
|
||||
labels = append(labels, option.label)
|
||||
}
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
func prefixRequest(escalates bool) agent.PermissionRequest {
|
||||
return agent.PermissionRequest{
|
||||
ToolName: "bash",
|
||||
AvailableDecisions: []agent.PermissionDecisionAction{
|
||||
agent.PermissionDecisionAllow,
|
||||
agent.PermissionDecisionAllowPrefix,
|
||||
agent.PermissionDecisionAlwaysAllowPrefix,
|
||||
agent.PermissionDecisionDeny,
|
||||
},
|
||||
PrefixApprovalEscalates: escalates,
|
||||
}
|
||||
}
|
||||
|
||||
// Approving a prefix also runs the command outside the sandbox, and the label
|
||||
// is the only place the person deciding could learn that. Both prefix options
|
||||
// escalate, so both must say so; covering only one would leave the other as a
|
||||
// silent path to the same outcome.
|
||||
func TestPrefixOptionsDiscloseLeavingTheSandbox(t *testing.T) {
|
||||
labels := prefixOptionLabels(prefixRequest(true))
|
||||
if len(labels) != 2 {
|
||||
t.Fatalf("expected both prefix options, got %#v", labels)
|
||||
}
|
||||
for _, label := range labels {
|
||||
if !strings.Contains(label, "outside the sandbox") {
|
||||
t.Errorf("prefix option %q does not say the command leaves the sandbox, so approving it is uninformed consent", label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The disclosure has to be conditional. When escalation will not happen, saying
|
||||
// it will is a false warning, and a prompt that cries wolf on every prefix is
|
||||
// one the user learns to ignore, which costs the disclosure its whole value.
|
||||
func TestPrefixOptionsStaySilentWhenNoEscalationHappens(t *testing.T) {
|
||||
for _, label := range prefixOptionLabels(prefixRequest(false)) {
|
||||
if strings.Contains(label, "sandbox") {
|
||||
t.Errorf("prefix option %q warns about the sandbox when approving would not leave it", label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The non-prefix options do not escalate, so the flag must not bleed into them.
|
||||
func TestNonPrefixOptionsNeverCarryTheDisclosure(t *testing.T) {
|
||||
for _, option := range permissionOptions(prefixRequest(true)) {
|
||||
if option.choice == permissionDecisionAllowPrefix || option.choice == permissionDecisionAlwaysAllowPrefix {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(option.label, "sandbox") {
|
||||
t.Errorf("option %q carries the prefix escalation disclosure but does not escalate", option.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user