From b03e82e3f2d3550fc1e1a2f5f72c0649f4bffd34 Mon Sep 17 00:00:00 2001 From: Abhisek Datta Date: Tue, 26 May 2026 21:06:57 +0530 Subject: [PATCH] feat: Add support for per-project sandbox overlays (#294) * feat: Add support for per-project sandbox overlays * chore: Maintain consistency with TUI experience * fix: Code review fixes --- cmd/sandbox/allow.go | 267 ++++++++++++++++++++++++++ cmd/sandbox/allow_test.go | 214 +++++++++++++++++++++ cmd/sandbox/project.go | 85 ++++++++ cmd/sandbox/project_list.go | 93 +++++++++ cmd/sandbox/project_list_test.go | 83 ++++++++ cmd/sandbox/project_reset.go | 64 ++++++ cmd/sandbox/project_reset_test.go | 68 +++++++ cmd/sandbox/project_show.go | 105 ++++++++++ cmd/sandbox/project_show_test.go | 90 +++++++++ cmd/sandbox/sandbox.go | 2 + config/config.go | 26 +++ config/sandbox_allow.go | 7 + config/sandbox_allow_test.go | 13 ++ config/sandbox_paths_test.go | 37 ++++ docs/sandbox.md | 42 ++++ internal/ui/sandbox_violation.go | 10 + internal/ui/sandbox_violation_test.go | 58 ++++++ sandbox/all_overrides.go | 27 +++ sandbox/all_overrides_test.go | 29 +++ sandbox/executor/apply.go | 42 ++++ sandbox/executor/apply_test.go | 55 ++++++ sandbox/overlay.go | 227 ++++++++++++++++++++++ sandbox/overlay_test.go | 172 +++++++++++++++++ sandbox/violation.go | 10 +- sandbox/violation_test.go | 12 ++ 25 files changed, 1837 insertions(+), 1 deletion(-) create mode 100644 cmd/sandbox/allow.go create mode 100644 cmd/sandbox/allow_test.go create mode 100644 cmd/sandbox/project.go create mode 100644 cmd/sandbox/project_list.go create mode 100644 cmd/sandbox/project_list_test.go create mode 100644 cmd/sandbox/project_reset.go create mode 100644 cmd/sandbox/project_reset_test.go create mode 100644 cmd/sandbox/project_show.go create mode 100644 cmd/sandbox/project_show_test.go create mode 100644 sandbox/all_overrides.go create mode 100644 sandbox/all_overrides_test.go create mode 100644 sandbox/overlay.go create mode 100644 sandbox/overlay_test.go diff --git a/cmd/sandbox/allow.go b/cmd/sandbox/allow.go new file mode 100644 index 0000000..9cf2d82 --- /dev/null +++ b/cmd/sandbox/allow.go @@ -0,0 +1,267 @@ +package sandbox + +import ( + "errors" + "fmt" + "io" + + "github.com/safedep/dry/usefulerror" + "github.com/safedep/pmg/config" + "github.com/safedep/pmg/errcodes" + "github.com/safedep/pmg/internal/ui" + pmgsandbox "github.com/safedep/pmg/sandbox" + "github.com/spf13/cobra" +) + +// allowFactory bundles the dependencies needed by `pmg sandbox allow`. Tests +// inject stubs. Production wiring uses defaultAllowFactory. +type allowFactory struct { + overlayDir func() string + repoRoot func() (string, error) + cache func() *pmgsandbox.ViolationCache + locked func() bool +} + +func defaultAllowFactory() allowFactory { + return allowFactory{ + overlayDir: func() string { return config.Get().SandboxOverlayDir() }, + repoRoot: resolveCurrentRepoRoot, + cache: func() *pmgsandbox.ViolationCache { + return pmgsandbox.NewViolationCache(config.Get().SandboxViolationCacheDir()) + }, + locked: func() bool { return config.Get().IsLocked() }, + } +} + +// NewAllowCommand returns the `pmg sandbox allow` command. +func NewAllowCommand() *cobra.Command { + return newAllowCommand(defaultAllowFactory()) +} + +type allowOptions struct { + last bool + all bool + force bool +} + +func newAllowCommand(factory allowFactory) *cobra.Command { + opts := &allowOptions{} + + cmd := &cobra.Command{ + Use: "allow [type=value …]", + Short: "Persist sandbox allowances for the current repository", + Long: "Save allowances into the current repo's sandbox project overlay so future PMG runs in this repo apply them automatically.\n\n" + + "Use --last to promote the primary violation from the most recent cached report,\n" + + "or --last --all to promote every safe FS/exec violation from that report.\n" + + "Manual entries (type=value …) accept any allow type and persist as-is.", + Example: " pmg sandbox allow write=./.astro net-bind=localhost:4321\n" + + " pmg sandbox allow --last --all", + Args: cobra.ArbitraryArgs, + SilenceErrors: false, + RunE: func(cmd *cobra.Command, args []string) error { + if err := runAllow(cmd.OutOrStdout(), args, opts, factory); err != nil { + return sandboxErrorExit(cmd, err) + } + return nil + }, + } + cmd.Flags().BoolVar(&opts.last, "last", false, "Promote allowances from the most recent cached violation report") + cmd.Flags().BoolVar(&opts.all, "all", false, "With --last: promote every safe FS/exec violation (default: primary only)") + cmd.Flags().BoolVar(&opts.force, "force", false, "Allow saving entries that touch sensitive paths (.env, .npmrc, .ssh, ...)") + return cmd +} + +func runAllow(out io.Writer, args []string, opts *allowOptions, factory allowFactory) error { + // ApplySandbox ignores overlays under global_lockdown, so refuse up-front + // instead of letting the user think their allowances took effect. + if factory.locked != nil && factory.locked() { + return usefulerror.NewUsefulError(). + WithCode(errcodes.PermissionDenied). + WithHumanError("sandbox overlays are disabled while global_lockdown is in force"). + WithHelp("This machine's PMG configuration is locked. Contact your administrator to change sandbox policy."). + Wrap(errors.New("sandbox overlay refused under global_lockdown")) + } + + if !opts.last && len(args) == 0 { + return invalidArgumentError( + "nothing to save: pass type=value arguments or --last", + "Example: `pmg sandbox allow write=./.astro` or `pmg sandbox allow --last --all`.", + ) + } + if opts.all && !opts.last { + return invalidArgumentError( + "--all requires --last", + "Use `pmg sandbox allow --last --all` to promote every FS/exec violation from the most recent cached report.", + ) + } + + repoRoot, err := factory.repoRoot() + if err != nil { + return wrapUseful(fmt.Errorf("resolve repo root: %w", err), + errcodes.Unknown, + "Could not determine the current repository root. Ensure the working directory is accessible.") + } + if repoRoot == "" { + return invalidArgumentError( + "could not determine current repository root", + "Change to a directory inside the repository, then retry.", + ) + } + + overlayDir := factory.overlayDir() + if overlayDir == "" { + return invalidArgumentError( + "sandbox overlay directory is not configured", + "Ensure the PMG config directory is writable, then retry.", + ) + } + + overlay, _, err := pmgsandbox.LoadOverlayForRepo(overlayDir, repoRoot) + if err != nil { + return wrapUseful(err, errcodes.Unknown, + "Could not read the existing project overlay. Check the file under SandboxOverlayDir().") + } + if overlay == nil { + overlay = &pmgsandbox.Overlay{} + } + + pending, err := collectAllowEntries(args, opts, factory) + if err != nil { + return err + } + if len(pending) == 0 { + return invalidArgumentError( + "no eligible allowances to save", + "Run a sandboxed command first to populate the violation cache, or pass explicit type=value arguments.", + ) + } + + if err := guardSensitiveEntries(pending, opts.force); err != nil { + return err + } + + addedEntries := make([]pmgsandbox.OverlayAllow, 0, len(pending)) + for _, entry := range pending { + if overlay.Add(entry) { + addedEntries = append(addedEntries, entry) + } + } + + if len(addedEntries) == 0 { + _, err := fmt.Fprintf(out, "%s\n", ui.Colors.Dim(fmt.Sprintf("No new allowances (%d already present).", len(pending)))) + return err + } + + path, err := pmgsandbox.SaveOverlay(overlayDir, repoRoot, overlay) + if err != nil { + return wrapUseful(err, errcodes.Unknown, + "Could not write the project overlay file. Check filesystem permissions for the overlay directory.") + } + + if _, err := fmt.Fprintf(out, "%s Saved %d allowance(s) for %s\n", ui.Colors.Green("✓"), len(addedEntries), repoRoot); err != nil { + return err + } + for _, e := range addedEntries { + if _, err := fmt.Fprintf(out, " %s %s=%s\n", ui.Colors.Dim("•"), e.Type, e.Value); err != nil { + return err + } + } + _, err = fmt.Fprintf(out, " %s %s\n", ui.Colors.Dim("overlay:"), ui.Colors.Dim(path)) + return err +} + +func collectAllowEntries(args []string, opts *allowOptions, factory allowFactory) ([]pmgsandbox.OverlayAllow, error) { + out := make([]pmgsandbox.OverlayAllow, 0, len(args)+1) + + for _, raw := range args { + override, err := config.ParseSingleOverride(raw) + if err != nil { + return nil, invalidArgumentError( + err.Error(), + "Each positional argument must be `type=value` (read, write, exec, net-connect, net-bind).", + ) + } + out = append(out, pmgsandbox.OverlayAllow{Type: override.Type, Value: override.Value}) + } + + if !opts.last { + return out, nil + } + + suggestions, err := suggestionsFromCache(factory.cache(), opts.all) + if err != nil { + return nil, err + } + for _, sugg := range suggestions { + typ := overrideTypeForKind(sugg.Kind) + if typ == "" { + continue + } + // Normalize through the manual-entry validator so stored values match + // how applyRuntimeOverrides resolves them against the policy. Skip on + // rejection so one bad target does not block the rest of the report. + normalized, err := config.ParseSingleOverride(fmt.Sprintf("%s=%s", typ, sugg.Target)) + if err != nil { + continue + } + out = append(out, pmgsandbox.OverlayAllow{Type: normalized.Type, Value: normalized.Value}) + } + return out, nil +} + +// suggestionsFromCache loads the latest cached violation report and returns +// the override suggestions to promote. When all is true, every safe FS/exec +// suggestion is returned, otherwise just the primary one (if any). +func suggestionsFromCache(cache *pmgsandbox.ViolationCache, all bool) ([]pmgsandbox.OverrideSuggestion, error) { + entry, err := cache.Latest() + if err != nil { + return nil, wrapUseful(err, errcodes.Unknown, + "Could not read the sandbox violation cache. Check the cache directory and retry.") + } + if entry == nil || entry.Record.Report == nil { + return nil, notFoundError( + "no cached violation report", + "Run a sandboxed command that hits a denial first, then retry `pmg sandbox allow --last`.", + ) + } + + if all { + return pmgsandbox.BuildAllOverrides(entry.Record.Report), nil + } + if override := pmgsandbox.BuildExplanation(entry.Record.Report).Override; override != nil { + return []pmgsandbox.OverrideSuggestion{*override}, nil + } + return nil, nil +} + +// overrideTypeForKind maps a ViolationKind to the matching SandboxAllowType. +// Only FS + exec are handled; network kinds are not classified by drivers and +// will never reach this function via BuildAllOverrides. +func overrideTypeForKind(kind pmgsandbox.ViolationKind) config.SandboxAllowType { + switch kind { + case pmgsandbox.ViolationKindFSRead: + return config.SandboxAllowRead + case pmgsandbox.ViolationKindFSWrite, pmgsandbox.ViolationKindFSDeleteOrRename: + return config.SandboxAllowWrite + case pmgsandbox.ViolationKindExec: + return config.SandboxAllowExec + default: + return "" + } +} + +func guardSensitiveEntries(entries []pmgsandbox.OverlayAllow, force bool) error { + if force { + return nil + } + for _, e := range entries { + if pmgsandbox.IsSensitiveProjectTarget(e.Value) { + return usefulerror.NewUsefulError(). + WithCode(errcodes.PermissionDenied). + WithHumanError(fmt.Sprintf("refusing to allow sensitive target: %s", e.Value)). + WithHelp("Re-run with --force to allow saving this entry, after verifying the path is intentional."). + Wrap(errors.New("sensitive target")) + } + } + return nil +} diff --git a/cmd/sandbox/allow_test.go b/cmd/sandbox/allow_test.go new file mode 100644 index 0000000..06c2537 --- /dev/null +++ b/cmd/sandbox/allow_test.go @@ -0,0 +1,214 @@ +package sandbox + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/safedep/pmg/config" + pmgsandbox "github.com/safedep/pmg/sandbox" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type allowDeps struct { + overlayDir string + repoRoot string + cache *pmgsandbox.ViolationCache +} + +func newAllowDeps(t *testing.T) *allowDeps { + t.Helper() + return &allowDeps{ + overlayDir: t.TempDir(), + repoRoot: filepath.Clean(t.TempDir()), + cache: pmgsandbox.NewViolationCache(t.TempDir()), + } +} + +func runAllowCmd(t *testing.T, deps *allowDeps, args ...string) (string, string, error) { + t.Helper() + cmd := newAllowCommand(allowFactory{ + overlayDir: func() string { return deps.overlayDir }, + repoRoot: func() (string, error) { return deps.repoRoot, nil }, + cache: func() *pmgsandbox.ViolationCache { return deps.cache }, + }) + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs(args) + err := cmd.Execute() + return stdout.String(), stderr.String(), err +} + +func TestAllow_PositionalSaved(t *testing.T) { + deps := newAllowDeps(t) + stdout, stderr, err := runAllowCmd(t, deps, "net-bind=localhost:4321") + require.NoError(t, err, "stderr: %s", stderr) + assert.Contains(t, stdout, "Saved 1 allowance") + + overlay, _, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir, deps.repoRoot) + require.NoError(t, err) + require.NotNil(t, overlay) + require.Len(t, overlay.Allow, 1) + assert.Equal(t, config.SandboxAllowNetBind, overlay.Allow[0].Type) + assert.Equal(t, "localhost:4321", overlay.Allow[0].Value) +} + +func TestAllow_LastAllPromotesFromCache(t *testing.T) { + deps := newAllowDeps(t) + writeTarget := filepath.Join(deps.repoRoot, ".astro") + execTarget := "/usr/bin/sh" + report := &pmgsandbox.ViolationReport{ + SandboxName: pmgsandbox.DriverSeatbelt, + Violations: []pmgsandbox.Violation{ + {Kind: pmgsandbox.ViolationKindFSWrite, Target: writeTarget}, + {Kind: pmgsandbox.ViolationKindExec, Target: execTarget}, + }, + } + _, err := deps.cache.Write(report) + require.NoError(t, err) + + _, stderr, err := runAllowCmd(t, deps, "--last", "--all") + require.NoError(t, err, "stderr: %s", stderr) + + overlay, _, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir, deps.repoRoot) + require.NoError(t, err) + require.NotNil(t, overlay) + assert.ElementsMatch(t, []pmgsandbox.OverlayAllow{ + {Type: config.SandboxAllowWrite, Value: writeTarget}, + {Type: config.SandboxAllowExec, Value: execTarget}, + }, overlay.Allow) +} + +func TestAllow_LastPromotesPrimaryOnly(t *testing.T) { + deps := newAllowDeps(t) + writeTarget := filepath.Join(deps.repoRoot, ".astro") + execTarget := "/usr/bin/sh" + report := &pmgsandbox.ViolationReport{ + SandboxName: pmgsandbox.DriverSeatbelt, + Violations: []pmgsandbox.Violation{ + {Kind: pmgsandbox.ViolationKindFSWrite, Target: writeTarget}, + {Kind: pmgsandbox.ViolationKindExec, Target: execTarget}, + }, + } + _, err := deps.cache.Write(report) + require.NoError(t, err) + + _, _, err = runAllowCmd(t, deps, "--last") + require.NoError(t, err) + + overlay, _, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir, deps.repoRoot) + require.NoError(t, err) + require.NotNil(t, overlay) + require.Len(t, overlay.Allow, 1) +} + +func TestAllow_LastNoCacheErrors(t *testing.T) { + deps := newAllowDeps(t) + _, _, err := runAllowCmd(t, deps, "--last") + require.Error(t, err) +} + +func TestAllow_RefusesSensitiveTargetWithoutForce(t *testing.T) { + deps := newAllowDeps(t) + secret := filepath.Join(deps.repoRoot, ".env") + _, _, err := runAllowCmd(t, deps, "read="+secret) + require.Error(t, err) + assert.Contains(t, err.Error(), "sensitive target") + + overlay, _, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir, deps.repoRoot) + require.NoError(t, err) + assert.Nil(t, overlay) +} + +func TestAllow_SensitiveTargetWithForceSaves(t *testing.T) { + deps := newAllowDeps(t) + secret := filepath.Join(deps.repoRoot, ".env") + _, _, err := runAllowCmd(t, deps, "--force", "read="+secret) + require.NoError(t, err) + overlay, _, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir, deps.repoRoot) + require.NoError(t, err) + require.NotNil(t, overlay) + assert.Len(t, overlay.Allow, 1) +} + +func TestAllow_Dedupes(t *testing.T) { + deps := newAllowDeps(t) + _, _, err := runAllowCmd(t, deps, "exec=/usr/bin/sh") + require.NoError(t, err) + stdout, _, err := runAllowCmd(t, deps, "exec=/usr/bin/sh") + require.NoError(t, err) + assert.Contains(t, stdout, "No new allowances") + + overlay, _, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir, deps.repoRoot) + require.NoError(t, err) + require.NotNil(t, overlay) + assert.Len(t, overlay.Allow, 1) +} + +func TestAllow_NothingToSaveErrors(t *testing.T) { + deps := newAllowDeps(t) + _, _, err := runAllowCmd(t, deps) + require.Error(t, err) +} + +func TestAllow_AllWithoutLastErrors(t *testing.T) { + deps := newAllowDeps(t) + _, _, err := runAllowCmd(t, deps, "--all", "exec=/usr/bin/sh") + require.Error(t, err) +} + +func TestAllow_InvalidPositionalRejected(t *testing.T) { + deps := newAllowDeps(t) + _, _, err := runAllowCmd(t, deps, "garbage") + require.Error(t, err) +} + +func TestAllow_RefusedUnderLockdown(t *testing.T) { + deps := newAllowDeps(t) + cmd := newAllowCommand(allowFactory{ + overlayDir: func() string { return deps.overlayDir }, + repoRoot: func() (string, error) { return deps.repoRoot, nil }, + cache: func() *pmgsandbox.ViolationCache { return deps.cache }, + locked: func() bool { return true }, + }) + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"exec=/usr/bin/sh"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "global_lockdown") + + overlay, _, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir, deps.repoRoot) + require.NoError(t, err) + assert.Nil(t, overlay) +} + +func TestAllow_LastNormalizesRelativeTargets(t *testing.T) { + deps := newAllowDeps(t) + cwd, err := os.Getwd() + require.NoError(t, err) + relTarget := "./.astro" + wantAbs := filepath.Join(cwd, ".astro") + + report := &pmgsandbox.ViolationReport{ + SandboxName: pmgsandbox.DriverSeatbelt, + Violations: []pmgsandbox.Violation{ + {Kind: pmgsandbox.ViolationKindFSWrite, Target: relTarget}, + }, + } + _, err = deps.cache.Write(report) + require.NoError(t, err) + + _, _, err = runAllowCmd(t, deps, "--last") + require.NoError(t, err) + + overlay, _, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir, deps.repoRoot) + require.NoError(t, err) + require.NotNil(t, overlay) + require.Len(t, overlay.Allow, 1) + assert.Equal(t, wantAbs, overlay.Allow[0].Value, "cache-derived target should be normalized to absolute") +} diff --git a/cmd/sandbox/project.go b/cmd/sandbox/project.go new file mode 100644 index 0000000..3539f47 --- /dev/null +++ b/cmd/sandbox/project.go @@ -0,0 +1,85 @@ +package sandbox + +import ( + "fmt" + "io" + "os" + + "github.com/safedep/pmg/config" + "github.com/safedep/pmg/internal/ui" + pmgsandbox "github.com/safedep/pmg/sandbox" + "github.com/spf13/cobra" +) + +// projectDeps is the dependency surface for `pmg sandbox project` subcommands. +type projectDeps struct { + overlayDir func() string + repoRoot func() (string, error) +} + +func defaultProjectDeps() projectDeps { + return projectDeps{ + overlayDir: func() string { return config.Get().SandboxOverlayDir() }, + repoRoot: resolveCurrentRepoRoot, + } +} + +// resolveCurrentRepoRoot returns the git toplevel for the current working +// directory, falling back to the cwd itself when not inside a git work tree. +func resolveCurrentRepoRoot() (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", err + } + return pmgsandbox.ResolveRepoRoot(cwd) +} + +// renderProjectSection prints the standard PMG section header +// (blank line, cyan title, dashed separator). +func renderProjectSection(out io.Writer, title string) error { + if _, err := fmt.Fprintln(out); err != nil { + return err + } + if _, err := fmt.Fprintln(out, ui.Colors.Cyan(title)); err != nil { + return err + } + _, err := fmt.Fprintln(out, ui.Colors.Normal("--------------------")) + return err +} + +// renderKeyValueBlock prints aligned "Key: value" pairs with bold keys. Empty +// values are skipped so optional fields do not produce empty lines. +func renderKeyValueBlock(out io.Writer, entries [][2]string) error { + width := 0 + for _, e := range entries { + if e[1] != "" && len(e[0]) > width { + width = len(e[0]) + } + } + for _, e := range entries { + if e[1] == "" { + continue + } + label := fmt.Sprintf("%-*s", width+1, e[0]+":") + if _, err := fmt.Fprintf(out, "%s %s\n", ui.Colors.Bold(label), e[1]); err != nil { + return err + } + } + return nil +} + +// NewProjectCommand returns the `pmg sandbox project` parent command. +func NewProjectCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "project", + Short: "Inspect and manage the current repository's sandbox overlay", + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + deps := defaultProjectDeps() + cmd.AddCommand(newProjectShowCommand(deps)) + cmd.AddCommand(newProjectResetCommand(deps)) + cmd.AddCommand(newProjectListCommand(deps)) + return cmd +} diff --git a/cmd/sandbox/project_list.go b/cmd/sandbox/project_list.go new file mode 100644 index 0000000..5b4ee81 --- /dev/null +++ b/cmd/sandbox/project_list.go @@ -0,0 +1,93 @@ +package sandbox + +import ( + "fmt" + "io" + "time" + + "github.com/safedep/pmg/errcodes" + "github.com/safedep/pmg/internal/ui" + pmgsandbox "github.com/safedep/pmg/sandbox" + "github.com/spf13/cobra" +) + +type projectListOptions struct { + jsonOut bool +} + +func newProjectListCommand(deps projectDeps) *cobra.Command { + opts := &projectListOptions{} + + cmd := &cobra.Command{ + Use: "list", + Short: "List all known sandbox project overlays", + Args: cobra.NoArgs, + SilenceErrors: false, + RunE: func(cmd *cobra.Command, args []string) error { + if err := runProjectList(cmd.OutOrStdout(), cmd.ErrOrStderr(), opts, deps); err != nil { + return sandboxErrorExit(cmd, err) + } + return nil + }, + } + cmd.Flags().BoolVar(&opts.jsonOut, "json", false, "Emit entries as JSON") + return cmd +} + +type projectListJSONEntry struct { + RepoRoot string `json:"repo_root"` + Entries int `json:"entries"` + Path string `json:"path"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type projectListJSONOutput struct { + Entries []projectListJSONEntry `json:"entries"` +} + +func runProjectList(out, _ io.Writer, opts *projectListOptions, deps projectDeps) error { + overlays, err := pmgsandbox.ListOverlays(deps.overlayDir()) + if err != nil { + return wrapUseful(err, errcodes.Unknown, "Could not list project overlays.") + } + + if opts.jsonOut { + payload := projectListJSONOutput{Entries: make([]projectListJSONEntry, 0, len(overlays))} + for _, e := range overlays { + item := projectListJSONEntry{ + RepoRoot: e.Overlay.RepoRoot, + Entries: len(e.Overlay.Allow), + Path: e.Path, + } + if !e.Overlay.UpdatedAt.IsZero() { + item.UpdatedAt = e.Overlay.UpdatedAt.UTC().Format(time.RFC3339) + } + payload.Entries = append(payload.Entries, item) + } + return writeJSONIndent(out, payload) + } + + if err := renderProjectSection(out, "Project Overlays"); err != nil { + return err + } + + if len(overlays) == 0 { + _, err := fmt.Fprintln(out, ui.Colors.Dim("No project overlays.")) + return err + } + + rows := [][]string{{ + ui.Colors.Bold("REPO"), + ui.Colors.Bold("ENTRIES"), + ui.Colors.Bold("UPDATED"), + }} + dash := ui.Colors.Dim("—") + for _, e := range overlays { + updated := dash + if !e.Overlay.UpdatedAt.IsZero() { + updated = e.Overlay.UpdatedAt.UTC().Format(time.RFC3339) + } + rows = append(rows, []string{e.Overlay.RepoRoot, fmt.Sprintf("%d", len(e.Overlay.Allow)), updated}) + } + return renderTable(out, rows, nil) +} diff --git a/cmd/sandbox/project_list_test.go b/cmd/sandbox/project_list_test.go new file mode 100644 index 0000000..a4da98d --- /dev/null +++ b/cmd/sandbox/project_list_test.go @@ -0,0 +1,83 @@ +package sandbox + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/safedep/pmg/config" + pmgsandbox "github.com/safedep/pmg/sandbox" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func execProjectList(t *testing.T, overlayDir string, args ...string) (string, string, error) { + t.Helper() + cmd := newProjectListCommand(projectDeps{ + overlayDir: func() string { return overlayDir }, + }) + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs(args) + err := cmd.Execute() + return stdout.String(), stderr.String(), err +} + +func TestProjectList_Empty(t *testing.T) { + dir := t.TempDir() + stdout, _, err := execProjectList(t, dir) + require.NoError(t, err) + assert.Contains(t, stdout, "No project overlays") +} + +func TestProjectList_Human(t *testing.T) { + dir := t.TempDir() + for _, repo := range []string{"/repo/a", "/repo/b"} { + _, err := pmgsandbox.SaveOverlay(dir, repo, &pmgsandbox.Overlay{ + Allow: []pmgsandbox.OverlayAllow{{Type: config.SandboxAllowExec, Value: "/bin/x"}}, + }) + require.NoError(t, err) + } + + stdout, _, err := execProjectList(t, dir) + require.NoError(t, err) + assert.Contains(t, stdout, "/repo/a") + assert.Contains(t, stdout, "/repo/b") +} + +func TestProjectList_JSON(t *testing.T) { + dir := t.TempDir() + for _, repo := range []string{"/repo/a", "/repo/b"} { + _, err := pmgsandbox.SaveOverlay(dir, repo, &pmgsandbox.Overlay{ + Allow: []pmgsandbox.OverlayAllow{{Type: config.SandboxAllowExec, Value: "/bin/x"}}, + }) + require.NoError(t, err) + } + + stdout, _, err := execProjectList(t, dir, "--json") + require.NoError(t, err) + var payload struct { + Entries []struct { + RepoRoot string `json:"repo_root"` + Entries int `json:"entries"` + Path string `json:"path"` + } `json:"entries"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &payload)) + require.Len(t, payload.Entries, 2) + assert.Equal(t, "/repo/a", payload.Entries[0].RepoRoot) + assert.Equal(t, 1, payload.Entries[0].Entries) +} + +func TestProjectList_JSONEmpty(t *testing.T) { + dir := t.TempDir() + stdout, _, err := execProjectList(t, dir, "--json") + require.NoError(t, err) + var payload struct { + Entries []any `json:"entries"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &payload)) + assert.NotNil(t, payload.Entries) + assert.Empty(t, payload.Entries) +} diff --git a/cmd/sandbox/project_reset.go b/cmd/sandbox/project_reset.go new file mode 100644 index 0000000..a34d021 --- /dev/null +++ b/cmd/sandbox/project_reset.go @@ -0,0 +1,64 @@ +package sandbox + +import ( + "fmt" + "io" + + "github.com/safedep/pmg/errcodes" + "github.com/safedep/pmg/internal/ui" + pmgsandbox "github.com/safedep/pmg/sandbox" + "github.com/spf13/cobra" +) + +type projectResetOptions struct { + yes bool +} + +func newProjectResetCommand(deps projectDeps) *cobra.Command { + opts := &projectResetOptions{} + + cmd := &cobra.Command{ + Use: "reset", + Short: "Delete the sandbox overlay for the current repository", + Args: cobra.NoArgs, + SilenceErrors: false, + RunE: func(cmd *cobra.Command, args []string) error { + if err := runProjectReset(cmd.OutOrStdout(), opts, deps); err != nil { + return sandboxErrorExit(cmd, err) + } + return nil + }, + } + cmd.Flags().BoolVar(&opts.yes, "yes", false, "Confirm deletion without an interactive prompt") + return cmd +} + +func runProjectReset(out io.Writer, opts *projectResetOptions, deps projectDeps) error { + if !opts.yes { + return invalidArgumentError( + "refusing to reset without --yes", + "Re-run with `pmg sandbox project reset --yes` to delete the overlay.", + ) + } + + repoRoot, err := deps.repoRoot() + if err != nil { + return wrapUseful(err, errcodes.Unknown, "Could not determine the current repository root.") + } + overlay, path, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir(), repoRoot) + if err != nil { + return wrapUseful(err, errcodes.Unknown, "Could not read the project overlay file.") + } + if overlay == nil { + _, err := fmt.Fprintf(out, "%s\n", ui.Colors.Dim(fmt.Sprintf("No project overlay for %s", repoRoot))) + return err + } + if err := pmgsandbox.DeleteOverlayForRepo(deps.overlayDir(), repoRoot); err != nil { + return wrapUseful(err, errcodes.Unknown, "Could not delete the project overlay file.") + } + if _, err := fmt.Fprintf(out, "%s Deleted overlay for %s\n", ui.Colors.Green("✓"), repoRoot); err != nil { + return err + } + _, err = fmt.Fprintf(out, " %s\n", ui.Colors.Dim(path)) + return err +} diff --git a/cmd/sandbox/project_reset_test.go b/cmd/sandbox/project_reset_test.go new file mode 100644 index 0000000..06201e9 --- /dev/null +++ b/cmd/sandbox/project_reset_test.go @@ -0,0 +1,68 @@ +package sandbox + +import ( + "bytes" + "path/filepath" + "testing" + + "github.com/safedep/pmg/config" + pmgsandbox "github.com/safedep/pmg/sandbox" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func execProjectReset(t *testing.T, overlayDir, repoRoot string, args ...string) (string, string, error) { + t.Helper() + cmd := newProjectResetCommand(projectDeps{ + overlayDir: func() string { return overlayDir }, + repoRoot: func() (string, error) { return repoRoot, nil }, + }) + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs(args) + err := cmd.Execute() + return stdout.String(), stderr.String(), err +} + +func TestProjectReset_RequiresYes(t *testing.T) { + dir := t.TempDir() + repo := filepath.Clean(t.TempDir()) + _, err := pmgsandbox.SaveOverlay(dir, repo, &pmgsandbox.Overlay{ + Allow: []pmgsandbox.OverlayAllow{{Type: config.SandboxAllowExec, Value: "/x"}}, + }) + require.NoError(t, err) + + _, _, err = execProjectReset(t, dir, repo) + require.Error(t, err) + + overlay, _, err := pmgsandbox.LoadOverlayForRepo(dir, repo) + require.NoError(t, err) + require.NotNil(t, overlay) +} + +func TestProjectReset_WithYesDeletes(t *testing.T) { + dir := t.TempDir() + repo := filepath.Clean(t.TempDir()) + _, err := pmgsandbox.SaveOverlay(dir, repo, &pmgsandbox.Overlay{ + Allow: []pmgsandbox.OverlayAllow{{Type: config.SandboxAllowExec, Value: "/x"}}, + }) + require.NoError(t, err) + + stdout, _, err := execProjectReset(t, dir, repo, "--yes") + require.NoError(t, err) + assert.Contains(t, stdout, repo) + assert.Contains(t, stdout, "Deleted") + + overlay, _, err := pmgsandbox.LoadOverlayForRepo(dir, repo) + require.NoError(t, err) + assert.Nil(t, overlay) +} + +func TestProjectReset_MissingIsNoop(t *testing.T) { + dir := t.TempDir() + repo := filepath.Clean(t.TempDir()) + stdout, _, err := execProjectReset(t, dir, repo, "--yes") + require.NoError(t, err) + assert.Contains(t, stdout, "No project overlay") +} diff --git a/cmd/sandbox/project_show.go b/cmd/sandbox/project_show.go new file mode 100644 index 0000000..32f3586 --- /dev/null +++ b/cmd/sandbox/project_show.go @@ -0,0 +1,105 @@ +package sandbox + +import ( + "fmt" + "io" + "time" + + "github.com/safedep/pmg/errcodes" + "github.com/safedep/pmg/internal/ui" + pmgsandbox "github.com/safedep/pmg/sandbox" + "github.com/spf13/cobra" +) + +type projectShowOptions struct { + jsonOut bool +} + +func newProjectShowCommand(deps projectDeps) *cobra.Command { + opts := &projectShowOptions{} + + cmd := &cobra.Command{ + Use: "show", + Short: "Show the saved sandbox overlay for the current repository", + SilenceErrors: false, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := runProjectShow(cmd.OutOrStdout(), opts, deps); err != nil { + return sandboxErrorExit(cmd, err) + } + return nil + }, + } + cmd.Flags().BoolVar(&opts.jsonOut, "json", false, "Emit overlay as JSON") + return cmd +} + +type projectShowJSONAllow struct { + Type string `json:"type"` + Value string `json:"value"` +} + +type projectShowJSONPayload struct { + RepoRoot string `json:"repo_root"` + Path string `json:"path,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Allow []projectShowJSONAllow `json:"allow"` +} + +func runProjectShow(out io.Writer, opts *projectShowOptions, deps projectDeps) error { + repoRoot, err := deps.repoRoot() + if err != nil { + return wrapUseful(err, errcodes.Unknown, "Could not determine the current repository root.") + } + overlay, path, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir(), repoRoot) + if err != nil { + return wrapUseful(err, errcodes.Unknown, "Could not read the project overlay file.") + } + + if opts.jsonOut { + payload := projectShowJSONPayload{RepoRoot: repoRoot, Path: path, Allow: []projectShowJSONAllow{}} + if overlay != nil { + for _, a := range overlay.Allow { + payload.Allow = append(payload.Allow, projectShowJSONAllow{Type: string(a.Type), Value: a.Value}) + } + if !overlay.UpdatedAt.IsZero() { + payload.UpdatedAt = overlay.UpdatedAt.UTC().Format(time.RFC3339) + } + } + return writeJSONIndent(out, payload) + } + + if err := renderProjectSection(out, "Project Overlay"); err != nil { + return err + } + + if overlay == nil || len(overlay.Allow) == 0 { + _, err := fmt.Fprintf(out, "%s\n", ui.Colors.Dim(fmt.Sprintf("No project overlay for %s", repoRoot))) + return err + } + + updated := "" + if !overlay.UpdatedAt.IsZero() { + updated = overlay.UpdatedAt.UTC().Format(time.RFC3339) + } + if err := renderKeyValueBlock(out, [][2]string{ + {"Repo", repoRoot}, + {"Overlay", path}, + {"Updated", updated}, + }); err != nil { + return err + } + if _, err := fmt.Fprintln(out); err != nil { + return err + } + + rows := make([][]string, 0, len(overlay.Allow)+1) + rows = append(rows, []string{ + ui.Colors.Bold("TYPE"), + ui.Colors.Bold("VALUE"), + }) + for _, a := range overlay.Allow { + rows = append(rows, []string{string(a.Type), a.Value}) + } + return renderTable(out, rows, nil) +} diff --git a/cmd/sandbox/project_show_test.go b/cmd/sandbox/project_show_test.go new file mode 100644 index 0000000..a8cffb0 --- /dev/null +++ b/cmd/sandbox/project_show_test.go @@ -0,0 +1,90 @@ +package sandbox + +import ( + "bytes" + "encoding/json" + "path/filepath" + "testing" + + "github.com/safedep/pmg/config" + pmgsandbox "github.com/safedep/pmg/sandbox" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func execProjectShow(t *testing.T, overlayDir, repoRoot string, args ...string) (string, string, error) { + t.Helper() + cmd := newProjectShowCommand(projectDeps{ + overlayDir: func() string { return overlayDir }, + repoRoot: func() (string, error) { return repoRoot, nil }, + }) + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs(args) + err := cmd.Execute() + return stdout.String(), stderr.String(), err +} + +func TestProjectShow_Empty(t *testing.T) { + dir := t.TempDir() + repo := filepath.Clean(t.TempDir()) + stdout, _, err := execProjectShow(t, dir, repo) + require.NoError(t, err) + assert.Contains(t, stdout, repo) + assert.Contains(t, stdout, "No project overlay") +} + +func TestProjectShow_WithEntries(t *testing.T) { + dir := t.TempDir() + repo := filepath.Clean(t.TempDir()) + _, err := pmgsandbox.SaveOverlay(dir, repo, &pmgsandbox.Overlay{ + Allow: []pmgsandbox.OverlayAllow{{Type: config.SandboxAllowWrite, Value: "/repo/.astro"}}, + }) + require.NoError(t, err) + + stdout, _, err := execProjectShow(t, dir, repo) + require.NoError(t, err) + assert.Contains(t, stdout, repo) + assert.Contains(t, stdout, "write") + assert.Contains(t, stdout, "/repo/.astro") +} + +func TestProjectShow_JSON(t *testing.T) { + dir := t.TempDir() + repo := filepath.Clean(t.TempDir()) + _, err := pmgsandbox.SaveOverlay(dir, repo, &pmgsandbox.Overlay{ + Allow: []pmgsandbox.OverlayAllow{{Type: config.SandboxAllowExec, Value: "/usr/bin/sh"}}, + }) + require.NoError(t, err) + + stdout, _, err := execProjectShow(t, dir, repo, "--json") + require.NoError(t, err) + var payload struct { + RepoRoot string `json:"repo_root"` + Path string `json:"path"` + Allow []struct { + Type string `json:"type"` + Value string `json:"value"` + } `json:"allow"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &payload)) + assert.Equal(t, repo, payload.RepoRoot) + assert.NotEmpty(t, payload.Path) + require.Len(t, payload.Allow, 1) + assert.Equal(t, "exec", payload.Allow[0].Type) + assert.Equal(t, "/usr/bin/sh", payload.Allow[0].Value) +} + +func TestProjectShow_JSONEmpty(t *testing.T) { + dir := t.TempDir() + repo := filepath.Clean(t.TempDir()) + stdout, _, err := execProjectShow(t, dir, repo, "--json") + require.NoError(t, err) + var payload struct { + Allow []any `json:"allow"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &payload)) + assert.NotNil(t, payload.Allow) + assert.Empty(t, payload.Allow) +} diff --git a/cmd/sandbox/sandbox.go b/cmd/sandbox/sandbox.go index bf5e48c..19ce75f 100644 --- a/cmd/sandbox/sandbox.go +++ b/cmd/sandbox/sandbox.go @@ -22,5 +22,7 @@ func NewCommand() *cobra.Command { cmd.AddCommand(NewProfileCommand()) cmd.AddCommand(NewExplainCommand()) cmd.AddCommand(NewViolationsCommand()) + cmd.AddCommand(NewAllowCommand()) + cmd.AddCommand(NewProjectCommand()) return cmd } diff --git a/config/config.go b/config/config.go index d0017b6..124b347 100644 --- a/config/config.go +++ b/config/config.go @@ -43,6 +43,10 @@ const ( // Default sandbox profile directory is relative to the config directory. pmgDefaultSandboxProfileDir = "sandbox/profiles" + // Default sandbox overlay directory is relative to the config directory. + // Per-repo overlays persisted by `pmg sandbox allow` live here. + pmgDefaultSandboxOverlayDir = "sandbox/overlays" + // Default sandbox violation cache directory is relative to the cache root. pmgDefaultSandboxViolationCacheDir = "sandbox/violations" @@ -205,6 +209,7 @@ type RuntimeConfig struct { configLocked bool // global file present and opted into lockdown (global_lockdown: true) eventLogDir string sandboxProfileDir string + sandboxOverlayDir string sandboxViolationCacheDir string viper *viper.Viper } @@ -270,6 +275,11 @@ func (r *RuntimeConfig) SandboxProfileDir() string { return r.sandboxProfileDir } +// SandboxOverlayDir returns the path to the per-repo sandbox overlay directory. +func (r *RuntimeConfig) SandboxOverlayDir() string { + return r.sandboxOverlayDir +} + // SandboxViolationCacheDir returns the path to the sandbox violation cache directory. func (r *RuntimeConfig) SandboxViolationCacheDir() string { return r.sandboxViolationCacheDir @@ -399,11 +409,17 @@ func initConfig() { panic(fmt.Errorf("failed to get sandbox violation cache directory: %w", err)) } + sandboxOverlayDir, err := sandboxOverlayDir() + if err != nil { + panic(fmt.Errorf("failed to get sandbox overlay directory: %w", err)) + } + globalConfig.configDir = configDir globalConfig.configFilePath = activeConfigPath globalConfig.userConfigFilePath = userConfigPath globalConfig.eventLogDir = eventLogDir globalConfig.sandboxProfileDir = sandboxProfileDir + globalConfig.sandboxOverlayDir = sandboxOverlayDir globalConfig.sandboxViolationCacheDir = sandboxViolationCacheDir // A globally managed config enforces lockdown only when it opts in via @@ -581,6 +597,16 @@ func sandboxProfileDir() (string, error) { return filepath.Join(configDir, pmgDefaultSandboxProfileDir), nil } +// sandboxOverlayDir computes the path to the per-repo sandbox overlay directory. +func sandboxOverlayDir() (string, error) { + configDir, err := configDir() + if err != nil { + return "", fmt.Errorf("failed to get config directory: %w", err) + } + + return filepath.Join(configDir, pmgDefaultSandboxOverlayDir), nil +} + // sandboxViolationCacheDir computes the path to the sandbox violation cache directory. func sandboxViolationCacheDir() (string, error) { cacheDir, err := cacheDir() diff --git a/config/sandbox_allow.go b/config/sandbox_allow.go index f3443e9..89b4eee 100644 --- a/config/sandbox_allow.go +++ b/config/sandbox_allow.go @@ -35,6 +35,13 @@ func parseSandboxAllowOverrides(raw []string) ([]SandboxAllowOverride, error) { return overrides, nil } +// ParseSingleOverride is the exported entry point for callers outside this +// package (e.g. cmd handlers persisting overlay entries). It mirrors the +// validation used for --sandbox-allow flag values. +func ParseSingleOverride(raw string) (SandboxAllowOverride, error) { + return parseSingleOverride(raw) +} + // parseSingleOverride parses and validates a single "type=value" string. func parseSingleOverride(raw string) (SandboxAllowOverride, error) { // Split on first '=' only to handle values containing '=' diff --git a/config/sandbox_allow_test.go b/config/sandbox_allow_test.go index c138ed8..ea9765c 100644 --- a/config/sandbox_allow_test.go +++ b/config/sandbox_allow_test.go @@ -254,3 +254,16 @@ func TestParseSandboxAllowOverrides_FirstErrorStops(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "missing '=' separator") } + +func TestParseSingleOverride_Exported(t *testing.T) { + got, err := ParseSingleOverride("net-bind=localhost:4321") + require.NoError(t, err) + assert.Equal(t, SandboxAllowNetBind, got.Type) + assert.Equal(t, "localhost:4321", got.Value) + assert.Equal(t, "net-bind=localhost:4321", got.Raw) +} + +func TestParseSingleOverride_ExportedRejectsInvalid(t *testing.T) { + _, err := ParseSingleOverride("garbage") + assert.Error(t, err) +} diff --git a/config/sandbox_paths_test.go b/config/sandbox_paths_test.go index 947652b..07c6b0d 100644 --- a/config/sandbox_paths_test.go +++ b/config/sandbox_paths_test.go @@ -61,6 +61,43 @@ func TestSandboxProfileDirRespectsXDGConfigHome(t *testing.T) { assert.Equal(t, expected, Get().SandboxProfileDir()) } +func TestSandboxOverlayDir(t *testing.T) { + tests := []struct { + name string + envKey string + envVal string + expected func(t *testing.T) string + }{ + { + name: "default under user config dir", + envKey: "PMG_CONFIG_DIR", + envVal: "", + expected: func(t *testing.T) string { + userConfigDir, err := os.UserConfigDir() + require.NoError(t, err) + return filepath.Join(userConfigDir, pmgDefaultHomeRelativePath, pmgDefaultSandboxOverlayDir) + }, + }, + { + name: "honors PMG_CONFIG_DIR override", + envKey: "PMG_CONFIG_DIR", + envVal: "/tmp/pmg-test/custom-config", + expected: func(t *testing.T) string { + return filepath.Join("/tmp/pmg-test/custom-config", pmgDefaultSandboxOverlayDir) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(tc.envKey, tc.envVal) + initConfig() + + assert.Equal(t, tc.expected(t), Get().SandboxOverlayDir()) + }) + } +} + func TestSandboxViolationCacheDir(t *testing.T) { tests := []struct { name string diff --git a/docs/sandbox.md b/docs/sandbox.md index 75e33d4..89aaf86 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -179,6 +179,48 @@ mandatory deny for that credential file. PMG treats both channels as explicit us Suppression is exact-match only; broad paths or globs do not opt out. `.git/hooks` does not accept opt-outs. +### Project Overlays + +A project overlay is a per-repository allow-set that PMG applies automatically on every run in +that repo, so you do not have to retype the same `--sandbox-allow` flags. The overlay is local +only, keyed by the repo's git toplevel (CWD when not a git repo), and stored under +`/sandbox/overlays/`. It is purely additive on top of the resolved policy, never +weakens mandatory denies, and is ignored when `global_lockdown` is set. + +```bash +# Save manual allowances for the current repo +pmg sandbox allow write=./.astro net-bind=localhost:4321 + +# Promote the primary violation from the most recent cached report +pmg sandbox allow --last + +# Promote every safe FS/exec violation from that report +pmg sandbox allow --last --all + +# Allow a sensitive path (e.g. .env, .npmrc) explicitly +pmg sandbox allow --force read=./.env + +# Inspect the current repo's overlay +pmg sandbox project show +pmg sandbox project show --json + +# List overlays across all known repos +pmg sandbox project list + +# Delete the current repo's overlay +pmg sandbox project reset --yes +``` + +Notes: + +- `--last`/`--last --all` only auto-promotes filesystem and exec denials. Network allowances + (`net-connect`, `net-bind`) must be passed manually as `type=value` because drivers do not + classify network denials yet. +- `pmg sandbox allow` refuses sensitive targets (`.env*`, `.npmrc`, `.ssh`, `.aws`, `.kube`, + `.gnupg`, ...) unless `--force` is given. +- Applied overlay entries are recorded in the audit event log with a `+overlay` source tag so + they can be distinguished from `--sandbox-allow` flags. +
Custom policy overrides using Policy Templates diff --git a/internal/ui/sandbox_violation.go b/internal/ui/sandbox_violation.go index 4b36270..67bb164 100644 --- a/internal/ui/sandbox_violation.go +++ b/internal/ui/sandbox_violation.go @@ -150,6 +150,16 @@ func RenderSandboxViolation(out io.Writer, rec *pmgsandbox.ViolationCacheRecord) if _, err := fmt.Fprintln(out); err != nil { return err } + // `pmg sandbox allow` refuses sensitive targets without --force, so + // do not suggest a command that would immediately fail. + if !pmgsandbox.IsSensitiveProjectTarget(exp.Override.Target) { + if _, err := fmt.Fprintln(out, Colors.Dim("Remember for this project: pmg sandbox allow --last --all")); err != nil { + return err + } + if _, err := fmt.Fprintln(out); err != nil { + return err + } + } } if exp.Primary != nil { diff --git a/internal/ui/sandbox_violation_test.go b/internal/ui/sandbox_violation_test.go index 89358a9..f944d0d 100644 --- a/internal/ui/sandbox_violation_test.go +++ b/internal/ui/sandbox_violation_test.go @@ -120,3 +120,61 @@ func TestRenderSandboxViolationRejectsNilRecord(t *testing.T) { assert.Error(t, RenderSandboxViolation(&buf, nil)) assert.Error(t, RenderSandboxViolation(&buf, &pmgsandbox.ViolationCacheRecord{})) } + +func TestRenderSandboxViolationIncludesRememberHint(t *testing.T) { + var buf bytes.Buffer + rec := &pmgsandbox.ViolationCacheRecord{ + SchemaVersion: pmgsandbox.ViolationCacheSchemaVersion, + Report: &pmgsandbox.ViolationReport{ + SandboxName: pmgsandbox.DriverSeatbelt, + PolicyName: "npm-restrictive", + Violations: []pmgsandbox.Violation{{ + Kind: pmgsandbox.ViolationKindFSWrite, + Target: "/repo/.astro", + RuleLabel: "deny write", + }}, + }, + } + require.NoError(t, RenderSandboxViolation(&buf, rec)) + assert.Contains(t, buf.String(), "pmg sandbox allow --last --all") +} + +func TestRenderSandboxViolationOmitsRememberHintWithoutOverride(t *testing.T) { + var buf bytes.Buffer + rec := &pmgsandbox.ViolationCacheRecord{ + SchemaVersion: pmgsandbox.ViolationCacheSchemaVersion, + Report: &pmgsandbox.ViolationReport{ + SandboxName: pmgsandbox.DriverSeatbelt, + PolicyName: "npm-restrictive", + Violations: []pmgsandbox.Violation{{ + Kind: pmgsandbox.ViolationKindGenericDeny, + RuleLabel: "deny generic", + }}, + }, + } + require.NoError(t, RenderSandboxViolation(&buf, rec)) + assert.NotContains(t, buf.String(), "pmg sandbox allow --last --all") +} + +func TestRenderSandboxViolationOmitsRememberHintForSensitiveTarget(t *testing.T) { + var buf bytes.Buffer + rec := &pmgsandbox.ViolationCacheRecord{ + SchemaVersion: pmgsandbox.ViolationCacheSchemaVersion, + Report: &pmgsandbox.ViolationReport{ + SandboxName: pmgsandbox.DriverSeatbelt, + PolicyName: "npm-restrictive", + Violations: []pmgsandbox.Violation{{ + Kind: pmgsandbox.ViolationKindFSRead, + Target: "/repo/.env", + RuleLabel: "deny read", + }}, + }, + } + require.NoError(t, RenderSandboxViolation(&buf, rec)) + out := buf.String() + // The "Suggested override" line still shows so users see the manual fix. + assert.Contains(t, out, "--sandbox-allow read=") + // But the persistent-save hint is suppressed because `pmg sandbox allow` + // would refuse this target without --force. + assert.NotContains(t, out, "pmg sandbox allow --last --all") +} diff --git a/sandbox/all_overrides.go b/sandbox/all_overrides.go new file mode 100644 index 0000000..eb3ddf3 --- /dev/null +++ b/sandbox/all_overrides.go @@ -0,0 +1,27 @@ +package sandbox + +// BuildAllOverrides maps every safe FS/exec violation in report to an +// OverrideSuggestion. Network violations are intentionally skipped because +// drivers do not classify network denials yet. Returns nil when the report is +// empty. Duplicates (same Kind+Target) are collapsed so callers do not have to +// de-dup against the existing overlay before passing the result through. +func BuildAllOverrides(report *ViolationReport) []OverrideSuggestion { + if report == nil || len(report.Violations) == 0 { + return nil + } + + seen := make(map[OverrideSuggestion]struct{}, len(report.Violations)) + out := make([]OverrideSuggestion, 0, len(report.Violations)) + for i := range report.Violations { + sugg := overrideSuggestion(report.Violations[i]) + if sugg == nil { + continue + } + if _, dup := seen[*sugg]; dup { + continue + } + seen[*sugg] = struct{}{} + out = append(out, *sugg) + } + return out +} diff --git a/sandbox/all_overrides_test.go b/sandbox/all_overrides_test.go new file mode 100644 index 0000000..161763a --- /dev/null +++ b/sandbox/all_overrides_test.go @@ -0,0 +1,29 @@ +package sandbox + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuildAllOverridesMapsAllSafeFSAndExec(t *testing.T) { + report := &ViolationReport{ + Violations: []Violation{ + {Kind: ViolationKindFSWrite, Target: "/repo/.astro"}, + {Kind: ViolationKindExec, Target: "/usr/bin/sh"}, + {Kind: ViolationKindFSWrite, Target: "/repo/.astro"}, // duplicate collapsed + {Kind: ViolationKindGenericDeny, Target: "/something/else"}, // unsupported kind dropped + {Kind: ViolationKindFSRead, Target: "**/*.env"}, // unsafe target dropped + }, + } + got := BuildAllOverrides(report) + assert.ElementsMatch(t, []OverrideSuggestion{ + {Kind: ViolationKindFSWrite, Target: "/repo/.astro"}, + {Kind: ViolationKindExec, Target: "/usr/bin/sh"}, + }, got) +} + +func TestBuildAllOverridesNilOrEmpty(t *testing.T) { + assert.Empty(t, BuildAllOverrides(nil)) + assert.Empty(t, BuildAllOverrides(&ViolationReport{})) +} diff --git a/sandbox/executor/apply.go b/sandbox/executor/apply.go index 4e1ce20..2117476 100644 --- a/sandbox/executor/apply.go +++ b/sandbox/executor/apply.go @@ -3,6 +3,7 @@ package executor import ( "context" "fmt" + "os" "os/exec" "path/filepath" @@ -117,6 +118,21 @@ func ApplySandbox(ctx context.Context, cmd *exec.Cmd, pmName string, opts ...app log.Debugf("Loaded sandbox policy %s", policy.Name) + // Apply the per-repo project overlay (saved via `pmg sandbox allow`). When + // global_lockdown is set, overlays are ignored so the locked baseline is + // authoritative. An empty cwd is tolerated by ResolveRepoRoot's callers + // downstream, so swallow a Getwd error here. + cwd, _ := os.Getwd() + if repoRoot, repoErr := sandbox.ResolveRepoRoot(cwd); repoErr != nil { + log.Warnf("Project overlay: resolve repo root: %v", repoErr) + } else if _, err := applyProjectOverlay(policy, cfg.SandboxOverlayDir(), repoRoot, cfg.IsLocked()); err != nil { + log.Warnf("Project overlay: apply: %v", err) + // A failed overlay load means the user's saved allowances were silently + // dropped. Echo to stderr so users at normal verbosity see why their + // sandbox is more restrictive than expected. + fmt.Fprintf(os.Stderr, "pmg: warning: project overlay could not be applied: %v\n", err) + } + // Apply runtime --sandbox-allow overrides to the policy before execution if len(cfg.SandboxAllowOverrides) > 0 { applyRuntimeOverrides(policy, cfg.SandboxAllowOverrides) @@ -210,6 +226,32 @@ func removeExactMatch(slice []string, value string) []string { return result } +// applyProjectOverlay loads the per-repo overlay (when one exists) and feeds +// its entries through applyRuntimeOverrides. Returns the number of entries +// applied. A nil/missing overlay is a clean no-op. When locked, the overlay +// is ignored entirely. +func applyProjectOverlay(policy *sandbox.SandboxPolicy, overlayDir, repoRoot string, locked bool) (int, error) { + if locked { + log.Debugf("Project overlay: skipping under global_lockdown") + return 0, nil + } + + overlay, _, err := sandbox.LoadOverlayForRepo(overlayDir, repoRoot) + if err != nil { + return 0, fmt.Errorf("project overlay: load: %w", err) + } + if overlay == nil || len(overlay.Allow) == 0 { + return 0, nil + } + + entries := overlay.ToAllowOverrides() + applyRuntimeOverrides(policy, entries) + // The "+overlay" suffix tags audit events as overlay-sourced. + logSandboxOverrides(policy.Name+"+overlay", entries) + log.Infof("Project overlay: applied %d saved allowance(s) for %s", len(entries), repoRoot) + return len(entries), nil +} + // logSandboxOverrides records sandbox allow overrides in the audit event log. func logSandboxOverrides(profileName string, overrides []config.SandboxAllowOverride) { entries := make([]map[string]string, 0, len(overrides)) diff --git a/sandbox/executor/apply_test.go b/sandbox/executor/apply_test.go index ce318de..f0928f1 100644 --- a/sandbox/executor/apply_test.go +++ b/sandbox/executor/apply_test.go @@ -9,6 +9,7 @@ import ( "github.com/safedep/pmg/config" "github.com/safedep/pmg/sandbox" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestApplyRuntimeOverrides_Read(t *testing.T) { @@ -249,3 +250,57 @@ func TestApplyRuntimeOverrides_VariableDenyNotRemovedByAbsoluteOverride(t *testi assert.Equal(t, []string{"${CWD}/blocked.txt"}, policy.Filesystem.DenyWrite) } + +func TestApplyProjectOverlayAppendsEntries(t *testing.T) { + dir := t.TempDir() + repo := "/repo/example" + _, err := sandbox.SaveOverlay(dir, repo, &sandbox.Overlay{ + Allow: []sandbox.OverlayAllow{ + {Type: config.SandboxAllowWrite, Value: "/repo/example/.astro"}, + {Type: config.SandboxAllowNetBind, Value: "localhost:4321"}, + }, + }) + require.NoError(t, err) + + policy := &sandbox.SandboxPolicy{Name: "test"} + applied, err := applyProjectOverlay(policy, dir, repo, false) + assert.NoError(t, err) + assert.Equal(t, 2, applied) + assert.Contains(t, policy.Filesystem.AllowWrite, "/repo/example/.astro") + assert.Contains(t, policy.Network.AllowBind, "localhost:4321") + if assert.NotNil(t, policy.AllowNetworkBind) { + assert.True(t, *policy.AllowNetworkBind) + } +} + +func TestApplyProjectOverlaySkippedWhenLocked(t *testing.T) { + dir := t.TempDir() + repo := "/repo/example" + _, err := sandbox.SaveOverlay(dir, repo, &sandbox.Overlay{ + Allow: []sandbox.OverlayAllow{{Type: config.SandboxAllowWrite, Value: "/repo/example/.astro"}}, + }) + require.NoError(t, err) + + policy := &sandbox.SandboxPolicy{Name: "test"} + applied, err := applyProjectOverlay(policy, dir, repo, true) + assert.NoError(t, err) + assert.Equal(t, 0, applied) + assert.Empty(t, policy.Filesystem.AllowWrite) +} + +func TestApplyProjectOverlayMissingFileIsNoop(t *testing.T) { + dir := filepath.Join(t.TempDir(), "no-such") + policy := &sandbox.SandboxPolicy{Name: "test"} + applied, err := applyProjectOverlay(policy, dir, "/repo/example", false) + assert.NoError(t, err) + assert.Equal(t, 0, applied) + _, statErr := os.Stat(dir) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestApplyProjectOverlayEmptyArgsNoop(t *testing.T) { + policy := &sandbox.SandboxPolicy{Name: "test"} + applied, err := applyProjectOverlay(policy, "", "", false) + assert.NoError(t, err) + assert.Equal(t, 0, applied) +} diff --git a/sandbox/overlay.go b/sandbox/overlay.go new file mode 100644 index 0000000..b39d595 --- /dev/null +++ b/sandbox/overlay.go @@ -0,0 +1,227 @@ +package sandbox + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/safedep/dry/log" + "github.com/safedep/pmg/config" + "gopkg.in/yaml.v3" +) + +// OverlaySchemaVersion is bumped when the on-disk YAML layout changes incompatibly. +const OverlaySchemaVersion = 1 + +const overlayFileSuffix = ".yml" + +// Overlay is the on-disk representation of a per-repo allow-set. One file per +// repo, named by sha256(repo_root)[:16], lives under SandboxOverlayDir(). +type Overlay struct { + SchemaVersion int `yaml:"schema_version"` + RepoRoot string `yaml:"repo_root"` + CreatedAt time.Time `yaml:"created_at,omitempty"` + UpdatedAt time.Time `yaml:"updated_at,omitempty"` + Allow []OverlayAllow `yaml:"allow"` +} + +// OverlayAllow is a single persisted allow entry. The field shape matches +// config.SandboxAllowOverride so conversion is mechanical. +type OverlayAllow struct { + Type config.SandboxAllowType `yaml:"type"` + Value string `yaml:"value"` +} + +// OverlayListEntry pairs an Overlay with the file path it was loaded from, +// for `pmg sandbox project list` output. +type OverlayListEntry struct { + Path string + Overlay Overlay +} + +// Add appends entry when no (Type, Value) duplicate already exists. Returns +// true when added, false when the entry was a duplicate. +func (o *Overlay) Add(entry OverlayAllow) bool { + for _, existing := range o.Allow { + if existing.Type == entry.Type && existing.Value == entry.Value { + return false + } + } + o.Allow = append(o.Allow, entry) + return true +} + +// ToAllowOverrides converts overlay entries to the runtime override shape used +// by sandbox/executor.applyRuntimeOverrides. +func (o *Overlay) ToAllowOverrides() []config.SandboxAllowOverride { + out := make([]config.SandboxAllowOverride, 0, len(o.Allow)) + for _, a := range o.Allow { + out = append(out, config.SandboxAllowOverride{ + Type: a.Type, + Value: a.Value, + Raw: fmt.Sprintf("%s=%s", a.Type, a.Value), + }) + } + return out +} + +// ResolveRepoRoot returns the git toplevel for cwd, falling back to cwd itself +// when not inside a git work tree. The returned path is filepath.Clean'd and +// has its symlinks evaluated where possible so it forms a stable key. +func ResolveRepoRoot(cwd string) (string, error) { + if cwd == "" { + return "", errors.New("overlay: empty cwd") + } + + root := cwd + cmd := exec.Command("git", "-C", cwd, "rev-parse", "--show-toplevel") + if out, err := cmd.Output(); err == nil { + if s := strings.TrimSpace(string(out)); s != "" { + root = s + } + } + + if resolved, err := filepath.EvalSymlinks(root); err == nil { + root = resolved + } else if !os.IsNotExist(err) { + // A genuine resolution failure (permissions, broken mid-path symlink) + // leaves us with a non-canonical key. Surface it so users can see why + // their overlay does not match across runs. + log.Warnf("overlay: eval symlinks for %s: %v", root, err) + } + return filepath.Clean(root), nil +} + +// overlayFileFor returns the canonical absolute path of a repo's overlay file +// inside dir. The filename is sha256(repo_root)[:16] so we never have to +// path-escape the repo root. +func overlayFileFor(dir, repoRoot string) string { + sum := sha256.Sum256([]byte(repoRoot)) + name := hex.EncodeToString(sum[:])[:16] + overlayFileSuffix + return filepath.Join(dir, name) +} + +// SaveOverlay writes overlay to dir, creating dir lazily. The schema version, +// repo root, and updated timestamp are normalized. CreatedAt is preserved from +// an existing file when present, otherwise set to now. Returns the path +// written. +func SaveOverlay(dir, repoRoot string, overlay *Overlay) (string, error) { + if dir == "" { + return "", errors.New("overlay: empty directory") + } + if repoRoot == "" { + return "", errors.New("overlay: empty repo root") + } + if overlay == nil { + return "", errors.New("overlay: nil overlay") + } + + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("overlay: create dir: %w", err) + } + + path := overlayFileFor(dir, repoRoot) + now := time.Now().UTC() + + overlay.SchemaVersion = OverlaySchemaVersion + overlay.RepoRoot = repoRoot + if overlay.CreatedAt.IsZero() { + overlay.CreatedAt = now + if existing, err := loadOverlayFile(path); err == nil && existing != nil && !existing.CreatedAt.IsZero() { + overlay.CreatedAt = existing.CreatedAt + } + } + overlay.UpdatedAt = now + + data, err := yaml.Marshal(overlay) + if err != nil { + return "", fmt.Errorf("overlay: marshal: %w", err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + return "", fmt.Errorf("overlay: write: %w", err) + } + return path, nil +} + +// LoadOverlayForRepo returns the overlay for repoRoot in dir, or (nil, "", nil) +// when the file is missing. Returns an error only for genuine IO or parse +// failures so callers can treat absence as "no overlay" without sniffing. +func LoadOverlayForRepo(dir, repoRoot string) (*Overlay, string, error) { + if dir == "" || repoRoot == "" { + return nil, "", nil + } + path := overlayFileFor(dir, repoRoot) + overlay, err := loadOverlayFile(path) + if err != nil || overlay == nil { + return nil, "", err + } + return overlay, path, nil +} + +func loadOverlayFile(path string) (*Overlay, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("overlay: read %s: %w", path, err) + } + var overlay Overlay + if err := yaml.Unmarshal(data, &overlay); err != nil { + return nil, fmt.Errorf("overlay: parse %s: %w", path, err) + } + if overlay.SchemaVersion != 0 && overlay.SchemaVersion != OverlaySchemaVersion { + return nil, fmt.Errorf("overlay: %s has unknown schema_version %d", path, overlay.SchemaVersion) + } + return &overlay, nil +} + +// DeleteOverlayForRepo removes the overlay file for repoRoot in dir. A missing +// file is not an error so callers can treat reset as idempotent. +func DeleteOverlayForRepo(dir, repoRoot string) error { + path := overlayFileFor(dir, repoRoot) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("overlay: remove %s: %w", path, err) + } + return nil +} + +// ListOverlays returns every readable overlay under dir, sorted by RepoRoot. +// A missing dir yields an empty slice with no error. +func ListOverlays(dir string) ([]OverlayListEntry, error) { + if dir == "" { + return nil, nil + } + dirents, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("overlay: read dir: %w", err) + } + + out := make([]OverlayListEntry, 0, len(dirents)) + for _, d := range dirents { + if d.IsDir() || !strings.HasSuffix(d.Name(), overlayFileSuffix) { + continue + } + path := filepath.Join(dir, d.Name()) + overlay, err := loadOverlayFile(path) + if err != nil || overlay == nil { + // Skip corrupt files rather than failing the whole list, so users + // can still inspect/prune via the same command. + continue + } + out = append(out, OverlayListEntry{Path: path, Overlay: *overlay}) + } + + sort.Slice(out, func(i, j int) bool { return out[i].Overlay.RepoRoot < out[j].Overlay.RepoRoot }) + return out, nil +} diff --git a/sandbox/overlay_test.go b/sandbox/overlay_test.go new file mode 100644 index 0000000..1251c6a --- /dev/null +++ b/sandbox/overlay_test.go @@ -0,0 +1,172 @@ +package sandbox + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/safedep/pmg/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func evalPath(t *testing.T, p string) string { + t.Helper() + v, err := filepath.EvalSymlinks(p) + require.NoError(t, err) + return v +} + +func TestOverlayResolveRepoRootGit(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + repo := t.TempDir() + require.NoError(t, exec.Command("git", "-C", repo, "init", "-q").Run()) + sub := filepath.Join(repo, "pkg", "deep") + require.NoError(t, os.MkdirAll(sub, 0o755)) + + got, err := ResolveRepoRoot(sub) + require.NoError(t, err) + assert.Equal(t, evalPath(t, repo), evalPath(t, got)) +} + +func TestOverlayResolveRepoRootNonGitFallsBackToCWD(t *testing.T) { + dir := t.TempDir() + got, err := ResolveRepoRoot(dir) + require.NoError(t, err) + assert.Equal(t, evalPath(t, dir), evalPath(t, got)) +} + +func TestOverlayResolveRepoRootEmpty(t *testing.T) { + _, err := ResolveRepoRoot("") + assert.Error(t, err) +} + +func TestOverlaySaveLoadRoundTrip(t *testing.T) { + dir := t.TempDir() + repo := "/repo/example" + + in := &Overlay{ + Allow: []OverlayAllow{ + {Type: config.SandboxAllowWrite, Value: "/repo/example/.astro"}, + {Type: config.SandboxAllowNetBind, Value: "localhost:4321"}, + }, + } + path, err := SaveOverlay(dir, repo, in) + require.NoError(t, err) + assert.FileExists(t, path) + assert.Equal(t, OverlaySchemaVersion, in.SchemaVersion) + assert.Equal(t, repo, in.RepoRoot) + assert.False(t, in.UpdatedAt.IsZero()) + assert.False(t, in.CreatedAt.IsZero()) + + loaded, loadedPath, err := LoadOverlayForRepo(dir, repo) + require.NoError(t, err) + assert.Equal(t, path, loadedPath) + require.NotNil(t, loaded) + assert.Equal(t, repo, loaded.RepoRoot) + assert.ElementsMatch(t, in.Allow, loaded.Allow) +} + +func TestOverlaySavePreservesCreatedAt(t *testing.T) { + dir := t.TempDir() + repo := "/repo/example" + + first := &Overlay{Allow: []OverlayAllow{{Type: config.SandboxAllowExec, Value: "/bin/sh"}}} + _, err := SaveOverlay(dir, repo, first) + require.NoError(t, err) + createdAt := first.CreatedAt + + second := &Overlay{Allow: []OverlayAllow{{Type: config.SandboxAllowExec, Value: "/bin/zsh"}}} + _, err = SaveOverlay(dir, repo, second) + require.NoError(t, err) + assert.Equal(t, createdAt, second.CreatedAt) +} + +func TestLoadOverlayForRepoMissingReturnsNil(t *testing.T) { + dir := t.TempDir() + overlay, path, err := LoadOverlayForRepo(dir, "/nope") + require.NoError(t, err) + assert.Nil(t, overlay) + assert.Empty(t, path) +} + +func TestLoadOverlayForRepoEmptyArgsReturnsNil(t *testing.T) { + overlay, _, err := LoadOverlayForRepo("", "/repo") + require.NoError(t, err) + assert.Nil(t, overlay) + + overlay, _, err = LoadOverlayForRepo(t.TempDir(), "") + require.NoError(t, err) + assert.Nil(t, overlay) +} + +func TestOverlayAddDedupsByTypeAndValue(t *testing.T) { + o := &Overlay{} + assert.True(t, o.Add(OverlayAllow{Type: config.SandboxAllowWrite, Value: "/a"})) + assert.False(t, o.Add(OverlayAllow{Type: config.SandboxAllowWrite, Value: "/a"})) + assert.True(t, o.Add(OverlayAllow{Type: config.SandboxAllowRead, Value: "/a"})) + assert.Len(t, o.Allow, 2) +} + +func TestOverlayToAllowOverrides(t *testing.T) { + o := &Overlay{ + Allow: []OverlayAllow{ + {Type: config.SandboxAllowWrite, Value: "/a"}, + {Type: config.SandboxAllowNetBind, Value: "localhost:1234"}, + }, + } + got := o.ToAllowOverrides() + require.Len(t, got, 2) + assert.Equal(t, config.SandboxAllowWrite, got[0].Type) + assert.Equal(t, "/a", got[0].Value) + assert.Equal(t, "write=/a", got[0].Raw) + assert.Equal(t, config.SandboxAllowNetBind, got[1].Type) + assert.Equal(t, "net-bind=localhost:1234", got[1].Raw) +} + +func TestDeleteOverlayForRepoIdempotent(t *testing.T) { + dir := t.TempDir() + repo := "/repo/x" + _, err := SaveOverlay(dir, repo, &Overlay{Allow: []OverlayAllow{{Type: config.SandboxAllowExec, Value: "/bin/x"}}}) + require.NoError(t, err) + require.NoError(t, DeleteOverlayForRepo(dir, repo)) + // Second call is a no-op. + require.NoError(t, DeleteOverlayForRepo(dir, repo)) +} + +func TestListOverlaysReturnsAllSorted(t *testing.T) { + dir := t.TempDir() + repos := []string{"/repo/c", "/repo/a", "/repo/b"} + for _, r := range repos { + _, err := SaveOverlay(dir, r, &Overlay{Allow: []OverlayAllow{{Type: config.SandboxAllowExec, Value: "/bin/x"}}}) + require.NoError(t, err) + } + listed, err := ListOverlays(dir) + require.NoError(t, err) + require.Len(t, listed, 3) + assert.Equal(t, "/repo/a", listed[0].Overlay.RepoRoot) + assert.Equal(t, "/repo/b", listed[1].Overlay.RepoRoot) + assert.Equal(t, "/repo/c", listed[2].Overlay.RepoRoot) +} + +func TestListOverlaysMissingDirIsEmpty(t *testing.T) { + dir := filepath.Join(t.TempDir(), "does", "not", "exist") + listed, err := ListOverlays(dir) + require.NoError(t, err) + assert.Empty(t, listed) +} + +func TestListOverlaysSkipsCorruptFiles(t *testing.T) { + dir := t.TempDir() + _, err := SaveOverlay(dir, "/repo/a", &Overlay{Allow: []OverlayAllow{{Type: config.SandboxAllowExec, Value: "/bin/x"}}}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "garbage.yml"), []byte("{not valid yaml]"), 0o644)) + + listed, err := ListOverlays(dir) + require.NoError(t, err) + require.Len(t, listed, 1) + assert.Equal(t, "/repo/a", listed[0].Overlay.RepoRoot) +} diff --git a/sandbox/violation.go b/sandbox/violation.go index 7e25978..98329f2 100644 --- a/sandbox/violation.go +++ b/sandbox/violation.go @@ -160,6 +160,13 @@ func isProjectPath(target, cwd string) bool { return cleanTarget == cleanCwd || strings.HasPrefix(cleanTarget, cleanCwd+string(filepath.Separator)) } +// IsSensitiveProjectTarget reports whether target points to a known sensitive +// file or directory (credential stores, dotfiles like .env/.npmrc). Used by +// the overlay save-time guard to refuse implicit credential allowances. +func IsSensitiveProjectTarget(target string) bool { + return isSensitiveProjectFile(target) +} + func isSensitiveProjectFile(target string) bool { if target == "" || isParentRelativePath(target) { return false @@ -176,7 +183,8 @@ func isSensitiveProjectFile(target string) bool { default: return strings.Contains(target, string(filepath.Separator)+".ssh") || strings.Contains(target, string(filepath.Separator)+".aws") || - strings.Contains(target, string(filepath.Separator)+".kube") + strings.Contains(target, string(filepath.Separator)+".kube") || + strings.Contains(target, string(filepath.Separator)+".gnupg") } } diff --git a/sandbox/violation_test.go b/sandbox/violation_test.go index b9ebc34..6af75c0 100644 --- a/sandbox/violation_test.go +++ b/sandbox/violation_test.go @@ -184,3 +184,15 @@ func TestBuildExplanationEmptyReport(t *testing.T) { assert.Nil(t, exp.Override) assert.Equal(t, 0, exp.AdditionalDenials) } + +func TestIsSensitiveProjectTargetExported(t *testing.T) { + assert.True(t, IsSensitiveProjectTarget("./.env")) + assert.True(t, IsSensitiveProjectTarget(filepath.Join("/repo", ".npmrc"))) + assert.False(t, IsSensitiveProjectTarget(filepath.Join("/repo", ".astro"))) + assert.False(t, IsSensitiveProjectTarget("../.env")) +} + +func TestIsSensitiveProjectTargetGNUPGFiles(t *testing.T) { + assert.True(t, IsSensitiveProjectTarget("/home/user/.gnupg/pubring.kbx")) + assert.True(t, IsSensitiveProjectTarget("/home/user/.gnupg/private-keys-v1.d/abc.key")) +}