mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
Zero already had a complete Windows sandbox (restricted token + workspace ACLs + WFP network filters), but it was unreachable in dev and plain go build: the backend was only selected when the standalone helper exes were found adjacent to the binary or on PATH (release layout only), so every bash command hard-failed with 'Windows sandbox command runner is not available'. Add self-dispatch: the running zero binary acts as its own helper via two hidden subcommands, with three-tier discovery (adjacent exe, PATH, then self-dispatch via os.Executable) mirroring the Linux fallback. Also surface the elevation requirement for 'zero sandbox setup' (WFP/ACL need Administrator) instead of a raw ACCESS_DENIED. Tests cover the three discovery tiers, dispatch routing, and the unavailable edge.
718 lines
24 KiB
Go
718 lines
24 KiB
Go
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
|
|
)
|
|
|
|
type sandboxCommandOptions struct {
|
|
json bool
|
|
confirm bool
|
|
effective bool
|
|
reason string
|
|
path string
|
|
}
|
|
|
|
func runSandbox(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
if len(args) == 0 {
|
|
return writeExecUsageError(stderr, "sandbox subcommand required. Use `zero sandbox policy` or `zero sandbox grants list`.")
|
|
}
|
|
switch args[0] {
|
|
case "-h", "--help", "help":
|
|
if err := writeSandboxHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
case "policy":
|
|
return runSandboxPolicy(args[1:], stdout, stderr, deps)
|
|
case "setup":
|
|
return runSandboxSetup(args[1:], stdout, stderr, deps)
|
|
case "check":
|
|
return runSandboxCheck(args[1:], stdout, stderr, deps)
|
|
case "grants":
|
|
return runSandboxGrants(args[1:], stdout, stderr, deps)
|
|
default:
|
|
return writeExecUsageError(stderr, fmt.Sprintf("unknown sandbox subcommand %q", args[0]))
|
|
}
|
|
}
|
|
|
|
func runSandboxPolicy(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
options, help, err := parseSandboxCommandOptions(args, sandboxCommandFlags{allowEffective: true})
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeSandboxPolicyHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
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)
|
|
}
|
|
// Surface config resolution failures instead of silently falling back to
|
|
// DefaultPolicy(), which would otherwise misreport the active sandbox posture.
|
|
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)
|
|
if options.effective {
|
|
// Compute the effective write roots exactly the way the engine does:
|
|
// workspace root first, then the user-granted extras from the global
|
|
// config. A stale config entry (e.g. a directory that no longer
|
|
// exists) must not crash `zero sandbox policy --effective` — fall
|
|
// back to the workspace root and surface the error visibly instead.
|
|
writeRoots := []string{workspaceRoot}
|
|
var writeRootsErr error
|
|
if scope, scopeErr := zeroSandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots); scopeErr != nil {
|
|
writeRootsErr = scopeErr
|
|
// NewScope only fails on extras, so a workspace-only scope cannot
|
|
// fail; use it so the fallback renders the same symlink-resolved
|
|
// workspace root as the success path.
|
|
if fallback, fallbackErr := zeroSandbox.NewScope(workspaceRoot, nil); fallbackErr == nil {
|
|
writeRoots = fallback.Roots()
|
|
}
|
|
} else {
|
|
writeRoots = scope.Roots()
|
|
}
|
|
return runSandboxPolicyEffective(options, workspaceRoot, policy, backend, plan, store.FilePath(), writeRoots, writeRootsErr, stdout)
|
|
}
|
|
if options.json {
|
|
payload := struct {
|
|
Policy zeroSandbox.Policy `json:"policy"`
|
|
Backend zeroSandbox.Backend `json:"backend"`
|
|
Plan zeroSandbox.BackendPlan `json:"plan"`
|
|
GrantsPath string `json:"grantsPath"`
|
|
}{
|
|
Policy: policy,
|
|
Backend: backend,
|
|
Plan: plan,
|
|
GrantsPath: store.FilePath(),
|
|
}
|
|
if err := writePrettyJSON(stdout, payload); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if _, err := fmt.Fprintln(stdout, formatSandboxPolicy(workspaceRoot, policy, backend, plan, store.FilePath())); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func runSandboxSetup(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
options, help, err := parseSandboxCommandOptions(args, sandboxCommandFlags{})
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeSandboxSetupHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
workspaceRoot, err := resolveWorkspaceRoot("", deps)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
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{})
|
|
if backend.Platform != "windows" {
|
|
message := "No native sandbox setup is required for " + displayPlatform(backend.Platform)
|
|
return writeSandboxSetupResult(stdout, options.json, sandboxSetupResult{
|
|
Platform: displayPlatform(backend.Platform),
|
|
Backend: backend.Name,
|
|
Ran: false,
|
|
Message: message,
|
|
})
|
|
}
|
|
if backend.Name != zeroSandbox.BackendWindowsRestrictedToken || !backend.Available || backend.Executable == "" {
|
|
message := "Windows sandbox setup helper is not available"
|
|
if strings.TrimSpace(backend.Message) != "" {
|
|
message += ": " + backend.Message
|
|
}
|
|
return writeAppError(stderr, message, exitProvider)
|
|
}
|
|
scope, err := zeroSandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
profile := zeroSandbox.PermissionProfileFromPolicy(workspaceRoot, policy, scope)
|
|
// Resolve the setup helper the same way the runner is resolved: a standalone
|
|
// .exe when shipped (release), else self-dispatch via the running zero binary
|
|
// (dev / plain build). This mirrors the backend's command-runner resolution
|
|
// so `zero sandbox setup` works in every layout, not just release.
|
|
setupHelper := zeroSandbox.ResolveWindowsSandboxSetupHelper(nil)
|
|
if !setupHelper.Available() {
|
|
return writeAppError(stderr, "Windows sandbox setup helper is not available", exitProvider)
|
|
}
|
|
setupArgs, err := zeroSandbox.BuildWindowsSandboxSetupArgs(zeroSandbox.WindowsSandboxSetupArgsOptions{
|
|
CommandCWD: workspaceRoot,
|
|
WorkspaceRoots: []string{workspaceRoot},
|
|
PermissionProfile: profile,
|
|
})
|
|
if err != nil {
|
|
return writeAppError(stderr, err.Error(), exitCrash)
|
|
}
|
|
setupArgs = append(append([]string{}, setupHelper.ArgsPrefix...), setupArgs...)
|
|
if err := deps.runSandboxSetupHelper(setupHelper.Name, setupArgs, stdout, stderr); err != nil {
|
|
return writeAppError(stderr, "Windows sandbox setup failed: "+err.Error(), exitProvider)
|
|
}
|
|
return writeSandboxSetupResult(stdout, options.json, sandboxSetupResult{
|
|
Platform: "windows",
|
|
Backend: backend.Name,
|
|
Helper: setupHelper.Name,
|
|
Ran: true,
|
|
Message: "Windows sandbox setup complete",
|
|
})
|
|
}
|
|
|
|
type sandboxSetupResult struct {
|
|
Platform string `json:"platform"`
|
|
Backend zeroSandbox.BackendName `json:"backend"`
|
|
Helper string `json:"helper,omitempty"`
|
|
Ran bool `json:"ran"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
func writeSandboxSetupResult(stdout io.Writer, asJSON bool, result sandboxSetupResult) int {
|
|
if asJSON {
|
|
if err := writePrettyJSON(stdout, result); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if _, err := fmt.Fprintln(stdout, result.Message); err != nil {
|
|
return exitCrash
|
|
}
|
|
if result.Helper != "" {
|
|
if _, err := fmt.Fprintln(stdout, "helper: "+result.Helper); err != nil {
|
|
return exitCrash
|
|
}
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func displayPlatform(platform string) string {
|
|
if strings.TrimSpace(platform) == "" {
|
|
return "this platform"
|
|
}
|
|
return platform
|
|
}
|
|
|
|
// sandboxGuards is the resolved set of pre-execution safety guards the engine
|
|
// applies for the effective policy. There is no layered/merged config today, so
|
|
// the "effective" view is the fully-resolved DefaultPolicy plus the platform
|
|
// backend and the always-on static guards.
|
|
type sandboxGuards struct {
|
|
InteractiveCommand bool `json:"interactiveCommand"`
|
|
Network bool `json:"network"`
|
|
Workspace bool `json:"workspace"`
|
|
}
|
|
|
|
func resolveSandboxGuards(policy zeroSandbox.Policy) sandboxGuards {
|
|
return sandboxGuards{
|
|
// Interactive-command detection is a static pre-exec guard that always
|
|
// runs in the bash tool regardless of policy toggles.
|
|
InteractiveCommand: true,
|
|
Network: policy.Network == zeroSandbox.NetworkDeny,
|
|
Workspace: policy.EnforceWorkspace,
|
|
}
|
|
}
|
|
|
|
func runSandboxPolicyEffective(options sandboxCommandOptions, workspaceRoot string, policy zeroSandbox.Policy, backend zeroSandbox.Backend, plan zeroSandbox.BackendPlan, grantsPath string, writeRoots []string, writeRootsErr error, stdout io.Writer) int {
|
|
guards := resolveSandboxGuards(policy)
|
|
if options.json {
|
|
payload := struct {
|
|
WorkspaceRoot string `json:"workspaceRoot"`
|
|
WriteRoots []string `json:"writeRoots"`
|
|
// WriteRootsError carries the fail-soft scope construction error so
|
|
// JSON consumers see the same signal as the text write_roots_error
|
|
// line: a stale sandbox.additionalWriteRoots entry means the real
|
|
// entrypoints would refuse to launch, not run workspace-only.
|
|
WriteRootsError string `json:"writeRootsError,omitempty"`
|
|
Policy zeroSandbox.Policy `json:"policy"`
|
|
Backend zeroSandbox.Backend `json:"backend"`
|
|
Plan zeroSandbox.BackendPlan `json:"plan"`
|
|
Guards sandboxGuards `json:"guards"`
|
|
GrantsPath string `json:"grantsPath"`
|
|
}{
|
|
WorkspaceRoot: workspaceRoot,
|
|
WriteRoots: writeRoots,
|
|
Policy: policy,
|
|
Backend: backend,
|
|
Plan: plan,
|
|
Guards: guards,
|
|
GrantsPath: grantsPath,
|
|
}
|
|
if writeRootsErr != nil {
|
|
payload.WriteRootsError = writeRootsErr.Error()
|
|
}
|
|
if err := writePrettyJSON(stdout, payload); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if _, err := fmt.Fprintln(stdout, formatEffectiveSandboxPolicy(workspaceRoot, policy, backend, plan, guards, grantsPath, writeRoots, writeRootsErr)); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func formatEffectiveSandboxPolicy(workspaceRoot string, policy zeroSandbox.Policy, backend zeroSandbox.Backend, plan zeroSandbox.BackendPlan, guards sandboxGuards, grantsPath string, writeRoots []string, writeRootsErr error) string {
|
|
lines := []string{
|
|
"Zero effective sandbox policy",
|
|
"root: " + workspaceRoot,
|
|
"mode: " + string(policy.Mode),
|
|
"network: " + string(policy.Network),
|
|
"enforce_workspace: " + fmt.Sprintf("%t", policy.EnforceWorkspace),
|
|
"write_roots: " + strings.Join(writeRoots, ", "),
|
|
}
|
|
if writeRootsErr != nil {
|
|
// Fail soft, visibly: a stale sandbox.additionalWriteRoots entry must
|
|
// not hide the rest of the status output.
|
|
lines = append(lines, "write_roots_error: "+writeRootsErr.Error())
|
|
}
|
|
lines = append(lines,
|
|
"backend: "+string(backend.Name),
|
|
"target_backend: "+string(plan.TargetBackend),
|
|
"support_level: "+string(plan.SupportLevel),
|
|
"command_wrapped: "+fmt.Sprintf("%t", plan.CommandWrapped),
|
|
"enforcement_level: "+string(plan.EnforcementLevel),
|
|
"requires_platform_sandbox: "+fmt.Sprintf("%t", plan.RequiresPlatformSandbox),
|
|
"interactive_command_guard: "+enabledLabel(guards.InteractiveCommand),
|
|
"network_guard: "+enabledLabel(guards.Network),
|
|
"workspace_guard: "+enabledLabel(guards.Workspace),
|
|
"grants: "+grantsPath,
|
|
)
|
|
if plan.DowngradeReason != "" {
|
|
lines = append(lines, "downgrade_reason: "+plan.DowngradeReason)
|
|
}
|
|
if backend.Platform != "" {
|
|
lines = append(lines, "backend_platform: "+backend.Platform)
|
|
}
|
|
for _, restriction := range plan.Restrictions {
|
|
lines = append(lines, "restriction: "+restriction)
|
|
}
|
|
for _, warning := range plan.Warnings {
|
|
lines = append(lines, "warning: "+warning)
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func enabledLabel(enabled bool) string {
|
|
if enabled {
|
|
return "enabled"
|
|
}
|
|
return "disabled"
|
|
}
|
|
|
|
func runSandboxGrants(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
if len(args) == 0 {
|
|
return writeExecUsageError(stderr, "sandbox grants subcommand required. Use `zero sandbox grants list`.")
|
|
}
|
|
switch args[0] {
|
|
case "-h", "--help", "help":
|
|
if err := writeSandboxGrantsHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
case "list":
|
|
options, help, err := parseSandboxCommandOptions(args[1:], sandboxCommandFlags{})
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeSandboxGrantsHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
store, err := deps.newSandboxStore()
|
|
if err != nil {
|
|
return writeAppError(stderr, err.Error(), exitCrash)
|
|
}
|
|
grants, err := store.List()
|
|
if err != nil {
|
|
return writeAppError(stderr, err.Error(), exitCrash)
|
|
}
|
|
prefixes, err := store.ListCommandPrefixes()
|
|
if err != nil {
|
|
return writeAppError(stderr, err.Error(), exitCrash)
|
|
}
|
|
if options.json {
|
|
if err := writePrettyJSON(stdout, struct {
|
|
Grants []zeroSandbox.Grant `json:"grants"`
|
|
CommandPrefixes []zeroSandbox.CommandPrefixGrant `json:"commandPrefixes,omitempty"`
|
|
}{Grants: grants, CommandPrefixes: prefixes}); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if _, err := fmt.Fprintln(stdout, zeroSandbox.FormatGrantListWithCommandPrefixes(grants, prefixes)); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
case "allow", "deny":
|
|
return runSandboxGrantSet(args[0], args[1:], stdout, stderr, deps)
|
|
case "revoke":
|
|
return runSandboxGrantRevoke(args[1:], stdout, stderr, deps)
|
|
case "clear":
|
|
return runSandboxGrantClear(args[1:], stdout, stderr, deps)
|
|
default:
|
|
return writeExecUsageError(stderr, fmt.Sprintf("unknown sandbox grants subcommand %q", args[0]))
|
|
}
|
|
}
|
|
|
|
func runSandboxGrantSet(command string, args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
options, positional, help, err := parseSandboxPositionalOptions(args)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeSandboxGrantSetHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if len(positional) != 1 {
|
|
return writeExecUsageError(stderr, "usage: zero sandbox grants "+command+" <tool> [--path file] [--reason text] [--json]")
|
|
}
|
|
decision := zeroSandbox.GrantAllow
|
|
if command == "deny" {
|
|
decision = zeroSandbox.GrantDeny
|
|
}
|
|
store, err := deps.newSandboxStore()
|
|
if err != nil {
|
|
return writeAppError(stderr, err.Error(), exitCrash)
|
|
}
|
|
input := zeroSandbox.GrantInput{
|
|
ToolName: positional[0],
|
|
Decision: decision,
|
|
Reason: options.reason,
|
|
}
|
|
if options.path != "" {
|
|
// --path persists an exact-file grant. Resolve to an absolute path so it
|
|
// matches how scopes are stored (and how `revoke --path` looks them up).
|
|
abs, absErr := filepath.Abs(options.path)
|
|
if absErr != nil {
|
|
return writeExecUsageError(stderr, absErr.Error())
|
|
}
|
|
input.Scope = abs
|
|
input.ScopeKind = zeroSandbox.ScopeFile
|
|
}
|
|
grant, err := store.Grant(input)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if options.json {
|
|
if err := writePrettyJSON(stdout, struct {
|
|
Grant zeroSandbox.Grant `json:"grant"`
|
|
}{Grant: grant}); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if _, err := fmt.Fprintf(stdout, "Sandbox grant saved: %s [%s]\n", grant.ToolName, grant.Decision); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func runSandboxGrantRevoke(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
options, positional, help, err := parseSandboxPositionalOptions(args)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeSandboxGrantRevokeHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if len(positional) != 1 {
|
|
return writeExecUsageError(stderr, "usage: zero sandbox grants revoke <tool> [--path file] [--json]")
|
|
}
|
|
store, err := deps.newSandboxStore()
|
|
if err != nil {
|
|
return writeAppError(stderr, err.Error(), exitCrash)
|
|
}
|
|
// With --path, revoke only the grant scoped to that exact file/dir; otherwise
|
|
// revoke every grant for the tool (the pre-existing behavior).
|
|
var revoked int
|
|
if options.path != "" {
|
|
revoked, err = store.RevokePath(positional[0], options.path)
|
|
} else {
|
|
revoked, err = store.Revoke(positional[0])
|
|
}
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if options.json {
|
|
if err := writePrettyJSON(stdout, struct {
|
|
Revoked int `json:"revoked"`
|
|
}{Revoked: revoked}); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if _, err := fmt.Fprintf(stdout, "Sandbox grants revoked: %d\n", revoked); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func runSandboxGrantClear(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
options, help, err := parseSandboxCommandOptions(args, sandboxCommandFlags{allowConfirm: true})
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeSandboxGrantClearHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if !options.confirm {
|
|
return writeExecUsageError(stderr, "zero sandbox grants clear requires --confirm")
|
|
}
|
|
store, err := deps.newSandboxStore()
|
|
if err != nil {
|
|
return writeAppError(stderr, err.Error(), exitCrash)
|
|
}
|
|
cleared, err := store.Clear()
|
|
if err != nil {
|
|
return writeAppError(stderr, err.Error(), exitCrash)
|
|
}
|
|
if options.json {
|
|
if err := writePrettyJSON(stdout, struct {
|
|
Cleared int `json:"cleared"`
|
|
}{Cleared: cleared}); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if _, err := fmt.Fprintf(stdout, "Sandbox grants cleared: %d\n", cleared); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
type sandboxCommandFlags struct {
|
|
allowConfirm bool
|
|
allowEffective bool
|
|
}
|
|
|
|
func parseSandboxCommandOptions(args []string, flags sandboxCommandFlags) (sandboxCommandOptions, bool, error) {
|
|
options := sandboxCommandOptions{}
|
|
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 flags.allowConfirm && arg == "--confirm":
|
|
options.confirm = true
|
|
case flags.allowEffective && arg == "--effective":
|
|
options.effective = true
|
|
case strings.HasPrefix(arg, "-"):
|
|
return options, false, execUsageError{fmt.Sprintf("unknown sandbox flag %q", arg)}
|
|
default:
|
|
return options, false, execUsageError{fmt.Sprintf("unexpected sandbox argument %q", arg)}
|
|
}
|
|
}
|
|
return options, false, nil
|
|
}
|
|
|
|
func parseSandboxPositionalOptions(args []string) (sandboxCommandOptions, []string, bool, error) {
|
|
options := sandboxCommandOptions{}
|
|
positional := []string{}
|
|
for index := 0; index < len(args); index++ {
|
|
arg := args[index]
|
|
switch {
|
|
case arg == "-h" || arg == "--help" || arg == "help":
|
|
return options, positional, true, nil
|
|
case arg == "--json":
|
|
options.json = true
|
|
case arg == "--reason":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, positional, false, err
|
|
}
|
|
options.reason = value
|
|
index = next
|
|
case strings.HasPrefix(arg, "--reason="):
|
|
options.reason = strings.TrimSpace(strings.TrimPrefix(arg, "--reason="))
|
|
case arg == "--path":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, positional, false, err
|
|
}
|
|
// An explicit but empty --path is a user error, not "tool-wide": treating
|
|
// it as unset would silently widen an allow to the whole tool, or make a
|
|
// revoke drop every grant for the tool. Fail closed.
|
|
if strings.TrimSpace(value) == "" {
|
|
return options, positional, false, execUsageError{"--path requires a non-empty file path"}
|
|
}
|
|
options.path = strings.TrimSpace(value)
|
|
index = next
|
|
case strings.HasPrefix(arg, "--path="):
|
|
value := strings.TrimSpace(strings.TrimPrefix(arg, "--path="))
|
|
if value == "" {
|
|
return options, positional, false, execUsageError{"--path requires a non-empty file path"}
|
|
}
|
|
options.path = value
|
|
case strings.HasPrefix(arg, "-"):
|
|
return options, positional, false, execUsageError{fmt.Sprintf("unknown sandbox grants flag %q", arg)}
|
|
default:
|
|
positional = append(positional, strings.TrimSpace(arg))
|
|
}
|
|
}
|
|
return options, positional, false, nil
|
|
}
|
|
|
|
func formatSandboxPolicy(workspaceRoot string, policy zeroSandbox.Policy, backend zeroSandbox.Backend, plan zeroSandbox.BackendPlan, grantsPath string) string {
|
|
lines := []string{
|
|
"Zero sandbox policy",
|
|
"root: " + workspaceRoot,
|
|
"mode: " + string(policy.Mode),
|
|
"network: " + string(policy.Network),
|
|
"backend: " + string(backend.Name),
|
|
"target_backend: " + string(plan.TargetBackend),
|
|
"support_level: " + string(plan.SupportLevel),
|
|
"command_wrapped: " + fmt.Sprintf("%t", plan.CommandWrapped),
|
|
"enforcement_level: " + string(plan.EnforcementLevel),
|
|
"requires_platform_sandbox: " + fmt.Sprintf("%t", plan.RequiresPlatformSandbox),
|
|
}
|
|
if plan.DowngradeReason != "" {
|
|
lines = append(lines, "downgrade_reason: "+plan.DowngradeReason)
|
|
}
|
|
if backend.Platform != "" {
|
|
lines = append(lines, "backend_platform: "+backend.Platform)
|
|
}
|
|
lines = append(lines,
|
|
"backend_available: "+fmt.Sprintf("%t", backend.Available),
|
|
"backend_fallback: "+fmt.Sprintf("%t", backend.Fallback),
|
|
"backend_command_wrapping: "+fmt.Sprintf("%t", backend.CommandWrapping),
|
|
"backend_native_isolation: "+fmt.Sprintf("%t", backend.NativeIsolation),
|
|
"grants: "+grantsPath,
|
|
)
|
|
if backend.Message != "" {
|
|
lines = append(lines, "backend_message: "+backend.Message)
|
|
}
|
|
for _, warning := range plan.Warnings {
|
|
lines = append(lines, "warning: "+warning)
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func writeSandboxHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero sandbox <command>
|
|
|
|
Commands:
|
|
policy Inspect active sandbox policy and platform backend
|
|
setup Run native platform sandbox setup
|
|
check Evaluate the sandbox decision for a hypothetical tool action
|
|
grants Manage persistent sandbox grants
|
|
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func writeSandboxPolicyHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero sandbox policy [flags]
|
|
|
|
Flags:
|
|
--effective Print the resolved effective policy (merged config + guards)
|
|
--json Print JSON output
|
|
-h, --help Show this help
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func writeSandboxSetupHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero sandbox setup [flags]
|
|
|
|
Runs native platform sandbox setup when the selected backend requires it.
|
|
|
|
Flags:
|
|
--json Print JSON output
|
|
-h, --help Show this help
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func writeSandboxGrantsHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero sandbox grants <command>
|
|
|
|
Commands:
|
|
list List persistent sandbox grants
|
|
allow Persistently allow a tool
|
|
deny Persistently deny a tool
|
|
revoke Revoke a tool grant
|
|
clear Clear all sandbox grants
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func writeSandboxGrantSetHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero sandbox grants allow <tool> [flags]
|
|
zero sandbox grants deny <tool> [flags]
|
|
|
|
Flags:
|
|
--reason <text> Human-readable reason for the grant
|
|
--path <path> Scope the grant to one exact file (default: tool-wide)
|
|
--json Print JSON output
|
|
-h, --help Show this help
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func writeSandboxGrantRevokeHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero sandbox grants revoke <tool> [flags]
|
|
|
|
Flags:
|
|
--path <path> Revoke only the grant scoped to this exact file/dir
|
|
(default: revoke every grant for the tool)
|
|
--json Print JSON output
|
|
-h, --help Show this help
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func writeSandboxGrantClearHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero sandbox grants clear --confirm [flags]
|
|
|
|
Flags:
|
|
--confirm Confirm removal of all sandbox grants
|
|
--json Print JSON output
|
|
-h, --help Show this help
|
|
`)
|
|
return err
|
|
}
|