mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fc61fc433 | ||
|
|
bb103b2bc2 | ||
|
|
538b31ed75 | ||
|
|
968d0830f1 | ||
|
|
7dd3d575d8 | ||
|
|
0684cf4c4b | ||
|
|
3b9be6060e | ||
|
|
c2429d00ac | ||
|
|
99c74d9b86 |
No files matched your search
@@ -1617,9 +1617,15 @@ func sandboxRestrictedShellRetryCandidate(call ToolCall, args map[string]any, re
|
||||
|
||||
func sandboxDeniedShellResult(result tools.Result) bool {
|
||||
if result.ExecutionOutcome != nil {
|
||||
// Typed denials must be handled by their exact capability path. Never
|
||||
// turn a structured narrow denial into the legacy unrestricted retry.
|
||||
return false
|
||||
// Only a typed platform denial that explicitly requests unrestricted
|
||||
// recovery may enter the legacy retry path. Narrow capability denials
|
||||
// remain on their exact capability path.
|
||||
denial := result.ExecutionOutcome.Denial
|
||||
return denial != nil &&
|
||||
denial.Source == execution.DenialSourcePlatformSandbox &&
|
||||
denial.Capability.Kind == execution.CapabilityUnrestricted &&
|
||||
denial.Recoverable &&
|
||||
denial.NextAction == execution.DenialNextActionRequestApproval
|
||||
}
|
||||
return result.Status == tools.StatusError && result.Meta[tools.SandboxLikelyDeniedMeta] == "true"
|
||||
}
|
||||
|
||||
@@ -64,6 +64,22 @@ func TestTypedExecutionOutcomeOverridesLegacySandboxHeuristics(t *testing.T) {
|
||||
t.Fatal("narrow protected-metadata denial must not become an unrestricted retry")
|
||||
}
|
||||
|
||||
nonRecoverableDenial := protectedDenial
|
||||
nonRecoverableDenial.ExecutionOutcome = &execution.Outcome{
|
||||
State: execution.StateDenied,
|
||||
Kind: execution.OutcomeEnforcementDenied,
|
||||
Denial: &execution.Denial{
|
||||
Capability: execution.Capability{Kind: execution.CapabilityUnrestricted, Scope: "host"},
|
||||
Source: execution.DenialSourcePlatformSandbox,
|
||||
Reason: "sandbox enforcement failed closed",
|
||||
Recoverable: false,
|
||||
NextAction: execution.DenialNextActionRequestApproval,
|
||||
},
|
||||
}
|
||||
if sandboxRestrictedShellRetryCandidate(call, nil, nonRecoverableDenial, options) {
|
||||
t.Fatal("non-recoverable sandbox denial must not become an unrestricted retry")
|
||||
}
|
||||
|
||||
networkDenial := protectedDenial
|
||||
networkDenial.ExecutionOutcome = &execution.Outcome{
|
||||
State: execution.StateDenied,
|
||||
@@ -288,6 +304,18 @@ func (tool *sandboxDeniedRetryTool) Run(_ context.Context, args map[string]any)
|
||||
return tools.Result{
|
||||
Status: tools.StatusError,
|
||||
Output: "touch: cannot touch '/home/user/.npm/cache': Read-only file system",
|
||||
ExecutionOutcome: &execution.Outcome{
|
||||
State: execution.StateDenied,
|
||||
Kind: execution.OutcomeEnforcementDenied,
|
||||
Exit: &execution.Exit{Code: 1},
|
||||
Denial: &execution.Denial{
|
||||
Capability: execution.Capability{Kind: execution.CapabilityUnrestricted, Scope: "host"},
|
||||
Source: execution.DenialSourcePlatformSandbox,
|
||||
Reason: "sandbox blocked command execution",
|
||||
Recoverable: true,
|
||||
NextAction: execution.DenialNextActionRequestApproval,
|
||||
},
|
||||
},
|
||||
Meta: map[string]string{
|
||||
"exit_code": "1",
|
||||
tools.SandboxLikelyDeniedMeta: "true",
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -378,7 +379,31 @@ func TestCredentialDenyReadPathsIn(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
awsDir := filepath.Join(home, ".aws")
|
||||
gcloudDir := filepath.Join(home, ".config", "gcloud")
|
||||
if err := mkdirAll(awsDir, gcloudDir); err != nil {
|
||||
npmrc := filepath.Join(home, ".npmrc")
|
||||
ghHosts := filepath.Join(home, ".config", "gh", "hosts.yml")
|
||||
netrc := filepath.Join(home, ".netrc")
|
||||
dockerConfig := filepath.Join(home, ".docker", "config.json")
|
||||
kubeConfig := filepath.Join(home, ".kube", "config")
|
||||
zeroConfig := filepath.Join(home, ".config", "zero")
|
||||
if err := mkdirAll(
|
||||
awsDir,
|
||||
gcloudDir,
|
||||
filepath.Dir(ghHosts),
|
||||
filepath.Dir(dockerConfig),
|
||||
filepath.Dir(kubeConfig),
|
||||
filepath.Dir(zeroConfig),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{npmrc, ghHosts, netrc, dockerConfig, kubeConfig, zeroConfig} {
|
||||
if path == zeroConfig {
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(zeroConfig, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyFile := filepath.Join(home, "sa-key.json")
|
||||
@@ -387,7 +412,17 @@ func TestCredentialDenyReadPathsIn(t *testing.T) {
|
||||
}
|
||||
|
||||
paths := credentialDenyReadPathsIn(home, keyFile, nil)
|
||||
for _, want := range normalizeProfilePaths([]string{awsDir, gcloudDir, keyFile}) {
|
||||
for _, want := range normalizeProfilePaths([]string{
|
||||
awsDir,
|
||||
gcloudDir,
|
||||
keyFile,
|
||||
npmrc,
|
||||
ghHosts,
|
||||
netrc,
|
||||
dockerConfig,
|
||||
kubeConfig,
|
||||
zeroConfig,
|
||||
}) {
|
||||
if !stringSliceContains(paths, want) {
|
||||
t.Errorf("credential deny paths = %#v, want %q included", paths, want)
|
||||
}
|
||||
@@ -418,3 +453,58 @@ func TestCredentialDenyReadPathsIn(t *testing.T) {
|
||||
t.Errorf("credential deny paths without home = %#v, want key file included", homeless)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialDenyReadPathsForEnvironmentHonorsConfigOverrides(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
configHome := filepath.Join(root, "xdg")
|
||||
npmrc := filepath.Join(root, "npm", "userconfig")
|
||||
ghConfigDir := filepath.Join(root, "gh")
|
||||
cloudSDKConfig := filepath.Join(root, "gcloud")
|
||||
netrc := filepath.Join(root, "netrc")
|
||||
dockerConfigDir := filepath.Join(root, "docker")
|
||||
kubeConfigA := filepath.Join(root, "kube", "a")
|
||||
kubeConfigB := filepath.Join(root, "kube", "b")
|
||||
zeroConfig := filepath.Join(configHome, "zero")
|
||||
filePaths := []string{
|
||||
npmrc,
|
||||
filepath.Join(ghConfigDir, "hosts.yml"),
|
||||
netrc,
|
||||
filepath.Join(dockerConfigDir, "config.json"),
|
||||
kubeConfigA,
|
||||
kubeConfigB,
|
||||
zeroConfig,
|
||||
}
|
||||
if err := os.MkdirAll(cloudSDKConfig, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range filePaths {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if path == zeroConfig {
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
got := credentialDenyReadPathsForEnvironment(credentialPathEnvironment{
|
||||
ConfigHome: configHome,
|
||||
CloudSDKConfig: cloudSDKConfig,
|
||||
NPMUserConfig: npmrc,
|
||||
GHConfigDir: ghConfigDir,
|
||||
Netrc: netrc,
|
||||
DockerConfigDir: dockerConfigDir,
|
||||
KubeConfig: strings.Join([]string{kubeConfigA, kubeConfigB}, string(filepath.ListSeparator)),
|
||||
}, nil)
|
||||
wantPaths := append(filePaths, cloudSDKConfig)
|
||||
for _, want := range normalizeProfilePaths(wantPaths) {
|
||||
if !stringSliceContains(got, want) {
|
||||
t.Errorf("credential deny paths = %#v, want override %q included", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+91
-12
@@ -145,9 +145,10 @@ func permissionProfileReadRoots(workspaceRoot string, policy Policy, scope *Scop
|
||||
}
|
||||
|
||||
// credentialDenyReadPaths returns default deny-read entries for well-known
|
||||
// credential stores (~/.aws, ~/.config/gcloud, ~/.azure, and the file
|
||||
// GOOGLE_APPLICATION_CREDENTIALS points to) so sandboxed commands cannot read
|
||||
// cloud secrets under the read-all workspace posture. Two deliberate limits:
|
||||
// credential stores so sandboxed commands cannot read secrets under the
|
||||
// read-all workspace posture. This includes tool configuration files that can
|
||||
// carry credentials and are now discoverable through the preserved caller
|
||||
// environment. Two deliberate limits:
|
||||
//
|
||||
// - Windows is skipped: a non-empty profile DenyRead switches the Windows
|
||||
// runner onto the capability-SID/ACL deny path and away from the
|
||||
@@ -166,23 +167,100 @@ func credentialDenyReadPaths(policy Policy) []string {
|
||||
// A failed home lookup only drops the home-based candidates; the
|
||||
// GOOGLE_APPLICATION_CREDENTIALS target must be protected regardless.
|
||||
home, _ := os.UserHomeDir()
|
||||
return credentialDenyReadPathsIn(home, os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"), policy.AllowRead)
|
||||
return credentialDenyReadPathsForEnvironment(credentialPathEnvironment{
|
||||
Home: home,
|
||||
ConfigHome: os.Getenv("XDG_CONFIG_HOME"),
|
||||
CloudSDKConfig: os.Getenv("CLOUDSDK_CONFIG"),
|
||||
GoogleCredentials: os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"),
|
||||
NPMUserConfig: firstNonEmpty(os.Getenv("NPM_CONFIG_USERCONFIG"), os.Getenv("npm_config_userconfig")),
|
||||
GHConfigDir: os.Getenv("GH_CONFIG_DIR"),
|
||||
Netrc: os.Getenv("NETRC"),
|
||||
DockerConfigDir: os.Getenv("DOCKER_CONFIG"),
|
||||
KubeConfig: os.Getenv("KUBECONFIG"),
|
||||
}, policy.AllowRead)
|
||||
}
|
||||
|
||||
// credentialDenyReadPathsIn is the pure core of credentialDenyReadPaths,
|
||||
// separated so tests can exercise it against a synthetic home directory.
|
||||
func credentialDenyReadPathsIn(home string, googleCredentials string, allowRead []string) []string {
|
||||
return credentialDenyReadPathsForEnvironment(credentialPathEnvironment{
|
||||
Home: home,
|
||||
GoogleCredentials: googleCredentials,
|
||||
}, allowRead)
|
||||
}
|
||||
|
||||
type credentialPathEnvironment struct {
|
||||
Home string
|
||||
ConfigHome string
|
||||
CloudSDKConfig string
|
||||
GoogleCredentials string
|
||||
NPMUserConfig string
|
||||
GHConfigDir string
|
||||
Netrc string
|
||||
DockerConfigDir string
|
||||
KubeConfig string
|
||||
}
|
||||
|
||||
func credentialDenyReadPathsForEnvironment(env credentialPathEnvironment, allowRead []string) []string {
|
||||
var candidates []string
|
||||
if home = strings.TrimSpace(home); home != "" {
|
||||
home := strings.TrimSpace(env.Home)
|
||||
configHome := strings.TrimSpace(env.ConfigHome)
|
||||
if configHome == "" && home != "" {
|
||||
configHome = filepath.Join(home, ".config")
|
||||
}
|
||||
if home != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".aws"),
|
||||
filepath.Join(home, ".config", "gcloud"),
|
||||
filepath.Join(home, ".azure"),
|
||||
)
|
||||
}
|
||||
if target := strings.TrimSpace(googleCredentials); target != "" {
|
||||
npmUserConfig := strings.TrimSpace(env.NPMUserConfig)
|
||||
if npmUserConfig == "" && home != "" {
|
||||
npmUserConfig = filepath.Join(home, ".npmrc")
|
||||
}
|
||||
netrc := strings.TrimSpace(env.Netrc)
|
||||
if netrc == "" && home != "" {
|
||||
netrc = filepath.Join(home, ".netrc")
|
||||
}
|
||||
dockerConfigDir := strings.TrimSpace(env.DockerConfigDir)
|
||||
if dockerConfigDir == "" && home != "" {
|
||||
dockerConfigDir = filepath.Join(home, ".docker")
|
||||
}
|
||||
candidates = append(candidates, npmUserConfig, netrc)
|
||||
if dockerConfigDir != "" {
|
||||
candidates = append(candidates, filepath.Join(dockerConfigDir, "config.json"))
|
||||
}
|
||||
if configHome != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(configHome, "zero"),
|
||||
)
|
||||
}
|
||||
cloudSDKConfig := strings.TrimSpace(env.CloudSDKConfig)
|
||||
if cloudSDKConfig == "" && configHome != "" {
|
||||
cloudSDKConfig = filepath.Join(configHome, "gcloud")
|
||||
}
|
||||
if cloudSDKConfig != "" {
|
||||
candidates = append(candidates, cloudSDKConfig)
|
||||
}
|
||||
ghConfigDir := strings.TrimSpace(env.GHConfigDir)
|
||||
if ghConfigDir == "" && configHome != "" {
|
||||
ghConfigDir = filepath.Join(configHome, "gh")
|
||||
}
|
||||
if ghConfigDir != "" {
|
||||
candidates = append(candidates, filepath.Join(ghConfigDir, "hosts.yml"))
|
||||
}
|
||||
if target := strings.TrimSpace(env.GoogleCredentials); target != "" {
|
||||
candidates = append(candidates, target)
|
||||
}
|
||||
kubeConfig := strings.TrimSpace(env.KubeConfig)
|
||||
if kubeConfig == "" && home != "" {
|
||||
kubeConfig = filepath.Join(home, ".kube", "config")
|
||||
}
|
||||
for _, path := range filepath.SplitList(kubeConfig) {
|
||||
if path = strings.TrimSpace(path); path != "" {
|
||||
candidates = append(candidates, path)
|
||||
}
|
||||
}
|
||||
allowRoots := normalizeProfilePaths(allowRead)
|
||||
out := make([]string, 0, len(candidates))
|
||||
for _, path := range normalizeProfilePaths(candidates) {
|
||||
@@ -206,11 +284,12 @@ func credentialDenyReadPathsIn(home string, googleCredentials string, allowRead
|
||||
|
||||
// userGitConfigReadPaths returns the user's global git config FILES so a
|
||||
// sandboxed git can read identity and config (user.name/email, aliases) instead
|
||||
// of failing with "unable to access ~/.gitconfig". It is deliberately the config
|
||||
// files only — not the ~/.config/git directory, which can hold an XDG credential
|
||||
// store — so credentials and the rest of HOME stay unreadable. Granted at the
|
||||
// macOS-seatbelt read rule (not the cross-platform PermissionProfile) so the
|
||||
// HOME-dependent paths don't leak into the platform-agnostic policy snapshot.
|
||||
// of failing with "unable to access ~/.gitconfig". On macOS, where reads are
|
||||
// allow-listed, granting only these files avoids exposing the surrounding
|
||||
// configuration directory. Linux uses a read-all profile with explicit
|
||||
// credential deny rules. The paths are granted at the macOS seatbelt read rule
|
||||
// rather than the cross-platform PermissionProfile so HOME-dependent paths
|
||||
// don't leak into the platform-agnostic policy snapshot.
|
||||
func userGitConfigReadPaths() []string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || strings.TrimSpace(home) == "" {
|
||||
|
||||
@@ -27,13 +27,10 @@ var fallbackSandboxRuntimes = struct {
|
||||
}{roots: make(map[string]string)}
|
||||
|
||||
type SandboxRuntime struct {
|
||||
Root string `json:"root,omitempty"`
|
||||
Home string `json:"home,omitempty"`
|
||||
Cache string `json:"cache,omitempty"`
|
||||
Config string `json:"config,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Temp string `json:"temp,omitempty"`
|
||||
Root string `json:"root,omitempty"`
|
||||
Cache string `json:"cache,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
Temp string `json:"temp,omitempty"`
|
||||
}
|
||||
|
||||
func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) {
|
||||
@@ -75,21 +72,15 @@ func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error)
|
||||
}
|
||||
}()
|
||||
runtimeState := SandboxRuntime{
|
||||
Root: root,
|
||||
Home: filepath.Join(root, "home"),
|
||||
Cache: filepath.Join(root, "cache"),
|
||||
Config: filepath.Join(root, "config"),
|
||||
Data: filepath.Join(root, "data"),
|
||||
State: filepath.Join(root, "state"),
|
||||
Temp: filepath.Join(root, "tmp"),
|
||||
Root: root,
|
||||
Cache: filepath.Join(root, "cache"),
|
||||
Data: filepath.Join(root, "data"),
|
||||
Temp: filepath.Join(root, "tmp"),
|
||||
}
|
||||
directories := []string{
|
||||
runtimeState.Root,
|
||||
runtimeState.Home,
|
||||
runtimeState.Cache,
|
||||
runtimeState.Config,
|
||||
runtimeState.Data,
|
||||
runtimeState.State,
|
||||
runtimeState.Temp,
|
||||
filepath.Join(runtimeState.Cache, "npm"),
|
||||
filepath.Join(runtimeState.Cache, "yarn"),
|
||||
@@ -207,16 +198,11 @@ func sandboxRuntimeEnvironment(env []string, runtimeState *SandboxRuntime) []str
|
||||
return env
|
||||
}
|
||||
overrides := []string{
|
||||
"HOME=" + runtimeState.Home,
|
||||
"XDG_CACHE_HOME=" + runtimeState.Cache,
|
||||
"XDG_CONFIG_HOME=" + runtimeState.Config,
|
||||
"XDG_DATA_HOME=" + runtimeState.Data,
|
||||
"XDG_STATE_HOME=" + runtimeState.State,
|
||||
"TMPDIR=" + runtimeState.Temp,
|
||||
"TMP=" + runtimeState.Temp,
|
||||
"TEMP=" + runtimeState.Temp,
|
||||
"npm_config_cache=" + filepath.Join(runtimeState.Cache, "npm"),
|
||||
"NPM_CONFIG_USERCONFIG=" + filepath.Join(runtimeState.Config, "npmrc"),
|
||||
"YARN_CACHE_FOLDER=" + filepath.Join(runtimeState.Cache, "yarn"),
|
||||
"COREPACK_HOME=" + filepath.Join(runtimeState.Cache, "corepack"),
|
||||
"PIP_CACHE_DIR=" + filepath.Join(runtimeState.Cache, "pip"),
|
||||
|
||||
@@ -2,7 +2,9 @@ package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -22,12 +24,18 @@ func TestPrepareSandboxRuntimeStaysOutsideWorkspace(t *testing.T) {
|
||||
if pathWithinRoot(workspace, runtimeState.Root) {
|
||||
t.Fatalf("runtime root %q must stay outside workspace %q", runtimeState.Root, workspace)
|
||||
}
|
||||
for _, path := range []string{runtimeState.Home, runtimeState.Cache, runtimeState.Config, runtimeState.Data, runtimeState.State, runtimeState.Temp} {
|
||||
for _, path := range []string{runtimeState.Cache, runtimeState.Data, runtimeState.Temp, filepath.Join(runtimeState.Data, "cargo")} {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || !info.IsDir() {
|
||||
t.Fatalf("managed runtime directory %q was not prepared: %v", path, err)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"home", "config", "state"} {
|
||||
path := filepath.Join(runtimeState.Root, name)
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("obsolete synthetic runtime directory %q exists: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareSandboxRuntimeCleansExpiredSibling(t *testing.T) {
|
||||
@@ -116,34 +124,63 @@ func TestCleanupSandboxRuntimeSkipsActiveLease(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxRuntimeEnvironmentUsesManagedState(t *testing.T) {
|
||||
func TestSandboxRuntimeEnvironmentPreservesUserConfiguration(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "runtime")
|
||||
runtimeState := SandboxRuntime{
|
||||
Root: root,
|
||||
Home: filepath.Join(root, "home"),
|
||||
Cache: filepath.Join(root, "cache"),
|
||||
Config: filepath.Join(root, "config"),
|
||||
Data: filepath.Join(root, "data"),
|
||||
State: filepath.Join(root, "state"),
|
||||
Temp: filepath.Join(root, "tmp"),
|
||||
Root: root,
|
||||
Cache: filepath.Join(root, "cache"),
|
||||
Data: filepath.Join(root, "data"),
|
||||
Temp: filepath.Join(root, "tmp"),
|
||||
}
|
||||
env := sandboxRuntimeEnvironment([]string{
|
||||
"HOME=/workspace",
|
||||
"HOME=/home/user",
|
||||
"XDG_CACHE_HOME=/host/cache",
|
||||
"XDG_CONFIG_HOME=/home/user/.config",
|
||||
"XDG_DATA_HOME=/home/user/.local/share",
|
||||
"XDG_STATE_HOME=/home/user/.local/state",
|
||||
"NPM_CONFIG_USERCONFIG=/home/user/.npmrc",
|
||||
"PATH=/usr/bin",
|
||||
}, &runtimeState)
|
||||
|
||||
for key, want := range map[string]string{
|
||||
"HOME": runtimeState.Home,
|
||||
"XDG_CACHE_HOME": runtimeState.Cache,
|
||||
"XDG_CONFIG_HOME": runtimeState.Config,
|
||||
"XDG_DATA_HOME": runtimeState.Data,
|
||||
"XDG_STATE_HOME": runtimeState.State,
|
||||
"TMPDIR": runtimeState.Temp,
|
||||
"npm_config_cache": filepath.Join(runtimeState.Cache, "npm"),
|
||||
"NPM_CONFIG_USERCONFIG": filepath.Join(runtimeState.Config, "npmrc"),
|
||||
"YARN_CACHE_FOLDER": filepath.Join(runtimeState.Cache, "yarn"),
|
||||
"COREPACK_HOME": filepath.Join(runtimeState.Cache, "corepack"),
|
||||
"HOME": "/home/user",
|
||||
"XDG_CONFIG_HOME": "/home/user/.config",
|
||||
"XDG_DATA_HOME": "/home/user/.local/share",
|
||||
"XDG_STATE_HOME": "/home/user/.local/state",
|
||||
"NPM_CONFIG_USERCONFIG": "/home/user/.npmrc",
|
||||
} {
|
||||
if got := envListValue(env, key, ""); got != want {
|
||||
t.Fatalf("%s = %q, want %q; env=%#v", key, got, want, env)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxRuntimeEnvironmentUsesManagedWritableState(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "runtime")
|
||||
runtimeState := SandboxRuntime{
|
||||
Root: root,
|
||||
Cache: filepath.Join(root, "cache"),
|
||||
Data: filepath.Join(root, "data"),
|
||||
Temp: filepath.Join(root, "tmp"),
|
||||
}
|
||||
env := sandboxRuntimeEnvironment([]string{
|
||||
"XDG_CACHE_HOME=/host/cache",
|
||||
"TMPDIR=/host/tmp",
|
||||
"PATH=/usr/bin",
|
||||
}, &runtimeState)
|
||||
|
||||
for key, want := range map[string]string{
|
||||
"XDG_CACHE_HOME": runtimeState.Cache,
|
||||
"TMPDIR": runtimeState.Temp,
|
||||
"TMP": runtimeState.Temp,
|
||||
"TEMP": runtimeState.Temp,
|
||||
"npm_config_cache": filepath.Join(runtimeState.Cache, "npm"),
|
||||
"YARN_CACHE_FOLDER": filepath.Join(runtimeState.Cache, "yarn"),
|
||||
"COREPACK_HOME": filepath.Join(runtimeState.Cache, "corepack"),
|
||||
"PIP_CACHE_DIR": filepath.Join(runtimeState.Cache, "pip"),
|
||||
"GOCACHE": filepath.Join(runtimeState.Cache, "go-build"),
|
||||
"GOMODCACHE": filepath.Join(runtimeState.Data, "go-mod"),
|
||||
"CARGO_HOME": filepath.Join(runtimeState.Data, "cargo"),
|
||||
} {
|
||||
if got := envListValue(env, key, ""); got != want {
|
||||
t.Fatalf("%s = %q, want %q; env=%#v", key, got, want, env)
|
||||
@@ -154,9 +191,93 @@ func TestSandboxRuntimeEnvironmentUsesManagedState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxRuntimeEnvironmentPreservesGitGlobalIgnore(t *testing.T) {
|
||||
git, err := exec.LookPath("git")
|
||||
if err != nil {
|
||||
t.Skip("git is unavailable")
|
||||
}
|
||||
root := t.TempDir()
|
||||
home := filepath.Join(root, "home")
|
||||
configHome := filepath.Join(home, ".config")
|
||||
repository := filepath.Join(root, "repository")
|
||||
runtimeRoot := filepath.Join(root, "runtime")
|
||||
for _, directory := range []string{
|
||||
filepath.Join(configHome, "git"),
|
||||
repository,
|
||||
filepath.Join(runtimeRoot, "cache"),
|
||||
filepath.Join(runtimeRoot, "data"),
|
||||
filepath.Join(runtimeRoot, "tmp"),
|
||||
} {
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configHome, "git", "ignore"), []byte("ignored.txt\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(repository, "ignored.txt"), []byte("ignored\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(repository, "visible.txt"), []byte("visible\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
runtimeState := SandboxRuntime{
|
||||
Root: runtimeRoot,
|
||||
Cache: filepath.Join(runtimeRoot, "cache"),
|
||||
Data: filepath.Join(runtimeRoot, "data"),
|
||||
Temp: filepath.Join(runtimeRoot, "tmp"),
|
||||
}
|
||||
inherited := append(os.Environ(),
|
||||
"GIT_CONFIG_GLOBAL="+filepath.Join(root, "missing-global-config"),
|
||||
"GIT_DIR="+filepath.Join(root, "wrong-repository"),
|
||||
"GIT_CONFIG_COUNT=1",
|
||||
"GIT_CONFIG_KEY_0=core.excludesFile",
|
||||
"GIT_CONFIG_VALUE_0="+filepath.Join(root, "missing-ignore"),
|
||||
)
|
||||
env := sandboxRuntimeEnvironment(upsertEnvList(withoutGitEnvironmentOverrides(inherited),
|
||||
"HOME="+home,
|
||||
"XDG_CONFIG_HOME="+configHome,
|
||||
"GIT_CONFIG_NOSYSTEM=1",
|
||||
"GIT_TERMINAL_PROMPT=0",
|
||||
), &runtimeState)
|
||||
runGit := func(args ...string) string {
|
||||
t.Helper()
|
||||
command := exec.Command(git, args...)
|
||||
command.Dir = repository
|
||||
command.Env = env
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output)
|
||||
}
|
||||
return string(output)
|
||||
}
|
||||
runGit("init", "--quiet")
|
||||
status := runGit("status", "--short", "--untracked-files=all")
|
||||
if strings.Contains(status, "ignored.txt") {
|
||||
t.Fatalf("global ignore was not honored:\n%s", status)
|
||||
}
|
||||
if !strings.Contains(status, "visible.txt") {
|
||||
t.Fatalf("non-ignored file is missing from status:\n%s", status)
|
||||
}
|
||||
}
|
||||
|
||||
func withoutGitEnvironmentOverrides(env []string) []string {
|
||||
out := make([]string, 0, len(env))
|
||||
for _, value := range env {
|
||||
key, _, ok := strings.Cut(value, "=")
|
||||
if ok && strings.HasPrefix(strings.ToUpper(key), "GIT_") {
|
||||
continue
|
||||
}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestEngineCommandPlanCarriesManagedRuntime(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
cacheRoot := t.TempDir()
|
||||
t.Setenv("HOME", filepath.Join(t.TempDir(), "home"))
|
||||
original := sandboxUserCacheDir
|
||||
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
|
||||
t.Cleanup(func() { sandboxUserCacheDir = original })
|
||||
@@ -189,8 +310,8 @@ func TestEngineCommandPlanCarriesManagedRuntime(t *testing.T) {
|
||||
} else if !inUse {
|
||||
t.Fatal("command plan runtime must be marked in use")
|
||||
}
|
||||
if got := envListValue(plan.Env, "HOME", ""); got != plan.PermissionProfile.Runtime.Home {
|
||||
t.Fatalf("HOME = %q, want managed home %q", got, plan.PermissionProfile.Runtime.Home)
|
||||
if got := envListValue(plan.Env, "HOME", ""); got != os.Getenv("HOME") {
|
||||
t.Fatalf("HOME = %q, want caller home %q", got, os.Getenv("HOME"))
|
||||
}
|
||||
foundWriteRoot := false
|
||||
for _, root := range plan.PermissionProfile.FileSystem.WriteRoots {
|
||||
|
||||
@@ -316,6 +316,26 @@ func TestExecCommandUsesStructuredAdapterDenial(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecCommandDoesNotInferNativeSandboxDenialFromOutput(t *testing.T) {
|
||||
result := execToolResult(execToolResultInput{
|
||||
commandText: "touch \"$HOME/probe\"",
|
||||
output: "touch: cannot touch '/home/user/probe': Read-only file system",
|
||||
exited: true,
|
||||
exitCode: 1,
|
||||
enforcement: execution.Enforcement{Backend: string(sandbox.BackendLinuxBwrap), Level: string(sandbox.EnforcementNative)},
|
||||
})
|
||||
|
||||
if result.ExecutionOutcome == nil || result.ExecutionOutcome.State != execution.StateFailed || result.ExecutionOutcome.Kind != execution.OutcomeApplicationFailure {
|
||||
t.Fatalf("execution outcome = %#v, want failed/application_failure", result.ExecutionOutcome)
|
||||
}
|
||||
if result.ExecutionOutcome.Denial != nil {
|
||||
t.Fatalf("command output created a typed denial: %#v", result.ExecutionOutcome.Denial)
|
||||
}
|
||||
if result.Meta[SandboxLikelyDeniedMeta] == "true" {
|
||||
t.Fatalf("command output created sandbox-denial metadata: %#v", result.Meta)
|
||||
}
|
||||
}
|
||||
|
||||
func executionRequestHasCapability(request execution.Request, kind execution.CapabilityKind, scope string) bool {
|
||||
for _, capability := range request.Capabilities {
|
||||
if capability.Kind == kind && (scope == "" || capability.Scope == scope) {
|
||||
|
||||
Reference in new issue
Block a user