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
}
+26
View File
@@ -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()
+7
View File
@@ -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 '='
+13
View File
@@ -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)
}
+37
View File
@@ -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
+42
View File
@@ -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
`<config_dir>/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.
<details>
<summary>Custom policy overrides using Policy Templates</summary>
+10
View File
@@ -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 {
+58
View File
@@ -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")
}
+27
View File
@@ -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
}
+29
View File
@@ -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{}))
}
+42
View File
@@ -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))
+55
View File
@@ -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)
}
+227
View File
@@ -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
}
+172
View File
@@ -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)
}
+9 -1
View File
@@ -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")
}
}
+12
View File
@@ -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"))
}