mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* feat(sandbox): online/offline Windows network identity (run approved network commands) Approved network commands bricked on Windows with "windows sandbox setup is out of date: network policy changed": the setup marker was locked to network=deny, so any command granted network (curl, git push, npm install) ran with network=allow and failed marker validation. Fix with a two-identity model that fits Zero's restricted-token architecture — no extra logon users, no DPAPI, no admin at runtime: mint one synthetic "offline-marker" SID per sandbox home and scope the persistent WFP block filter to THAT SID only. Network is then gated purely by the restricted token's SID set: - deny (offline): token = write-capability SIDs + offline-marker SID -> block filter matches -> no network; writes jailed by the capability SIDs. - allow (online, approved): token = write-capability SIDs only -> filter never matches -> network reaches out; writes jailed identically. The setup marker becomes mode-AGNOSTIC: it fingerprints the provisioned offline infrastructure (NetworkInfraHash + OfflineFilterSID), not the per-command mode, so one setup validly serves both an allow and a deny command. Schema bumped 3->4 (forces a clean re-setup; old markers scoped the filter to write SIDs). - windows_runner.go: Offline SID in WindowsCapabilitySIDs (schema 2, back-compat upgrade) + WindowsOfflineMarkerSID + windowsRuntimeTokenSIDs. - windows_network.go: BuildWindowsNetworkInfraPlan + WindowsNetworkInfraHash; drop the now-dead mode-coupled BuildWindowsNetworkPlan/*Hash helpers. - windows_setup.go: marker schema 4; validation drops per-command-mode equality. - windows_setup_windows.go: setup always installs the offline-scoped infra filter. - windows_command_runner_windows.go: compose the token SID set per network mode. Cross-platform core fully tested (mode selector, mode-independent infra+hash, back-compat SID upgrade, one-marker-validates-both-modes regression, schema-3 rejected); sandbox/doctor suites + windows cross-compile green. The WFP ALE_USER_ID/restricting-SID behavior needs the gated Windows integration smoke test. * docs(sandbox): note the Schannel HTTPS limitation under the Windows restricted token Document the known SEC_E_NO_CREDENTIALS limitation at the token-composition site: an approved online command reaches the network, but HTTPS via Windows Schannel fails inside the restricted token because Schannel can't acquire its per-user TLS credential under a WRITE_RESTRICTED/LUA token. Fundamental restricted-token ↔ Schannel incompatibility, unsolved even in the reference sandboxes (codex#17459). Workarounds: degraded mode or the in-process web_fetch tool. Comment-only. * docs(sandbox): keep the Schannel-limitation note vendor-neutral
320 lines
11 KiB
Go
320 lines
11 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
const WindowsSandboxSetupName = "zero-windows-sandbox-setup.exe"
|
|
|
|
const windowsSandboxSetupMarkerSchemaVersion = 4
|
|
|
|
type WindowsSandboxSetupArgsOptions struct {
|
|
SandboxHome string
|
|
CommandCWD string
|
|
WorkspaceRoots []string
|
|
PermissionProfile PermissionProfile
|
|
}
|
|
|
|
type WindowsSandboxSetupConfig struct {
|
|
SandboxHome string
|
|
CommandCWD string
|
|
WorkspaceRoots []string
|
|
PermissionProfile PermissionProfile
|
|
}
|
|
|
|
type WindowsSandboxSetupMarker struct {
|
|
SchemaVersion int `json:"schemaVersion"`
|
|
ACLPlanHash string `json:"aclPlanHash"`
|
|
ACLPlanEntries int `json:"aclPlanEntries"`
|
|
// NetworkInfraHash fingerprints the mode-INDEPENDENT network infrastructure
|
|
// setup provisioned (block filters scoped to the offline-marker SID), so one
|
|
// marker validly serves both an allow command and a deny command. It replaces
|
|
// the old per-command NetworkPolicyHash/NetworkPlanHash, which locked the
|
|
// marker to a single mode and bricked approved network commands.
|
|
NetworkInfraHash string `json:"networkInfraHash"`
|
|
OfflineFilterSID string `json:"offlineFilterSid"`
|
|
NetworkFilters int `json:"networkFilters"`
|
|
}
|
|
|
|
// WindowsSandboxSetupPathForRunner derives the setup helper's path from a
|
|
// standalone command-runner path (the sibling .exe in the release layout).
|
|
// Retained for that layout; self-dispatch callers use
|
|
// ResolveWindowsSandboxSetupHelper instead.
|
|
func WindowsSandboxSetupPathForRunner(runnerPath string) string {
|
|
if strings.TrimSpace(runnerPath) == "" {
|
|
return ""
|
|
}
|
|
return filepath.Join(filepath.Dir(runnerPath), WindowsSandboxSetupName)
|
|
}
|
|
|
|
func WindowsSandboxSetupMarkerPath(sandboxHome string) string {
|
|
return filepath.Join(sandboxHome, "windows-setup.json")
|
|
}
|
|
|
|
func BuildWindowsSandboxSetupArgs(options WindowsSandboxSetupArgsOptions) ([]string, error) {
|
|
commandCWD := strings.TrimSpace(options.CommandCWD)
|
|
if commandCWD == "" {
|
|
return nil, errors.New("windows sandbox setup requires command cwd")
|
|
}
|
|
sandboxHome := strings.TrimSpace(options.SandboxHome)
|
|
if sandboxHome == "" {
|
|
var err error
|
|
sandboxHome, err = ResolveWindowsSandboxHome(nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
workspaceRoots := trimNonEmptyStrings(options.WorkspaceRoots)
|
|
if len(workspaceRoots) == 0 {
|
|
workspaceRoots = []string{commandCWD}
|
|
}
|
|
profileJSON, err := json.Marshal(options.PermissionProfile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal windows sandbox setup permission profile: %w", err)
|
|
}
|
|
args := []string{
|
|
"--sandbox-home", sandboxHome,
|
|
"--command-cwd", commandCWD,
|
|
"--permission-profile", string(profileJSON),
|
|
}
|
|
for _, root := range workspaceRoots {
|
|
args = append(args, "--workspace-root", root)
|
|
}
|
|
return args, nil
|
|
}
|
|
|
|
func ParseWindowsSandboxSetupArgs(args []string) (WindowsSandboxSetupConfig, error) {
|
|
var config WindowsSandboxSetupConfig
|
|
var profileJSON string
|
|
for index := 0; index < len(args); index++ {
|
|
arg := args[index]
|
|
switch arg {
|
|
case "--command-cwd":
|
|
value, next, err := nextWindowsSandboxFlagValue(args, index)
|
|
if err != nil {
|
|
return WindowsSandboxSetupConfig{}, err
|
|
}
|
|
config.CommandCWD = strings.TrimSpace(value)
|
|
index = next
|
|
case "--sandbox-home":
|
|
value, next, err := nextWindowsSandboxFlagValue(args, index)
|
|
if err != nil {
|
|
return WindowsSandboxSetupConfig{}, err
|
|
}
|
|
config.SandboxHome = strings.TrimSpace(value)
|
|
index = next
|
|
case "--workspace-root":
|
|
value, next, err := nextWindowsSandboxFlagValue(args, index)
|
|
if err != nil {
|
|
return WindowsSandboxSetupConfig{}, err
|
|
}
|
|
if root := strings.TrimSpace(value); root != "" {
|
|
config.WorkspaceRoots = append(config.WorkspaceRoots, root)
|
|
}
|
|
index = next
|
|
case "--permission-profile":
|
|
value, next, err := nextWindowsSandboxFlagValue(args, index)
|
|
if err != nil {
|
|
return WindowsSandboxSetupConfig{}, err
|
|
}
|
|
profileJSON = strings.TrimSpace(value)
|
|
index = next
|
|
default:
|
|
return WindowsSandboxSetupConfig{}, fmt.Errorf("unknown windows sandbox setup flag %q", arg)
|
|
}
|
|
}
|
|
if config.CommandCWD == "" {
|
|
return WindowsSandboxSetupConfig{}, errors.New("missing --command-cwd")
|
|
}
|
|
if config.SandboxHome == "" {
|
|
return WindowsSandboxSetupConfig{}, errors.New("missing --sandbox-home")
|
|
}
|
|
if len(config.WorkspaceRoots) == 0 {
|
|
config.WorkspaceRoots = []string{config.CommandCWD}
|
|
}
|
|
if profileJSON == "" {
|
|
return WindowsSandboxSetupConfig{}, errors.New("missing --permission-profile")
|
|
}
|
|
if err := json.Unmarshal([]byte(profileJSON), &config.PermissionProfile); err != nil {
|
|
return WindowsSandboxSetupConfig{}, fmt.Errorf("invalid --permission-profile: %w", err)
|
|
}
|
|
return config, nil
|
|
}
|
|
|
|
func RunWindowsSandboxSetup(args []string, stderr io.Writer) int {
|
|
config, err := ParseWindowsSandboxSetupArgs(args)
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error())
|
|
return 2
|
|
}
|
|
return runWindowsSandboxSetup(config, stderr)
|
|
}
|
|
|
|
func (config WindowsSandboxSetupConfig) commandConfig() WindowsSandboxCommandConfig {
|
|
return WindowsSandboxCommandConfig{
|
|
SandboxHome: config.SandboxHome,
|
|
CommandCWD: config.CommandCWD,
|
|
WorkspaceRoots: cloneStrings(config.WorkspaceRoots),
|
|
PermissionProfile: config.PermissionProfile,
|
|
SandboxLevel: WindowsSandboxLevelRestrictedToken,
|
|
}
|
|
}
|
|
|
|
func WindowsSandboxSetupConfigFromCommand(config WindowsSandboxCommandConfig) WindowsSandboxSetupConfig {
|
|
return WindowsSandboxSetupConfig{
|
|
SandboxHome: config.SandboxHome,
|
|
CommandCWD: config.CommandCWD,
|
|
WorkspaceRoots: cloneStrings(config.WorkspaceRoots),
|
|
PermissionProfile: config.PermissionProfile,
|
|
}
|
|
}
|
|
|
|
func BuildWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) (WindowsSandboxSetupMarker, error) {
|
|
plan, err := BuildWindowsACLPlan(config.commandConfig())
|
|
if err != nil {
|
|
return WindowsSandboxSetupMarker{}, err
|
|
}
|
|
hash, err := WindowsACLPlanHash(plan)
|
|
if err != nil {
|
|
return WindowsSandboxSetupMarker{}, err
|
|
}
|
|
// Fingerprint the mode-INDEPENDENT network infrastructure (block filters
|
|
// scoped to the offline-marker SID), NOT the per-command network mode, so the
|
|
// marker validates for both allow and deny commands against this one setup.
|
|
infraPlan, err := BuildWindowsNetworkInfraPlan(config.commandConfig())
|
|
if err != nil {
|
|
return WindowsSandboxSetupMarker{}, err
|
|
}
|
|
infraHash, err := WindowsNetworkInfraHash(infraPlan)
|
|
if err != nil {
|
|
return WindowsSandboxSetupMarker{}, err
|
|
}
|
|
offlineSID := ""
|
|
if len(infraPlan.IdentitySIDs) > 0 {
|
|
offlineSID = infraPlan.IdentitySIDs[0]
|
|
}
|
|
return WindowsSandboxSetupMarker{
|
|
SchemaVersion: windowsSandboxSetupMarkerSchemaVersion,
|
|
ACLPlanHash: hash,
|
|
ACLPlanEntries: len(plan.Entries),
|
|
NetworkInfraHash: infraHash,
|
|
OfflineFilterSID: offlineSID,
|
|
NetworkFilters: len(infraPlan.Filters),
|
|
}, nil
|
|
}
|
|
|
|
func WriteWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) (WindowsSandboxSetupMarker, error) {
|
|
marker, err := BuildWindowsSandboxSetupMarker(config)
|
|
if err != nil {
|
|
return WindowsSandboxSetupMarker{}, err
|
|
}
|
|
path := WindowsSandboxSetupMarkerPath(config.SandboxHome)
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
return WindowsSandboxSetupMarker{}, fmt.Errorf("create windows sandbox setup marker dir: %w", err)
|
|
}
|
|
bytes, err := json.MarshalIndent(marker, "", " ")
|
|
if err != nil {
|
|
return WindowsSandboxSetupMarker{}, fmt.Errorf("marshal windows sandbox setup marker: %w", err)
|
|
}
|
|
tmp, err := os.CreateTemp(filepath.Dir(path), ".windows-setup-*.tmp")
|
|
if err != nil {
|
|
return WindowsSandboxSetupMarker{}, fmt.Errorf("create windows sandbox setup marker temp file: %w", err)
|
|
}
|
|
tmpPath := tmp.Name()
|
|
if _, err := tmp.Write(bytes); err != nil {
|
|
_ = tmp.Close()
|
|
_ = os.Remove(tmpPath)
|
|
return WindowsSandboxSetupMarker{}, fmt.Errorf("write windows sandbox setup marker temp file: %w", err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
return WindowsSandboxSetupMarker{}, fmt.Errorf("close windows sandbox setup marker temp file: %w", err)
|
|
}
|
|
if err := os.Rename(tmpPath, path); err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
return WindowsSandboxSetupMarker{}, fmt.Errorf("replace windows sandbox setup marker: %w", err)
|
|
}
|
|
return marker, nil
|
|
}
|
|
|
|
func ValidateWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) error {
|
|
expected, err := BuildWindowsSandboxSetupMarker(config)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
path := WindowsSandboxSetupMarkerPath(config.SandboxHome)
|
|
bytes, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("windows sandbox is not initialized for this workspace — run `zero sandbox setup` from an elevated (Administrator) terminal (missing %s)", filepath.Base(path))
|
|
}
|
|
return fmt.Errorf("read windows sandbox setup marker: %w", err)
|
|
}
|
|
var actual WindowsSandboxSetupMarker
|
|
if err := json.Unmarshal(bytes, &actual); err != nil {
|
|
return fmt.Errorf("parse windows sandbox setup marker: %w", err)
|
|
}
|
|
if actual.SchemaVersion != expected.SchemaVersion {
|
|
return fmt.Errorf("windows sandbox setup is out of date: schema %d, want %d", actual.SchemaVersion, expected.SchemaVersion)
|
|
}
|
|
if actual.ACLPlanHash != expected.ACLPlanHash || actual.ACLPlanEntries != expected.ACLPlanEntries {
|
|
return errors.New("windows sandbox setup is out of date: permission roots or deny lists changed")
|
|
}
|
|
// Mode-agnostic: validate the provisioned infrastructure, never the
|
|
// per-command network mode — so an approved (allow) network command and an
|
|
// ordinary (deny) command both validate against this one setup.
|
|
if actual.NetworkInfraHash != expected.NetworkInfraHash {
|
|
return errors.New("windows sandbox setup is out of date: network infrastructure changed")
|
|
}
|
|
if actual.OfflineFilterSID != expected.OfflineFilterSID {
|
|
return errors.New("windows sandbox setup is out of date: offline network identity changed")
|
|
}
|
|
if actual.NetworkFilters != expected.NetworkFilters {
|
|
return errors.New("windows sandbox setup is out of date: network enforcement plan changed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func WindowsACLPlanHash(plan WindowsACLPlan) (string, error) {
|
|
entries := canonicalWindowsACLEntries(plan.Entries)
|
|
bytes, err := json.Marshal(entries)
|
|
if err != nil {
|
|
return "", fmt.Errorf("marshal windows ACL plan hash input: %w", err)
|
|
}
|
|
sum := sha256.Sum256(bytes)
|
|
return hex.EncodeToString(sum[:]), nil
|
|
}
|
|
|
|
func canonicalWindowsACLEntries(entries []WindowsACLEntry) []WindowsACLEntry {
|
|
out := make([]WindowsACLEntry, 0, len(entries))
|
|
for _, entry := range dedupeWindowsACLEntries(entries) {
|
|
entry.Path = windowsCapabilityPathKey(entry.Path)
|
|
entry.Capability = strings.ToLower(strings.TrimSpace(entry.Capability))
|
|
out = append(out, entry)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
left := out[i]
|
|
right := out[j]
|
|
if left.Action != right.Action {
|
|
return left.Action < right.Action
|
|
}
|
|
if left.Path != right.Path {
|
|
return left.Path < right.Path
|
|
}
|
|
if left.Capability != right.Capability {
|
|
return left.Capability < right.Capability
|
|
}
|
|
return !left.Materialize && right.Materialize
|
|
})
|
|
return out
|
|
}
|