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
This commit is contained in:
Abhisek Datta
2026-05-26 21:06:57 +05:30
committed by GitHub
parent 321896996f
commit b03e82e3f2
25 changed files with 1837 additions and 1 deletions
+267
View File
@@ -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
}
+214
View File
@@ -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")
}
+85
View File
@@ -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
}
+93
View File
@@ -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)
}
+83
View File
@@ -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)
}
+64
View File
@@ -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
}
+68
View File
@@ -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")
}
+105
View File
@@ -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)
}
+90
View File
@@ -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)
}
+2
View File
@@ -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
}