mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* Prompt for shell network permissions Route network-risk shell calls through the existing permission prompt instead of hard-denying them at sandbox preflight. Approved network prompts apply the current turn/session request-permissions overlay so the effective sandbox policy is widened only after user approval. Move network command detection into the shell AST analyzer for package installers, gh/git network subcommands, and python http.server so quoted process patterns such as pkill -f "python3 -m http.server 8000" are not misclassified as network activity. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestAnalyzeCommand|TestClassifyDoesNotFlagQuotedHttpServerPattern|TestEvaluateExemptsNetworkToolsFromDenyByDefault|TestEngineClassifiesNetworkAndDestructiveShellCommands|TestGrantRequestPermissionsNetworkOverlaysPolicyForTurn' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPersistentCommandPrefixStillPromptsForNetwork|TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestBash|TestRegistryRunsBashThroughSandboxEngine' Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Auto-allow native-sandboxed shell commands Remove the old hidden bash auto-allow environment/config path. Ordinary shell commands now use the active native sandbox as the permission boundary by default, while network, destructive, and workspace policy checks still run first. Policy-only and disabled sandbox backends continue to require approval for shell commands, so unsandboxed execution is not silently allowed. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestSandboxedBash|TestShellSandboxActive|TestEvaluateExemptsNetworkToolsFromDenyByDefault|TestGrantRequestPermissionsNetworkOverlaysPolicyForTurn|TestAnalyzeCommand|TestClassifyDoesNotFlagQuotedHttpServerPattern' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestBashAuto|TestBashStillPromptsWithoutActiveSandbox|TestRegistryRunsBashThroughSandboxEngine' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestApplyConfiguredSandboxPolicy|TestApplyConfiguredAutonomyCeiling' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPersistentCommandPrefixStillPromptsForNetwork|TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/daemon -run 'TestScrubWorkerEnvRemovesReentrancyMarkers' Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Prompt for destructive shell approval Remove the separate DenyDestructiveShell policy layer from active sandbox decisions. Destructive shell classification now produces an approval prompt in ask/auto flows instead of a sandbox hard-deny, while no-approval paths remain blocked. Drop the removed destructive-shell policy field from sandbox policy output, backend capabilities, snapshots, and Windows golden data. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPromptsForDestructiveShellInsteadOfSandboxDeny|TestRunPersistentCommandPrefixStillPromptsForNetwork|TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestEngineClassifiesNetworkAndDestructiveShellCommands|TestBackendCapabilitiesReflectDisabledPolicy' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxPolicy|TestEffectiveSandboxPolicy' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/zerocommands -run 'TestSandboxPolicySnapshotFromPolicyFillsEffectiveMode' Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Remove web-tool network policy switch Remove the old EnforceToolNetwork policy option and the NetworkHostAllowed per-tool gate so sandbox network policy only controls sandboxed shell egress. Keep web_search and web_fetch on their own tool permission and URL safety paths, with tests proving deny/scoped shell network policy no longer blocks in-process web tools. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestEvaluateExemptsNetworkToolsFromShellNetworkPolicy|TestEngineAllowsNetworkSideEffectWhenShellPolicyBlocksNetwork|TestGrantRequestPermissionsNetworkOverlaysPolicyForTurn|TestScopedNetworkGateInEvaluate' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestWebSearchRunWithSandbox|TestWebFetchRunWithSandbox|TestRegistryRoutesWebFetchThroughSandboxPath' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPersistentCommandPrefixStillPromptsForNetwork|TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant' Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check * Prompt for outside-workspace file access Convert recoverable outside-workspace file path violations into permission prompts instead of hard-denying in ask/auto modes. Approved prompts now grant the requested filesystem scope through the existing request_permissions overlay for the current turn or session, so the follow-up sandbox evaluation and scoped file tools use the same policy path. Keep unsafe mode, apply_patch escapes, symlink traversal, and non-recoverable path cases hard-denied. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestEngineAutoAllowsWorkspaceFileMutationTools|TestEngineDeniesOutOfWorkspacePaths|TestGrantRequestPermissionsReadDoesNotGrantWrite|TestGrantRequestPermissionsSessionPersists' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPromptsAndAllowsOutsideWorkspaceWrite|TestRunSessionAllowsLaterOutsideWorkspaceWrite|TestRunAppliesSandboxEvenInUnsafeMode' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestRegistryAppliesSandboxBeforeToolExecution|TestRegistrySandboxGatesPathAliasKeys|TestRegistryAllowsPromptToolWithPersistentSandboxGrant' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxCheckJSONDeniesOutOfWorkspaceWrite' Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check * Fix Windows network prompt test fixture Use a Windows curl.cmd shim and cmd.exe PATH syntax for TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant while keeping the POSIX fake curl fixture for Linux and macOS. This keeps the test focused on network prompt approval and turn network grants instead of relying on a POSIX shell script being executable on Windows. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant|TestRunPersistentCommandPrefixStillPromptsForNetwork' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestClassifyASTCatchesNetworkProgramsRegexMisses|TestClassifyFlagsPipedInstallerVariants' Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check * Remove sandbox autonomy ceilings Sandbox permission decisions now match grants by tool and scope only, without MaxAutonomy or request autonomy checks. Persistent/session grants, permission approvals, workspace-write auto-allow, and native-sandbox shell auto-allow no longer route through an autonomy ceiling. Remove the sandbox.maxAutonomy config and CLI surfaces, including grant --auto, policy output, sandbox check autonomy input, and sandbox grant snapshots. MCP permission autonomy is intentionally unchanged because it is a separate MCP permission model. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/agent ./internal/tools ./internal/zerocommands ./internal/config ./internal/cli ./internal/tui Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Fail closed without native sandbox Remove AllowPolicyOnlyRunner from sandbox policy and snapshots. A restricted/managed command plan now errors when native sandbox support is unavailable instead of running through a policy-only direct runner. Policy-only backend data remains as diagnostics for capability/reporting paths, but it no longer authorizes command execution when a platform sandbox is required. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/zerocommands ./internal/cli ./internal/tui Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/tools Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Remove scoped shell network policy Drop the domain-scoped shell network mode and the local scoped-egress proxy path from the active sandbox model. Shell network policy now serializes and evaluates only deny or allow. Request-permissions network grants widen to allow without domain lists, and platform profiles consume the binary mode directly. The Linux helper no longer accepts proxy-route flags, Windows network hashes only the binary mode, and web tool tests assert they remain separate from shell network policy. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tools ./internal/notify Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Align permission grant ordering and prompt text Move persistent and session grant matching ahead of recoverable sandbox prompts while keeping explicit denies and non-recoverable path violations fail-closed. Clean TUI permission transcript/card text so normal prompts show action, reason, scope, and readable block details instead of raw permission/risk/violation key-value metadata. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tui ./internal/agent ./internal/tools Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Remove policy-only sandbox terminology Replace the unavailable native-backend diagnostic path with explicit unavailable support/backend states, and stop presenting missing native isolation as a policy-only fallback. Rename structured sandbox denial metadata from violation to block across permission events, stream-json, snapshots, TUI rendering, and sandbox-check output so user-facing flow matches blocked action language. Update README, work split docs, smoke test selectors, and Windows sandbox policy golden output for the new backend/support names and block payloads. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/agent ./internal/tools ./internal/tui ./internal/cli ./internal/doctor ./internal/zerocommands ./internal/streamjson Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: scripts/sandbox-smoke.sh Tested: git diff --check * Fix sandbox review follow-ups Fail closed for unknown network modes, keep network prompts ahead of bash allow grants, preserve v1 grant-file reads, and classify unparseable commands with obvious network programs as network-risk. Bump stream-json schema to v2 and document the block metadata field after the violation-to-block rename. Tests: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/streamjson ./internal/specialist ./internal/cli -run 'TestExec.*Stream|TestStreamJSON|TestExecProtocol|TestExecReadsStreamJSONImages|TestExecAcceptsImageOnlyStreamJSONMessage|TestExecInputFormatStreamJSON' Tests: GOCACHE=/tmp/zero-go-cache go test ./... Tests: scripts/sandbox-smoke.sh Tests: git diff --check
780 lines
27 KiB
Go
780 lines
27 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Gitlawb/zero/internal/agent"
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
"github.com/Gitlawb/zero/internal/mcp"
|
|
"github.com/Gitlawb/zero/internal/sandbox"
|
|
"github.com/Gitlawb/zero/internal/sessions"
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
)
|
|
|
|
func TestRunExecHelpDocumentsProtocolFlags(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run([]string{"exec", "--help"}, &stdout, &stderr)
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d", exitSuccess, exitCode)
|
|
}
|
|
for _, want := range []string{
|
|
"--auto",
|
|
"--enabled-tools",
|
|
"--disabled-tools",
|
|
"--list-tools",
|
|
"--input-format text|stream-json",
|
|
"--output-format text|json|stream-json",
|
|
"--resume",
|
|
"--fork",
|
|
} {
|
|
if !strings.Contains(stdout.String(), want) {
|
|
t.Fatalf("expected exec help to contain %q, got %q", want, stdout.String())
|
|
}
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunExecListsFilteredToolsWithoutPromptOrProvider(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"exec", "--list-tools", "--enabled-tools", "read_file,grep"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
for _, want := range []string{"Tools visible to model", "read_file", "grep"} {
|
|
if !strings.Contains(stdout.String(), want) {
|
|
t.Fatalf("expected tool list to contain %q, got %q", want, stdout.String())
|
|
}
|
|
}
|
|
if strings.Contains(stdout.String(), "bash") {
|
|
t.Fatalf("expected filtered tool list to hide bash, got %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunExecListsToolsAsStreamJSONWhenRequested(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"exec", "--list-tools", "--output-format", "stream-json", "--enabled-tools", "read_file"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
events := decodeJSONLines(t, stdout.String())
|
|
if len(events) != 3 {
|
|
t.Fatalf("expected run_start, final, run_end events, got %#v", events)
|
|
}
|
|
if events[0]["type"] != "run_start" || events[1]["type"] != "final" || events[2]["type"] != "run_end" {
|
|
t.Fatalf("unexpected stream-json tool list events: %#v", events)
|
|
}
|
|
text, _ := events[1]["text"].(string)
|
|
if !strings.Contains(text, "Tools visible to model") || !strings.Contains(text, "read_file") {
|
|
t.Fatalf("expected final event to contain tool list, got %#v", events[1])
|
|
}
|
|
for _, name := range []string{"bash", "grep", "write_file"} {
|
|
if strings.Contains(text, name) {
|
|
t.Fatalf("unexpected non-enabled tool %q leaked into stream-json output: %#v", name, events[1])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunExecListsMCPToolsWithoutProviderResolution(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
providerResolved := false
|
|
closed := false
|
|
|
|
exitCode := runWithDeps([]string{"exec", "--list-tools", "--enabled-tools", "mcp_docs_lookup"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
providerResolved = true
|
|
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
|
|
},
|
|
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
|
if workspaceRoot != cwd {
|
|
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
|
}
|
|
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
|
"docs": {Type: "stdio", Command: "docs-mcp"},
|
|
}}, nil
|
|
},
|
|
registerMCPTools: func(ctx context.Context, registry *tools.Registry, cfg config.MCPConfig, options mcp.RegisterOptions) (mcpToolRuntime, error) {
|
|
registry.Register(cliFakeMCPRegistryTool{})
|
|
return closeFunc(func() error {
|
|
closed = true
|
|
return nil
|
|
}), nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if providerResolved {
|
|
t.Fatal("provider config should not be resolved for --list-tools")
|
|
}
|
|
if !closed {
|
|
t.Fatal("MCP runtime was not closed after --list-tools")
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "mcp_docs_lookup") || !strings.Contains(stdout.String(), "Lookup documentation") {
|
|
t.Fatalf("expected MCP tool in list output, got %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunExecLogsMCPRuntimeCloseError(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"exec", "--list-tools", "--enabled-tools", "mcp_docs_lookup"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
|
if workspaceRoot != cwd {
|
|
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
|
}
|
|
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
|
"docs": {Type: "stdio", Command: "docs-mcp"},
|
|
}}, nil
|
|
},
|
|
newMCPStore: func() (*mcp.PermissionStore, error) {
|
|
return nil, nil
|
|
},
|
|
registerMCPTools: func(ctx context.Context, registry *tools.Registry, cfg config.MCPConfig, options mcp.RegisterOptions) (mcpToolRuntime, error) {
|
|
registry.Register(cliFakeMCPRegistryTool{})
|
|
return closeFunc(func() error {
|
|
return errors.New("close failed")
|
|
}), nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "mcp_close_error: close failed") {
|
|
t.Fatalf("stderr = %q, want MCP close error", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunExecRejectsInvalidProtocolOptionsBeforeRuntime(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
args []string
|
|
want string
|
|
}{
|
|
{name: "auto", args: []string{"exec", "--auto", "chaos", "hello"}, want: "Invalid autonomy level"},
|
|
{name: "enabled", args: []string{"exec", "--enabled-tools", "missing_tool", "hello"}, want: "Unknown tool"},
|
|
{name: "overlap", args: []string{"exec", "--enabled-tools", "read_file", "--disabled-tools", "read_file", "hello"}, want: "both enabled and disabled"},
|
|
{name: "input", args: []string{"exec", "--input-format", "yaml", "hello"}, want: "Invalid input format"},
|
|
{name: "resume-fork", args: []string{"exec", "--resume", "abc", "--fork", "def", "hello"}, want: "Use either --resume or --fork, not both"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run(tc.args, &stdout, &stderr)
|
|
|
|
if exitCode != exitUsage {
|
|
t.Fatalf("expected exit code %d, got %d", exitUsage, exitCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout before runtime, got %q", stdout.String())
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, tc.want) {
|
|
t.Fatalf("expected stderr to contain %q, got %q", tc.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunExecRejectsFlagShapedMissingValues(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run([]string{"exec", "--model", "--output-format", "json", "hello"}, &stdout, &stderr)
|
|
|
|
if exitCode != exitUsage {
|
|
t.Fatalf("expected exit code %d, got %d", exitUsage, exitCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, "--model requires a value") {
|
|
t.Fatalf("expected missing model value error, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRunExecStreamJSONUsageErrorsStayInProtocol(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"exec", "--input-format", "stream-json", "--output-format", "stream-json"}, &stdout, &stderr, appDeps{
|
|
stdin: strings.NewReader("{bad\n"),
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{}, errors.New("provider should not be resolved for invalid stream-json input")
|
|
},
|
|
})
|
|
|
|
if exitCode != exitUsage {
|
|
t.Fatalf("expected exit code %d, got %d", exitUsage, exitCode)
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
events := decodeJSONLines(t, stdout.String())
|
|
if len(events) != 2 || events[0]["type"] != "error" || events[1]["type"] != "run_end" {
|
|
t.Fatalf("expected stream-json error and run_end, got %#v", events)
|
|
}
|
|
if events[0]["code"] != "usage_error" || !strings.Contains(events[0]["message"].(string), "Invalid stream-json input") {
|
|
t.Fatalf("expected usage error event, got %#v", events[0])
|
|
}
|
|
if events[1]["exitCode"] != float64(exitUsage) {
|
|
t.Fatalf("expected usage exit code, got %#v", events[1])
|
|
}
|
|
}
|
|
|
|
func TestRunExecStreamJSONOutputsRunEndAndRecordsSession(t *testing.T) {
|
|
dataHome := t.TempDir()
|
|
t.Setenv("XDG_DATA_HOME", dataHome)
|
|
cwd := t.TempDir()
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"exec", "--output-format", "stream-json", "persist this"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
|
return execResolvedConfig(), nil
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
return echoExecProvider{}, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
|
|
events := decodeJSONLines(t, stdout.String())
|
|
types := jsonEventTypes(events)
|
|
for _, want := range []string{"run_start", "text", "final", "run_end"} {
|
|
if !slices.Contains(types, want) {
|
|
t.Fatalf("expected event %q in %v; output %q", want, types, stdout.String())
|
|
}
|
|
}
|
|
runStart := events[0]
|
|
sessionID, ok := runStart["sessionId"].(string)
|
|
if !ok || sessionID == "" {
|
|
t.Fatalf("expected run_start sessionId, got %#v", runStart)
|
|
}
|
|
if runStart["provider"] != "openai-compatible" || runStart["apiModel"] != "echo-model" {
|
|
t.Fatalf("expected resolved runtime metadata in run_start, got %#v", runStart)
|
|
}
|
|
if got := events[len(events)-1]["type"]; got != "run_end" {
|
|
t.Fatalf("expected last event run_end, got %#v", events[len(events)-1])
|
|
}
|
|
|
|
store := sessions.NewStore(sessions.StoreOptions{
|
|
RootDir: filepath.Join(dataHome, "zero", "sessions"),
|
|
})
|
|
recorded, err := store.ReadEvents(sessionID)
|
|
if err != nil {
|
|
t.Fatalf("ReadEvents returned error: %v", err)
|
|
}
|
|
if len(recorded) != 2 || recorded[0].Type != sessions.EventMessage || recorded[1].Type != sessions.EventMessage {
|
|
t.Fatalf("recorded events = %#v", recorded)
|
|
}
|
|
}
|
|
|
|
func TestRunExecStreamJSONEmitsAndRecordsPermissionEvents(t *testing.T) {
|
|
dataHome := t.TempDir()
|
|
t.Setenv("XDG_DATA_HOME", dataHome)
|
|
cwd := t.TempDir()
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"exec", "--output-format", "stream-json", "write notes"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
|
return execResolvedConfig(), nil
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
return toolCallingExecProvider{
|
|
toolCallID: "call_write",
|
|
toolName: "write_file",
|
|
arguments: `{"path":"notes.txt","content":"hello"}`,
|
|
answer: "write allowed",
|
|
}, nil
|
|
},
|
|
newSandboxStore: func() (*sandbox.GrantStore, error) {
|
|
return sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
|
|
events := decodeJSONLines(t, stdout.String())
|
|
eventTypes := jsonEventTypes(events)
|
|
if slices.Contains(eventTypes, "permission_request") {
|
|
t.Fatalf("workspace write should not request permission in %v; output %q", eventTypes, stdout.String())
|
|
}
|
|
permissionEvent := findJSONEvent(t, events, "permission_decision")
|
|
if permissionEvent["id"] != "call_write" || permissionEvent["name"] != "write_file" || permissionEvent["action"] != "allow" {
|
|
t.Fatalf("unexpected permission event: %#v", permissionEvent)
|
|
}
|
|
if permissionEvent["permission"] != "prompt" || permissionEvent["permissionMode"] != "auto" || permissionEvent["sideEffect"] != "write" {
|
|
t.Fatalf("unexpected permission metadata: %#v", permissionEvent)
|
|
}
|
|
if permissionEvent["permissionGranted"] == true {
|
|
t.Fatalf("workspace allow should not be recorded as user-granted permission: %#v", permissionEvent)
|
|
}
|
|
if permissionEvent["reason"] != "workspace write is allowed" {
|
|
t.Fatalf("unexpected workspace permission reason: %#v", permissionEvent)
|
|
}
|
|
risk, ok := permissionEvent["risk"].(map[string]any)
|
|
if !ok || risk["level"] == "" {
|
|
t.Fatalf("expected permission risk payload, got %#v", permissionEvent)
|
|
}
|
|
|
|
sessionID, ok := events[0]["sessionId"].(string)
|
|
if !ok || sessionID == "" {
|
|
t.Fatalf("expected run_start sessionId, got %#v", events[0])
|
|
}
|
|
store := sessions.NewStore(sessions.StoreOptions{
|
|
RootDir: filepath.Join(dataHome, "zero", "sessions"),
|
|
})
|
|
recorded, err := store.ReadEvents(sessionID)
|
|
if err != nil {
|
|
t.Fatalf("ReadEvents returned error: %v", err)
|
|
}
|
|
permissionRecord := findSessionEvent(t, recorded, sessions.EventPermissionDecision)
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(permissionRecord.Payload, &payload); err != nil {
|
|
t.Fatalf("decode permission payload: %v", err)
|
|
}
|
|
if payload["toolCallId"] != "call_write" || payload["name"] != "write_file" || payload["action"] != "allow" {
|
|
t.Fatalf("unexpected recorded permission payload: %#v", payload)
|
|
}
|
|
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
exitCode = runWithDeps([]string{"exec", "--skip-permissions-unsafe", "--output-format", "stream-json", "write approved"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
|
return execResolvedConfig(), nil
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
return toolCallingExecProvider{
|
|
toolCallID: "call_write_approved",
|
|
toolName: "write_file",
|
|
arguments: `{"path":"approved.txt","content":"hello"}`,
|
|
answer: "write approved",
|
|
}, nil
|
|
},
|
|
newSandboxStore: func() (*sandbox.GrantStore, error) {
|
|
return sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("approved exec exit = %d, stderr = %q", exitCode, stderr.String())
|
|
}
|
|
if stderr.String() != "" {
|
|
t.Fatalf("approved exec wrote stderr = %q", stderr.String())
|
|
}
|
|
approvedEvents := decodeJSONLines(t, stdout.String())
|
|
approvedPermissionEvent := findJSONEvent(t, approvedEvents, "permission_decision")
|
|
if approvedPermissionEvent["id"] != "call_write_approved" || approvedPermissionEvent["action"] != "allow" {
|
|
t.Fatalf("unexpected approved permission event: %#v", approvedPermissionEvent)
|
|
}
|
|
if approvedPermissionEvent["permissionGranted"] != true {
|
|
t.Fatalf("approved permission event did not preserve permissionGranted: %#v", approvedPermissionEvent)
|
|
}
|
|
approvedSessionID, ok := approvedEvents[0]["sessionId"].(string)
|
|
if !ok || approvedSessionID == "" {
|
|
t.Fatalf("expected approved run_start sessionId, got %#v", approvedEvents[0])
|
|
}
|
|
approvedRecorded, err := store.ReadEvents(approvedSessionID)
|
|
if err != nil {
|
|
t.Fatalf("ReadEvents approved session returned error: %v", err)
|
|
}
|
|
approvedPermissionRecord := findSessionEvent(t, approvedRecorded, sessions.EventPermissionDecision)
|
|
payload = map[string]any{}
|
|
if err := json.Unmarshal(approvedPermissionRecord.Payload, &payload); err != nil {
|
|
t.Fatalf("decode approved permission payload: %v", err)
|
|
}
|
|
if payload["permissionGranted"] != true {
|
|
t.Fatalf("approved recorded permission payload did not preserve permissionGranted: %#v", payload)
|
|
}
|
|
}
|
|
|
|
func TestRunExecStreamJSONRunStartUsesResolvedAPIModel(t *testing.T) {
|
|
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
|
cwd := t.TempDir()
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"exec", "--output-format", "stream-json", "hello"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{
|
|
ActiveProvider: "work",
|
|
Provider: config.ProviderProfile{
|
|
Name: "work",
|
|
Model: "sonnet-4.5",
|
|
},
|
|
MaxTurns: 2,
|
|
}, nil
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
return echoExecProvider{}, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
events := decodeJSONLines(t, stdout.String())
|
|
runStart := events[0]
|
|
if runStart["provider"] != "anthropic" {
|
|
t.Fatalf("expected resolved provider kind anthropic, got %#v", runStart)
|
|
}
|
|
if runStart["model"] != "sonnet-4.5" {
|
|
t.Fatalf("expected logical model alias, got %#v", runStart)
|
|
}
|
|
if runStart["apiModel"] != "claude-sonnet-4-5-20250929" {
|
|
t.Fatalf("expected resolved API model, got %#v", runStart)
|
|
}
|
|
}
|
|
|
|
func TestExecEventWriterTruncatesStreamJSONToolResults(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
writer := execEventWriter{
|
|
stdout: &stdout,
|
|
format: execOutputStreamJSON,
|
|
runID: "run_test",
|
|
streamedText: &strings.Builder{},
|
|
}
|
|
writer.toolResult(agent.ToolResult{
|
|
ToolCallID: "call_1",
|
|
Name: "read_file",
|
|
Status: tools.StatusOK,
|
|
Output: strings.Repeat("x", streamJSONToolResultOutputLimit+100),
|
|
Meta: map[string]string{
|
|
"sandbox_backend": "bubblewrap",
|
|
"sandbox_wrapped": "true",
|
|
},
|
|
})
|
|
if writer.err != nil {
|
|
t.Fatalf("toolResult returned writer error: %v", writer.err)
|
|
}
|
|
|
|
events := decodeJSONLines(t, stdout.String())
|
|
if len(events) != 1 {
|
|
t.Fatalf("expected one tool_result event, got %#v", events)
|
|
}
|
|
if events[0]["truncated"] != true {
|
|
t.Fatalf("expected truncated=true, got %#v", events[0])
|
|
}
|
|
if events[0]["name"] != "read_file" {
|
|
t.Fatalf("expected tool_result name to be read_file, got %#v", events[0])
|
|
}
|
|
meta := events[0]["meta"].(map[string]any)
|
|
if meta["sandbox_backend"] != "bubblewrap" || meta["sandbox_wrapped"] != "true" {
|
|
t.Fatalf("expected sandbox metadata, got %#v", meta)
|
|
}
|
|
output := events[0]["output"].(string)
|
|
if len(output) >= streamJSONToolResultOutputLimit+100 || !strings.Contains(output, "[truncated]") {
|
|
t.Fatalf("expected shortened output with marker, got len=%d", len(output))
|
|
}
|
|
}
|
|
|
|
func TestExecEventWriterEmitsPermissionDecisionReason(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
writer := execEventWriter{
|
|
stdout: &stdout,
|
|
format: execOutputStreamJSON,
|
|
runID: "run_test",
|
|
sessionID: "session_test",
|
|
streamedText: &strings.Builder{},
|
|
}
|
|
|
|
writer.permission(agent.PermissionEvent{
|
|
ToolCallID: "call_1",
|
|
ToolName: "write_file",
|
|
Action: agent.PermissionActionAllow,
|
|
Permission: string(tools.PermissionPrompt),
|
|
PermissionGranted: true,
|
|
PermissionMode: agent.PermissionModeAuto,
|
|
SideEffect: string(tools.SideEffectWrite),
|
|
Reason: "write access required",
|
|
DecisionReason: "approved by operator",
|
|
})
|
|
|
|
if writer.err != nil {
|
|
t.Fatalf("permission returned writer error: %v", writer.err)
|
|
}
|
|
events := decodeJSONLines(t, stdout.String())
|
|
if len(events) != 1 {
|
|
t.Fatalf("expected one permission event, got %#v", events)
|
|
}
|
|
if events[0]["type"] != "permission_decision" {
|
|
t.Fatalf("expected permission_decision event, got %#v", events[0])
|
|
}
|
|
if events[0]["decisionReason"] != "approved by operator" {
|
|
t.Fatalf("expected decisionReason to be preserved, got %#v", events[0])
|
|
}
|
|
}
|
|
|
|
func TestRunExecStreamJSONProviderErrorEmitsErrorAndRunEnd(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"exec", "--output-format", "stream-json", "hello"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{}, errors.New("provider failed")
|
|
},
|
|
})
|
|
|
|
if exitCode != exitProvider {
|
|
t.Fatalf("expected provider exit %d, got %d", exitProvider, exitCode)
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
events := decodeJSONLines(t, stdout.String())
|
|
if len(events) != 2 {
|
|
t.Fatalf("expected error and run_end events, got %#v", events)
|
|
}
|
|
if events[0]["type"] != "error" || events[0]["code"] != "provider_error" {
|
|
t.Fatalf("expected provider error event, got %#v", events[0])
|
|
}
|
|
if events[1]["type"] != "run_end" || events[1]["exitCode"] != float64(exitProvider) {
|
|
t.Fatalf("expected run_end provider exit, got %#v", events[1])
|
|
}
|
|
if events[0]["runId"] != events[1]["runId"] {
|
|
t.Fatalf("expected matching runId, got %#v", events)
|
|
}
|
|
}
|
|
|
|
func TestRunExecJSONInterruptedEmitsTerminalEvents(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"exec", "--output-format", "json", "hello"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) { return cwd, nil },
|
|
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) { return execResolvedConfig(), nil },
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { return canceledExecProvider{}, nil },
|
|
})
|
|
|
|
if exitCode != exitInterrupted {
|
|
t.Fatalf("expected interrupted exit %d, got %d: %s", exitInterrupted, exitCode, stderr.String())
|
|
}
|
|
// -o json must end the stream with a terminal error+done, not just print
|
|
// "Interrupted." to stderr (which the stream-json path already avoided).
|
|
events := decodeJSONLines(t, stdout.String())
|
|
if len(events) < 2 {
|
|
t.Fatalf("expected a terminal error+done on -o json interruption, got %#v", events)
|
|
}
|
|
errEvent := events[len(events)-2]
|
|
doneEvent := events[len(events)-1]
|
|
if errEvent["type"] != "error" || errEvent["code"] != "interrupted" {
|
|
t.Fatalf("expected interrupted error event, got %#v", errEvent)
|
|
}
|
|
if doneEvent["type"] != "done" || doneEvent["exit_code"] != float64(exitInterrupted) {
|
|
t.Fatalf("expected done event with interrupted exit code, got %#v", doneEvent)
|
|
}
|
|
}
|
|
|
|
func TestRunExecReadsStreamJSONPromptFromStdin(t *testing.T) {
|
|
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
|
cwd := t.TempDir()
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"exec", "--input-format", "stream-json", "--output-format", "stream-json"}, &stdout, &stderr, appDeps{
|
|
stdin: strings.NewReader(`{"schemaVersion":2,"type":"prompt","content":"from stdin"}` + "\n"),
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
|
return execResolvedConfig(), nil
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
return echoExecProvider{}, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
events := decodeJSONLines(t, stdout.String())
|
|
final := events[len(events)-2]
|
|
if final["type"] != "final" || final["text"] != "from stdin" {
|
|
t.Fatalf("expected final event from stdin, got %#v", final)
|
|
}
|
|
}
|
|
|
|
type cliFakeMCPRegistryTool struct{}
|
|
|
|
func (cliFakeMCPRegistryTool) Name() string {
|
|
return "mcp_docs_lookup"
|
|
}
|
|
|
|
func (cliFakeMCPRegistryTool) Description() string {
|
|
return "Lookup documentation"
|
|
}
|
|
|
|
func (cliFakeMCPRegistryTool) Parameters() tools.Schema {
|
|
return tools.Schema{
|
|
Type: "object",
|
|
AdditionalProperties: false,
|
|
Properties: map[string]tools.PropertySchema{
|
|
"query": {Type: "string"},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (cliFakeMCPRegistryTool) Safety() tools.Safety {
|
|
return tools.Safety{
|
|
SideEffect: tools.SideEffectNetwork,
|
|
Permission: tools.PermissionAllow,
|
|
Reason: "MCP test tool",
|
|
}
|
|
}
|
|
|
|
func (cliFakeMCPRegistryTool) Run(context.Context, map[string]any) tools.Result {
|
|
return tools.Result{Status: tools.StatusOK, Output: "ok"}
|
|
}
|
|
|
|
type closeFunc func() error
|
|
|
|
func (fn closeFunc) Close() error {
|
|
return fn()
|
|
}
|
|
|
|
func (fn closeFunc) Skipped() []mcp.SkippedServer {
|
|
return nil
|
|
}
|
|
|
|
type toolCallingExecProvider struct {
|
|
toolCallID string
|
|
toolName string
|
|
arguments string
|
|
answer string
|
|
}
|
|
|
|
func (provider toolCallingExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
|
for _, message := range request.Messages {
|
|
if message.Role == zeroruntime.MessageRoleTool {
|
|
ch := make(chan zeroruntime.StreamEvent, 2)
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: provider.answer}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
|
close(ch)
|
|
return ch, nil
|
|
}
|
|
}
|
|
ch := make(chan zeroruntime.StreamEvent, 4)
|
|
select {
|
|
case <-ctx.Done():
|
|
close(ch)
|
|
return ch, ctx.Err()
|
|
case ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: provider.toolCallID, ToolName: provider.toolName}:
|
|
}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: provider.toolCallID, ArgumentsFragment: provider.arguments}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: provider.toolCallID}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
|
close(ch)
|
|
return ch, nil
|
|
}
|
|
|
|
func findJSONEvent(t *testing.T, events []map[string]any, eventType string) map[string]any {
|
|
t.Helper()
|
|
for _, event := range events {
|
|
if event["type"] == eventType {
|
|
return event
|
|
}
|
|
}
|
|
t.Fatalf("event %q not found in %#v", eventType, events)
|
|
return nil
|
|
}
|
|
|
|
func findSessionEvent(t *testing.T, events []sessions.Event, eventType sessions.EventType) sessions.Event {
|
|
t.Helper()
|
|
for _, event := range events {
|
|
if event.Type == eventType {
|
|
return event
|
|
}
|
|
}
|
|
t.Fatalf("session event %q not found in %#v", eventType, events)
|
|
return sessions.Event{}
|
|
}
|