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
265 lines
8.6 KiB
Go
265 lines
8.6 KiB
Go
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
|
|
"github.com/Gitlawb/zero/internal/zerocommands"
|
|
)
|
|
|
|
type sandboxCheckOptions struct {
|
|
tool string
|
|
sideEffect string
|
|
path string
|
|
reason string
|
|
json bool
|
|
}
|
|
|
|
// sandboxCheckReport is the combined snapshot `zero sandbox check` emits: the
|
|
// active plan (policy + backend + restrictions), the decision the engine would
|
|
// make for the described tool action, and any persistent grant that matches the
|
|
// tool. It is the production consumer of the zerocommands sandbox-snapshot
|
|
// contract, giving operators and CI a stable, redacted JSON view of "what would
|
|
// the sandbox do for this action?".
|
|
type sandboxCheckReport struct {
|
|
Tool string `json:"tool"`
|
|
Plan zerocommands.SandboxPlanSnapshot `json:"plan"`
|
|
Decision zerocommands.SandboxDecisionSnapshot `json:"decision"`
|
|
Grant zerocommands.SandboxGrantMatchSnapshot `json:"grant"`
|
|
}
|
|
|
|
func runSandboxCheck(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
options, help, err := parseSandboxCheckArgs(args)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeSandboxCheckHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if strings.TrimSpace(options.tool) == "" {
|
|
return writeExecUsageError(stderr, "tool name is required: zero sandbox check <tool> [flags]")
|
|
}
|
|
|
|
workspaceRoot, err := resolveWorkspaceRoot("", deps)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
store, err := deps.newSandboxStore()
|
|
if err != nil {
|
|
return writeAppError(stderr, err.Error(), exitCrash)
|
|
}
|
|
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
|
|
if err != nil {
|
|
return writeAppError(stderr, err.Error(), exitProvider)
|
|
}
|
|
policy := applyConfiguredSandboxPolicy(zeroSandbox.DefaultPolicy(), resolved.Sandbox)
|
|
backend := deps.selectSandboxBackend(zeroSandbox.BackendOptions{})
|
|
plan := backend.BuildPlan(workspaceRoot, policy)
|
|
|
|
sideEffect, err := parseSandboxCheckSideEffect(options.sideEffect)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
requestArgs := map[string]any{}
|
|
scopeAbs := workspaceRoot
|
|
if path := strings.TrimSpace(options.path); path != "" {
|
|
requestArgs["path"] = path
|
|
if filepath.IsAbs(path) {
|
|
scopeAbs = filepath.Clean(path)
|
|
} else {
|
|
scopeAbs = filepath.Join(workspaceRoot, path)
|
|
}
|
|
}
|
|
|
|
// Build the engine with the SAME write scope the real entrypoints use
|
|
// (workspace root + configured additional write roots) so the check's
|
|
// decision matches actual enforcement and a stale extra-root config surfaces
|
|
// here too, instead of silently falling back to a workspace-only scope.
|
|
scope, err := zeroSandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
|
|
if err != nil {
|
|
return writeAppError(stderr, err.Error(), exitProvider)
|
|
}
|
|
engine := zeroSandbox.NewEngine(zeroSandbox.EngineOptions{
|
|
WorkspaceRoot: workspaceRoot,
|
|
Policy: policy,
|
|
Store: store,
|
|
Backend: backend,
|
|
Scope: scope,
|
|
})
|
|
ctx, stop := signalContext()
|
|
defer stop()
|
|
decision := engine.Evaluate(ctx, zeroSandbox.Request{
|
|
WorkspaceRoot: workspaceRoot,
|
|
ToolName: options.tool,
|
|
SideEffect: sideEffect,
|
|
PermissionMode: zeroSandbox.PermissionModeAsk,
|
|
Args: requestArgs,
|
|
Reason: strings.TrimSpace(options.reason),
|
|
})
|
|
|
|
lookup, err := store.Lookup(options.tool, scopeAbs)
|
|
if err != nil {
|
|
return writeAppError(stderr, err.Error(), exitCrash)
|
|
}
|
|
|
|
report := sandboxCheckReport{
|
|
Tool: strings.TrimSpace(options.tool),
|
|
Plan: zerocommands.SandboxPlanSnapshotFromPlan(plan),
|
|
Decision: zerocommands.SandboxDecisionSnapshotFromDecision(decision),
|
|
Grant: zerocommands.SandboxGrantMatchSnapshotFromLookup(options.tool, lookup),
|
|
}
|
|
|
|
if options.json {
|
|
if err := writePrettyJSON(stdout, report); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if _, err := fmt.Fprintln(stdout, formatSandboxCheckReport(report)); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func parseSandboxCheckArgs(args []string) (sandboxCheckOptions, bool, error) {
|
|
options := sandboxCheckOptions{}
|
|
for index := 0; index < len(args); index++ {
|
|
arg := args[index]
|
|
switch {
|
|
case arg == "-h" || arg == "--help" || arg == "help":
|
|
return options, true, nil
|
|
case arg == "--json":
|
|
options.json = true
|
|
case arg == "--side-effect" || arg == "--path" || arg == "--reason":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
assignSandboxCheckFlag(&options, arg, value)
|
|
index = next
|
|
case strings.HasPrefix(arg, "--side-effect="):
|
|
value, err := requiredInlineFlagValue(arg, "--side-effect")
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.sideEffect = value
|
|
case strings.HasPrefix(arg, "--path="):
|
|
value, err := requiredInlineFlagValue(arg, "--path")
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.path = value
|
|
case strings.HasPrefix(arg, "--reason="):
|
|
value, err := requiredInlineFlagValue(arg, "--reason")
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.reason = value
|
|
case strings.HasPrefix(arg, "-"):
|
|
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
|
|
default:
|
|
if options.tool != "" {
|
|
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
|
|
}
|
|
options.tool = arg
|
|
}
|
|
}
|
|
return options, false, nil
|
|
}
|
|
|
|
func assignSandboxCheckFlag(options *sandboxCheckOptions, flag string, value string) {
|
|
switch flag {
|
|
case "--side-effect":
|
|
options.sideEffect = value
|
|
case "--path":
|
|
options.path = value
|
|
case "--reason":
|
|
options.reason = value
|
|
}
|
|
}
|
|
|
|
// parseSandboxCheckSideEffect validates --side-effect against the closed set the
|
|
// help advertises (empty defaults to read), so a typo returns a usage error
|
|
// instead of being passed to the engine as an unknown side effect.
|
|
func parseSandboxCheckSideEffect(value string) (zeroSandbox.SideEffect, error) {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "", "read":
|
|
return zeroSandbox.SideEffectRead, nil
|
|
case "write":
|
|
return zeroSandbox.SideEffectWrite, nil
|
|
case "shell":
|
|
return zeroSandbox.SideEffectShell, nil
|
|
case "network":
|
|
return zeroSandbox.SideEffectNetwork, nil
|
|
case "none":
|
|
return zeroSandbox.SideEffectNone, nil
|
|
default:
|
|
return "", execUsageError{fmt.Sprintf("invalid --side-effect %q: expected read, write, shell, network, or none", value)}
|
|
}
|
|
}
|
|
|
|
func formatSandboxCheckReport(report sandboxCheckReport) string {
|
|
lines := []string{
|
|
"Sandbox check: " + report.Tool,
|
|
"decision: " + report.Decision.Action,
|
|
}
|
|
if reason := strings.TrimSpace(report.Decision.Reason); reason != "" {
|
|
lines = append(lines, "reason: "+reason)
|
|
}
|
|
risk := report.Decision.Risk
|
|
riskLine := "risk: " + risk.Level
|
|
if len(risk.Categories) > 0 {
|
|
riskLine += " [" + strings.Join(risk.Categories, ", ") + "]"
|
|
}
|
|
lines = append(lines, riskLine)
|
|
if block := report.Decision.Block; block != nil {
|
|
line := "block: [" + block.Code + "] " + block.Reason
|
|
if block.Path != "" {
|
|
line += " (path: " + block.Path + ")"
|
|
}
|
|
lines = append(lines, line)
|
|
}
|
|
if report.Grant.Matched && report.Grant.Grant != nil {
|
|
lines = append(lines, "grant: matched "+report.Grant.Grant.Decision)
|
|
} else {
|
|
lines = append(lines, "grant: no grant matched this action")
|
|
}
|
|
lines = append(lines,
|
|
"policy: mode="+report.Plan.Policy.EffectiveMode+" network="+report.Plan.Policy.Network+fmt.Sprintf(" enforceWorkspace=%t", report.Plan.Policy.EnforceWorkspace),
|
|
"backend: "+report.Plan.Backend.Name+fmt.Sprintf(" (available=%t)", report.Plan.Backend.Available),
|
|
)
|
|
if len(report.Plan.Restrictions) > 0 {
|
|
lines = append(lines, "restrictions:")
|
|
for _, restriction := range report.Plan.Restrictions {
|
|
lines = append(lines, " - "+restriction)
|
|
}
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func writeSandboxCheckHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero sandbox check <tool> [flags]
|
|
|
|
Evaluates the sandbox decision for a hypothetical tool action against the
|
|
resolved policy, and prints the plan, the decision (allow/prompt/deny with risk
|
|
and any block), and any persistent grant that matches the tool. Secrets in
|
|
grant reasons are redacted.
|
|
|
|
Flags:
|
|
--side-effect <kind> read | write | shell | network | none (default read)
|
|
--path <path> Target path for the action (read/write checks)
|
|
--reason <text> Reason recorded with the request
|
|
--json Print the machine-readable snapshot
|
|
-h, --help Show this help
|
|
`)
|
|
return err
|
|
}
|