mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
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:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 '='
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}))
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user