feat: Add Sandbox Inspection and Debugging Commands (#261)

* feat: add sandbox DX commands

* fix: Linter errors

* fix: Sandbox deny log parsing

* fix: Sandbox docs

* refactor: Maintain SSOT across pkg dependencies

* fix: Linter errors
This commit is contained in:
Abhisek Datta
2026-05-19 14:40:54 +05:30
committed by GitHub
parent 46cc70db53
commit b8588e3df4
97 changed files with 8796 additions and 478 deletions
+1
View File
@@ -42,3 +42,4 @@ go test ./config/ -v -count=1 # Run specific package tests
- Prefer failing fast by returning errors up the call stack
- When soft failure is acceptable, log with `log.Warnf` from `github.com/safedep/dry/log`
- Do not use `_ = someFunc()` to discard errors silently
- For CLI/user-facing errors, prefer `usefulerror` with a specific code and actionable help so `ui.ErrorExit` does not classify expected failures as `Unknown`
+287
View File
@@ -0,0 +1,287 @@
package sandbox
import (
"context"
"fmt"
"io"
"github.com/safedep/pmg/internal/ui"
pmgsandbox "github.com/safedep/pmg/sandbox"
"github.com/safedep/pmg/sandbox/platform"
"github.com/spf13/cobra"
)
type doctorOptions struct {
jsonOut bool
driver string
}
func NewDoctorCommand() *cobra.Command {
return newDoctorCommand(platform.DefaultProbes)
}
func newDoctorCommand(probes func() []pmgsandbox.Probe) *cobra.Command {
opts := &doctorOptions{}
cmd := &cobra.Command{
Use: "doctor",
Short: "Run sandbox probes and report on host readiness",
Example: " pmg sandbox doctor\n pmg sandbox doctor --driver landlock",
Args: cobra.NoArgs,
SilenceErrors: false,
RunE: func(cmd *cobra.Command, args []string) error {
err := runDoctor(cmd.Context(), cmd.OutOrStdout(), opts, probes)
if err != nil {
if _, isFail := err.(*doctorFailError); isFail {
cmd.SilenceErrors = true
cmd.SilenceUsage = true
return err
}
return sandboxErrorExit(cmd, err)
}
return err
},
}
cmd.Flags().BoolVar(&opts.jsonOut, "json", false, "Emit probe results as JSON")
cmd.Flags().StringVar(&opts.driver, "driver", "", "Filter probes to a single driver: seatbelt|bubblewrap|landlock")
return cmd
}
func runDoctor(ctx context.Context, out io.Writer, opts *doctorOptions, probes func() []pmgsandbox.Probe) error {
if err := validateDriver(opts.driver); err != nil {
return err
}
if ctx == nil {
ctx = context.Background()
}
all := probes()
filtered := filterByDriver(all, opts.driver)
results := pmgsandbox.RunProbes(ctx, filtered)
if opts.jsonOut {
if err := writeJSON(out, results); err != nil {
return err
}
} else {
if err := renderHuman(out, results); err != nil {
return err
}
}
if exitCodeForResults(results) != 0 {
return &doctorFailError{}
}
return nil
}
type doctorFailError struct{}
func (e *doctorFailError) Error() string { return "" }
func (e *doctorFailError) ExitCode() int { return ExitCodeProbeFailure }
func exitCodeForResults(results []pmgsandbox.ProbeResult) int {
for _, r := range results {
if r.Status == pmgsandbox.ProbeStatusFail {
return ExitCodeProbeFailure
}
}
return 0
}
// driverProbeNames lists which probes belong to each driver. The apparmor
// probe is shared by bubblewrap and landlock (both rely on unprivileged user
// namespaces on Linux) and excluded from the darwin-only seatbelt filter.
var driverProbeNames = map[pmgsandbox.DriverName]map[string]struct{}{
pmgsandbox.DriverSeatbelt: {
pmgsandbox.ProbeSeatbeltDriver: {},
pmgsandbox.ProbeSeatbeltCanary: {},
},
pmgsandbox.DriverBubblewrap: {
pmgsandbox.ProbeBwrapDriver: {},
pmgsandbox.ProbeBwrapCanary: {},
pmgsandbox.ProbeAppArmorUserns: {},
},
pmgsandbox.DriverLandlock: {
pmgsandbox.ProbeLandlockDriver: {},
pmgsandbox.ProbeLandlockCanary: {},
pmgsandbox.ProbeAppArmorUserns: {},
},
}
func filterByDriver(probes []pmgsandbox.Probe, driver string) []pmgsandbox.Probe {
if driver == "" {
return probes
}
want := driverProbeNames[pmgsandbox.DriverName(driver)]
out := make([]pmgsandbox.Probe, 0, len(probes))
for _, p := range probes {
if _, ok := want[p.Name()]; ok {
out = append(out, p)
}
}
return out
}
type jsonFix struct {
Description string `json:"description"`
Command string `json:"command,omitempty"`
Docs string `json:"docs,omitempty"`
}
type jsonProbeResult struct {
Name string `json:"name"`
Status string `json:"status"`
Summary string `json:"summary,omitempty"`
Detail string `json:"detail,omitempty"`
Fixes []jsonFix `json:"fixes,omitempty"`
}
type jsonReport struct {
Results []jsonProbeResult `json:"results"`
}
func writeJSON(out io.Writer, results []pmgsandbox.ProbeResult) error {
report := jsonReport{Results: make([]jsonProbeResult, 0, len(results))}
for _, r := range results {
fixes := make([]jsonFix, 0, len(r.Fixes))
for _, f := range r.Fixes {
fixes = append(fixes, jsonFix{Description: f.Description, Command: f.Command, Docs: f.Docs})
}
report.Results = append(report.Results, jsonProbeResult{
Name: r.Name,
Status: string(r.Status),
Summary: r.Summary,
Detail: r.Detail,
Fixes: fixes,
})
}
return writeJSONIndent(out, report)
}
func renderHuman(out io.Writer, results []pmgsandbox.ProbeResult) error {
if len(results) == 0 {
_, err := fmt.Fprintln(out, ui.Colors.Dim("No probes to run."))
return err
}
if _, err := fmt.Fprintln(out); err != nil {
return err
}
if _, err := fmt.Fprintln(out, ui.Colors.Cyan("Sandbox Diagnostics")); err != nil {
return err
}
if _, err := fmt.Fprintln(out, ui.Colors.Normal("--------------------")); err != nil {
return err
}
rows := make([][]string, 0, len(results)+1)
rows = append(rows, []string{
ui.Colors.Bold("STATUS"),
ui.Colors.Bold("CHECK"),
ui.Colors.Bold("SUMMARY"),
ui.Colors.Bold("FIX"),
})
for _, r := range results {
rows = append(rows, []string{
statusBadge(r.Status),
displayName(r.Name),
truncate(r.Summary, 60),
fixHint(r.Fixes),
})
}
if err := renderTable(out, rows, nil); err != nil {
return err
}
for _, r := range results {
if r.Status == pmgsandbox.ProbeStatusOK {
continue
}
if err := renderDetail(out, r); err != nil {
return err
}
}
return nil
}
func renderDetail(out io.Writer, r pmgsandbox.ProbeResult) error {
if _, err := fmt.Fprintln(out); err != nil {
return err
}
if _, err := fmt.Fprintf(out, "%s %s — %s\n", statusBadge(r.Status), ui.Colors.Bold(displayName(r.Name)), r.Summary); err != nil {
return err
}
if r.Detail != "" {
if _, err := fmt.Fprintf(out, " %s\n", ui.Colors.Dim(r.Detail)); err != nil {
return err
}
}
for i, f := range r.Fixes {
if _, err := fmt.Fprintf(out, " %s %s\n", ui.Colors.Cyan(fmt.Sprintf("Fix %d:", i+1)), f.Description); err != nil {
return err
}
if f.Command != "" {
if _, err := fmt.Fprintf(out, " %s %s\n", ui.Colors.Dim("$"), f.Command); err != nil {
return err
}
}
if f.Docs != "" {
if _, err := fmt.Fprintf(out, " %s %s\n", ui.Colors.Dim("docs:"), f.Docs); err != nil {
return err
}
}
}
return nil
}
func statusBadge(s pmgsandbox.ProbeStatus) string {
switch s {
case pmgsandbox.ProbeStatusOK:
return ui.Colors.Green("OK")
case pmgsandbox.ProbeStatusWarn:
return ui.Colors.Yellow("WARN")
case pmgsandbox.ProbeStatusFail:
return ui.Colors.Red("FAIL")
case pmgsandbox.ProbeStatusSkipped:
return ui.Colors.Dim("SKIPPED")
default:
return string(s)
}
}
func fixHint(fixes []pmgsandbox.ProbeFix) string {
if len(fixes) == 0 {
return ui.Colors.Dim("—")
}
first := truncate(fixes[0].Description, 50)
if len(fixes) > 1 {
return fmt.Sprintf("%s %s", first, ui.Colors.Dim(fmt.Sprintf("(+%d more)", len(fixes)-1)))
}
return first
}
func displayName(probeName string) string {
switch probeName {
case pmgsandbox.ProbeSeatbeltDriver:
return "Seatbelt driver"
case pmgsandbox.ProbeBwrapDriver:
return "Bubblewrap driver"
case pmgsandbox.ProbeLandlockDriver:
return "Landlock ABI"
case pmgsandbox.ProbeAppArmorUserns:
return "AppArmor user namespaces"
case pmgsandbox.ProbeSeatbeltCanary:
return "Seatbelt canary"
case pmgsandbox.ProbeBwrapCanary:
return "Bubblewrap canary"
case pmgsandbox.ProbeLandlockCanary:
return "Landlock canary"
}
return probeName
}
+225
View File
@@ -0,0 +1,225 @@
package sandbox
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
pmgsandbox "github.com/safedep/pmg/sandbox"
"github.com/safedep/pmg/sandbox/platform"
"github.com/safedep/pmg/usefulerror"
)
// stubProbe is a minimal probe used by tests.
type stubProbe struct {
name string
result pmgsandbox.ProbeResult
}
func (s *stubProbe) Name() string { return s.name }
func (s *stubProbe) Run(_ context.Context) pmgsandbox.ProbeResult { return s.result }
func newStub(name string, status pmgsandbox.ProbeStatus) pmgsandbox.Probe {
return &stubProbe{
name: name,
result: pmgsandbox.ProbeResult{
Name: name,
Status: status,
Summary: name + " summary",
Detail: name + " detail",
Fixes: []pmgsandbox.ProbeFix{{Description: name + " fix", Command: "do thing", Docs: "https://example/" + name}},
},
}
}
func TestFilterByDriver(t *testing.T) {
all := []pmgsandbox.Probe{
newStub("driver.seatbelt.available", pmgsandbox.ProbeStatusOK),
newStub("driver.bwrap.available", pmgsandbox.ProbeStatusOK),
newStub("driver.landlock.abi", pmgsandbox.ProbeStatusOK),
newStub("linux.apparmor.userns", pmgsandbox.ProbeStatusWarn),
newStub("canary.seatbelt", pmgsandbox.ProbeStatusOK),
newStub("canary.bubblewrap", pmgsandbox.ProbeStatusOK),
newStub("canary.landlock", pmgsandbox.ProbeStatusOK),
}
cases := []struct {
driver string
want []string
}{
{"", []string{
"driver.seatbelt.available", "driver.bwrap.available", "driver.landlock.abi",
"linux.apparmor.userns", "canary.seatbelt", "canary.bubblewrap", "canary.landlock",
}},
{"seatbelt", []string{"driver.seatbelt.available", "canary.seatbelt"}},
{"bubblewrap", []string{"driver.bwrap.available", "linux.apparmor.userns", "canary.bubblewrap"}},
{"landlock", []string{"driver.landlock.abi", "linux.apparmor.userns", "canary.landlock"}},
}
for _, tc := range cases {
t.Run("driver="+tc.driver, func(t *testing.T) {
got := filterByDriver(all, tc.driver)
names := make([]string, 0, len(got))
for _, p := range got {
names = append(names, p.Name())
}
assert.Equal(t, tc.want, names)
})
}
}
func TestExitCodeForResults(t *testing.T) {
cases := []struct {
name string
results []pmgsandbox.ProbeResult
want int
}{
{"all ok", []pmgsandbox.ProbeResult{{Status: pmgsandbox.ProbeStatusOK}, {Status: pmgsandbox.ProbeStatusOK}}, 0},
{"warn is ok", []pmgsandbox.ProbeResult{{Status: pmgsandbox.ProbeStatusOK}, {Status: pmgsandbox.ProbeStatusWarn}}, 0},
{"skipped is ok", []pmgsandbox.ProbeResult{{Status: pmgsandbox.ProbeStatusSkipped}}, 0},
{"fail trips", []pmgsandbox.ProbeResult{{Status: pmgsandbox.ProbeStatusOK}, {Status: pmgsandbox.ProbeStatusFail}}, ExitCodeProbeFailure},
{"empty is ok", nil, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, exitCodeForResults(tc.results))
})
}
}
func TestRenderHuman_ContainsKeySubstrings(t *testing.T) {
results := []pmgsandbox.ProbeResult{
{
Name: "driver.seatbelt.available",
Status: pmgsandbox.ProbeStatusOK,
Summary: "sandbox-exec ready",
},
{
Name: "canary.seatbelt",
Status: pmgsandbox.ProbeStatusFail,
Summary: "canary blocked",
Detail: "policy denied read",
Fixes: []pmgsandbox.ProbeFix{{Description: "Update seatbelt policy", Command: "pmg fix --last", Docs: "https://docs/seatbelt"}},
},
}
var buf bytes.Buffer
require.NoError(t, renderHuman(&buf, results))
out := buf.String()
assert.Contains(t, out, "STATUS")
assert.Contains(t, out, "Seatbelt driver")
assert.Contains(t, out, "Seatbelt canary")
assert.Contains(t, out, "sandbox-exec ready")
assert.Contains(t, out, "Update seatbelt policy")
assert.NotContains(t, out, "driver.seatbelt.available")
assert.Contains(t, out, "policy denied read")
assert.Contains(t, out, "pmg fix --last")
assert.Contains(t, out, "https://docs/seatbelt")
}
func TestRunDoctor_JSONRoundtrip(t *testing.T) {
factory := func() []pmgsandbox.Probe {
return []pmgsandbox.Probe{
newStub("driver.seatbelt.available", pmgsandbox.ProbeStatusOK),
newStub("canary.seatbelt", pmgsandbox.ProbeStatusFail),
}
}
var buf bytes.Buffer
opts := &doctorOptions{jsonOut: true}
err := runDoctor(context.Background(), &buf, opts, factory)
require.Error(t, err)
_, ok := err.(*doctorFailError)
require.True(t, ok, "expected doctorFailError, got %T", err)
var report jsonReport
require.NoError(t, json.Unmarshal(buf.Bytes(), &report))
require.Len(t, report.Results, 2)
assert.Equal(t, "driver.seatbelt.available", report.Results[0].Name)
assert.Equal(t, "ok", report.Results[0].Status)
assert.Equal(t, "canary.seatbelt", report.Results[1].Name)
assert.Equal(t, "fail", report.Results[1].Status)
assert.Equal(t, "canary.seatbelt fix", report.Results[1].Fixes[0].Description)
}
func TestRunDoctor_HumanSuccess(t *testing.T) {
factory := func() []pmgsandbox.Probe {
return []pmgsandbox.Probe{newStub("driver.seatbelt.available", pmgsandbox.ProbeStatusOK)}
}
var buf bytes.Buffer
err := runDoctor(context.Background(), &buf, &doctorOptions{}, factory)
require.NoError(t, err)
assert.Contains(t, buf.String(), "Seatbelt driver")
}
func TestRunDoctor_UnknownDriver(t *testing.T) {
var buf bytes.Buffer
err := runDoctor(context.Background(), &buf, &doctorOptions{driver: "bogus"}, platform.DefaultProbes)
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown driver")
usefulErr, ok := usefulerror.AsUsefulError(err)
require.True(t, ok)
assert.Equal(t, usefulerror.ErrCodeInvalidArgument, usefulErr.Code())
}
func TestDoctorCommandRejectsUnexpectedArgsWithUsage(t *testing.T) {
cmd := newDoctorCommand(func() []pmgsandbox.Probe { return nil })
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"extra"})
err := cmd.Execute()
require.Error(t, err)
assert.Contains(t, stderr.String(), "unknown command")
assert.Contains(t, stdout.String(), "Usage:")
assert.Contains(t, stdout.String(), "doctor [flags]")
assert.Contains(t, stdout.String(), "pmg sandbox doctor --driver landlock")
}
func TestDoctorCommandRuntimeErrorUsesSandboxErrorExit(t *testing.T) {
cmd := newDoctorCommand(func() []pmgsandbox.Probe { return nil })
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"--driver", "bogus"})
err := cmd.Execute()
require.Error(t, err)
assert.Empty(t, stdout.String())
assert.Empty(t, stderr.String())
assert.Contains(t, err.Error(), "unknown driver")
}
func TestRunDoctor_DriverFilter(t *testing.T) {
factory := func() []pmgsandbox.Probe {
return []pmgsandbox.Probe{
newStub("driver.seatbelt.available", pmgsandbox.ProbeStatusOK),
newStub("driver.bwrap.available", pmgsandbox.ProbeStatusOK),
newStub("canary.bubblewrap", pmgsandbox.ProbeStatusOK),
}
}
var buf bytes.Buffer
err := runDoctor(context.Background(), &buf, &doctorOptions{driver: "bubblewrap"}, factory)
require.NoError(t, err)
out := buf.String()
assert.Contains(t, out, "Bubblewrap driver")
assert.Contains(t, out, "Bubblewrap canary")
assert.False(t, strings.Contains(out, "Seatbelt driver"), "seatbelt should be filtered out:\n%s", out)
}
func TestDoctorFailError_ExitCode(t *testing.T) {
e := &doctorFailError{}
assert.Equal(t, ExitCodeProbeFailure, e.ExitCode())
}
+269
View File
@@ -0,0 +1,269 @@
package sandbox
import (
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/safedep/pmg/config"
"github.com/safedep/pmg/internal/ui"
pmgsandbox "github.com/safedep/pmg/sandbox"
"github.com/safedep/pmg/usefulerror"
"github.com/spf13/cobra"
)
// ExitCodeExplainFail is returned when explain cannot produce output.
const ExitCodeExplainFail = 2
// cacheFactory returns the ViolationCache used to look up cached reports.
type cacheFactory func() *pmgsandbox.ViolationCache
type explainOptions struct {
last bool
jsonOut bool
}
// NewExplainCommand returns the `pmg sandbox explain` subcommand.
func NewExplainCommand() *cobra.Command {
return newExplainCommand(func() *pmgsandbox.ViolationCache {
return pmgsandbox.NewViolationCache(config.Get().SandboxViolationCacheDir())
})
}
// newExplainCommand allows callers (tests) to inject a cache factory.
func newExplainCommand(factory cacheFactory) *cobra.Command {
opts := &explainOptions{}
cmd := &cobra.Command{
Use: "explain [--last | -]",
Short: "Explain a sandbox violation from the local cache or piped JSON",
Example: " pmg sandbox explain --last\n pmg sandbox explain - < violation.json",
SilenceErrors: false,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
err := runExplain(cmd.OutOrStdout(), cmd.InOrStdin(), args, opts, factory)
if err != nil {
return sandboxErrorExit(cmd, err)
}
return err
},
}
cmd.Flags().BoolVar(&opts.last, "last", false, "Read the most recent cached violation report")
cmd.Flags().BoolVar(&opts.jsonOut, "json", false, "Emit explanation and report as JSON")
return cmd
}
// explainFailError carries a friendly message and a non-zero exit code.
type explainFailError struct {
usefulerror.UsefulError
}
// ExitCode reports the explain exit code so main can propagate it.
func (e *explainFailError) ExitCode() int { return ExitCodeExplainFail }
func newExplainFailError(code, msg, help string) *explainFailError {
return &explainFailError{
UsefulError: usefulerror.Useful().
WithCode(code).
WithHumanError(msg).
WithHelp(help).
Wrap(errors.New(msg)),
}
}
func runExplain(out io.Writer, in io.Reader, args []string, opts *explainOptions, factory cacheFactory) error {
stdinMode := len(args) == 1 && args[0] == "-"
if len(args) == 1 && !stdinMode {
return newExplainFailError(
usefulerror.ErrCodeInvalidArgument,
fmt.Sprintf("unexpected argument %q (use --last or pipe JSON with `-`)", args[0]),
explainUsageHelp(),
)
}
if opts.last && stdinMode {
return newExplainFailError(
usefulerror.ErrCodeInvalidArgument,
"--last and `-` are mutually exclusive",
explainUsageHelp(),
)
}
if !opts.last && !stdinMode {
return newExplainFailError(
usefulerror.ErrCodeInvalidArgument,
"no input: pass --last to read the most recent cached violation, or pipe a violation record JSON on stdin with `-`",
explainUsageHelp(),
)
}
var (
record *pmgsandbox.ViolationCacheRecord
err error
)
if opts.last {
record, err = readLatestFromCache(factory)
} else {
record, err = readRecordFromStdin(in)
}
if err != nil {
return err
}
if opts.jsonOut {
return writeExplainJSON(out, record)
}
return renderExplanation(out, record)
}
func readLatestFromCache(factory cacheFactory) (*pmgsandbox.ViolationCacheRecord, error) {
cache := factory()
entry, err := cache.Latest()
if err != nil {
return nil, newExplainFailError(
usefulerror.ErrCodeUnknown,
fmt.Sprintf("read cache: %v", err),
"Check the sandbox violation cache directory and retry.",
)
}
if entry == nil {
return nil, newExplainFailError(
usefulerror.ErrCodeNotFound,
"no violations cached yet — run a sandboxed command first",
"Run a sandboxed package manager command first, then retry `pmg sandbox explain --last`.",
)
}
rec := entry.Record
if err := validateViolationCacheRecord(&rec, "cache JSON"); err != nil {
return nil, err
}
return &rec, nil
}
func readRecordFromStdin(in io.Reader) (*pmgsandbox.ViolationCacheRecord, error) {
data, err := io.ReadAll(in)
if err != nil {
return nil, newExplainFailError(
usefulerror.ErrCodeInvalidArgument,
fmt.Sprintf("read stdin: %v", err),
explainUsageHelp(),
)
}
if len(strings.TrimSpace(string(data))) == 0 {
return nil, newExplainFailError(
usefulerror.ErrCodeInvalidArgument,
"stdin is empty: pipe a ViolationCacheRecord JSON document",
explainUsageHelp(),
)
}
var rec pmgsandbox.ViolationCacheRecord
if err := json.Unmarshal(data, &rec); err != nil {
return nil, newExplainFailError(
usefulerror.ErrCodeInvalidArgument,
fmt.Sprintf("parse stdin JSON: %v", err),
"Pipe a valid ViolationCacheRecord JSON document to `pmg sandbox explain -`.",
)
}
if err := validateViolationCacheRecord(&rec, "stdin JSON"); err != nil {
return nil, err
}
return &rec, nil
}
func validateViolationCacheRecord(rec *pmgsandbox.ViolationCacheRecord, source string) error {
if rec == nil {
return newExplainFailError(usefulerror.ErrCodeInvalidArgument, fmt.Sprintf("%s is empty", source), explainUsageHelp())
}
if rec.SchemaVersion == 0 {
return newExplainFailError(usefulerror.ErrCodeInvalidArgument, fmt.Sprintf("%s is missing schema_version", source), explainUsageHelp())
}
if rec.SchemaVersion != pmgsandbox.ViolationCacheSchemaVersion {
return newExplainFailError(usefulerror.ErrCodeInvalidArgument, fmt.Sprintf("unknown schema_version %d (expected %d)", rec.SchemaVersion, pmgsandbox.ViolationCacheSchemaVersion), explainUsageHelp())
}
if rec.Report == nil {
return newExplainFailError(usefulerror.ErrCodeInvalidArgument, fmt.Sprintf("%s is missing report", source), explainUsageHelp())
}
return nil
}
func explainUsageHelp() string {
return "Use `pmg sandbox explain --last` or pipe a violation record JSON with `pmg sandbox explain -`."
}
// --- Human rendering ----------------------------------------------------
func renderExplanation(out io.Writer, rec *pmgsandbox.ViolationCacheRecord) error {
return ui.RenderSandboxViolation(out, rec)
}
// --- JSON output --------------------------------------------------------
type explainJSONPrimary struct {
Kind string `json:"kind"`
RawKind string `json:"raw_kind,omitempty"`
Target string `json:"target,omitempty"`
RuleTarget string `json:"rule_target,omitempty"`
Process string `json:"process,omitempty"`
RawLog string `json:"raw_log,omitempty"`
RuleLabel string `json:"rule_label,omitempty"`
}
type explainJSONExplanation struct {
Hint string `json:"hint"`
Details string `json:"details"`
SuggestedOverride string `json:"suggested_override"`
Primary *explainJSONPrimary `json:"primary"`
}
type explainJSONOutput struct {
Explanation explainJSONExplanation `json:"explanation"`
Report *pmgsandbox.ViolationReport `json:"report"`
RecordedAt string `json:"recorded_at,omitempty"`
}
func writeExplainJSON(out io.Writer, rec *pmgsandbox.ViolationCacheRecord) error {
if rec == nil || rec.Report == nil {
return errors.New("explain: empty record")
}
exp := pmgsandbox.BuildExplanation(rec.Report)
payload := explainJSONOutput{
Explanation: explainJSONExplanation{
Hint: ui.FormatSandboxHint(exp.Primary, exp.Override),
Details: ui.FormatSandboxDetails(rec.Report, exp.Primary),
SuggestedOverride: ui.FormatSandboxOverrideFlag(exp.Override),
},
Report: rec.Report,
}
if exp.Primary != nil {
payload.Explanation.Primary = &explainJSONPrimary{
Kind: string(exp.Primary.Kind),
RawKind: exp.Primary.RawKind,
Target: exp.Primary.Target,
RuleTarget: exp.Primary.RuleTarget,
Process: exp.Primary.Process,
RawLog: exp.Primary.RawLog,
RuleLabel: exp.Primary.RuleLabel,
}
}
if !rec.RecordedAt.IsZero() {
payload.RecordedAt = rec.RecordedAt.UTC().Format(time.RFC3339)
}
return writeJSONIndent(out, payload)
}
+227
View File
@@ -0,0 +1,227 @@
package sandbox
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
pmgsandbox "github.com/safedep/pmg/sandbox"
"github.com/safedep/pmg/usefulerror"
)
func sampleReport() *pmgsandbox.ViolationReport {
return &pmgsandbox.ViolationReport{
SandboxName: pmgsandbox.DriverSeatbelt,
PolicyName: "npm-restrictive",
CorrelationID: "corr-abc",
Violations: []pmgsandbox.Violation{
{
Kind: pmgsandbox.ViolationKindFSWrite,
Target: "/Users/dev/project/.env",
RuleLabel: "file-write* on sensitive project file",
Process: "npm",
RawLog: "deny file-write* /Users/dev/project/.env",
},
},
}
}
func writeFixtureCache(t *testing.T, dir string) *pmgsandbox.ViolationCache {
t.Helper()
cache := pmgsandbox.NewViolationCache(dir, pmgsandbox.WithClock(func() time.Time {
return time.Date(2026, 5, 14, 10, 30, 0, 0, time.UTC)
}))
_, err := cache.Write(sampleReport())
require.NoError(t, err)
return cache
}
func runExplainCmd(t *testing.T, factory cacheFactory, args []string, stdin string) (string, string, error) {
t.Helper()
cmd := newExplainCommand(factory)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetIn(strings.NewReader(stdin))
cmd.SetArgs(args)
err := cmd.Execute()
return out.String(), errOut.String(), err
}
func TestExplainLastEmptyCache(t *testing.T) {
dir := t.TempDir()
factory := func() *pmgsandbox.ViolationCache { return pmgsandbox.NewViolationCache(dir) }
stdout, _, err := runExplainCmd(t, factory, []string{"--last"}, "")
require.Error(t, err)
fe, ok := err.(*explainFailError)
require.True(t, ok)
assert.Equal(t, ExitCodeExplainFail, fe.ExitCode())
usefulErr, ok := usefulerror.AsUsefulError(err)
require.True(t, ok)
assert.Equal(t, usefulerror.ErrCodeNotFound, usefulErr.Code())
assert.Empty(t, stdout)
assert.Contains(t, err.Error(), "no violations cached")
}
func TestExplainLastRendersHuman(t *testing.T) {
dir := t.TempDir()
writeFixtureCache(t, dir)
factory := func() *pmgsandbox.ViolationCache { return pmgsandbox.NewViolationCache(dir) }
stdout, _, err := runExplainCmd(t, factory, []string{"--last"}, "")
require.NoError(t, err)
assert.Contains(t, stdout, "seatbelt")
assert.Contains(t, stdout, "npm-restrictive")
assert.Contains(t, stdout, "Reason:")
assert.Contains(t, stdout, "Details:")
assert.Contains(t, stdout, "Primary violation:")
assert.Contains(t, stdout, "/Users/dev/project/.env")
}
func TestExplainLastJSON(t *testing.T) {
dir := t.TempDir()
writeFixtureCache(t, dir)
factory := func() *pmgsandbox.ViolationCache { return pmgsandbox.NewViolationCache(dir) }
stdout, _, err := runExplainCmd(t, factory, []string{"--last", "--json"}, "")
require.NoError(t, err)
var payload struct {
Explanation struct {
Hint string `json:"hint"`
Details string `json:"details"`
SuggestedOverride string `json:"suggested_override"`
Primary struct {
Kind string `json:"kind"`
Target string `json:"target"`
} `json:"primary"`
} `json:"explanation"`
Report *pmgsandbox.ViolationReport `json:"report"`
RecordedAt string `json:"recorded_at"`
}
require.NoError(t, json.Unmarshal([]byte(stdout), &payload))
assert.NotEmpty(t, payload.Explanation.Hint)
assert.Equal(t, "fs_write", payload.Explanation.Primary.Kind)
assert.Equal(t, "/Users/dev/project/.env", payload.Explanation.Primary.Target)
require.NotNil(t, payload.Report)
assert.Equal(t, pmgsandbox.DriverSeatbelt, payload.Report.SandboxName)
assert.NotEmpty(t, payload.RecordedAt)
}
func TestExplainLastRejectsCacheRecordWithNilReport(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "violation-99999999T999999.999999999Z-bad.json")
body := []byte(`{"schema_version":1,"recorded_at":"2026-05-14T10:30:00Z","report":null}`)
require.NoError(t, os.WriteFile(path, body, 0o644))
factory := func() *pmgsandbox.ViolationCache { return pmgsandbox.NewViolationCache(dir) }
stdout, _, err := runExplainCmd(t, factory, []string{"--last"}, "")
require.Error(t, err)
fe, ok := err.(*explainFailError)
require.True(t, ok)
assert.Equal(t, ExitCodeExplainFail, fe.ExitCode())
assert.Empty(t, stdout)
assert.Contains(t, err.Error(), "cache JSON is missing report")
}
func TestExplainStdinValid(t *testing.T) {
rec := pmgsandbox.ViolationCacheRecord{
SchemaVersion: pmgsandbox.ViolationCacheSchemaVersion,
RecordedAt: time.Date(2026, 5, 14, 10, 30, 0, 0, time.UTC),
Report: sampleReport(),
}
data, err := json.Marshal(rec)
require.NoError(t, err)
factory := func() *pmgsandbox.ViolationCache {
t.Fatal("cache should not be consulted in stdin mode")
return nil
}
stdout, _, err := runExplainCmd(t, factory, []string{"-"}, string(data))
require.NoError(t, err)
assert.Contains(t, stdout, "Primary violation:")
assert.Contains(t, stdout, "/Users/dev/project/.env")
}
func TestExplainStdinInvalidJSON(t *testing.T) {
factory := func() *pmgsandbox.ViolationCache { return nil }
_, _, err := runExplainCmd(t, factory, []string{"-"}, "not json {{")
require.Error(t, err)
fe, ok := err.(*explainFailError)
require.True(t, ok)
assert.Equal(t, ExitCodeExplainFail, fe.ExitCode())
assert.Contains(t, err.Error(), "parse stdin JSON")
}
func TestExplainStdinSchemaVersion(t *testing.T) {
cases := []struct {
name string
body string
want string
}{
{
name: "missing schema_version",
body: `{"report":{"SandboxName":"seatbelt","PolicyName":"p","Violations":[{"Kind":"fs_write","Target":"/x","RuleLabel":"r"}]}}`,
want: "missing schema_version",
},
{
name: "unknown schema_version",
body: `{"schema_version":999,"report":{"SandboxName":"seatbelt","PolicyName":"p"}}`,
want: "unknown schema_version",
},
}
factory := func() *pmgsandbox.ViolationCache { return nil }
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, _, err := runExplainCmd(t, factory, []string{"-"}, tc.body)
require.Error(t, err)
fe, ok := err.(*explainFailError)
require.True(t, ok)
assert.Equal(t, ExitCodeExplainFail, fe.ExitCode())
assert.Contains(t, err.Error(), tc.want)
})
}
}
func TestExplainMutualExclusion(t *testing.T) {
factory := func() *pmgsandbox.ViolationCache { return nil }
_, _, err := runExplainCmd(t, factory, []string{"--last", "-"}, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "mutually exclusive")
}
func TestExplainRejectsUnexpectedArgsWithUsage(t *testing.T) {
factory := func() *pmgsandbox.ViolationCache { return nil }
stdout, stderr, err := runExplainCmd(t, factory, []string{"one", "two"}, "")
require.Error(t, err)
assert.Contains(t, stderr, "accepts at most 1 arg(s), received 2")
assert.Contains(t, stdout, "Usage:")
assert.Contains(t, stdout, "explain [--last | -] [flags]")
assert.Contains(t, stdout, "pmg sandbox explain --last")
}
func TestExplainNoMode(t *testing.T) {
factory := func() *pmgsandbox.ViolationCache { return nil }
_, _, err := runExplainCmd(t, factory, []string{}, "")
require.Error(t, err)
usefulErr, ok := usefulerror.AsUsefulError(err)
require.True(t, ok)
assert.Equal(t, usefulerror.ErrCodeInvalidArgument, usefulErr.Code())
assert.Contains(t, err.Error(), "no input")
assert.Contains(t, err.Error(), "--last")
}
+155
View File
@@ -0,0 +1,155 @@
package sandbox
import (
"encoding/json"
"errors"
"fmt"
"io"
"regexp"
"strings"
"github.com/safedep/pmg/internal/ui"
pmgsandbox "github.com/safedep/pmg/sandbox"
"github.com/safedep/pmg/usefulerror"
"github.com/spf13/cobra"
)
const ExitCodeProbeFailure = 2
var sandboxErrorExit = func(_ *cobra.Command, err error) error {
type exitCoder interface{ ExitCode() int }
if ec, ok := err.(exitCoder); ok {
ui.ErrorExitWithCode(err, ec.ExitCode())
return nil
}
ui.ErrorExit(err)
return nil
}
var validDrivers = map[pmgsandbox.DriverName]struct{}{
pmgsandbox.DriverSeatbelt: {},
pmgsandbox.DriverBubblewrap: {},
pmgsandbox.DriverLandlock: {},
}
func validateDriver(name string) error {
if name == "" {
return nil
}
if _, ok := validDrivers[pmgsandbox.DriverName(name)]; !ok {
return invalidArgumentError(
fmt.Sprintf("unknown driver %q", name),
"Use one of: seatbelt, bubblewrap, landlock",
)
}
return nil
}
func invalidArgumentError(message, help string) error {
return usefulerror.Useful().
WithCode(usefulerror.ErrCodeInvalidArgument).
WithHumanError(message).
WithHelp(help).
Wrap(errors.New(message))
}
func notFoundError(message, help string) error {
return usefulerror.Useful().
WithCode(usefulerror.ErrCodeNotFound).
WithHumanError(message).
WithHelp(help).
Wrap(errors.New(message))
}
func writeJSONIndent(out io.Writer, v any) error {
enc := json.NewEncoder(out)
enc.SetIndent("", " ")
return enc.Encode(v)
}
// renderTable prints rows with ANSI-aware column alignment. The first row is
// treated as a header. After each data row, optional continuation lines may be
// emitted via the after callback (passed the data row index; -1 for header).
func renderTable(out io.Writer, rows [][]string, after func(rowIdx int) error) error {
if len(rows) == 0 {
return nil
}
cols := len(rows[0])
widths := make([]int, cols)
for _, row := range rows {
for i, cell := range row {
if w := visibleWidth(cell); w > widths[i] {
widths[i] = w
}
}
}
for rIdx, row := range rows {
for i, cell := range row {
if i == cols-1 {
if _, err := fmt.Fprint(out, cell); err != nil {
return err
}
continue
}
pad := widths[i] - visibleWidth(cell)
if _, err := fmt.Fprint(out, cell, strings.Repeat(" ", pad+2)); err != nil {
return err
}
}
if _, err := fmt.Fprintln(out); err != nil {
return err
}
if after != nil {
dataIdx := rIdx - 1
if err := after(dataIdx); err != nil {
return err
}
}
}
return nil
}
// firstColumnIndent returns blanks the width of the first column plus the
// two-space padding renderTable uses, for continuation-line alignment.
func firstColumnIndent(rows [][]string) string {
if len(rows) == 0 {
return ""
}
w := 0
for _, row := range rows {
if v := visibleWidth(row[0]); v > w {
w = v
}
}
return strings.Repeat(" ", w+2)
}
var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`)
// visibleWidth returns the printable width of s with ANSI escape sequences
// stripped — text/tabwriter counts escape bytes as visible chars, misaligning
// colored cells.
func visibleWidth(s string) int {
return len(ansiEscapeRe.ReplaceAllString(s, ""))
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
if n <= 3 {
return s[:n]
}
return s[:n-3] + "..."
}
func truncateLeft(s string, n int) string {
if len(s) <= n {
return s
}
if n <= 3 {
return s[len(s)-n:]
}
return "..." + s[len(s)-(n-3):]
}
+20
View File
@@ -0,0 +1,20 @@
package sandbox
import (
"os"
"testing"
"github.com/spf13/cobra"
)
func TestMain(m *testing.M) {
oldErrorExit := sandboxErrorExit
sandboxErrorExit = func(cmd *cobra.Command, err error) error {
cmd.SilenceErrors = true
cmd.SilenceUsage = true
return err
}
code := m.Run()
sandboxErrorExit = oldErrorExit
os.Exit(code)
}
+34
View File
@@ -0,0 +1,34 @@
package sandbox
import (
"github.com/safedep/pmg/config"
pmgsandbox "github.com/safedep/pmg/sandbox"
"github.com/spf13/cobra"
)
// registryFactory builds a ProfileRegistry. Tests inject a stub.
type registryFactory func() (pmgsandbox.ProfileRegistry, error)
func defaultRegistryFactory() (pmgsandbox.ProfileRegistry, error) {
return pmgsandbox.NewProfileRegistry(
pmgsandbox.WithUserProfileDir(config.Get().SandboxProfileDir()),
)
}
// NewProfileCommand returns the `pmg sandbox profile` parent command.
func NewProfileCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "profile",
Short: "Inspect sandbox profiles",
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
cmd.AddCommand(newProfileListCommand(defaultRegistryFactory))
cmd.AddCommand(newProfileShowCommand(defaultRegistryFactory))
cmd.AddCommand(newProfileInitCommand(defaultRegistryFactory))
cmd.AddCommand(newProfileLintCommand(defaultRegistryFactory))
cmd.AddCommand(newProfileDiffCommand(defaultRegistryFactory))
return cmd
}
+136
View File
@@ -0,0 +1,136 @@
package sandbox
import (
"bytes"
"fmt"
"io"
"strings"
"github.com/pmezard/go-difflib/difflib"
pmgsandbox "github.com/safedep/pmg/sandbox"
"github.com/safedep/pmg/sandbox/platform"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
const ExitCodeDiffError = 2
type profileDiffOptions struct {
driver string
cwd string
home string
}
func newProfileDiffCommand(factory registryFactory) *cobra.Command {
opts := &profileDiffOptions{}
cmd := &cobra.Command{
Use: "diff <a> <b>",
Short: "Diff two resolved sandbox profiles (or their driver-rendered output)",
Example: " pmg sandbox profile diff npm-restrictive pypi-restrictive",
Args: cobra.ExactArgs(2),
SilenceErrors: false,
RunE: func(cmd *cobra.Command, args []string) error {
err := runProfileDiff(cmd.OutOrStdout(), cmd.ErrOrStderr(), args[0], args[1], opts, factory)
if err != nil {
if _, isDiff := err.(*diffPresentError); isDiff {
cmd.SilenceErrors = true
cmd.SilenceUsage = true
return err
}
return sandboxErrorExit(cmd, err)
}
return err
},
}
cmd.Flags().StringVar(&opts.driver, "driver", "", "Render each policy for a specific driver before diffing: seatbelt|bubblewrap|landlock")
cmd.Flags().StringVar(&opts.cwd, "cwd", "", "Override ${CWD} during expansion (defaults to current working directory)")
cmd.Flags().StringVar(&opts.home, "home", "", "Override ${HOME} during expansion (defaults to current user home)")
return cmd
}
type diffPresentError struct{}
func (e *diffPresentError) Error() string { return "" }
func (e *diffPresentError) ExitCode() int { return 1 }
type diffOpError struct{ err error }
func (e *diffOpError) Error() string { return e.err.Error() }
func (e *diffOpError) Unwrap() error { return e.err }
func (e *diffOpError) ExitCode() int { return ExitCodeDiffError }
func runProfileDiff(out io.Writer, errOut io.Writer, nameA, nameB string, opts *profileDiffOptions, factory registryFactory) error {
if err := validateDriver(opts.driver); err != nil {
return &diffOpError{err: err}
}
registry, err := factory()
if err != nil {
return &diffOpError{err: err}
}
dataA, err := materialize(registry, nameA, opts)
if err != nil {
return &diffOpError{err: err}
}
dataB, err := materialize(registry, nameB, opts)
if err != nil {
return &diffOpError{err: err}
}
if bytes.Equal(dataA, dataB) {
if _, err := fmt.Fprintln(errOut, "profiles are identical"); err != nil {
return &diffOpError{err: err}
}
return nil
}
diff := difflib.UnifiedDiff{
A: difflib.SplitLines(string(dataA)),
B: difflib.SplitLines(string(dataB)),
FromFile: nameA,
ToFile: nameB,
Context: 3,
}
text, err := difflib.GetUnifiedDiffString(diff)
if err != nil {
return &diffOpError{err: fmt.Errorf("failed to render diff: %w", err)}
}
if _, err := io.WriteString(out, text); err != nil {
return &diffOpError{err: err}
}
if !strings.HasSuffix(text, "\n") {
if _, err := fmt.Fprintln(out); err != nil {
return &diffOpError{err: err}
}
}
return &diffPresentError{}
}
func materialize(registry pmgsandbox.ProfileRegistry, name string, opts *profileDiffOptions) ([]byte, error) {
policy, err := registry.ResolveProfile(name, pmgsandbox.ResolveOptions{
CWD: opts.cwd,
Home: opts.home,
})
if err != nil {
return nil, err
}
if opts.driver != "" {
return platform.Render(pmgsandbox.DriverName(opts.driver), policy)
}
// Strip inherits since both sides are post-resolution.
policy.Inherits = ""
data, err := yaml.Marshal(policy)
if err != nil {
return nil, fmt.Errorf("failed to marshal resolved policy %s: %w", name, err)
}
return data, nil
}
+165
View File
@@ -0,0 +1,165 @@
package sandbox
import (
"bytes"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func writeDiffUserProfile(t *testing.T, dir, name, body string) {
t.Helper()
require.NoError(t, os.MkdirAll(dir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, name+".yml"), []byte(body), 0o644))
}
func runDiffCmd(t *testing.T, factory registryFactory, args ...string) (string, string, error) {
t.Helper()
cmd := newProfileDiffCommand(factory)
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetArgs(args)
err := cmd.Execute()
return stdout.String(), stderr.String(), err
}
func TestProfileDiffSameProfileNoOutput(t *testing.T) {
stdout, stderr, err := runDiffCmd(t, newTestRegistry(t, ""), "npm-restrictive", "npm-restrictive")
require.NoError(t, err)
assert.Empty(t, stdout, "stdout should be empty when profiles are identical")
assert.Contains(t, stderr, "identical")
}
func TestProfileDiffDistinctProfiles(t *testing.T) {
stdout, _, err := runDiffCmd(t, newTestRegistry(t, ""), "npm-restrictive", "pypi-restrictive")
require.Error(t, err)
de, ok := err.(*diffPresentError)
require.True(t, ok, "expected diffPresentError, got %T", err)
assert.Equal(t, 1, de.ExitCode())
assert.NotEmpty(t, stdout)
assert.Contains(t, stdout, "--- npm-restrictive")
assert.Contains(t, stdout, "+++ pypi-restrictive")
// Some +/- body lines should be present.
hasPlus := false
hasMinus := false
for _, line := range strings.Split(stdout, "\n") {
if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") {
hasPlus = true
}
if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") {
hasMinus = true
}
}
assert.True(t, hasPlus, "expected at least one '+' line")
assert.True(t, hasMinus, "expected at least one '-' line")
}
func TestProfileDiffDriverNative(t *testing.T) {
driver := ""
switch runtime.GOOS {
case "darwin":
driver = "seatbelt"
case "linux":
driver = "bubblewrap"
default:
t.Skipf("no native driver for %s", runtime.GOOS)
}
stdout, _, err := runDiffCmd(t, newTestRegistry(t, ""), "npm-restrictive", "pypi-restrictive", "--driver", driver)
require.Error(t, err)
_, ok := err.(*diffPresentError)
require.True(t, ok, "expected diffPresentError, got %T", err)
assert.NotEmpty(t, stdout)
if driver == "seatbelt" {
// SBPL output should contain version line or s-expressions, not YAML keys.
assert.True(t,
strings.Contains(stdout, "(version") || strings.Contains(stdout, "allow") || strings.Contains(stdout, "deny"),
"expected SBPL-ish body, got: %s", stdout)
assert.NotContains(t, stdout, "package_managers:")
}
}
func TestProfileDiffUnknownProfile(t *testing.T) {
stdout, stderr, err := runDiffCmd(t, newTestRegistry(t, ""), "npm-restrictive", "no-such-profile-xyz")
require.Error(t, err)
de, ok := err.(*diffOpError)
require.True(t, ok, "expected diffOpError, got %T", err)
assert.Equal(t, ExitCodeDiffError, de.ExitCode())
assert.Empty(t, stdout)
assert.Empty(t, stderr)
assert.Contains(t, err.Error(), "no-such-profile-xyz")
}
func TestProfileDiffUnknownDriver(t *testing.T) {
stdout, stderr, err := runDiffCmd(t, newTestRegistry(t, ""), "npm-restrictive", "npm-restrictive", "--driver", "bogus")
require.Error(t, err)
de, ok := err.(*diffOpError)
require.True(t, ok, "expected diffOpError, got %T", err)
assert.Equal(t, ExitCodeDiffError, de.ExitCode())
assert.Empty(t, stdout)
assert.Empty(t, stderr)
assert.Contains(t, err.Error(), "unknown driver")
}
func TestProfileDiffMissingProfileShowsUsage(t *testing.T) {
stdout, stderr, err := runDiffCmd(t, newTestRegistry(t, ""), "npm-restrictive")
require.Error(t, err)
assert.Contains(t, stderr, "accepts 2 arg(s), received 1")
assert.Contains(t, stdout, "Usage:")
assert.Contains(t, stdout, "diff <a> <b> [flags]")
assert.Contains(t, stdout, "pmg sandbox profile diff npm-restrictive pypi-restrictive")
}
func TestProfileDiffCWDOverride(t *testing.T) {
// Two user profiles with identical bodies referencing ${CWD}. Diffing
// them with the same name but different --cwd values isn't supported by
// the CLI (single --cwd), so we exercise the path delta by using two
// distinct user profiles with hard-coded different cwd-style paths.
//
// Easier: use the same profile twice with no --cwd vs --cwd, which the
// CLI doesn't allow on a per-side basis. So we approximate by giving
// both sides the same profile under different file names but with
// different baked-in paths — proving cwd-derived deltas show up in YAML.
dir := t.TempDir()
writeDiffUserProfile(t, dir, "p-a", `name: p-a
description: a
package_managers:
- npm
filesystem:
allow_read:
- /aaa
`)
writeDiffUserProfile(t, dir, "p-b", `name: p-b
description: b
package_managers:
- npm
filesystem:
allow_read:
- /bbb
`)
stdout, _, err := runDiffCmd(t, newTestRegistry(t, dir), "p-a", "p-b")
require.Error(t, err)
_, ok := err.(*diffPresentError)
require.True(t, ok)
assert.Contains(t, stdout, "/aaa")
assert.Contains(t, stdout, "/bbb")
}
func TestDiffPresentErrorExitCode(t *testing.T) {
e := &diffPresentError{}
assert.Equal(t, 1, e.ExitCode())
}
func TestDiffOpErrorExitCode(t *testing.T) {
e := &diffOpError{}
assert.Equal(t, ExitCodeDiffError, e.ExitCode())
}
+192
View File
@@ -0,0 +1,192 @@
package sandbox
import (
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/spf13/cobra"
)
// profileNameRe enforces a single path-safe segment. Rejects slashes,
// dot-prefixes, leading dashes, spaces, and other shell-hostile characters.
var profileNameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`)
type profileInitOptions struct {
from string
packageManagers []string
description string
}
func newProfileInitCommand(factory registryFactory) *cobra.Command {
opts := &profileInitOptions{}
cmd := &cobra.Command{
Use: "init <name>",
Short: "Scaffold a new user sandbox profile",
Long: "Create a starter YAML profile under the user profile directory.\n\n" +
"Prefer --from <builtin> to emit a minimal child profile that inherits a built-in.\n" +
"Copying the entire built-in YAML by hand is discouraged: the child stays small\n" +
"and only declares its additive deltas.",
Example: " pmg sandbox profile init my-npm --from npm-restrictive",
Args: cobra.ExactArgs(1),
SilenceErrors: false,
RunE: func(cmd *cobra.Command, args []string) error {
err := runProfileInit(cmd.OutOrStdout(), args[0], opts, factory)
if err != nil {
return sandboxErrorExit(cmd, err)
}
return err
},
}
cmd.Flags().StringVar(&opts.from, "from", "", "Built-in profile to inherit from (recommended)")
cmd.Flags().StringSliceVar(&opts.packageManagers, "package-manager", nil, "Package manager this profile applies to (repeatable)")
cmd.Flags().StringVar(&opts.description, "description", "", "One-line description for the profile")
return cmd
}
func runProfileInit(out io.Writer, name string, opts *profileInitOptions, factory registryFactory) error {
if !profileNameRe.MatchString(name) {
return invalidArgumentError(
fmt.Sprintf("invalid profile name %q", name),
"Use a single path-safe name matching "+profileNameRe.String(),
)
}
registry, err := factory()
if err != nil {
return err
}
userDir := registry.UserProfileDir()
if userDir == "" {
return invalidArgumentError(
"user profile directory is not configured",
"Configure the sandbox profile directory, then retry `pmg sandbox profile init`.",
)
}
if opts.from != "" {
if _, ok := registry.BuiltinProfileYAML(opts.from); !ok {
return notFoundError(
fmt.Sprintf("unknown built-in profile %q", opts.from),
"Use `pmg sandbox profile list` to see known built-in profiles.",
)
}
}
pms := opts.packageManagers
placeholderPM := false
if len(pms) == 0 {
pms = []string{"npm"}
placeholderPM = true
}
target := filepath.Join(userDir, name+".yml")
if _, err := os.Stat(target); err == nil {
return invalidArgumentError(
fmt.Sprintf("profile already exists at %s", target),
"Choose a different profile name, or edit the existing profile file to merge changes.",
)
} else if !os.IsNotExist(err) {
return fmt.Errorf("failed to stat %s: %w", target, err)
}
if err := os.MkdirAll(userDir, 0o755); err != nil {
return fmt.Errorf("failed to create user profile directory %s: %w", userDir, err)
}
content := renderScaffold(name, opts.description, opts.from, pms, placeholderPM)
if err := os.WriteFile(target, []byte(content), 0o644); err != nil {
return fmt.Errorf("failed to write %s: %w", target, err)
}
_, err = fmt.Fprintln(out, target)
return err
}
func renderScaffold(name, description, inheritsFrom string, pms []string, placeholderPM bool) string {
var b strings.Builder
b.WriteString("# pmg sandbox profile — scaffolded by `pmg sandbox profile init`.\n")
b.WriteString("# Edit this file to fit your workflow, then validate with:\n")
b.WriteString("# pmg sandbox profile show ")
b.WriteString(name)
b.WriteString(" --resolved\n")
b.WriteString("\n")
b.WriteString("name: ")
b.WriteString(name)
b.WriteString("\n")
if description != "" {
b.WriteString("description: ")
b.WriteString(yamlString(description))
b.WriteString("\n")
}
if inheritsFrom != "" {
b.WriteString("\n# Inherit from a built-in profile. Rules declared below are merged additively\n")
b.WriteString("# over the parent — you do not need to repeat the parent's allow-lists.\n")
b.WriteString("inherits: ")
b.WriteString(inheritsFrom)
b.WriteString("\n")
}
b.WriteString("\n# Package managers this profile applies to.")
if placeholderPM {
b.WriteString(" Placeholder — update to match your usage.")
}
b.WriteString("\npackage_managers:\n")
for _, pm := range pms {
b.WriteString(" - ")
b.WriteString(pm)
b.WriteString("\n")
}
b.WriteString("\n# Filesystem access — additive over the parent (if any).\n")
b.WriteString("filesystem:\n")
if inheritsFrom == "" {
b.WriteString(" # Starter rule so the policy validates. Replace with the paths you actually need.\n")
b.WriteString(" allow_read:\n")
b.WriteString(" - ${CWD}/**\n")
} else {
b.WriteString(" allow_read: []\n")
}
b.WriteString(" allow_write: []\n")
b.WriteString(" deny_read: []\n")
b.WriteString(" deny_write: []\n")
b.WriteString("\n# Network egress rules — additive over the parent (if any).\n")
b.WriteString("# Patterns are \"host:port\"; use \"*:443\" or \"registry.npmjs.org:443\".\n")
b.WriteString("network:\n")
b.WriteString(" allow_outbound: []\n")
b.WriteString(" deny_outbound: []\n")
b.WriteString(" allow_bind: []\n")
b.WriteString("\n# Process execution rules — additive over the parent (if any).\n")
b.WriteString("process:\n")
b.WriteString(" allow_exec: []\n")
b.WriteString(" deny_exec: []\n")
return b.String()
}
// yamlString quotes s for YAML when it contains characters that would otherwise
// confuse the parser. Plain scalars are emitted unquoted to keep the scaffold
// readable for hand-editing.
func yamlString(s string) string {
if s == "" {
return `""`
}
for _, r := range s {
if r == ':' || r == '#' || r == '\'' || r == '"' || r == '\n' || r == '\t' {
return fmt.Sprintf("%q", s)
}
}
return s
}
+222
View File
@@ -0,0 +1,222 @@
package sandbox
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
pmgsandbox "github.com/safedep/pmg/sandbox"
"github.com/safedep/pmg/usefulerror"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestRegistryFactory(t *testing.T, dir string) registryFactory {
t.Helper()
return func() (pmgsandbox.ProfileRegistry, error) {
return pmgsandbox.NewProfileRegistry(pmgsandbox.WithUserProfileDir(dir))
}
}
func runInitCmd(t *testing.T, dir string, args ...string) (string, string, error) {
t.Helper()
cmd := newProfileInitCommand(newTestRegistryFactory(t, dir))
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetArgs(args)
err := cmd.Execute()
return stdout.String(), stderr.String(), err
}
func TestProfileInit_HappyPath_InheritsBuiltin(t *testing.T) {
dir := t.TempDir()
stdout, stderr, err := runInitCmd(t, dir, "my-npm", "--from", "npm-restrictive", "--package-manager", "npm")
require.NoError(t, err, "stderr: %s", stderr)
expected := filepath.Join(dir, "my-npm.yml")
assert.Equal(t, expected+"\n", stdout)
data, err := os.ReadFile(expected)
require.NoError(t, err)
assert.Contains(t, string(data), "inherits: npm-restrictive")
assert.Contains(t, string(data), "name: my-npm")
assert.Contains(t, string(data), "- npm\n")
// Round-trip: registry must parse and validate the new file.
registry, err := pmgsandbox.NewProfileRegistry(pmgsandbox.WithUserProfileDir(dir))
require.NoError(t, err)
policy, err := registry.GetProfile("my-npm")
require.NoError(t, err)
assert.Equal(t, "my-npm", policy.Name)
assert.Equal(t, []string{"npm"}, policy.PackageManagers)
}
func TestProfileInit_Standalone(t *testing.T) {
dir := t.TempDir()
_, stderr, err := runInitCmd(t, dir, "standalone", "--package-manager", "npm", "--description", "test profile")
require.NoError(t, err, "stderr: %s", stderr)
data, err := os.ReadFile(filepath.Join(dir, "standalone.yml"))
require.NoError(t, err)
assert.NotContains(t, string(data), "inherits:")
assert.Contains(t, string(data), "description: test profile")
registry, err := pmgsandbox.NewProfileRegistry(pmgsandbox.WithUserProfileDir(dir))
require.NoError(t, err)
policy, err := registry.GetProfile("standalone")
require.NoError(t, err)
assert.Equal(t, "test profile", policy.Description)
}
func TestProfileInit_DefaultPackageManager(t *testing.T) {
dir := t.TempDir()
_, _, err := runInitCmd(t, dir, "defaults", "--from", "npm-restrictive")
require.NoError(t, err)
data, err := os.ReadFile(filepath.Join(dir, "defaults.yml"))
require.NoError(t, err)
assert.Contains(t, string(data), "Placeholder")
assert.Contains(t, string(data), "- npm\n")
}
func TestProfileInit_RefuseOverwrite(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "exists.yml")
require.NoError(t, os.WriteFile(target, []byte("preexisting"), 0o644))
_, _, err := runInitCmd(t, dir, "exists", "--from", "npm-restrictive")
require.Error(t, err)
assert.Contains(t, err.Error(), "already exists")
assert.Contains(t, err.Error(), target)
usefulErr, ok := usefulerror.AsUsefulError(err)
require.True(t, ok)
assert.Equal(t, usefulerror.ErrCodeInvalidArgument, usefulErr.Code())
data, err := os.ReadFile(target)
require.NoError(t, err)
assert.Equal(t, "preexisting", string(data))
}
func TestProfileInit_MissingNameShowsInstruction(t *testing.T) {
dir := t.TempDir()
stdout, stderr, err := runInitCmd(t, dir)
require.Error(t, err)
assert.Contains(t, stderr, "accepts 1 arg(s), received 0")
assert.Contains(t, stdout, "Usage:")
assert.Contains(t, stdout, "init <name> [flags]")
assert.Contains(t, stdout, "Examples:")
assert.Contains(t, stdout, "pmg sandbox profile init my-npm --from npm-restrictive")
}
func TestProfileInit_InvalidName(t *testing.T) {
cases := []struct {
name string
arg string
}{
{"relative traversal", "../foo"},
{"absolute", "/abs"},
{"space", "bad name"},
{"leading dash", "-bad"},
{"leading underscore", "_bad"},
{"empty", ""},
{"slash", "a/b"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
args := []string{"--from", "npm-restrictive", "--", tc.arg}
if tc.arg == "" {
// cobra ExactArgs(1) will reject the empty-name case via args
// length, but we still want to verify rejection — pass an
// explicit empty string as the positional arg.
cmd := newProfileInitCommand(newTestRegistryFactory(t, dir))
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{""})
err := cmd.Execute()
require.Error(t, err)
return
}
_, _, err := runInitCmd(t, dir, args...)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid profile name")
usefulErr, ok := usefulerror.AsUsefulError(err)
require.True(t, ok)
assert.Equal(t, usefulerror.ErrCodeInvalidArgument, usefulErr.Code())
})
}
}
func TestProfileInit_UnknownBuiltin(t *testing.T) {
dir := t.TempDir()
_, _, err := runInitCmd(t, dir, "child", "--from", "no-such-builtin")
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown built-in profile")
usefulErr, ok := usefulerror.AsUsefulError(err)
require.True(t, ok)
assert.Equal(t, usefulerror.ErrCodeNotFound, usefulErr.Code())
}
func TestProfileInit_StdoutIsExactlyThePath(t *testing.T) {
dir := t.TempDir()
stdout, _, err := runInitCmd(t, dir, "exact", "--from", "npm-restrictive")
require.NoError(t, err)
expected := filepath.Join(dir, "exact.yml") + "\n"
assert.Equal(t, expected, stdout)
}
func TestProfileInit_CreatesMissingParentDir(t *testing.T) {
base := t.TempDir()
dir := filepath.Join(base, "nested", "user-profiles")
_, _, err := runInitCmd(t, dir, "child", "--from", "npm-restrictive")
require.NoError(t, err)
info, err := os.Stat(dir)
require.NoError(t, err)
assert.True(t, info.IsDir())
}
func TestProfileNameRegex(t *testing.T) {
good := []string{"a", "A", "0", "abc", "abc-def", "abc_def", "ABC123-_x"}
bad := []string{"", "-a", "_a", "a/b", "a b", "a.b", "..", "../x"}
for _, s := range good {
assert.True(t, profileNameRe.MatchString(s), "should accept %q", s)
}
for _, s := range bad {
assert.False(t, profileNameRe.MatchString(s), "should reject %q", s)
}
}
// Compile-time check that newProfileInitCommand returns *cobra.Command.
var _ = func() *cobra.Command {
return newProfileInitCommand(func() (pmgsandbox.ProfileRegistry, error) { return nil, nil })
}
// Sanity check the rendered scaffold uses tokens the parser actually accepts.
func TestProfileInit_ScaffoldSnippet(t *testing.T) {
dir := t.TempDir()
_, _, err := runInitCmd(t, dir, "demo", "--from", "npm-restrictive", "--package-manager", "npm", "--description", "demo profile")
require.NoError(t, err)
data, err := os.ReadFile(filepath.Join(dir, "demo.yml"))
require.NoError(t, err)
s := string(data)
for _, want := range []string{"name: demo", "description: demo profile", "inherits: npm-restrictive", "package_managers:", "filesystem:", "network:", "process:"} {
assert.True(t, strings.Contains(s, want), "scaffold missing %q\n--- scaffold ---\n%s", want, s)
}
}
+216
View File
@@ -0,0 +1,216 @@
package sandbox
import (
"fmt"
"io"
"os"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/internal/ui"
pmgsandbox "github.com/safedep/pmg/sandbox"
"github.com/spf13/cobra"
)
const ExitCodeLintFail = 2
type profileLintOptions struct {
strict bool
verbose bool
jsonOut bool
}
func newProfileLintCommand(factory registryFactory) *cobra.Command {
opts := &profileLintOptions{}
cmd := &cobra.Command{
Use: "lint <path|name>",
Short: "Lint a sandbox profile for schema issues, overly broad rules, and conflicts",
Example: " pmg sandbox profile lint npm-restrictive\n pmg sandbox profile lint ./my-profile.yml --strict",
Args: cobra.ExactArgs(1),
SilenceErrors: false,
RunE: func(cmd *cobra.Command, args []string) error {
err := runProfileLint(cmd.OutOrStdout(), args[0], opts, factory)
if err != nil {
if _, isFail := err.(*lintFailError); isFail {
cmd.SilenceErrors = true
cmd.SilenceUsage = true
return err
}
return sandboxErrorExit(cmd, err)
}
return err
},
}
cmd.Flags().BoolVar(&opts.strict, "strict", false, "Treat warnings as errors (non-zero exit)")
cmd.Flags().BoolVar(&opts.verbose, "verbose", false, "Include info-level issues")
cmd.Flags().BoolVar(&opts.jsonOut, "json", false, "Emit lint results as JSON")
return cmd
}
type lintFailError struct{}
func (e *lintFailError) Error() string { return "" }
func (e *lintFailError) ExitCode() int { return ExitCodeLintFail }
func runProfileLint(out io.Writer, name string, opts *profileLintOptions, factory registryFactory) error {
registry, err := factory()
if err != nil {
return err
}
policy, resolvedName, err := resolveProfileForLint(name, registry)
if err != nil {
return err
}
issues := pmgsandbox.LintProfile(policy)
if !opts.verbose {
issues = filterInfo(issues)
}
if opts.jsonOut {
if err := writeLintJSON(out, resolvedName, issues); err != nil {
return err
}
} else {
if err := renderLintHuman(out, resolvedName, issues); err != nil {
return err
}
}
if shouldFailLint(issues, opts.strict) {
return &lintFailError{}
}
return nil
}
func resolveProfileForLint(name string, registry pmgsandbox.ProfileRegistry) (*pmgsandbox.SandboxPolicy, string, error) {
if _, ok := registry.BuiltinProfileYAML(name); ok {
policy, err := registry.GetProfile(name)
if err != nil {
return nil, "", err
}
return policy, name, nil
}
summaries, err := registry.ListProfiles()
if err != nil {
log.Warnf("sandbox: failed to enumerate user profiles: %v", err)
}
for _, s := range summaries {
if s.Source == pmgsandbox.ProfileSourceUser && s.Name == name {
policy, loadErr := registry.LoadCustomProfile(s.Path)
if loadErr != nil {
return nil, "", loadErr
}
return policy, s.Path, nil
}
}
if _, statErr := os.Stat(name); statErr == nil {
policy, loadErr := registry.LoadCustomProfile(name)
if loadErr != nil {
return nil, "", loadErr
}
return policy, name, nil
}
return nil, "", notFoundError(
fmt.Sprintf("sandbox profile not found: %s", name),
"Use `pmg sandbox profile list` to see available profiles, or pass an existing profile YAML path.",
)
}
func filterInfo(issues []pmgsandbox.LintIssue) []pmgsandbox.LintIssue {
out := make([]pmgsandbox.LintIssue, 0, len(issues))
for _, i := range issues {
if i.Level == pmgsandbox.LintLevelInfo {
continue
}
out = append(out, i)
}
return out
}
func shouldFailLint(issues []pmgsandbox.LintIssue, strict bool) bool {
for _, i := range issues {
if i.Level == pmgsandbox.LintLevelError {
return true
}
if strict && i.Level == pmgsandbox.LintLevelWarn {
return true
}
}
return false
}
type jsonLintReport struct {
Profile string `json:"profile"`
Issues []pmgsandbox.LintIssue `json:"issues"`
}
func writeLintJSON(out io.Writer, profile string, issues []pmgsandbox.LintIssue) error {
if issues == nil {
issues = []pmgsandbox.LintIssue{}
}
return writeJSONIndent(out, jsonLintReport{Profile: profile, Issues: issues})
}
func renderLintHuman(out io.Writer, profile string, issues []pmgsandbox.LintIssue) error {
if _, err := fmt.Fprintln(out); err != nil {
return err
}
if _, err := fmt.Fprintln(out, ui.Colors.Cyan("Profile Lint: ")+ui.Colors.Bold(profile)); err != nil {
return err
}
if _, err := fmt.Fprintln(out, ui.Colors.Normal("---------------")); err != nil {
return err
}
if len(issues) == 0 {
_, err := fmt.Fprintln(out, ui.Colors.Green("OK")+" no issues found")
return err
}
rows := make([][]string, 0, len(issues)+1)
rows = append(rows, []string{
ui.Colors.Bold("LEVEL"),
ui.Colors.Bold("CODE"),
ui.Colors.Bold("FIELD"),
ui.Colors.Bold("MESSAGE"),
})
for _, i := range issues {
rows = append(rows, []string{
lintLevelBadge(i.Level),
i.Code,
i.Field,
i.Message,
})
}
indent := firstColumnIndent(rows)
return renderTable(out, rows, func(dataIdx int) error {
if dataIdx < 0 {
return nil
}
issue := issues[dataIdx]
if issue.Rule == "" {
return nil
}
_, err := fmt.Fprintf(out, "%s%s %s\n", indent, ui.Colors.Dim("rule:"), ui.Colors.Dim(issue.Rule))
return err
})
}
func lintLevelBadge(level pmgsandbox.LintLevel) string {
switch level {
case pmgsandbox.LintLevelError:
return ui.Colors.Red("ERROR")
case pmgsandbox.LintLevelWarn:
return ui.Colors.Yellow("WARN")
case pmgsandbox.LintLevelInfo:
return ui.Colors.Dim("INFO")
}
return string(level)
}
+223
View File
@@ -0,0 +1,223 @@
package sandbox
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
pmgsandbox "github.com/safedep/pmg/sandbox"
"github.com/safedep/pmg/usefulerror"
)
func writeUserProfileLint(t *testing.T, dir, name, body string) string {
t.Helper()
path := filepath.Join(dir, name+".yml")
require.NoError(t, os.WriteFile(path, []byte(body), 0o644))
return path
}
func TestProfileLint_BuiltinClean(t *testing.T) {
cmd := newProfileLintCommand(newTestRegistry(t, ""))
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"npm-restrictive"})
require.NoError(t, cmd.Execute())
assert.Contains(t, stdout.String(), "Profile Lint")
}
func TestProfileLint_JSONOutput(t *testing.T) {
dir := t.TempDir()
writeUserProfileLint(t, dir, "broad", `name: broad
description: broad profile
package_managers:
- npm
filesystem:
allow_read:
- /**
allow_write:
- ${HOME}/**
`)
cmd := newProfileLintCommand(newTestRegistry(t, dir))
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"broad", "--json"})
err := cmd.Execute()
// Lint surfaces warnings but no errors; exit should be zero without --strict.
require.NoError(t, err)
var report struct {
Profile string `json:"profile"`
Issues []pmgsandbox.LintIssue `json:"issues"`
}
require.NoError(t, json.Unmarshal(stdout.Bytes(), &report))
assert.Equal(t, filepath.Join(dir, "broad.yml"), report.Profile)
codes := map[string]int{}
for _, i := range report.Issues {
codes[i.Code]++
}
assert.Equal(t, 1, codes["broad.root_glob"])
assert.Equal(t, 1, codes["broad.home_glob"])
}
func TestProfileLint_StrictFailsOnWarn(t *testing.T) {
dir := t.TempDir()
writeUserProfileLint(t, dir, "warn", `name: warn
description: warn profile
package_managers:
- npm
filesystem:
allow_read:
- /**
`)
cmd := newProfileLintCommand(newTestRegistry(t, dir))
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"warn", "--strict"})
err := cmd.Execute()
require.Error(t, err)
fail, ok := err.(*lintFailError)
require.True(t, ok, "expected *lintFailError, got %T", err)
assert.Equal(t, ExitCodeLintFail, fail.ExitCode())
}
func TestProfileLint_NoStrictWarnSucceeds(t *testing.T) {
dir := t.TempDir()
writeUserProfileLint(t, dir, "warn", `name: warn
description: warn profile
package_managers:
- npm
filesystem:
allow_read:
- /**
`)
cmd := newProfileLintCommand(newTestRegistry(t, dir))
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"warn"})
require.NoError(t, cmd.Execute())
assert.Contains(t, stdout.String(), "WARN")
}
func TestProfileLint_VerboseShowsInfo(t *testing.T) {
dir := t.TempDir()
writeUserProfileLint(t, dir, "dead", `name: dead
description: dead rule profile
package_managers:
- npm
filesystem:
allow_read:
- ${HOME}/.cache/npm/**
- ${HOME}/.cache/npm/foo
`)
t.Run("default hides info", func(t *testing.T) {
cmd := newProfileLintCommand(newTestRegistry(t, dir))
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"dead", "--json"})
require.NoError(t, cmd.Execute())
var report struct {
Issues []pmgsandbox.LintIssue `json:"issues"`
}
require.NoError(t, json.Unmarshal(stdout.Bytes(), &report))
for _, i := range report.Issues {
assert.NotEqual(t, pmgsandbox.LintLevelInfo, i.Level)
}
})
t.Run("verbose shows info", func(t *testing.T) {
cmd := newProfileLintCommand(newTestRegistry(t, dir))
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"dead", "--json", "--verbose"})
require.NoError(t, cmd.Execute())
var report struct {
Issues []pmgsandbox.LintIssue `json:"issues"`
}
require.NoError(t, json.Unmarshal(stdout.Bytes(), &report))
foundInfo := false
for _, i := range report.Issues {
if i.Level == pmgsandbox.LintLevelInfo {
foundInfo = true
}
}
assert.True(t, foundInfo, "expected at least one info-level issue with --verbose")
})
}
func TestProfileLint_UnknownProfile(t *testing.T) {
cmd := newProfileLintCommand(newTestRegistry(t, ""))
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"does-not-exist"})
err := cmd.Execute()
require.Error(t, err)
assert.Empty(t, stderr.String())
assert.Contains(t, err.Error(), "not found")
usefulErr, ok := usefulerror.AsUsefulError(err)
require.True(t, ok)
assert.Equal(t, usefulerror.ErrCodeNotFound, usefulErr.Code())
}
func TestProfileLintMissingTargetShowsUsage(t *testing.T) {
cmd := newProfileLintCommand(newTestRegistry(t, ""))
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{})
err := cmd.Execute()
require.Error(t, err)
assert.Contains(t, stderr.String(), "accepts 1 arg(s), received 0")
assert.Contains(t, stdout.String(), "Usage:")
assert.Contains(t, stdout.String(), "lint <path|name> [flags]")
assert.Contains(t, stdout.String(), "pmg sandbox profile lint npm-restrictive")
}
func TestProfileLint_LiteralPath(t *testing.T) {
dir := t.TempDir()
path := writeUserProfileLint(t, dir, "literal", `name: literal
description: literal
package_managers:
- npm
filesystem:
allow_read:
- /tmp
`)
// Use a registry without user dir — must fall through to the literal path branch.
cmd := newProfileLintCommand(newTestRegistry(t, ""))
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{path, "--json"})
require.NoError(t, cmd.Execute())
var report struct {
Profile string `json:"profile"`
}
require.NoError(t, json.Unmarshal(stdout.Bytes(), &report))
assert.Equal(t, path, report.Profile)
}
+146
View File
@@ -0,0 +1,146 @@
package sandbox
import (
"fmt"
"io"
"strings"
"github.com/safedep/pmg/internal/ui"
pmgsandbox "github.com/safedep/pmg/sandbox"
"github.com/spf13/cobra"
)
type profileListOptions struct {
jsonOut bool
}
func newProfileListCommand(factory registryFactory) *cobra.Command {
opts := &profileListOptions{}
cmd := &cobra.Command{
Use: "list",
Short: "List available sandbox profiles (built-in and user)",
Example: " pmg sandbox profile list",
SilenceErrors: false,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
err := runProfileList(cmd.OutOrStdout(), opts, factory)
if err != nil {
return sandboxErrorExit(cmd, err)
}
return err
},
}
cmd.Flags().BoolVar(&opts.jsonOut, "json", false, "Emit profiles as JSON")
return cmd
}
func runProfileList(out io.Writer, opts *profileListOptions, factory registryFactory) error {
registry, err := factory()
if err != nil {
return err
}
summaries, err := registry.ListProfiles()
if err != nil {
return err
}
if opts.jsonOut {
return writeProfileListJSON(out, summaries)
}
return renderProfileListHuman(out, summaries)
}
type jsonProfileSummary struct {
Name string `json:"name"`
Source string `json:"source"`
Path string `json:"path,omitempty"`
Inherits string `json:"inherits,omitempty"`
PackageManagers []string `json:"package_managers,omitempty"`
Description string `json:"description,omitempty"`
Shadowed bool `json:"shadowed,omitempty"`
}
type jsonProfileListReport struct {
Profiles []jsonProfileSummary `json:"profiles"`
}
func writeProfileListJSON(out io.Writer, summaries []pmgsandbox.ProfileSummary) error {
report := jsonProfileListReport{Profiles: make([]jsonProfileSummary, 0, len(summaries))}
for _, s := range summaries {
report.Profiles = append(report.Profiles, jsonProfileSummary{
Name: s.Name,
Source: string(s.Source),
Path: s.Path,
Inherits: s.Inherits,
PackageManagers: s.PackageManagers,
Description: s.Description,
Shadowed: s.Shadowed,
})
}
return writeJSONIndent(out, report)
}
func renderProfileListHuman(out io.Writer, summaries []pmgsandbox.ProfileSummary) error {
if len(summaries) == 0 {
_, err := fmt.Fprintln(out, ui.Colors.Dim("No sandbox profiles available."))
return err
}
if _, err := fmt.Fprintln(out); err != nil {
return err
}
if _, err := fmt.Fprintln(out, ui.Colors.Cyan("Sandbox Profiles")); err != nil {
return err
}
if _, err := fmt.Fprintln(out, ui.Colors.Normal("-----------------")); err != nil {
return err
}
rows := make([][]string, 0, len(summaries)+1)
rows = append(rows, []string{
ui.Colors.Bold("STATUS"),
ui.Colors.Bold("NAME"),
ui.Colors.Bold("SOURCE"),
ui.Colors.Bold("INHERITS"),
ui.Colors.Bold("PMS"),
ui.Colors.Bold("DESCRIPTION"),
})
for _, s := range summaries {
rows = append(rows, []string{
statusCell(s),
s.Name,
sourceCell(s),
emptyDash(s.Inherits),
truncate(strings.Join(s.PackageManagers, ","), 30),
truncate(s.Description, 60),
})
}
return renderTable(out, rows, nil)
}
func statusCell(s pmgsandbox.ProfileSummary) string {
if s.Shadowed {
return ui.Colors.Dim("SHADOWED")
}
return " "
}
func sourceCell(s pmgsandbox.ProfileSummary) string {
if s.Source == pmgsandbox.ProfileSourceBuiltin {
return ui.Colors.Dim("builtin")
}
return truncateLeft(s.Path, 50)
}
func emptyDash(s string) string {
if s == "" {
return ui.Colors.Dim("—")
}
return s
}
+116
View File
@@ -0,0 +1,116 @@
package sandbox
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
pmgsandbox "github.com/safedep/pmg/sandbox"
)
func newTestRegistry(t *testing.T, userDir string) registryFactory {
t.Helper()
return func() (pmgsandbox.ProfileRegistry, error) {
opts := []pmgsandbox.RegistryOption{}
if userDir != "" {
opts = append(opts, pmgsandbox.WithUserProfileDir(userDir))
}
return pmgsandbox.NewProfileRegistry(opts...)
}
}
func writeTestUserProfile(t *testing.T, dir, name string) {
t.Helper()
body := "name: " + name + `
description: user ` + name + `
package_managers:
- npm
filesystem:
allow_read:
- /tmp
allow_write:
- /tmp
deny_read: []
deny_write: []
`
require.NoError(t, os.WriteFile(filepath.Join(dir, name+".yml"), []byte(body), 0o644))
}
func TestProfileListHuman(t *testing.T) {
cmd := newProfileListCommand(newTestRegistry(t, ""))
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{})
require.NoError(t, cmd.Execute())
out := stdout.String()
assert.Contains(t, out, "Sandbox Profiles")
assert.Contains(t, out, "npm-restrictive")
assert.Contains(t, out, "builtin")
}
func TestProfileListJSON(t *testing.T) {
dir := t.TempDir()
writeTestUserProfile(t, dir, "my-custom")
cmd := newProfileListCommand(newTestRegistry(t, dir))
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"--json"})
require.NoError(t, cmd.Execute())
var report jsonProfileListReport
require.NoError(t, json.Unmarshal(stdout.Bytes(), &report))
require.NotEmpty(t, report.Profiles)
var foundBuiltin, foundUser bool
for _, p := range report.Profiles {
if p.Source == "builtin" {
foundBuiltin = true
}
if p.Source == "user" && p.Name == "my-custom" {
foundUser = true
assert.NotEmpty(t, p.Path)
}
}
assert.True(t, foundBuiltin)
assert.True(t, foundUser)
}
func TestProfileListShadowedTag(t *testing.T) {
dir := t.TempDir()
writeTestUserProfile(t, dir, "npm-restrictive")
cmd := newProfileListCommand(newTestRegistry(t, dir))
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{})
require.NoError(t, cmd.Execute())
assert.Contains(t, stdout.String(), "SHADOWED")
}
func TestProfileListRejectsUnexpectedArgs(t *testing.T) {
cmd := newProfileListCommand(newTestRegistry(t, ""))
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"extra"})
err := cmd.Execute()
require.Error(t, err)
assert.Contains(t, stderr.String(), "unknown command")
assert.Contains(t, stdout.String(), "Usage:")
assert.Contains(t, stdout.String(), "list [flags]")
assert.Contains(t, stdout.String(), "pmg sandbox profile list")
}
+170
View File
@@ -0,0 +1,170 @@
package sandbox
import (
"fmt"
"io"
"os"
"github.com/safedep/dry/log"
pmgsandbox "github.com/safedep/pmg/sandbox"
"github.com/safedep/pmg/sandbox/platform"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
type profileShowOptions struct {
resolved bool
driver string
cwd string
home string
jsonOut bool
}
func newProfileShowCommand(factory registryFactory) *cobra.Command {
opts := &profileShowOptions{}
cmd := &cobra.Command{
Use: "show <name>",
Short: "Show a sandbox profile (raw YAML, resolved policy, or driver-rendered)",
Example: " pmg sandbox profile show npm-restrictive --resolved",
Args: cobra.ExactArgs(1),
SilenceErrors: false,
RunE: func(cmd *cobra.Command, args []string) error {
err := runProfileShow(cmd.OutOrStdout(), args[0], opts, factory)
if err != nil {
return sandboxErrorExit(cmd, err)
}
return err
},
}
cmd.Flags().BoolVar(&opts.resolved, "resolved", false, "Print the policy after inheritance and variable expansion")
cmd.Flags().StringVar(&opts.driver, "driver", "", "Render the resolved policy for a specific driver: seatbelt|bubblewrap|landlock")
cmd.Flags().StringVar(&opts.cwd, "cwd", "", "Override ${CWD} during expansion (defaults to current working directory)")
cmd.Flags().StringVar(&opts.home, "home", "", "Override ${HOME} during expansion (defaults to current user home)")
cmd.Flags().BoolVar(&opts.jsonOut, "json", false, "Emit output as JSON")
return cmd
}
func runProfileShow(out io.Writer, name string, opts *profileShowOptions, factory registryFactory) error {
if err := validateDriver(opts.driver); err != nil {
return err
}
registry, err := factory()
if err != nil {
return err
}
if opts.driver != "" {
return runProfileShowDriver(out, name, opts, registry)
}
if opts.resolved {
return runProfileShowResolved(out, name, opts, registry)
}
return runProfileShowRaw(out, name, opts, registry)
}
func runProfileShowRaw(out io.Writer, name string, opts *profileShowOptions, registry pmgsandbox.ProfileRegistry) error {
source, path, data, err := loadProfileSource(name, registry)
if err != nil {
return err
}
if opts.jsonOut {
report := map[string]any{
"name": name,
"source": string(source),
"path": path,
"yaml": string(data),
}
return writeJSONIndent(out, report)
}
_, err = out.Write(data)
return err
}
func runProfileShowResolved(out io.Writer, name string, opts *profileShowOptions, registry pmgsandbox.ProfileRegistry) error {
policy, err := registry.ResolveProfile(name, pmgsandbox.ResolveOptions{
CWD: opts.cwd,
Home: opts.home,
})
if err != nil {
return err
}
if opts.jsonOut {
report := map[string]any{
"name": name,
"policy": policy,
}
return writeJSONIndent(out, report)
}
data, err := yaml.Marshal(policy)
if err != nil {
return fmt.Errorf("failed to marshal resolved policy: %w", err)
}
_, err = out.Write(data)
return err
}
func runProfileShowDriver(out io.Writer, name string, opts *profileShowOptions, registry pmgsandbox.ProfileRegistry) error {
policy, err := registry.ResolveProfile(name, pmgsandbox.ResolveOptions{
CWD: opts.cwd,
Home: opts.home,
})
if err != nil {
return err
}
rendered, err := platform.Render(pmgsandbox.DriverName(opts.driver), policy)
if err != nil {
return err
}
if opts.jsonOut {
report := map[string]any{
"name": name,
"driver": opts.driver,
"rendered": string(rendered),
}
return writeJSONIndent(out, report)
}
_, err = out.Write(rendered)
return err
}
func loadProfileSource(name string, registry pmgsandbox.ProfileRegistry) (pmgsandbox.ProfileSource, string, []byte, error) {
if data, ok := registry.BuiltinProfileYAML(name); ok {
return pmgsandbox.ProfileSourceBuiltin, "", data, nil
}
summaries, err := registry.ListProfiles()
if err != nil {
log.Warnf("sandbox: failed to enumerate user profiles: %v", err)
}
for _, s := range summaries {
if s.Source == pmgsandbox.ProfileSourceUser && s.Name == name {
data, readErr := os.ReadFile(s.Path)
if readErr != nil {
return "", "", nil, fmt.Errorf("failed to read user profile %s: %w", s.Path, readErr)
}
return pmgsandbox.ProfileSourceUser, s.Path, data, nil
}
}
if data, readErr := os.ReadFile(name); readErr == nil {
return pmgsandbox.ProfileSourceUser, name, data, nil
}
return "", "", nil, notFoundError(
fmt.Sprintf("sandbox profile not found: %s", name),
"Use `pmg sandbox profile list` to see available profiles, or pass an existing profile YAML path.",
)
}
+151
View File
@@ -0,0 +1,151 @@
package sandbox
import (
"bytes"
"encoding/json"
"runtime"
"strings"
"testing"
"github.com/safedep/pmg/usefulerror"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestProfileShowRaw(t *testing.T) {
cmd := newProfileShowCommand(newTestRegistry(t, ""))
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"npm-restrictive"})
require.NoError(t, cmd.Execute())
out := stdout.String()
assert.Contains(t, out, "name: npm-restrictive")
assert.Contains(t, out, "${CWD}", "raw output should preserve placeholders")
}
func TestProfileShowResolved(t *testing.T) {
cmd := newProfileShowCommand(newTestRegistry(t, ""))
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"npm-restrictive", "--resolved", "--cwd", "/work", "--home", "/h"})
require.NoError(t, cmd.Execute())
out := stdout.String()
assert.NotContains(t, out, "${CWD}")
assert.NotContains(t, out, "${HOME}")
assert.Contains(t, out, "/work")
assert.Contains(t, out, "/h")
}
func TestProfileShowJSONRaw(t *testing.T) {
cmd := newProfileShowCommand(newTestRegistry(t, ""))
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"npm-restrictive", "--json"})
require.NoError(t, cmd.Execute())
var report map[string]any
require.NoError(t, json.Unmarshal(stdout.Bytes(), &report))
assert.Equal(t, "npm-restrictive", report["name"])
assert.Equal(t, "builtin", report["source"])
assert.Contains(t, report["yaml"].(string), "name: npm-restrictive")
}
func TestProfileShowUnknownDriver(t *testing.T) {
cmd := newProfileShowCommand(newTestRegistry(t, ""))
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"npm-restrictive", "--driver", "bogus"})
err := cmd.Execute()
require.Error(t, err)
assert.Empty(t, stderr.String())
assert.Contains(t, err.Error(), "unknown driver")
usefulErr, ok := usefulerror.AsUsefulError(err)
require.True(t, ok)
assert.Equal(t, usefulerror.ErrCodeInvalidArgument, usefulErr.Code())
}
func TestProfileShowUnknownProfileReturnsNotFound(t *testing.T) {
cmd := newProfileShowCommand(newTestRegistry(t, ""))
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"does-not-exist"})
err := cmd.Execute()
require.Error(t, err)
assert.Empty(t, stdout.String())
assert.Empty(t, stderr.String())
assert.Contains(t, err.Error(), "not found")
usefulErr, ok := usefulerror.AsUsefulError(err)
require.True(t, ok)
assert.Equal(t, usefulerror.ErrCodeNotFound, usefulErr.Code())
}
func TestProfileShowMissingNameShowsUsage(t *testing.T) {
cmd := newProfileShowCommand(newTestRegistry(t, ""))
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{})
err := cmd.Execute()
require.Error(t, err)
assert.Contains(t, stderr.String(), "accepts 1 arg(s), received 0")
assert.Contains(t, stdout.String(), "Usage:")
assert.Contains(t, stdout.String(), "show <name> [flags]")
assert.Contains(t, stdout.String(), "pmg sandbox profile show npm-restrictive --resolved")
}
func TestProfileShowDriverNative(t *testing.T) {
cmd := newProfileShowCommand(newTestRegistry(t, ""))
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&bytes.Buffer{})
driver := ""
switch runtime.GOOS {
case "darwin":
driver = "seatbelt"
case "linux":
driver = "bubblewrap"
default:
t.Skipf("no native driver for %s", runtime.GOOS)
}
cmd.SetArgs([]string{"npm-restrictive", "--driver", driver})
require.NoError(t, cmd.Execute())
assert.NotEmpty(t, strings.TrimSpace(stdout.String()))
}
func TestProfileShowDriverNonNativeErrors(t *testing.T) {
cmd := newProfileShowCommand(newTestRegistry(t, ""))
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
driver := ""
switch runtime.GOOS {
case "darwin":
driver = "bubblewrap"
case "linux":
driver = "seatbelt"
default:
t.Skipf("no non-native driver to test on %s", runtime.GOOS)
}
cmd.SetArgs([]string{"npm-restrictive", "--driver", driver})
err := cmd.Execute()
require.Error(t, err)
assert.Empty(t, stderr.String())
assert.Contains(t, err.Error(), "not available")
}
+26
View File
@@ -0,0 +1,26 @@
// Package sandbox implements the `pmg sandbox` command subtree.
//
// This is the ONLY package permitted to import internal/ui for rendering
// sandbox diagnostics. The sandbox package produces structured probe
// results; presentation lives here.
package sandbox
import "github.com/spf13/cobra"
// NewCommand returns the `pmg sandbox` parent command.
func NewCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "sandbox",
Short: "Inspect and manage PMG sandbox configuration",
Long: "Tools for diagnosing the host sandbox environment and (in future PRs) managing sandbox profiles.",
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
cmd.AddCommand(NewDoctorCommand())
cmd.AddCommand(NewProfileCommand())
cmd.AddCommand(NewExplainCommand())
cmd.AddCommand(NewViolationsCommand())
return cmd
}
+26
View File
@@ -0,0 +1,26 @@
package sandbox
import (
"github.com/safedep/pmg/config"
pmgsandbox "github.com/safedep/pmg/sandbox"
"github.com/spf13/cobra"
)
// NewViolationsCommand returns the `pmg sandbox violations` parent
// command. Subcommands browse and (in future) manage the violation cache.
func NewViolationsCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "violations",
Short: "Browse and manage cached sandbox violation reports",
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
factory := func() *pmgsandbox.ViolationCache {
return pmgsandbox.NewViolationCache(config.Get().SandboxViolationCacheDir())
}
cmd.AddCommand(newViolationsListCommand(factory))
return cmd
}
+187
View File
@@ -0,0 +1,187 @@
package sandbox
import (
"errors"
"fmt"
"io"
"time"
"github.com/safedep/pmg/internal/ui"
pmgsandbox "github.com/safedep/pmg/sandbox"
"github.com/safedep/pmg/usefulerror"
"github.com/spf13/cobra"
)
const ExitCodeViolationsListFail = 2
type violationsListOptions struct {
limit int
jsonOut bool
}
type violationsListFailError struct {
usefulerror.UsefulError
}
func (e *violationsListFailError) ExitCode() int { return ExitCodeViolationsListFail }
func newViolationsListFailError(code, msg, help string) *violationsListFailError {
return &violationsListFailError{
UsefulError: usefulerror.Useful().
WithCode(code).
WithHumanError(msg).
WithHelp(help).
Wrap(errors.New(msg)),
}
}
func newViolationsListCommand(factory cacheFactory) *cobra.Command {
opts := &violationsListOptions{}
cmd := &cobra.Command{
Use: "list",
Short: "List cached sandbox violations as one-line summaries",
Example: " pmg sandbox violations list --limit 20",
SilenceErrors: false,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
err := runViolationsList(cmd.OutOrStdout(), cmd.ErrOrStderr(), opts, factory)
if err != nil {
return sandboxErrorExit(cmd, err)
}
return err
},
}
cmd.Flags().IntVar(&opts.limit, "limit", 10, "Maximum number of entries to show (0 means all)")
cmd.Flags().BoolVar(&opts.jsonOut, "json", false, "Emit entries as JSON")
return cmd
}
func runViolationsList(out, errOut io.Writer, opts *violationsListOptions, factory cacheFactory) error {
if opts.limit < 0 {
return newViolationsListFailError(
usefulerror.ErrCodeInvalidArgument,
fmt.Sprintf("invalid --limit %d (must be >= 0)", opts.limit),
"Pass --limit 0 to show all entries, or a positive limit.",
)
}
cache := factory()
entries, err := cache.List()
if err != nil {
return newViolationsListFailError(
usefulerror.ErrCodeUnknown,
fmt.Sprintf("read cache: %v", err),
"Check the sandbox violation cache directory and retry.",
)
}
if opts.limit > 0 && len(entries) > opts.limit {
entries = entries[:opts.limit]
}
if opts.jsonOut {
return writeViolationsListJSON(out, entries)
}
if len(entries) == 0 {
_, err := fmt.Fprintln(errOut, "no violations cached")
return err
}
return renderViolationsListTable(out, entries)
}
func renderViolationsListTable(out io.Writer, entries []pmgsandbox.ViolationCacheEntry) error {
rows := make([][]string, 0, len(entries)+1)
rows = append(rows, []string{
ui.Colors.Bold("RECORDED"),
ui.Colors.Bold("SANDBOX"),
ui.Colors.Bold("PROFILE"),
ui.Colors.Bold("KIND"),
ui.Colors.Bold("TARGET"),
})
dash := ui.Colors.Dim("—")
for _, e := range entries {
recorded := ""
if !e.Record.RecordedAt.IsZero() {
recorded = e.Record.RecordedAt.UTC().Format(time.RFC3339)
}
sandboxName := ""
profile := ""
if e.Record.Report != nil {
sandboxName = string(e.Record.Report.SandboxName)
profile = e.Record.Report.PolicyName
}
kind := dash
target := dash
if e.Record.Report != nil {
primary := pmgsandbox.BuildExplanation(e.Record.Report).Primary
if primary != nil {
if primary.Kind != "" {
kind = string(primary.Kind)
}
if primary.Target != "" {
target = truncate(primary.Target, 60)
}
}
}
rows = append(rows, []string{recorded, sandboxName, profile, kind, target})
}
return renderTable(out, rows, nil)
}
type violationsListJSONPrimary struct {
Kind string `json:"kind"`
Target string `json:"target,omitempty"`
RuleLabel string `json:"rule_label,omitempty"`
}
type violationsListJSONEntry struct {
Path string `json:"path"`
RecordedAt string `json:"recorded_at,omitempty"`
SandboxName string `json:"sandbox_name,omitempty"`
PolicyName string `json:"policy_name,omitempty"`
Primary *violationsListJSONPrimary `json:"primary,omitempty"`
ViolationCount int `json:"violation_count"`
}
type violationsListJSONOutput struct {
Entries []violationsListJSONEntry `json:"entries"`
}
func writeViolationsListJSON(out io.Writer, entries []pmgsandbox.ViolationCacheEntry) error {
payload := violationsListJSONOutput{Entries: make([]violationsListJSONEntry, 0, len(entries))}
for _, e := range entries {
item := violationsListJSONEntry{Path: e.Path}
if !e.Record.RecordedAt.IsZero() {
item.RecordedAt = e.Record.RecordedAt.UTC().Format(time.RFC3339)
}
if e.Record.Report != nil {
item.SandboxName = string(e.Record.Report.SandboxName)
item.PolicyName = e.Record.Report.PolicyName
item.ViolationCount = len(e.Record.Report.Violations)
primary := pmgsandbox.BuildExplanation(e.Record.Report).Primary
if primary != nil {
item.Primary = &violationsListJSONPrimary{
Kind: string(primary.Kind),
Target: primary.Target,
RuleLabel: primary.RuleLabel,
}
}
}
payload.Entries = append(payload.Entries, item)
}
return writeJSONIndent(out, payload)
}
+200
View File
@@ -0,0 +1,200 @@
package sandbox
import (
"bytes"
"encoding/json"
"strings"
"testing"
pmgsandbox "github.com/safedep/pmg/sandbox"
"github.com/safedep/pmg/usefulerror"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestCacheFactory(t *testing.T) (cacheFactory, *pmgsandbox.ViolationCache) {
t.Helper()
dir := t.TempDir()
cache := pmgsandbox.NewViolationCache(dir)
return func() *pmgsandbox.ViolationCache { return cache }, cache
}
func sampleViolationsReport(target string) *pmgsandbox.ViolationReport {
return &pmgsandbox.ViolationReport{
SandboxName: pmgsandbox.DriverSeatbelt,
PolicyName: "npm-restrictive",
Violations: []pmgsandbox.Violation{
{Kind: pmgsandbox.ViolationKindFSWrite, Target: target, RuleLabel: "file-write"},
},
}
}
func runList(t *testing.T, factory cacheFactory, args ...string) (string, string, error) {
t.Helper()
cmd := newViolationsListCommand(factory)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs(args)
err := cmd.Execute()
return out.String(), errOut.String(), err
}
func TestViolationsList_EmptyCache(t *testing.T) {
factory, _ := newTestCacheFactory(t)
stdout, stderr, err := runList(t, factory)
require.NoError(t, err)
assert.Empty(t, stdout)
assert.Contains(t, stderr, "no violations cached")
}
func TestViolationsList_TwoEntriesNewestFirst(t *testing.T) {
factory, cache := newTestCacheFactory(t)
_, err := cache.Write(sampleViolationsReport("/Users/dev/older"))
require.NoError(t, err)
_, err = cache.Write(sampleViolationsReport("/Users/dev/newer"))
require.NoError(t, err)
stdout, _, err := runList(t, factory)
require.NoError(t, err)
assert.Contains(t, stdout, "RECORDED")
assert.Contains(t, stdout, "SANDBOX")
assert.Contains(t, stdout, "seatbelt")
assert.Contains(t, stdout, "npm-restrictive")
assert.Contains(t, stdout, "/Users/dev/older")
assert.Contains(t, stdout, "/Users/dev/newer")
newerIdx := strings.Index(stdout, "/Users/dev/newer")
olderIdx := strings.Index(stdout, "/Users/dev/older")
require.NotEqual(t, -1, newerIdx)
require.NotEqual(t, -1, olderIdx)
assert.Less(t, newerIdx, olderIdx, "newest entry should appear first")
}
func TestViolationsList_LimitOne(t *testing.T) {
factory, cache := newTestCacheFactory(t)
_, err := cache.Write(sampleViolationsReport("/Users/dev/a"))
require.NoError(t, err)
_, err = cache.Write(sampleViolationsReport("/Users/dev/b"))
require.NoError(t, err)
stdout, _, err := runList(t, factory, "--limit", "1")
require.NoError(t, err)
assert.Contains(t, stdout, "/Users/dev/b")
assert.NotContains(t, stdout, "/Users/dev/a")
}
func TestViolationsListRejectsUnexpectedArgs(t *testing.T) {
factory, _ := newTestCacheFactory(t)
stdout, stderr, err := runList(t, factory, "extra")
require.Error(t, err)
assert.Contains(t, stderr, "unknown command")
assert.Contains(t, stdout, "Usage:")
assert.Contains(t, stdout, "list [flags]")
assert.Contains(t, stdout, "pmg sandbox violations list --limit 20")
}
func TestViolationsListRejectsNegativeLimit(t *testing.T) {
factory, _ := newTestCacheFactory(t)
_, _, err := runList(t, factory, "--limit", "-1")
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid --limit")
usefulErr, ok := usefulerror.AsUsefulError(err)
require.True(t, ok)
assert.Equal(t, usefulerror.ErrCodeInvalidArgument, usefulErr.Code())
}
func TestViolationsList_LimitZeroReturnsAll(t *testing.T) {
factory, cache := newTestCacheFactory(t)
for _, target := range []string{"/Users/dev/a", "/Users/dev/b", "/Users/dev/c"} {
_, err := cache.Write(sampleViolationsReport(target))
require.NoError(t, err)
}
stdout, _, err := runList(t, factory, "--limit", "0")
require.NoError(t, err)
assert.Contains(t, stdout, "/Users/dev/a")
assert.Contains(t, stdout, "/Users/dev/b")
assert.Contains(t, stdout, "/Users/dev/c")
}
func TestViolationsList_JSON(t *testing.T) {
factory, cache := newTestCacheFactory(t)
_, err := cache.Write(sampleViolationsReport("/Users/dev/x"))
require.NoError(t, err)
_, err = cache.Write(sampleViolationsReport("/Users/dev/y"))
require.NoError(t, err)
stdout, _, err := runList(t, factory, "--json")
require.NoError(t, err)
var payload struct {
Entries []struct {
Path string `json:"path"`
RecordedAt string `json:"recorded_at"`
SandboxName string `json:"sandbox_name"`
PolicyName string `json:"policy_name"`
Primary *struct {
Kind string `json:"kind"`
Target string `json:"target"`
RuleLabel string `json:"rule_label"`
} `json:"primary,omitempty"`
ViolationCount int `json:"violation_count"`
} `json:"entries"`
}
require.NoError(t, json.Unmarshal([]byte(stdout), &payload))
require.Len(t, payload.Entries, 2)
assert.Equal(t, "seatbelt", payload.Entries[0].SandboxName)
assert.Equal(t, "npm-restrictive", payload.Entries[0].PolicyName)
require.NotNil(t, payload.Entries[0].Primary)
assert.Equal(t, "fs_write", payload.Entries[0].Primary.Kind)
assert.Equal(t, 1, payload.Entries[0].ViolationCount)
}
func TestViolationsList_NoViolations_RendersDash(t *testing.T) {
factory, cache := newTestCacheFactory(t)
_, err := cache.Write(&pmgsandbox.ViolationReport{
SandboxName: pmgsandbox.DriverSeatbelt,
PolicyName: "npm-restrictive",
Violations: nil,
})
require.NoError(t, err)
stdout, _, err := runList(t, factory)
require.NoError(t, err)
assert.Contains(t, stdout, "seatbelt")
assert.Contains(t, stdout, "—")
}
func TestViolationsList_NoViolations_JSONOmitsPrimary(t *testing.T) {
factory, cache := newTestCacheFactory(t)
_, err := cache.Write(&pmgsandbox.ViolationReport{
SandboxName: pmgsandbox.DriverSeatbelt,
PolicyName: "npm-restrictive",
Violations: nil,
})
require.NoError(t, err)
stdout, _, err := runList(t, factory, "--json")
require.NoError(t, err)
assert.NotContains(t, stdout, "\"primary\"")
var payload map[string]any
require.NoError(t, json.Unmarshal([]byte(stdout), &payload))
entries, ok := payload["entries"].([]any)
require.True(t, ok)
require.Len(t, entries, 1)
}
+1 -1
View File
@@ -157,7 +157,7 @@ func resolveSandboxDriverName() string {
return "unavailable"
}
return sb.Name()
return string(sb.Name())
}
// describeCloudCredentials reports whether SafeDep Cloud credentials can be
+7 -2
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/internal/ui"
"github.com/safedep/pmg/internal/version"
"github.com/spf13/cobra"
@@ -16,8 +17,12 @@ func NewVersionCommand() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Print(ui.GeneratePMGBanner(version.Version, version.Commit))
fmt.Fprintf(os.Stdout, "Version: %s\n", version.Version)
fmt.Fprintf(os.Stdout, "CommitSHA: %s\n", version.Commit)
if _, err := fmt.Fprintf(os.Stdout, "Version: %s\n", version.Version); err != nil {
log.Warnf("failed to write version line: %v", err)
}
if _, err := fmt.Fprintf(os.Stdout, "CommitSHA: %s\n", version.Commit); err != nil {
log.Warnf("failed to write commit line: %v", err)
}
return nil
},
+82
View File
@@ -27,6 +27,9 @@ const (
// Allow overriding the config path from the environment
pmgConfigDirEnvKey = "PMG_CONFIG_DIR"
// Allow overriding the cache path from the environment
pmgCacheDirEnvKey = "PMG_CACHE_DIR"
// Config path is computed as the user config directory + the default relative path
// when not overridden by the environment variable
pmgDefaultHomeRelativePath = "safedep/pmg"
@@ -34,6 +37,12 @@ const (
// Default log directory is relative to the config directory.
pmgDefaultLogDir = "logs"
// Default sandbox profile directory is relative to the config directory.
pmgDefaultSandboxProfileDir = "sandbox/profiles"
// Default sandbox violation cache directory is relative to the cache root.
pmgDefaultSandboxViolationCacheDir = "sandbox/violations"
// Config file name.
// Important: The config file path and the schema should be backward compatible. In case of breaking config
// changes, we must introduce a new file name and a migration path.
@@ -179,6 +188,8 @@ type RuntimeConfig struct {
configDir string
configFilePath string
eventLogDir string
sandboxProfileDir string
sandboxViolationCacheDir string
viper *viper.Viper
}
@@ -202,6 +213,16 @@ func (r *RuntimeConfig) ConfigDir() string {
return r.configDir
}
// SandboxProfileDir returns the path to the user sandbox profile directory.
func (r *RuntimeConfig) SandboxProfileDir() string {
return r.sandboxProfileDir
}
// SandboxViolationCacheDir returns the path to the sandbox violation cache directory.
func (r *RuntimeConfig) SandboxViolationCacheDir() string {
return r.sandboxViolationCacheDir
}
func (r *RuntimeConfig) IsProxyModeEnabled() bool {
return r.Config.Proxy.Enabled
}
@@ -303,9 +324,21 @@ func initConfig() {
panic(fmt.Errorf("failed to get event log directory: %w", err))
}
sandboxProfileDir, err := sandboxProfileDir()
if err != nil {
panic(fmt.Errorf("failed to get sandbox profile directory: %w", err))
}
sandboxViolationCacheDir, err := sandboxViolationCacheDir()
if err != nil {
panic(fmt.Errorf("failed to get sandbox violation cache directory: %w", err))
}
globalConfig.configDir = configDir
globalConfig.configFilePath = configFilePath
globalConfig.eventLogDir = eventLogDir
globalConfig.sandboxProfileDir = sandboxProfileDir
globalConfig.sandboxViolationCacheDir = sandboxViolationCacheDir
loadConfig()
@@ -377,6 +410,55 @@ func eventLogDir() (string, error) {
}
}
// cacheDir computes the path to the cache root directory.
func cacheDir() (string, error) {
dir := os.Getenv(pmgCacheDirEnvKey)
if dir != "" {
return dir, nil
}
switch runtime.GOOS {
case "windows":
// Windows: %LOCALAPPDATA%\safedep\pmg or %USERPROFILE%\safedep\pmg
baseDir := os.Getenv("LOCALAPPDATA")
if baseDir == "" {
baseDir = os.Getenv("USERPROFILE")
if baseDir == "" {
return "", fmt.Errorf("could not determine Windows user directory for cache storage")
}
}
return filepath.Join(baseDir, pmgDefaultHomeRelativePath), nil
case "darwin", "linux":
userCacheDir, err := os.UserCacheDir()
if err != nil {
return "", fmt.Errorf("failed to retrieve user cache directory: %w", err)
}
return filepath.Join(userCacheDir, pmgDefaultHomeRelativePath), nil
default:
return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
}
}
// sandboxProfileDir computes the path to the sandbox profile directory.
func sandboxProfileDir() (string, error) {
configDir, err := configDir()
if err != nil {
return "", fmt.Errorf("failed to get config directory: %w", err)
}
return filepath.Join(configDir, pmgDefaultSandboxProfileDir), nil
}
// sandboxViolationCacheDir computes the path to the sandbox violation cache directory.
func sandboxViolationCacheDir() (string, error) {
cacheDir, err := cacheDir()
if err != nil {
return "", fmt.Errorf("failed to get cache directory: %w", err)
}
return filepath.Join(cacheDir, pmgDefaultSandboxViolationCacheDir), nil
}
// Get returns the global configuration.
// This is the public API for the configuration package. This package should guarantee
// that this function will never return nil.
+129
View File
@@ -0,0 +1,129 @@
package config
import (
"os"
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSandboxProfileDir(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, pmgDefaultSandboxProfileDir)
},
},
{
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", pmgDefaultSandboxProfileDir)
},
},
}
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().SandboxProfileDir())
})
}
}
func TestSandboxProfileDirRespectsXDGConfigHome(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("XDG_CONFIG_HOME is only honored on Linux by os.UserConfigDir")
}
tmp := t.TempDir()
t.Setenv("PMG_CONFIG_DIR", "")
t.Setenv("XDG_CONFIG_HOME", tmp)
initConfig()
expected := filepath.Join(tmp, pmgDefaultHomeRelativePath, pmgDefaultSandboxProfileDir)
assert.Equal(t, expected, Get().SandboxProfileDir())
}
func TestSandboxViolationCacheDir(t *testing.T) {
tests := []struct {
name string
envKey string
envVal string
expected func(t *testing.T) string
}{
{
name: "default under user cache dir",
envKey: "PMG_CACHE_DIR",
envVal: "",
expected: func(t *testing.T) string {
base, err := userCacheBase()
require.NoError(t, err)
return filepath.Join(base, pmgDefaultHomeRelativePath, pmgDefaultSandboxViolationCacheDir)
},
},
{
name: "honors PMG_CACHE_DIR override",
envKey: "PMG_CACHE_DIR",
envVal: "/tmp/pmg-test/custom-cache",
expected: func(t *testing.T) string {
return filepath.Join("/tmp/pmg-test/custom-cache", pmgDefaultSandboxViolationCacheDir)
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("PMG_CONFIG_DIR", "")
t.Setenv(tc.envKey, tc.envVal)
initConfig()
assert.Equal(t, tc.expected(t), Get().SandboxViolationCacheDir())
})
}
}
func TestSandboxViolationCacheDirRespectsXDGCacheHome(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("XDG_CACHE_HOME is only honored on Linux by os.UserCacheDir")
}
tmp := t.TempDir()
t.Setenv("PMG_CACHE_DIR", "")
t.Setenv("XDG_CACHE_HOME", tmp)
initConfig()
expected := filepath.Join(tmp, pmgDefaultHomeRelativePath, pmgDefaultSandboxViolationCacheDir)
assert.Equal(t, expected, Get().SandboxViolationCacheDir())
}
// userCacheBase returns the platform's user cache root the same way cacheDir()
// in config.go resolves it (sans the PMG_CACHE_DIR override).
func userCacheBase() (string, error) {
switch runtime.GOOS {
case "windows":
base := os.Getenv("LOCALAPPDATA")
if base == "" {
base = os.Getenv("USERPROFILE")
}
return base, nil
default:
return os.UserCacheDir()
}
}
+48
View File
@@ -80,6 +80,9 @@ See [Bubblewrap Installation](https://github.com/containers/bubblewrap#installat
See [configuration](./config.md) and [config/config.template.yml](../config/config.template.yml) for the configuration schema.
Once sandbox is enabled, you can run package manager commands with sandbox protection.
Run `pmg sandbox doctor` to see platform specific sandbox setup and driver status. Continue using
PMG as usual, sandbox will be applied to configured package managers automatically.
```bash
pmg npm install express
```
@@ -96,6 +99,51 @@ Run sandbox with custom policy file:
pmg --sandbox --sandbox-profile=/path/to/custom-policy.yml npm install express
```
### Sandbox Profile Commands
Use profile commands to inspect, create, and validate sandbox profiles.
```bash
# List built-in and user profiles
pmg sandbox profile list
# Scaffold a user profile that inherits from a built-in
pmg sandbox profile init my-npm --from npm-restrictive
# Show a profile, or its fully resolved policy
pmg sandbox profile show npm-restrictive
pmg sandbox profile show npm-restrictive --resolved
# Lint a built-in, user profile, or profile file
pmg sandbox profile lint npm-restrictive
pmg sandbox profile lint ./my-profile.yml --strict
# Compare two resolved profiles
pmg sandbox profile diff npm-restrictive pypi-restrictive
```
### Sandbox Debug Commands
Use these commands when a sandboxed package manager command fails and you need to inspect why.
```bash
# Check sandbox driver availability and host setup
pmg sandbox doctor
# List recent sandbox denials captured by PMG
pmg sandbox violations list
# Explain the latest captured denial and show a suggested override when possible
pmg sandbox explain --last
# Inspect the resolved policy PMG will apply
pmg sandbox profile show npm-restrictive --resolved
```
`pmg sandbox doctor` runs platform-specific checks for the current host. Cached violation reports
used by `violations list` and `explain --last` are currently produced by macOS Seatbelt diagnostics;
on Linux, Bubblewrap and Landlock denials may only appear as command errors such as `EACCES`.
### Runtime Allow Overrides
Use `--sandbox-allow` to make one-off exceptions without creating a custom profile. This is useful
+6 -1
View File
@@ -17,6 +17,7 @@ import (
"github.com/google/osv-scalibr/extractor/filesystem/language/python/requirements"
"github.com/google/osv-scalibr/extractor/filesystem/language/python/uvlock"
"github.com/google/osv-scalibr/fs"
"github.com/safedep/dry/log"
)
func getExtractorForFile(filename string) (filesystem.Extractor, error) {
@@ -55,7 +56,11 @@ func parseLockfile(lockfilePath, scanDir string, ecosystem packagev1.Ecosystem)
if err != nil {
return nil, fmt.Errorf("failed to open lockfile: %w", err)
}
defer file.Close()
defer func() {
if err := file.Close(); err != nil {
log.Warnf("failed to close lockfile: %v", err)
}
}()
inputConfig := &filesystem.ScanInput{
FS: fs.DirFS(scanDir),
+1 -1
View File
@@ -13,6 +13,7 @@ require (
github.com/google/uuid v1.6.0
github.com/jedib0t/go-pretty/v6 v6.7.9
github.com/landlock-lsm/go-landlock v0.7.0
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2
github.com/posthog/posthog-go v1.5.12
github.com/safedep/dry v0.0.0-20260513152148-f809919cc4ce
github.com/safedep/ptyx v0.2.1-0.20260119085117-f667570c2d12
@@ -68,7 +69,6 @@ require (
github.com/oklog/ulid/v2 v2.1.1 // indirect
github.com/package-url/packageurl-go v0.1.3 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
+10 -2
View File
@@ -59,7 +59,11 @@ func (m *defaultRcFileManager) Create(aliases []string) (string, error) {
if err != nil {
return "", err
}
defer f.Close()
defer func() {
if err := f.Close(); err != nil {
log.Warnf("failed to close rc file %s: %v", rcPath, err)
}
}()
for _, alias := range aliases {
if _, err := f.WriteString(alias); err != nil {
@@ -290,7 +294,11 @@ func (a *AliasManager) addSourceLine(configPath, sourceLine string) error {
return err
}
defer f.Close()
defer func() {
if err := f.Close(); err != nil {
log.Warnf("failed to close config file %s: %v", configPath, err)
}
}()
_, err = fmt.Fprintf(f, "\n%s", sourceLine)
return err
+5 -2
View File
@@ -6,12 +6,15 @@ import (
"github.com/safedep/pmg/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIsDisabled(t *testing.T) {
t.Run("returns true if PMG_DISABLE_TELEMETRY is set to true", func(t *testing.T) {
os.Setenv(telemetryDisableEnvKey, "true")
defer os.Unsetenv(telemetryDisableEnvKey)
require.NoError(t, os.Setenv(telemetryDisableEnvKey, "true"))
defer func() {
require.NoError(t, os.Unsetenv(telemetryDisableEnvKey))
}()
assert.True(t, IsDisabled())
})
+4 -1
View File
@@ -7,6 +7,7 @@ import (
"os"
"strings"
"github.com/safedep/dry/log"
"github.com/safedep/ptyx"
"golang.org/x/term"
)
@@ -93,7 +94,9 @@ func NewSession(ctx context.Context, cfg SessionConfig) (InteractiveSession, err
// 2. Set raw mode, save old state
oldState, err := c.MakeRaw()
if err != nil {
c.Close()
if closeErr := c.Close(); closeErr != nil {
log.Warnf("failed to close console after MakeRaw error: %v", closeErr)
}
return nil, fmt.Errorf("failed to set raw mode: %w", err)
}
+6 -1
View File
@@ -10,6 +10,11 @@ import (
// ErrorExit prints a minimal, clean error message and exits with a non-zero status code.
func ErrorExit(err error) {
ErrorExitWithCode(err, 1)
}
// ErrorExitWithCode prints a minimal, clean error message and exits with code.
func ErrorExitWithCode(err error, code int) {
log.Errorf("Exiting due to error: %s", err)
usefulErr := convertToUsefulError(err)
@@ -29,7 +34,7 @@ func ErrorExit(err error) {
printMinimalError(usefulErr.Code(), usefulErr.HumanError(), hint)
}
os.Exit(1)
os.Exit(code)
}
// printMinimalError prints error in minimal two-line format:
+183
View File
@@ -0,0 +1,183 @@
package ui
import (
"fmt"
"io"
"strings"
"time"
pmgsandbox "github.com/safedep/pmg/sandbox"
)
// FormatSandboxOverrideFlag renders an OverrideSuggestion as a `--sandbox-allow`
// CLI flag invocation. Returns "" when there is nothing safe to suggest.
//
// The flag name lives here (not in sandbox/) because it is CLI-surface owned
// by the cmd layer; the sandbox package only knows kind + target.
func FormatSandboxOverrideFlag(o *pmgsandbox.OverrideSuggestion) string {
if o == nil {
return ""
}
quoted := shellQuote(o.Target)
switch o.Kind {
case pmgsandbox.ViolationKindFSRead:
return "--sandbox-allow read=" + quoted
case pmgsandbox.ViolationKindFSWrite, pmgsandbox.ViolationKindFSDeleteOrRename:
return "--sandbox-allow write=" + quoted
case pmgsandbox.ViolationKindExec:
return "--sandbox-allow exec=" + quoted
default:
return ""
}
}
// FormatSandboxHint produces the short, one-line "Reason: ... Override: ..."
// summary shown above the detail block.
func FormatSandboxHint(primary *pmgsandbox.Violation, override *pmgsandbox.OverrideSuggestion) string {
if primary == nil {
return "Reason: sandbox denied an operation"
}
hint := "Reason: " + primary.RuleLabel
if flag := FormatSandboxOverrideFlag(override); flag != "" {
hint += ". Override: " + flag
}
return hint
}
// FormatSandboxDetails produces the multi-line detail block shown beneath the
// hint. Each line is "Label: value"; callers indent as they see fit.
func FormatSandboxDetails(report *pmgsandbox.ViolationReport, primary *pmgsandbox.Violation) string {
if primary == nil || report == nil {
return ""
}
process := primary.Process
if process == "" {
process = "unknown"
}
lines := []string{
"Sandbox: " + string(report.SandboxName),
"Policy: " + report.PolicyName,
"Correlation: " + report.CorrelationID,
"Process: " + process,
"Violation: " + primary.RuleLabel,
}
if primary.RuleTarget != "" && primary.RuleTarget != primary.Target {
lines = append(lines, "Matched rule: "+primary.RuleTarget)
}
if primary.RawLog != "" {
lines = append(lines, "Seatbelt log: "+primary.RawLog)
}
if len(report.Violations) > 1 {
lines = append(lines, fmt.Sprintf("Additional denials observed: %d", len(report.Violations)-1))
}
return strings.Join(lines, "\n")
}
// RenderSandboxViolation writes the full human-readable explanation for a
// cached violation record to out. cmd handlers should prefer this over
// re-implementing the layout — it owns the section ordering, colors, and
// separator conventions.
func RenderSandboxViolation(out io.Writer, rec *pmgsandbox.ViolationCacheRecord) error {
if rec == nil || rec.Report == nil {
return fmt.Errorf("render sandbox violation: empty record")
}
exp := pmgsandbox.BuildExplanation(rec.Report)
recordedAt := ""
if !rec.RecordedAt.IsZero() {
recordedAt = rec.RecordedAt.UTC().Format(time.RFC3339)
}
header := fmt.Sprintf("%s %s %s %s",
Colors.Dim("Sandbox:"), Colors.Bold(string(rec.Report.SandboxName)),
Colors.Dim("Profile:"), Colors.Bold(rec.Report.PolicyName),
)
if recordedAt != "" {
header = fmt.Sprintf("%s %s %s", header, Colors.Dim("Recorded:"), Colors.Normal(recordedAt))
}
if _, err := fmt.Fprintln(out, header); err != nil {
return err
}
if _, err := fmt.Fprintln(out, Colors.Normal("--------------------------------------------------------")); err != nil {
return err
}
if _, err := fmt.Fprintln(out); err != nil {
return err
}
hint := FormatSandboxHint(exp.Primary, exp.Override)
if hint != "" {
if _, err := fmt.Fprintln(out, hint); err != nil {
return err
}
if _, err := fmt.Fprintln(out); err != nil {
return err
}
}
details := FormatSandboxDetails(rec.Report, exp.Primary)
if details != "" {
if _, err := fmt.Fprintln(out, Colors.Bold("Details:")); err != nil {
return err
}
for _, line := range strings.Split(details, "\n") {
if _, err := fmt.Fprintf(out, " %s\n", line); err != nil {
return err
}
}
if _, err := fmt.Fprintln(out); err != nil {
return err
}
}
if flag := FormatSandboxOverrideFlag(exp.Override); flag != "" {
if _, err := fmt.Fprintln(out, Colors.Bold("Suggested override:")); err != nil {
return err
}
if _, err := fmt.Fprintf(out, " %s\n", Colors.Cyan(flag)); err != nil {
return err
}
if _, err := fmt.Fprintln(out); err != nil {
return err
}
}
if exp.Primary != nil {
if _, err := fmt.Fprintln(out, Colors.Bold("Primary violation:")); err != nil {
return err
}
if _, err := fmt.Fprintf(out, " %s %s\n", Colors.Dim("Kind:"), string(exp.Primary.Kind)); err != nil {
return err
}
if _, err := fmt.Fprintf(out, " %s %s\n", Colors.Dim("Target:"), exp.Primary.Target); err != nil {
return err
}
if _, err := fmt.Fprintf(out, " %s %s\n", Colors.Dim("Rule:"), exp.Primary.RuleLabel); err != nil {
return err
}
if exp.Primary.Process != "" {
if _, err := fmt.Fprintf(out, " %s %s\n", Colors.Dim("Process:"), exp.Primary.Process); err != nil {
return err
}
}
}
return nil
}
// shellQuote wraps value in single quotes, escaping any embedded single
// quotes. Used so suggested override flags can be copy-pasted into a POSIX
// shell verbatim regardless of spaces or quotes in the target.
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
}
+122
View File
@@ -0,0 +1,122 @@
package ui
import (
"bytes"
"testing"
"time"
pmgsandbox "github.com/safedep/pmg/sandbox"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFormatSandboxOverrideFlagNil(t *testing.T) {
assert.Empty(t, FormatSandboxOverrideFlag(nil))
}
func TestFormatSandboxOverrideFlagKinds(t *testing.T) {
tests := []struct {
kind pmgsandbox.ViolationKind
want string
}{
{pmgsandbox.ViolationKindFSRead, "--sandbox-allow read='./.env'"},
{pmgsandbox.ViolationKindFSWrite, "--sandbox-allow write='./.env'"},
{pmgsandbox.ViolationKindFSDeleteOrRename, "--sandbox-allow write='./.env'"},
{pmgsandbox.ViolationKindExec, "--sandbox-allow exec='./.env'"},
{pmgsandbox.ViolationKindGenericDeny, ""},
}
for _, tt := range tests {
t.Run(string(tt.kind), func(t *testing.T) {
got := FormatSandboxOverrideFlag(&pmgsandbox.OverrideSuggestion{
Kind: tt.kind,
Target: "./.env",
})
assert.Equal(t, tt.want, got)
})
}
}
func TestFormatSandboxOverrideFlagShellQuotesSpacesAndSingleQuotes(t *testing.T) {
got := FormatSandboxOverrideFlag(&pmgsandbox.OverrideSuggestion{
Kind: pmgsandbox.ViolationKindFSRead,
Target: "/tmp/My Dir/it's.env",
})
assert.Equal(t, "--sandbox-allow read='/tmp/My Dir/it'\\''s.env'", got)
}
func TestFormatSandboxHintEmpty(t *testing.T) {
assert.Equal(t, "Reason: sandbox denied an operation", FormatSandboxHint(nil, nil))
}
func TestFormatSandboxHintIncludesOverride(t *testing.T) {
primary := &pmgsandbox.Violation{
Kind: pmgsandbox.ViolationKindFSRead,
Target: "./.env",
RuleLabel: "read access denied: ./.env",
}
override := &pmgsandbox.OverrideSuggestion{Kind: pmgsandbox.ViolationKindFSRead, Target: "./.env"}
hint := FormatSandboxHint(primary, override)
assert.Contains(t, hint, "Reason: read access denied: ./.env")
assert.Contains(t, hint, "Override: --sandbox-allow read='./.env'")
}
func TestFormatSandboxDetailsIncludesMatchedRule(t *testing.T) {
report := &pmgsandbox.ViolationReport{
SandboxName: "seatbelt",
PolicyName: "npm-restrictive",
CorrelationID: "run-1",
Violations: []pmgsandbox.Violation{
{
Kind: pmgsandbox.ViolationKindFSRead,
Target: "./.env",
RuleTarget: "**/.env",
Process: "node",
RuleLabel: "read access denied: ./.env",
},
},
}
details := FormatSandboxDetails(report, &report.Violations[0])
assert.Contains(t, details, "Matched rule: **/.env")
assert.Contains(t, details, "Process: node")
assert.Contains(t, details, "Sandbox: seatbelt")
}
func TestFormatSandboxDetailsEmpty(t *testing.T) {
assert.Empty(t, FormatSandboxDetails(nil, nil))
assert.Empty(t, FormatSandboxDetails(&pmgsandbox.ViolationReport{}, nil))
}
func TestRenderSandboxViolationContainsKeySections(t *testing.T) {
rec := &pmgsandbox.ViolationCacheRecord{
SchemaVersion: pmgsandbox.ViolationCacheSchemaVersion,
RecordedAt: time.Date(2026, 5, 19, 10, 0, 0, 0, time.UTC),
Report: &pmgsandbox.ViolationReport{
SandboxName: "seatbelt",
PolicyName: "npm-restrictive",
CorrelationID: "run-1",
Violations: []pmgsandbox.Violation{
{
Kind: pmgsandbox.ViolationKindFSRead,
Target: "./.env",
RuleLabel: "read access denied: ./.env",
Process: "node",
},
},
},
}
var buf bytes.Buffer
require.NoError(t, RenderSandboxViolation(&buf, rec))
out := buf.String()
assert.Contains(t, out, "Reason:")
assert.Contains(t, out, "Details:")
assert.Contains(t, out, "Suggested override:")
assert.Contains(t, out, "Primary violation:")
assert.Contains(t, out, "--sandbox-allow read='./.env'")
}
func TestRenderSandboxViolationRejectsNilRecord(t *testing.T) {
var buf bytes.Buffer
assert.Error(t, RenderSandboxViolation(&buf, nil))
assert.Error(t, RenderSandboxViolation(&buf, &pmgsandbox.ViolationCacheRecord{}))
}
+21 -5
View File
@@ -13,6 +13,7 @@ import (
landlockCmd "github.com/safedep/pmg/cmd/landlock"
"github.com/safedep/pmg/cmd/npm"
"github.com/safedep/pmg/cmd/pypi"
sandboxCmd "github.com/safedep/pmg/cmd/sandbox"
"github.com/safedep/pmg/cmd/setup"
"github.com/safedep/pmg/cmd/version"
"github.com/safedep/pmg/config"
@@ -31,6 +32,12 @@ var (
logFile string
)
func setLogEnv(key, value string) {
if err := os.Setenv(key, value); err != nil {
log.Warnf("failed to set %s: %v", key, err)
}
}
func main() {
cmd := &cobra.Command{
Use: "pmg",
@@ -39,18 +46,18 @@ func main() {
// Always set this first because we will override the log
// level if debug or verbose is set
if logFile != "" {
os.Setenv("APP_LOG_FILE", logFile)
os.Setenv("APP_LOG_LEVEL", "info")
setLogEnv("APP_LOG_FILE", logFile)
setLogEnv("APP_LOG_LEVEL", "info")
}
// Set the log level when debug is enabled
if debug {
os.Setenv("APP_LOG_LEVEL", "debug")
setLogEnv("APP_LOG_LEVEL", "debug")
}
// Skip stdout logging when debugging is not enabled
if !debug {
os.Setenv("APP_LOG_SKIP_STDOUT_LOGGER", "true")
setLogEnv("APP_LOG_SKIP_STDOUT_LOGGER", "true")
}
// Apply config-based verbosity first
@@ -136,6 +143,7 @@ func main() {
cmd.AddCommand(version.NewVersionCommand())
cmd.AddCommand(setup.NewSetupCommand())
cmd.AddCommand(setup.NewRemoveCommand())
cmd.AddCommand(sandboxCmd.NewCommand())
cmd.AddCommand(cloud.NewCloudCommand())
cmd.AddCommand(configCmd.NewConfigCommand())
@@ -153,7 +161,11 @@ func main() {
})
defer analytics.Close()
defer eventlog.Close()
defer func() {
if err := eventlog.Close(); err != nil {
log.Warnf("failed to close eventlog: %v", err)
}
}()
defer func() {
if err := audit.Close(); err != nil {
log.Warnf("failed to close audit system: %v", err)
@@ -164,6 +176,10 @@ func main() {
analytics.TrackCI()
if err := cmd.Execute(); err != nil {
type exitCoder interface{ ExitCode() int }
if ec, ok := err.(exitCoder); ok {
os.Exit(ec.ExitCode())
}
os.Exit(1)
}
}
+5 -1
View File
@@ -168,7 +168,11 @@ func getPypiPackageDependencies(packageName, version string, packageTargets []*P
if res.StatusCode != 200 {
return nil, ErrFailedToFetchPackage.Wrap(err)
}
defer res.Body.Close()
defer func() {
if err := res.Body.Close(); err != nil {
log.Warnf("failed to close PyPI response body: %v", err)
}
}()
var pypipkg pypiPackage
err = json.NewDecoder(res.Body).Decode(&pypipkg)
+1 -1
View File
@@ -46,7 +46,7 @@ func ApplySandbox(ctx context.Context, cmd *exec.Cmd, pmName string, opts ...app
opt(applyConfig)
}
registry, err := sandbox.NewProfileRegistry()
registry, err := sandbox.NewProfileRegistry(sandbox.WithUserProfileDir(cfg.SandboxProfileDir()))
if err != nil {
return nil, fmt.Errorf("failed to create profile registry: %w", err)
}
+38 -205
View File
@@ -2,245 +2,78 @@ package executor
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/config"
"github.com/safedep/pmg/sandbox"
"github.com/safedep/pmg/usefulerror"
)
// WrapCommandExecutionError converts a package manager execution error into a
// user-facing error. When sandbox diagnostics are available, they take
// precedence over the generic exit-code-only message.
// user-facing error. It never attributes the failure to the sandbox: causation
// cannot be inferred from EPERM/EACCES returns alone, and a security tool
// should not make best-effort claims. Any observed sandbox denials are
// persisted to the violation cache for forensic review via
// `pmg sandbox violations list` and `pmg sandbox explain`.
func WrapCommandExecutionError(err error, result *sandbox.ExecutionResult, exitCode int) error {
if err == nil {
return nil
}
if result != nil {
report, diagErr := result.BestEffortViolation(err)
if diagErr != nil {
log.Warnf("failed to collect sandbox diagnostics: %v", diagErr)
} else if report != nil && len(report.Violations) > 0 {
return usefulerror.Useful().
WithCode(usefulerror.ErrCodeSandboxViolation).
WithHumanError("PMG sandbox blocked this command").
WithHelp(buildSandboxHint(report)).
WithAdditionalHelp(buildSandboxDetails(report)).
Wrap(err)
}
}
observed := observeAndPersistViolations(result, err)
humanError := "Failed to execute package manager command"
if exitCode >= 0 {
humanError = fmt.Sprintf("Package manager command exited with code: %d", exitCode)
}
return usefulerror.Useful().
help := "Check the package manager command and its arguments"
builder := usefulerror.Useful().
WithCode(usefulerror.ErrCodePackageManagerExecutionFailed).
WithHumanError(humanError).
WithHelp("Check the package manager command and its arguments").
Wrap(err)
WithHelp(help)
if observed > 0 {
builder = builder.WithAdditionalHelp(fmt.Sprintf(
"Sandbox observed %d denied operation(s) during this run. Run `pmg sandbox violations list` to investigate.",
observed,
))
}
func buildSandboxHint(report *sandbox.ViolationReport) string {
first := primarySandboxViolation(report)
if first == nil {
return "Reason: sandbox denied an operation"
return builder.Wrap(err)
}
hint := fmt.Sprintf("Reason: %s", first.RuleLabel)
if override := suggestSandboxOverride(*first); override != "" {
hint = fmt.Sprintf("%s. Override: %s", hint, override)
// observeAndPersistViolations collects any sandbox violation report associated
// with the run and writes it to the violation cache. Returns the number of
// violations observed. Failures are logged and swallowed; observability MUST
// NOT affect command exit.
func observeAndPersistViolations(result *sandbox.ExecutionResult, runErr error) int {
if result == nil {
return 0
}
return hint
report, diagErr := result.BestEffortViolation(runErr)
if diagErr != nil {
log.Warnf("failed to collect sandbox diagnostics: %v", diagErr)
return 0
}
func buildSandboxDetails(report *sandbox.ViolationReport) string {
first := primarySandboxViolation(report)
if first == nil {
return ""
}
lines := []string{
fmt.Sprintf("Sandbox: %s", report.SandboxName),
fmt.Sprintf("Policy: %s", report.PolicyName),
fmt.Sprintf("Correlation: %s", report.CorrelationID),
fmt.Sprintf("Process: %s", emptyFallback(first.Process, "unknown")),
fmt.Sprintf("Violation: %s", first.RuleLabel),
}
if first.RuleTarget != "" && first.RuleTarget != first.Target {
lines = append(lines, fmt.Sprintf("Matched rule: %s", first.RuleTarget))
}
if first.RawLog != "" {
lines = append(lines, fmt.Sprintf("Seatbelt log: %s", first.RawLog))
}
if len(report.Violations) > 1 {
lines = append(lines, fmt.Sprintf("Additional denials observed: %d", len(report.Violations)-1))
}
return strings.Join(lines, "\n")
}
func suggestSandboxOverride(v sandbox.Violation) string {
if !isSafeSandboxOverrideTarget(v.Target) {
return ""
}
quotedTarget := shellQuote(v.Target)
switch v.Kind {
case sandbox.ViolationKindFSRead:
return fmt.Sprintf("--sandbox-allow read=%s", quotedTarget)
case sandbox.ViolationKindFSWrite, sandbox.ViolationKindFSDeleteOrRename:
return fmt.Sprintf("--sandbox-allow write=%s", quotedTarget)
case sandbox.ViolationKindExec:
return fmt.Sprintf("--sandbox-allow exec=%s", quotedTarget)
default:
return ""
}
}
func isSafeSandboxOverrideTarget(value string) bool {
if value == "" {
return false
}
if strings.ContainsAny(value, "*?[]") {
return false
}
for _, r := range value {
if r == 0 || r < 0x20 || r == 0x7f {
return false
}
}
return true
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
}
func emptyFallback(value, fallback string) string {
if value == "" {
return fallback
}
return value
}
func primarySandboxViolation(report *sandbox.ViolationReport) *sandbox.Violation {
if report == nil || len(report.Violations) == 0 {
return nil
return 0
}
cwd, _ := os.Getwd()
bestIdx := 0
bestScore := scoreSandboxViolation(report.Violations[0], cwd)
for i := 1; i < len(report.Violations); i++ {
score := scoreSandboxViolation(report.Violations[i], cwd)
if score > bestScore || (score == bestScore && i > bestIdx) {
bestIdx = i
bestScore = score
}
cfg := config.Get()
if cfg == nil {
return len(report.Violations)
}
return &report.Violations[bestIdx]
dir := cfg.SandboxViolationCacheDir()
if dir == "" {
return len(report.Violations)
}
func scoreSandboxViolation(v sandbox.Violation, cwd string) int {
score := 0
switch v.Kind {
case sandbox.ViolationKindFSRead, sandbox.ViolationKindFSWrite:
score += 120
case sandbox.ViolationKindExec:
score += 110
case sandbox.ViolationKindFSDeleteOrRename:
score += 100
case sandbox.ViolationKindGenericDeny:
score += 10
default:
score += 30
if _, err := sandbox.NewViolationCache(dir).Write(report); err != nil {
log.Warnf("failed to persist sandbox violation report: %v", err)
}
if isSafeSandboxOverrideTarget(v.Target) {
score += 40
}
if v.Target != "" && v.Target != v.RuleTarget {
score += 20
}
if isProjectPath(v.Target, cwd) {
score += 80
}
if isSensitiveProjectFile(v.Target) {
score += 60
}
if isNoisySystemPath(v.Target) {
score -= 120
}
if v.Kind == sandbox.ViolationKindGenericDeny && v.Target == "" {
score -= 40
}
return score
}
func isProjectPath(target, cwd string) bool {
if target == "" || cwd == "" {
return false
}
if strings.HasPrefix(target, ".") {
return true
}
cleanTarget := filepath.Clean(target)
cleanCwd := filepath.Clean(cwd)
return cleanTarget == cleanCwd || strings.HasPrefix(cleanTarget, cleanCwd+string(filepath.Separator))
}
func isSensitiveProjectFile(target string) bool {
if target == "" {
return false
}
base := filepath.Base(target)
switch {
case strings.HasPrefix(base, ".env"):
return true
case base == ".npmrc", base == ".pypirc", base == ".netrc":
return true
case base == ".aws", base == ".ssh", base == ".kube", base == ".gnupg":
return true
default:
return strings.Contains(target, string(filepath.Separator)+".ssh") ||
strings.Contains(target, string(filepath.Separator)+".aws") ||
strings.Contains(target, string(filepath.Separator)+".kube")
}
}
func isNoisySystemPath(target string) bool {
switch target {
case "/dev/dtracehelper":
return true
default:
return false
}
return len(report.Violations)
}
+50 -78
View File
@@ -1,45 +1,48 @@
package executor
import (
"os"
"path/filepath"
"context"
"errors"
"os/exec"
"testing"
"github.com/safedep/pmg/sandbox"
"github.com/safedep/pmg/usefulerror"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSuggestSandboxOverrideSkipsGlobRuleTarget(t *testing.T) {
assert.Empty(t, suggestSandboxOverride(sandbox.Violation{
Kind: sandbox.ViolationKindFSRead,
Target: "**/.env",
}))
type fakeViolationSandbox struct {
report *sandbox.ViolationReport
}
func TestSuggestSandboxOverrideUsesConcretePath(t *testing.T) {
assert.Equal(t, "--sandbox-allow read='./.env'", suggestSandboxOverride(sandbox.Violation{
Kind: sandbox.ViolationKindFSRead,
Target: "./.env",
}))
func (f *fakeViolationSandbox) Execute(context.Context, *exec.Cmd, *sandbox.SandboxPolicy) (*sandbox.ExecutionResult, error) {
return sandbox.NewExecutionResult(), nil
}
func TestSuggestSandboxOverrideQuotesSpacesAndSingleQuotes(t *testing.T) {
assert.Equal(t, "--sandbox-allow read='/tmp/My Dir/it'\\''s.env'", suggestSandboxOverride(sandbox.Violation{
Kind: sandbox.ViolationKindFSRead,
Target: "/tmp/My Dir/it's.env",
}))
func (f *fakeViolationSandbox) Name() sandbox.DriverName {
return sandbox.DriverSeatbelt
}
func TestSuggestSandboxOverrideSkipsControlCharacters(t *testing.T) {
assert.Empty(t, suggestSandboxOverride(sandbox.Violation{
Kind: sandbox.ViolationKindFSRead,
Target: "/tmp/bad\npath",
}))
func (f *fakeViolationSandbox) IsAvailable() bool {
return true
}
func TestBuildSandboxDetailsIncludesMatchedRule(t *testing.T) {
details := buildSandboxDetails(&sandbox.ViolationReport{
SandboxName: "seatbelt",
func (f *fakeViolationSandbox) Close() error {
return nil
}
func (f *fakeViolationSandbox) BestEffortViolation(error) (*sandbox.ViolationReport, error) {
return f.report, nil
}
// WrapCommandExecutionError must never claim the sandbox blocked a command.
// Even when violations were observed, the user-facing error stays the package
// manager's native exit; a neutral breadcrumb points at the forensic command.
func TestWrapCommandExecutionErrorDoesNotAttributeFailureToSandbox(t *testing.T) {
result := sandbox.NewExecutionResult(sandbox.WithExecutionResultSandbox(&fakeViolationSandbox{
report: &sandbox.ViolationReport{
SandboxName: sandbox.DriverSeatbelt,
PolicyName: "npm-restrictive",
CorrelationID: "run-1",
Violations: []sandbox.Violation{
@@ -48,66 +51,35 @@ func TestBuildSandboxDetailsIncludesMatchedRule(t *testing.T) {
RawKind: "file-read",
Target: "./.env",
RuleTarget: "**/.env",
Process: "node",
RuleLabel: "read access denied: ./.env",
},
},
})
},
}))
assert.Contains(t, details, "Matched rule: **/.env")
err := WrapCommandExecutionError(errors.New("npm failed"), result, 1)
usefulErr, ok := usefulerror.AsUsefulError(err)
require.True(t, ok)
assert.Equal(t, usefulerror.ErrCodePackageManagerExecutionFailed, usefulErr.Code())
assert.Equal(t, "Package manager command exited with code: 1", usefulErr.HumanError())
assert.NotContains(t, usefulErr.Help(), "./.env")
assert.Contains(t, usefulErr.AdditionalHelp(), "pmg sandbox violations list")
}
func TestPrimarySandboxViolationPrefersConcreteProjectPathOverDefaultNoise(t *testing.T) {
cwd, err := os.Getwd()
assert.NoError(t, err)
func TestWrapCommandExecutionErrorOmitsBreadcrumbWhenNoViolations(t *testing.T) {
result := sandbox.NewExecutionResult(sandbox.WithExecutionResultSandbox(&fakeViolationSandbox{
report: nil,
}))
report := &sandbox.ViolationReport{
Violations: []sandbox.Violation{
{
Kind: sandbox.ViolationKindGenericDeny,
RawKind: "default",
Target: "/dev/dtracehelper",
RuleLabel: "sandbox denied access to /dev/dtracehelper",
},
{
Kind: sandbox.ViolationKindFSRead,
RawKind: "file-read",
Target: filepath.Join(cwd, ".env"),
RuleTarget: "**/.env",
RuleLabel: "read access denied: " + filepath.Join(cwd, ".env"),
},
},
err := WrapCommandExecutionError(errors.New("npm failed"), result, 1)
usefulErr, ok := usefulerror.AsUsefulError(err)
require.True(t, ok)
assert.Equal(t, usefulerror.ErrCodePackageManagerExecutionFailed, usefulErr.Code())
assert.NotContains(t, usefulErr.AdditionalHelp(), "pmg sandbox violations list")
}
primary := primarySandboxViolation(report)
if assert.NotNil(t, primary) {
assert.Equal(t, sandbox.ViolationKindFSRead, primary.Kind)
assert.Equal(t, filepath.Join(cwd, ".env"), primary.Target)
}
}
func TestBuildSandboxHintUsesRankedPrimaryViolation(t *testing.T) {
cwd, err := os.Getwd()
assert.NoError(t, err)
hint := buildSandboxHint(&sandbox.ViolationReport{
Violations: []sandbox.Violation{
{
Kind: sandbox.ViolationKindGenericDeny,
RawKind: "default",
Target: "/dev/dtracehelper",
RuleLabel: "sandbox denied access to /dev/dtracehelper",
},
{
Kind: sandbox.ViolationKindFSRead,
RawKind: "file-read",
Target: filepath.Join(cwd, ".env"),
RuleTarget: "**/.env",
RuleLabel: "read access denied: " + filepath.Join(cwd, ".env"),
},
},
})
assert.Contains(t, hint, "Reason: read access denied:")
assert.NotContains(t, hint, "/dev/dtracehelper")
func TestWrapCommandExecutionErrorReturnsNilOnNilError(t *testing.T) {
assert.NoError(t, WrapCommandExecutionError(nil, nil, 0))
}
+211
View File
@@ -0,0 +1,211 @@
package sandbox
import (
"regexp"
"strconv"
"strings"
"github.com/safedep/pmg/sandbox/util"
)
// LintLevel categorises a lint issue. Errors indicate a profile that cannot
// safely be used; warnings flag risky or contradictory configuration; info
// surfaces minor cleanups (e.g. dead rules) that callers can hide by default.
type LintLevel string
const (
LintLevelError LintLevel = "error"
LintLevelWarn LintLevel = "warn"
LintLevelInfo LintLevel = "info"
)
type LintIssue struct {
Level LintLevel `json:"level"`
Code string `json:"code"`
Message string `json:"message"`
Field string `json:"field,omitempty"`
Rule string `json:"rule,omitempty"`
}
var variableTokenRe = regexp.MustCompile(`\$\{[^}]+\}`)
// LintProfile returns issues in stable order: schema errors, then warnings in
// field-declaration order, then info-level findings.
func LintProfile(policy *SandboxPolicy) []LintIssue {
if policy == nil {
return []LintIssue{{
Level: LintLevelError,
Code: "schema.invalid",
Message: "policy is nil",
}}
}
var errors []LintIssue
var warns []LintIssue
var infos []LintIssue
schemaErr := policy.Validate()
if schemaErr != nil {
errors = append(errors, LintIssue{
Level: LintLevelError,
Code: "schema.invalid",
Message: schemaErr.Error(),
})
}
// ValidateResolved calls Validate first; only surface its error when the
// basic schema was valid, so we don't duplicate the message above.
if schemaErr == nil {
if err := policy.ValidateResolved(); err != nil {
errors = append(errors, LintIssue{
Level: LintLevelError,
Code: "schema.invalid",
Message: err.Error(),
})
}
}
// allowOnly marks lists where broadness and dead-rule checks make sense:
// deny-lists and network allow_bind are out of scope for those checks.
allowLists := []struct {
name string
rules []string
allowOnly bool
}{
{"filesystem.allow_read", policy.Filesystem.AllowRead, true},
{"filesystem.allow_write", policy.Filesystem.AllowWrite, true},
{"filesystem.deny_read", policy.Filesystem.DenyRead, false},
{"filesystem.deny_write", policy.Filesystem.DenyWrite, false},
{"network.allow_outbound", policy.Network.AllowOutbound, false},
{"network.deny_outbound", policy.Network.DenyOutbound, false},
{"network.allow_bind", policy.Network.AllowBind, false},
{"process.allow_exec", policy.Process.AllowExec, true},
{"process.deny_exec", policy.Process.DenyExec, false},
}
knownVars := strings.Join(util.SupportedVariables, ", ")
for _, list := range allowLists {
for i, rule := range list.rules {
for _, tok := range variableTokenRe.FindAllString(rule, -1) {
if util.IsSupportedVariable(tok) {
continue
}
warns = append(warns, LintIssue{
Level: LintLevelWarn,
Code: "vars.unresolved",
Message: "unsupported variable " + tok + " (known: " + knownVars + ")",
Field: fieldRef(list.name, i),
Rule: rule,
})
}
}
}
for _, list := range allowLists {
if !list.allowOnly {
continue
}
for i, rule := range list.rules {
code, msg := broadCheck(rule)
if code == "" {
continue
}
warns = append(warns, LintIssue{
Level: LintLevelWarn,
Code: code,
Message: msg,
Field: fieldRef(list.name, i),
Rule: rule,
})
}
}
conflictPairs := []struct {
allowName string
allow []string
denyName string
deny []string
}{
{"filesystem.allow_read", policy.Filesystem.AllowRead, "filesystem.deny_read", policy.Filesystem.DenyRead},
{"filesystem.allow_write", policy.Filesystem.AllowWrite, "filesystem.deny_write", policy.Filesystem.DenyWrite},
{"network.allow_outbound", policy.Network.AllowOutbound, "network.deny_outbound", policy.Network.DenyOutbound},
{"process.allow_exec", policy.Process.AllowExec, "process.deny_exec", policy.Process.DenyExec},
}
for _, pair := range conflictPairs {
denyIdx := map[string]int{}
for i, r := range pair.deny {
if _, exists := denyIdx[r]; !exists {
denyIdx[r] = i
}
}
for i, r := range pair.allow {
if j, ok := denyIdx[r]; ok {
warns = append(warns, LintIssue{
Level: LintLevelWarn,
Code: "conflict.allow_deny",
Message: "rule appears in both " + fieldRef(pair.allowName, i) + " and " + fieldRef(pair.denyName, j) + "; deny takes precedence",
Field: fieldRef(pair.allowName, i),
Rule: r,
})
}
}
}
// Dead rules: a later rule is "shadowed" only if it is a strict prefix
// match of an earlier rule whose pattern ends with "/**".
for _, list := range allowLists {
if !list.allowOnly {
continue
}
for i, rule := range list.rules {
for j := 0; j < i; j++ {
earlier := list.rules[j]
if !strings.HasSuffix(earlier, "/**") {
continue
}
prefix := strings.TrimSuffix(earlier, "/**")
if prefix == "" {
continue
}
if rule == earlier {
continue
}
stripped := strings.TrimSuffix(rule, "/**")
if stripped == prefix {
continue
}
if strings.HasPrefix(stripped, prefix+"/") {
infos = append(infos, LintIssue{
Level: LintLevelInfo,
Code: "dead.shadowed",
Message: "rule shadowed by " + fieldRef(list.name, j) + " (" + earlier + ")",
Field: fieldRef(list.name, i),
Rule: rule,
})
break
}
}
}
}
out := make([]LintIssue, 0, len(errors)+len(warns)+len(infos))
out = append(out, errors...)
out = append(out, warns...)
out = append(out, infos...)
return out
}
func broadCheck(rule string) (string, string) {
switch rule {
case "/**":
return "broad.root_glob", "rule grants access to entire filesystem (/**)"
case util.VarHome + "/**":
return "broad.home_glob", "rule grants access to entire user home (" + util.VarHome + "/**)"
case "**":
return "broad.all_glob", "rule uses unrestricted glob (**)"
}
return "", ""
}
func fieldRef(name string, idx int) string {
return name + "[" + strconv.Itoa(idx) + "]"
}
+232
View File
@@ -0,0 +1,232 @@
package sandbox
import (
"testing"
"github.com/stretchr/testify/assert"
)
// cleanPolicy returns a minimally-valid policy with no lint issues.
func cleanPolicy() *SandboxPolicy {
return &SandboxPolicy{
Name: "test",
PackageManagers: []string{"npm"},
Filesystem: FilesystemPolicy{
AllowRead: []string{"${CWD}/src/**", "${HOME}/.npmrc"},
AllowWrite: []string{"${CWD}/dist/**"},
DenyRead: []string{"/etc/shadow"},
DenyWrite: []string{"/etc/**"},
},
}
}
func TestLintProfile_Clean(t *testing.T) {
got := LintProfile(cleanPolicy())
assert.Empty(t, got)
}
func TestLintProfile_SchemaInvalid(t *testing.T) {
tests := []struct {
name string
policy *SandboxPolicy
want string
}{
{
name: "missing name",
policy: &SandboxPolicy{
PackageManagers: []string{"npm"},
Filesystem: FilesystemPolicy{AllowRead: []string{"/tmp"}},
},
want: "policy name is required",
},
{
name: "no package managers",
policy: &SandboxPolicy{
Name: "x",
Filesystem: FilesystemPolicy{AllowRead: []string{"/tmp"}},
},
want: "at least one package manager",
},
{
name: "no rules",
policy: &SandboxPolicy{
Name: "x",
PackageManagers: []string{"npm"},
},
want: "at least one access rule",
},
{
name: "nil",
policy: nil,
want: "policy is nil",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := LintProfile(tc.policy)
assert.NotEmpty(t, got)
assert.Equal(t, LintLevelError, got[0].Level)
assert.Equal(t, "schema.invalid", got[0].Code)
assert.Contains(t, got[0].Message, tc.want)
})
}
}
func TestLintProfile_UnresolvedVariables(t *testing.T) {
p := cleanPolicy()
p.Filesystem.AllowRead = append(p.Filesystem.AllowRead, "${USER}/foo", "${HOME}/${UNKNOWN}/bar")
got := LintProfile(p)
codes := map[string]int{}
for _, i := range got {
codes[i.Code]++
}
assert.Equal(t, 2, codes["vars.unresolved"])
// First unresolved should cite the rule and the field path.
found := false
for _, i := range got {
if i.Code == "vars.unresolved" && i.Rule == "${USER}/foo" {
found = true
assert.Contains(t, i.Field, "filesystem.allow_read[")
assert.Contains(t, i.Message, "${USER}")
}
}
assert.True(t, found, "expected vars.unresolved issue for ${USER}/foo")
}
func TestLintProfile_BroadRules(t *testing.T) {
tests := []struct {
name string
mut func(*SandboxPolicy)
code string
}{
{
name: "root glob",
mut: func(p *SandboxPolicy) {
p.Filesystem.AllowRead = []string{"/**"}
},
code: "broad.root_glob",
},
{
name: "home glob",
mut: func(p *SandboxPolicy) {
p.Filesystem.AllowWrite = []string{"${HOME}/**"}
},
code: "broad.home_glob",
},
{
name: "all glob",
mut: func(p *SandboxPolicy) {
p.Process.AllowExec = []string{"**"}
},
code: "broad.all_glob",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
p := cleanPolicy()
tc.mut(p)
got := LintProfile(p)
found := false
for _, i := range got {
if i.Code == tc.code {
found = true
assert.Equal(t, LintLevelWarn, i.Level)
}
}
assert.True(t, found, "expected %s issue", tc.code)
})
}
}
func TestLintProfile_NoBroadWarnOnDeny(t *testing.T) {
p := cleanPolicy()
p.Filesystem.DenyRead = []string{"/**", "${HOME}/**", "**"}
got := LintProfile(p)
for _, i := range got {
assert.NotContains(t, i.Code, "broad.", "deny lists should not produce broad.* warnings")
}
}
func TestLintProfile_ConflictAllowDeny(t *testing.T) {
p := cleanPolicy()
p.Filesystem.AllowRead = []string{"/etc/hosts"}
p.Filesystem.DenyRead = []string{"/etc/hosts"}
got := LintProfile(p)
found := false
for _, i := range got {
if i.Code == "conflict.allow_deny" {
found = true
assert.Equal(t, LintLevelWarn, i.Level)
assert.Equal(t, "/etc/hosts", i.Rule)
assert.Contains(t, i.Message, "filesystem.allow_read[0]")
assert.Contains(t, i.Message, "filesystem.deny_read[0]")
}
}
assert.True(t, found)
}
func TestLintProfile_DeadShadowed(t *testing.T) {
p := cleanPolicy()
p.Filesystem.AllowRead = []string{
"${HOME}/.cache/npm/**",
"${HOME}/.cache/npm/foo",
"${HOME}/.cache/npm/sub/**",
}
got := LintProfile(p)
infoCount := 0
for _, i := range got {
if i.Code == "dead.shadowed" {
infoCount++
assert.Equal(t, LintLevelInfo, i.Level)
}
}
assert.Equal(t, 2, infoCount)
}
func TestLintProfile_DeadShadowed_NoFalsePositive(t *testing.T) {
p := cleanPolicy()
// Sibling paths under same prefix; without /** earlier, should not shadow.
p.Filesystem.AllowRead = []string{"${HOME}/.npmrc", "${HOME}/.npmrc.bak"}
got := LintProfile(p)
for _, i := range got {
assert.NotEqual(t, "dead.shadowed", i.Code)
}
}
func TestLintProfile_OrderingErrorsBeforeWarnsBeforeInfo(t *testing.T) {
// Build a policy with all three classes.
p := &SandboxPolicy{
Name: "x",
PackageManagers: []string{}, // schema error
Filesystem: FilesystemPolicy{
AllowRead: []string{
"/**", // broad warn
"${HOME}/.cache/npm/**", // not shadowed
"${HOME}/.cache/npm/inside", // dead info
},
},
}
got := LintProfile(p)
// Walk levels; must be error*, then warn*, then info*.
phase := 0 // 0=error,1=warn,2=info
for _, i := range got {
switch i.Level {
case LintLevelError:
assert.LessOrEqual(t, phase, 0)
case LintLevelWarn:
if phase < 1 {
phase = 1
}
assert.LessOrEqual(t, phase, 1)
case LintLevelInfo:
phase = 2
}
}
}
+2 -2
View File
@@ -87,8 +87,8 @@ func (b *bubblewrapSandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy *
}
// Name returns the name of this sandbox implementation.
func (b *bubblewrapSandbox) Name() string {
return "bubblewrap"
func (b *bubblewrapSandbox) Name() sandbox.DriverName {
return sandbox.DriverBubblewrap
}
// IsAvailable returns true if bubblewrap (bwrap) is available on this system.
+1 -1
View File
@@ -18,7 +18,7 @@ func TestBubblewrapSandboxCreation(t *testing.T) {
sb, err := newBubblewrapSandbox()
require.NoError(t, err)
assert.NotNil(t, sb)
assert.Equal(t, "bubblewrap", sb.Name())
assert.Equal(t, sandbox.DriverBubblewrap, sb.Name())
}
func TestBubblewrapSandboxIsAvailable(t *testing.T) {
@@ -0,0 +1,35 @@
//go:build linux
// +build linux
package platform
import (
"fmt"
"strings"
"github.com/safedep/pmg/sandbox"
)
// RenderBubblewrap translates a SandboxPolicy into the bwrap argv that the
// Bubblewrap driver would invoke at runtime, encoded as one argument per
// line. One-arg-per-line is chosen over shell-quoted joining because bwrap
// arguments routinely contain absolute paths and option flags that would
// require non-trivial shell quoting; the per-line form is unambiguous and
// trivially round-trippable.
//
// This is a thin wrapper over the internal bubblewrap translator and is
// intended for inspection use cases such as
// `pmg setup sandbox profile show --driver=bwrap`.
func RenderBubblewrap(policy *sandbox.SandboxPolicy) ([]byte, error) {
if policy == nil {
return nil, fmt.Errorf("policy is nil")
}
t := newBubblewrapPolicyTranslator(newDefaultBubblewrapConfig())
args, err := t.translate(policy)
if err != nil {
return nil, err
}
return []byte(strings.Join(args, "\n") + "\n"), nil
}
@@ -0,0 +1,85 @@
//go:build linux
// +build linux
package platform
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/safedep/dry/utils"
"github.com/safedep/pmg/sandbox"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func withoutBubblewrapArgTriple(b []byte, option, source, dest string) []byte {
args := strings.Split(strings.TrimSuffix(string(b), "\n"), "\n")
normalized := make([]string, 0, len(args))
for i := 0; i < len(args); {
if i+2 < len(args) &&
args[i] == option &&
args[i+1] == source &&
args[i+2] == dest {
i += 3
continue
}
normalized = append(normalized, args[i])
i++
}
return []byte(strings.Join(normalized, "\n") + "\n")
}
func TestRenderBubblewrap_Golden(t *testing.T) {
tests := []struct {
name string
policy *sandbox.SandboxPolicy
goldenFile string
}{
{
name: "minimal allow read tmp",
policy: &sandbox.SandboxPolicy{
Name: "render-min",
Description: "minimal policy for render golden test",
PackageManagers: []string{"npm"},
Filesystem: sandbox.FilesystemPolicy{
AllowRead: []string{"/tmp"},
AllowWrite: []string{"/tmp"},
},
AllowPTY: utils.PtrTo(false),
},
goldenFile: "bubblewrap_minimal.argv",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("HOME", t.TempDir())
got, err := RenderBubblewrap(tc.policy)
require.NoError(t, err)
goldenPath := filepath.Join("testdata", tc.goldenFile)
if os.Getenv("UPDATE_GOLDEN") != "" {
require.NoError(t, os.WriteFile(goldenPath, got, 0o644))
}
expected, err := os.ReadFile(goldenPath)
require.NoError(t, err, "missing golden file: run with UPDATE_GOLDEN=1 to create")
if _, err := os.Stat("/lib64"); os.IsNotExist(err) {
expected = withoutBubblewrapArgTriple(expected, "--ro-bind-try", "/lib64", "/lib64")
}
assert.Equal(t, string(expected), string(got))
})
}
}
func TestRenderBubblewrap_NilPolicy(t *testing.T) {
_, err := RenderBubblewrap(nil)
assert.Error(t, err)
}
+62 -23
View File
@@ -269,6 +269,22 @@ func (t *bubblewrapPolicyTranslator) translateFilesystem(policy *sandbox.Sandbox
}
}
for _, pattern := range policy.Filesystem.DenyRead {
expanded, err := util.ExpandVariables(pattern)
if err != nil {
log.Warnf("Failed to expand variables in deny_read pattern '%s': %v", pattern, err)
continue
}
denyArgs, err := t.processDenyReadRule(expanded)
if err != nil {
log.Debugf("Deny read rule '%s' skipped: %v", expanded, err)
continue
}
args = append(args, denyArgs...)
}
for _, pattern := range policy.Filesystem.DenyWrite {
expanded, err := util.ExpandVariables(pattern)
if err != nil {
@@ -382,6 +398,52 @@ func (t *bubblewrapPolicyTranslator) translateFilesystem(policy *sandbox.Sandbox
return args, nil
}
// processDenyReadRule hides readable content. Files are masked with /dev/null;
// directories are overlaid with tmpfs so their host contents are not visible.
func (t *bubblewrapPolicyTranslator) processDenyReadRule(path string) ([]string, error) {
args := []string{}
if util.ContainsGlob(path) {
if strings.Contains(path, "**") {
parentDir := t.extractParentDir(path)
if parentDir == "" || parentDir == "." {
return args, nil
}
log.Warnf("Deny read glob '%s' uses **; hiding parent directory '%s' to avoid expanding many bubblewrap arguments", path, parentDir)
return t.processDenyReadRule(parentDir)
}
paths, _, err := t.expandGlobPattern(path, t.config.mandatoryDenyScanDepth, t.config.maxGlobPaths)
if err != nil {
return args, nil
}
for _, p := range paths {
if info, err := os.Stat(p); err == nil {
if info.IsDir() {
args = append(args, "--tmpfs", p)
} else {
args = append(args, "--ro-bind", "/dev/null", p)
}
}
}
return args, nil
}
if info, err := os.Stat(path); err == nil {
if info.IsDir() {
args = append(args, "--tmpfs", path)
} else {
args = append(args, "--ro-bind", "/dev/null", path)
}
} else if os.IsNotExist(err) {
log.Debugf("Deny read rule: skipping non-existent path '%s'", path)
}
return args, nil
}
// processDenyWriteRule handles deny_write rules without masking reads. Files
// and directories are mounted read-only over any earlier writable parent bind.
func (t *bubblewrapPolicyTranslator) processDenyWriteRule(path string) ([]string, error) {
@@ -585,29 +647,6 @@ func (t *bubblewrapPolicyTranslator) processDenyRule(path string) ([]string, err
return args, nil
}
// findFirstNonExistentPath walks up the directory tree to find the first path component
// that doesn't exist. This allows us to block file creation by mounting /dev/null.
//
// Example: If /home/user/.env doesn't exist but /home/user does, returns /home/user/.env
func (t *bubblewrapPolicyTranslator) findFirstNonExistentPath(path string) string {
path = filepath.Clean(path)
// Walk up the tree
for path != "/" && path != "." {
if _, err := os.Stat(path); os.IsNotExist(err) {
// Check if parent exists
parent := filepath.Dir(path)
if _, err := os.Stat(parent); err == nil {
// Parent exists, this is the first non-existent path
return path
}
}
path = filepath.Dir(path)
}
return ""
}
// expandGlobPattern expands a glob pattern to a list of concrete paths.
// Implements depth limiting and path count limiting to prevent DoS.
// Returns (paths, useFallback, error) where useFallback indicates if
@@ -70,7 +70,7 @@ func TestBubblewrapTranslatorFilesystemRules(t *testing.T) {
cases := []struct {
name string
policy *sandbox.SandboxPolicy
assert func(t *testing.T, args []string, err error)
assert func(t *testing.T, args []string, err error, policy *sandbox.SandboxPolicy)
}{
{
name: "simple read-only path",
@@ -79,7 +79,7 @@ func TestBubblewrapTranslatorFilesystemRules(t *testing.T) {
AllowRead: []string{"/usr/local"},
},
},
assert: func(t *testing.T, args []string, err error) {
assert: func(t *testing.T, args []string, err error, policy *sandbox.SandboxPolicy) {
require.NoError(t, err)
argsStr := argSliceToString(args)
// Should have read-only bind for the path
@@ -94,7 +94,7 @@ func TestBubblewrapTranslatorFilesystemRules(t *testing.T) {
AllowWrite: []string{"/tmp/test"},
},
},
assert: func(t *testing.T, args []string, err error) {
assert: func(t *testing.T, args []string, err error, policy *sandbox.SandboxPolicy) {
require.NoError(t, err)
argsStr := argSliceToString(args)
// Should have read-write bind for the path
@@ -110,7 +110,7 @@ func TestBubblewrapTranslatorFilesystemRules(t *testing.T) {
AllowWrite: []string{"${CWD}/node_modules"},
},
},
assert: func(t *testing.T, args []string, err error) {
assert: func(t *testing.T, args []string, err error, policy *sandbox.SandboxPolicy) {
require.NoError(t, err)
argsStr := argSliceToString(args)
@@ -131,7 +131,7 @@ func TestBubblewrapTranslatorFilesystemRules(t *testing.T) {
DenyWrite: []string{"/etc/passwd"},
},
},
assert: func(t *testing.T, args []string, err error) {
assert: func(t *testing.T, args []string, err error, policy *sandbox.SandboxPolicy) {
require.NoError(t, err)
if _, err := os.Stat("/etc/passwd"); err == nil {
@@ -140,6 +140,58 @@ func TestBubblewrapTranslatorFilesystemRules(t *testing.T) {
}
},
},
{
name: "deny read masks file with dev null",
policy: func() *sandbox.SandboxPolicy {
dir := t.TempDir()
path := filepath.Join(dir, "secret.txt")
require.NoError(t, os.WriteFile(path, []byte("secret"), 0o600))
return &sandbox.SandboxPolicy{
Filesystem: sandbox.FilesystemPolicy{
DenyRead: []string{path},
},
}
}(),
assert: func(t *testing.T, args []string, err error, policy *sandbox.SandboxPolicy) {
require.NoError(t, err)
assertDevNullMount(t, args, policy.Filesystem.DenyRead[0])
},
},
{
name: "deny read hides directory with tmpfs",
policy: func() *sandbox.SandboxPolicy {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("secret"), 0o600))
return &sandbox.SandboxPolicy{
Filesystem: sandbox.FilesystemPolicy{
DenyRead: []string{dir},
},
}
}(),
assert: func(t *testing.T, args []string, err error, policy *sandbox.SandboxPolicy) {
require.NoError(t, err)
assertTmpfsAt(t, args, policy.Filesystem.DenyRead[0])
},
},
{
name: "deny read globstar uses parent directory approximation",
policy: func() *sandbox.SandboxPolicy {
dir := t.TempDir()
require.NoError(t, os.Mkdir(filepath.Join(dir, "nested"), 0o700))
require.NoError(t, os.WriteFile(filepath.Join(dir, "nested", "secret.txt"), []byte("secret"), 0o600))
return &sandbox.SandboxPolicy{
Filesystem: sandbox.FilesystemPolicy{
DenyRead: []string{filepath.Join(dir, "**")},
},
}
}(),
assert: func(t *testing.T, args []string, err error, policy *sandbox.SandboxPolicy) {
require.NoError(t, err)
parentDir := filepath.Dir(policy.Filesystem.DenyRead[0])
assertTmpfsAt(t, args, parentDir)
assertNoDevNullMount(t, args, filepath.Join(parentDir, "nested", "secret.txt"))
},
},
{
name: "multiple paths",
policy: &sandbox.SandboxPolicy{
@@ -155,7 +207,7 @@ func TestBubblewrapTranslatorFilesystemRules(t *testing.T) {
},
},
},
assert: func(t *testing.T, args []string, err error) {
assert: func(t *testing.T, args []string, err error, policy *sandbox.SandboxPolicy) {
require.NoError(t, err)
argsStr := argSliceToString(args)
@@ -176,7 +228,7 @@ func TestBubblewrapTranslatorFilesystemRules(t *testing.T) {
config := newDefaultBubblewrapConfig()
translator := newBubblewrapPolicyTranslator(config)
args, err := translator.translate(tt.policy)
tt.assert(t, args, err)
tt.assert(t, args, err, tt.policy)
})
}
}
@@ -646,52 +698,6 @@ func TestExpandGlobstarPattern(t *testing.T) {
}
}
func TestFindFirstNonExistentPath(t *testing.T) {
tmpDir := t.TempDir()
// Create a directory structure
existingDir := filepath.Join(tmpDir, "existing")
require.NoError(t, os.MkdirAll(existingDir, 0755))
config := newDefaultBubblewrapConfig()
translator := newBubblewrapPolicyTranslator(config)
cases := []struct {
name string
path string
expected string
}{
{
name: "file in existing directory",
path: filepath.Join(existingDir, "nonexistent.txt"),
expected: filepath.Join(existingDir, "nonexistent.txt"),
},
{
name: "nested non-existent path",
path: filepath.Join(existingDir, "deep", "nested", "file.txt"),
expected: filepath.Join(existingDir, "deep"),
},
{
name: "completely non-existent path",
path: "/totally/nonexistent/path/file.txt",
expected: "", // No parent exists, can't block creation
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
result := translator.findFirstNonExistentPath(tt.path)
if tt.expected == "" {
// For completely non-existent paths, we might get empty or a high-level path
// Just verify no panic
assert.True(t, true)
} else {
assert.Equal(t, tt.expected, result)
}
})
}
}
// TestGlobFallbackThreshold verifies coarse-grained fallback behavior when patterns match too many paths
func TestGlobFallbackThreshold(t *testing.T) {
tmpDir := t.TempDir()
@@ -1007,6 +1013,17 @@ func assertNoTmpfsAt(t *testing.T, args []string, path string) {
}
}
func assertTmpfsAt(t *testing.T, args []string, path string) {
t.Helper()
for i := 0; i+1 < len(args); i++ {
if args[i] == "--tmpfs" && args[i+1] == path {
return
}
}
t.Fatalf("expected --tmpfs at %q, but none found", path)
}
func assertNoDevNullMount(t *testing.T, args []string, path string) {
t.Helper()
for i := 0; i+2 < len(args); i++ {
@@ -1016,6 +1033,17 @@ func assertNoDevNullMount(t *testing.T, args []string, path string) {
}
}
func assertDevNullMount(t *testing.T, args []string, path string) {
t.Helper()
for i := 0; i+2 < len(args); i++ {
if args[i] == "--ro-bind" && args[i+1] == "/dev/null" && args[i+2] == path {
return
}
}
t.Fatalf("expected /dev/null mount at %q, but none found", path)
}
func assertReadBind(t *testing.T, args []string, path string) {
t.Helper()
for i := 0; i+2 < len(args); i++ {
+2 -2
View File
@@ -47,8 +47,8 @@ func newLandlockSandbox() (sandbox.Sandbox, error) {
}
// Name returns the name of this sandbox implementation.
func (s *landlockSandbox) Name() string {
return "landlock"
func (s *landlockSandbox) Name() sandbox.DriverName {
return sandbox.DriverLandlock
}
// IsAvailable returns true if Landlock is available and functional on this system.
+1 -1
View File
@@ -16,7 +16,7 @@ import (
func TestLandlockSandbox_Name(t *testing.T) {
sb := &landlockSandbox{abi: newLandlockABI(4)}
assert.Equal(t, "landlock", sb.Name())
assert.Equal(t, sandbox.DriverLandlock, sb.Name())
}
func TestLandlockSandbox_IsAvailable_True(t *testing.T) {
+126
View File
@@ -0,0 +1,126 @@
//go:build linux
// +build linux
package platform
import (
"fmt"
"strings"
llsyscall "github.com/landlock-lsm/go-landlock/landlock/syscall"
"github.com/safedep/pmg/sandbox"
)
// landlockRenderFallbackABI is used when the host kernel does not support
// Landlock (so callers on non-landlock hosts still get a meaningful render
// for inspection). Set to the highest ABI version this translator knows
// about so all feature flags are enabled in the rendered ruleset.
const landlockRenderFallbackABI = 6
// RenderLandlock translates a SandboxPolicy into a human-readable summary of
// the Landlock ruleset the driver would apply. The summary lists the detected
// ABI level, the filesystem allow rules (path + symbolic access flags), the
// deny paths consumed by the seccomp supervisor, and the deny-exec list.
//
// This is a thin wrapper over the internal landlock translator and is
// intended for inspection use cases such as
// `pmg setup sandbox profile show --driver=landlock`. When the host kernel
// does not support Landlock, the renderer falls back to a default ABI so the
// output is still meaningful for design-time inspection; this fallback is
// noted in the rendered header.
func RenderLandlock(policy *sandbox.SandboxPolicy) ([]byte, error) {
if policy == nil {
return nil, fmt.Errorf("policy is nil")
}
abi, err := landlockDetectABI()
abiSource := "detected"
if err != nil {
abi = newLandlockABI(landlockRenderFallbackABI)
abiSource = "fallback"
}
ep, err := landlockTranslatePolicy(policy, abi)
if err != nil {
return nil, err
}
var sb strings.Builder
fmt.Fprintf(&sb, "# Landlock ruleset\n")
fmt.Fprintf(&sb, "# policy: %s\n", policy.Name)
fmt.Fprintf(&sb, "abi: %d (%s)\n", abi.Version, abiSource)
fmt.Fprintf(&sb, "features: refer=%t truncate=%t network=%t ioctl_dev=%t scoping=%t\n",
abi.HasRefer, abi.HasTruncate, abi.HasNetwork, abi.HasIoctlDev, abi.HasScoping)
fmt.Fprintf(&sb, "allow_pty: %t\n", ep.AllowPTY)
fmt.Fprintf(&sb, "skip_pid_namespace: %t\n", ep.SkipPIDNamespace)
fmt.Fprintf(&sb, "skip_ipc_namespace: %t\n", ep.SkipIPCNamespace)
fmt.Fprintf(&sb, "\nfilesystem_rules (%d):\n", len(ep.FilesystemRules))
for _, r := range ep.FilesystemRules {
fmt.Fprintf(&sb, " - path: %s\n access: %s\n", r.Path, landlockAccessFlagsString(r.Access))
}
fmt.Fprintf(&sb, "\ndeny_paths (%d):\n", len(ep.DenyPaths))
for _, d := range ep.DenyPaths {
fmt.Fprintf(&sb, " - path: %s\n mode: %s\n", d.Path, landlockDenyModeString(d.Mode))
}
fmt.Fprintf(&sb, "\ndeny_exec_paths (%d):\n", len(ep.DenyExecPaths))
for _, p := range ep.DenyExecPaths {
fmt.Fprintf(&sb, " - %s\n", p)
}
return []byte(sb.String()), nil
}
// landlockAccessFlagsString renders a Landlock AccessFs bitmask as a
// stable, space-separated list of symbolic flag names. The order is fixed so
// the output is suitable for golden tests and diffing.
func landlockAccessFlagsString(access uint64) string {
type bit struct {
mask uint64
name string
}
bits := []bit{
{uint64(llsyscall.AccessFSExecute), "execute"},
{uint64(llsyscall.AccessFSReadFile), "read_file"},
{uint64(llsyscall.AccessFSReadDir), "read_dir"},
{uint64(llsyscall.AccessFSWriteFile), "write_file"},
{uint64(llsyscall.AccessFSTruncate), "truncate"},
{uint64(llsyscall.AccessFSIoctlDev), "ioctl_dev"},
{uint64(llsyscall.AccessFSMakeReg), "make_reg"},
{uint64(llsyscall.AccessFSMakeDir), "make_dir"},
{uint64(llsyscall.AccessFSMakeSock), "make_sock"},
{uint64(llsyscall.AccessFSMakeFifo), "make_fifo"},
{uint64(llsyscall.AccessFSMakeBlock), "make_block"},
{uint64(llsyscall.AccessFSMakeChar), "make_char"},
{uint64(llsyscall.AccessFSMakeSym), "make_sym"},
{uint64(llsyscall.AccessFSRemoveFile), "remove_file"},
{uint64(llsyscall.AccessFSRemoveDir), "remove_dir"},
{uint64(llsyscall.AccessFSRefer), "refer"},
}
parts := []string{}
for _, b := range bits {
if access&b.mask != 0 {
parts = append(parts, b.name)
}
}
if len(parts) == 0 {
return "(none)"
}
return strings.Join(parts, "|")
}
func landlockDenyModeString(m denyMode) string {
switch m {
case denyRead:
return "read"
case denyWrite:
return "write"
case denyBoth:
return "both"
default:
return fmt.Sprintf("unknown(%d)", int(m))
}
}
@@ -0,0 +1,92 @@
//go:build linux
// +build linux
package platform
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/safedep/dry/utils"
"github.com/safedep/pmg/sandbox"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// landlockABILinePattern normalizes the ABI line so the golden is stable
// across kernels with and without Landlock support.
var landlockABILinePattern = regexp.MustCompile(`(?m)^abi: \d+ \(\w+\)$`)
// landlockFeaturesLinePattern normalizes the features line for the same reason.
var landlockFeaturesLinePattern = regexp.MustCompile(`(?m)^features: refer=\w+ truncate=\w+ network=\w+ ioctl_dev=\w+ scoping=\w+$`)
func normalizeLandlockOutput(t *testing.T, b []byte) []byte {
t.Helper()
b = landlockABILinePattern.ReplaceAll(b, []byte("abi: GOLDEN (GOLDEN)"))
b = landlockFeaturesLinePattern.ReplaceAll(b, []byte("features: GOLDEN"))
wd, err := os.Getwd()
require.NoError(t, err)
home, err := os.UserHomeDir()
require.NoError(t, err)
s := string(b)
s = strings.ReplaceAll(s, wd, "/src/sandbox/platform")
s = strings.ReplaceAll(s, home, "/root")
return []byte(s)
}
func TestRenderLandlock_Golden(t *testing.T) {
tests := []struct {
name string
policy *sandbox.SandboxPolicy
goldenFile string
}{
{
name: "minimal allow read tmp",
policy: &sandbox.SandboxPolicy{
Name: "render-min",
Description: "minimal policy for render golden test",
PackageManagers: []string{"npm"},
Filesystem: sandbox.FilesystemPolicy{
AllowRead: []string{"/tmp"},
AllowWrite: []string{"/tmp"},
},
Process: sandbox.ProcessPolicy{
DenyExec: []string{"/bin/sh"},
},
AllowPTY: utils.PtrTo(false),
},
goldenFile: "landlock_minimal.txt",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := RenderLandlock(tc.policy)
require.NoError(t, err)
goldenPath := filepath.Join("testdata", tc.goldenFile)
normalized := normalizeLandlockOutput(t, got)
if os.Getenv("UPDATE_GOLDEN") != "" {
require.NoError(t, os.WriteFile(goldenPath, normalized, 0o644))
}
expected, err := os.ReadFile(goldenPath)
require.NoError(t, err, "missing golden file: run with UPDATE_GOLDEN=1 to create")
assert.Equal(t, string(expected), string(normalized))
})
}
}
func TestRenderLandlock_NilPolicy(t *testing.T) {
_, err := RenderLandlock(nil)
assert.Error(t, err)
}
+6
View File
@@ -10,3 +10,9 @@ import "github.com/safedep/pmg/sandbox"
func NewSandbox() (sandbox.Sandbox, error) {
return newSeatbeltSandbox()
}
// NewSeatbeltSandbox returns a Seatbelt-backed sandbox instance regardless of
// any platform-wide driver selection. Useful for per-driver diagnostics.
func NewSeatbeltSandbox() (sandbox.Sandbox, error) {
return newSeatbeltSandbox()
}
+15 -3
View File
@@ -17,11 +17,11 @@ import (
// PMG_SANDBOX_DRIVER=landlock to force Landlock (no fallback — fails if
// Landlock is unavailable).
func NewSandbox() (sandbox.Sandbox, error) {
switch os.Getenv("PMG_SANDBOX_DRIVER") {
case "bubblewrap":
switch sandbox.DriverName(os.Getenv("PMG_SANDBOX_DRIVER")) {
case sandbox.DriverBubblewrap:
log.Debugf("PMG_SANDBOX_DRIVER=bubblewrap: forcing Bubblewrap sandbox")
return newBubblewrapSandbox()
case "landlock":
case sandbox.DriverLandlock:
log.Debugf("PMG_SANDBOX_DRIVER=landlock: forcing Landlock sandbox")
return newLandlockSandbox()
}
@@ -35,3 +35,15 @@ func NewSandbox() (sandbox.Sandbox, error) {
log.Debugf("Landlock not available (%v), falling back to Bubblewrap", err)
return newBubblewrapSandbox()
}
// NewBubblewrapSandbox returns a Bubblewrap-backed sandbox instance regardless
// of platform driver selection. Useful for per-driver diagnostics.
func NewBubblewrapSandbox() (sandbox.Sandbox, error) {
return newBubblewrapSandbox()
}
// NewLandlockSandbox returns a Landlock-backed sandbox instance regardless of
// platform driver selection. Useful for per-driver diagnostics.
func NewLandlockSandbox() (sandbox.Sandbox, error) {
return newLandlockSandbox()
}
+59
View File
@@ -0,0 +1,59 @@
//go:build linux
// +build linux
package platform
import (
"context"
"strings"
"github.com/safedep/pmg/sandbox"
)
const apparmorUsernsSysctlPath = "/proc/sys/kernel/apparmor_restrict_unprivileged_userns"
type apparmorProbe struct {
env probeEnv
path string
}
// NewAppArmorUsernsProbe returns a probe that warns when AppArmor restricts
// unprivileged user namespaces (which breaks bwrap-based sandboxing).
func NewAppArmorUsernsProbe() sandbox.Probe {
return &apparmorProbe{env: defaultProbeEnv{}, path: apparmorUsernsSysctlPath}
}
func (p *apparmorProbe) Name() string { return sandbox.ProbeAppArmorUserns }
func (p *apparmorProbe) Run(_ context.Context) sandbox.ProbeResult {
data, err := p.env.readFile(p.path)
if err != nil {
return sandbox.ProbeResult{
Name: sandbox.ProbeAppArmorUserns,
Status: sandbox.ProbeStatusSkipped,
Summary: "AppArmor userns sysctl not present",
Detail: err.Error(),
}
}
value := strings.TrimSpace(string(data))
if value == "0" {
return sandbox.ProbeResult{
Name: sandbox.ProbeAppArmorUserns,
Status: sandbox.ProbeStatusOK,
Summary: "Unprivileged user namespaces are not restricted by AppArmor",
}
}
return sandbox.ProbeResult{
Name: sandbox.ProbeAppArmorUserns,
Status: sandbox.ProbeStatusWarn,
Summary: "AppArmor restricts unprivileged user namespaces (value=" + value + ")",
Detail: "bwrap may fail with `setting up uid map: Permission denied` until an AppArmor profile permits it or the sysctl is relaxed.",
Fixes: []sandbox.ProbeFix{{
Description: "Temporarily relax the restriction (until next reboot).",
Command: "sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0",
Docs: "https://ubuntu.com/blog/ubuntu-23-10-restricted-unprivileged-user-namespaces",
}},
}
}
@@ -0,0 +1,50 @@
//go:build linux
// +build linux
package platform
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/safedep/pmg/sandbox"
)
func TestAppArmorProbe(t *testing.T) {
tests := []struct {
name string
env *fakeProbeEnv
want sandbox.ProbeStatus
}{
{
name: "ok unrestricted",
env: &fakeProbeEnv{readFileFn: func(string) ([]byte, error) { return []byte("0\n"), nil }},
want: sandbox.ProbeStatusOK,
},
{
name: "warn restricted",
env: &fakeProbeEnv{readFileFn: func(string) ([]byte, error) { return []byte("1\n"), nil }},
want: sandbox.ProbeStatusWarn,
},
{
name: "skip missing",
env: &fakeProbeEnv{readFileFn: func(string) ([]byte, error) { return nil, errors.New("not found") }},
want: sandbox.ProbeStatusSkipped,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
p := &apparmorProbe{env: tc.env, path: apparmorUsernsSysctlPath}
res := p.Run(context.Background())
assert.Equal(t, sandbox.ProbeAppArmorUserns, res.Name)
assert.Equal(t, tc.want, res.Status)
if tc.want == sandbox.ProbeStatusWarn {
assert.NotEmpty(t, res.Fixes)
}
})
}
}
+55
View File
@@ -0,0 +1,55 @@
//go:build linux
// +build linux
package platform
import (
"context"
"strings"
"github.com/safedep/pmg/sandbox"
)
type bwrapProbe struct {
env probeEnv
}
// NewBwrapProbe returns a probe that verifies `bwrap --version` runs.
func NewBwrapProbe() sandbox.Probe {
return &bwrapProbe{env: defaultProbeEnv{}}
}
func (p *bwrapProbe) Name() string { return sandbox.ProbeBwrapDriver }
func (p *bwrapProbe) Run(ctx context.Context) sandbox.ProbeResult {
path, err := p.env.lookPath("bwrap")
if err != nil {
return sandbox.ProbeResult{
Name: sandbox.ProbeBwrapDriver,
Status: sandbox.ProbeStatusFail,
Summary: "bwrap not found in PATH",
Detail: err.Error(),
Fixes: []sandbox.ProbeFix{bubblewrapInstallFix()},
}
}
out, err := p.env.runCommand(ctx, path, "--version")
if err != nil {
return sandbox.ProbeResult{
Name: sandbox.ProbeBwrapDriver,
Status: sandbox.ProbeStatusFail,
Summary: "bwrap --version failed",
Detail: strings.TrimSpace(string(out)) + ": " + err.Error(),
Fixes: []sandbox.ProbeFix{{
Description: "Reinstall bubblewrap and confirm unprivileged user namespaces are enabled.",
Command: "bwrap --version",
}},
}
}
return sandbox.ProbeResult{
Name: sandbox.ProbeBwrapDriver,
Status: sandbox.ProbeStatusOK,
Summary: strings.TrimSpace(string(out)),
}
}
@@ -0,0 +1,75 @@
//go:build linux
// +build linux
package platform
import (
"context"
"errors"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/safedep/pmg/sandbox"
)
type fakeProbeEnv struct {
lookPathFn func(string) (string, error)
statExecutableFn func(string) (os.FileInfo, error)
readFileFn func(string) ([]byte, error)
runCommandFn func(ctx context.Context, name string, args ...string) ([]byte, error)
}
func (f *fakeProbeEnv) lookPath(name string) (string, error) { return f.lookPathFn(name) }
func (f *fakeProbeEnv) statExecutable(path string) (os.FileInfo, error) {
return f.statExecutableFn(path)
}
func (f *fakeProbeEnv) readFile(path string) ([]byte, error) { return f.readFileFn(path) }
func (f *fakeProbeEnv) runCommand(ctx context.Context, name string, args ...string) ([]byte, error) {
return f.runCommandFn(ctx, name, args...)
}
func TestBwrapProbe(t *testing.T) {
tests := []struct {
name string
env *fakeProbeEnv
want sandbox.ProbeStatus
}{
{
name: "ok",
env: &fakeProbeEnv{
lookPathFn: func(string) (string, error) { return "/usr/bin/bwrap", nil },
runCommandFn: func(context.Context, string, ...string) ([]byte, error) { return []byte("bubblewrap 0.8.0\n"), nil },
},
want: sandbox.ProbeStatusOK,
},
{
name: "not in path",
env: &fakeProbeEnv{
lookPathFn: func(string) (string, error) { return "", errors.New("not found") },
},
want: sandbox.ProbeStatusFail,
},
{
name: "version failed",
env: &fakeProbeEnv{
lookPathFn: func(string) (string, error) { return "/usr/bin/bwrap", nil },
runCommandFn: func(context.Context, string, ...string) ([]byte, error) { return []byte("err"), errors.New("exit 1") },
},
want: sandbox.ProbeStatusFail,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
p := &bwrapProbe{env: tc.env}
res := p.Run(context.Background())
assert.Equal(t, sandbox.ProbeBwrapDriver, res.Name)
assert.Equal(t, tc.want, res.Status)
if tc.want != sandbox.ProbeStatusOK {
assert.NotEmpty(t, res.Fixes)
}
})
}
}
+197
View File
@@ -0,0 +1,197 @@
package platform
import (
"bytes"
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/safedep/dry/log"
"github.com/safedep/dry/utils"
"github.com/safedep/pmg/sandbox"
)
// canaryTimeout bounds the runtime of a single canary smoke test so a misconfigured
// kernel cannot wedge the doctor command.
const canaryTimeout = 15 * time.Second
// canaryTargetPath is the file the canary attempts to read under a deny-all
// policy. It is well-known on every supported host.
const canaryTargetPath = "/etc/hostname"
// canarySandboxFactory builds the sandbox driver under test. Separated from the
// probe body so tests can stub it.
type canarySandboxFactory func() (sandbox.Sandbox, error)
// canaryCommandFactory builds the command the canary attempts to run. Stubbed
// in tests; defaults to `cat /etc/hostname`.
type canaryCommandFactory func(ctx context.Context) *exec.Cmd
func defaultCanaryCommand(ctx context.Context) *exec.Cmd {
return exec.CommandContext(ctx, "cat", canaryTargetPath)
}
// denyAllCanaryPolicy returns a minimal valid policy that denies read access to
// the canary target. ValidateResolved requires at least one rule; the deny on
// canaryTargetPath satisfies that and unambiguously asserts the sandbox is
// blocking the read.
func denyAllCanaryPolicy() *sandbox.SandboxPolicy {
return &sandbox.SandboxPolicy{
Name: "pmg-canary",
Description: "deny-all canary probe",
PackageManagers: []string{"npm", "pip", "uv", "pypi"},
Filesystem: sandbox.FilesystemPolicy{
DenyRead: []string{canaryTargetPath},
},
AllowGitConfig: utils.PtrTo(false),
AllowPTY: utils.PtrTo(false),
AllowNetworkBind: utils.PtrTo(false),
}
}
// runCanary executes the canary smoke test. On success, the sandbox prevents
// the canary read and the command exits non-zero — that is the OK path.
func runCanary(ctx context.Context, name string, driver sandbox.DriverName, factory canarySandboxFactory, cmdFactory canaryCommandFactory) sandbox.ProbeResult {
ctx, cancel := context.WithTimeout(ctx, canaryTimeout)
defer cancel()
d := string(driver)
sb, err := factory()
if err != nil {
return sandbox.ProbeResult{
Name: name,
Status: sandbox.ProbeStatusFail,
Summary: d + " sandbox could not be constructed",
Detail: err.Error(),
Fixes: []sandbox.ProbeFix{driverInstallFix(driver)},
}
}
defer func() { _ = sb.Close() }()
if !sb.IsAvailable() {
return sandbox.ProbeResult{
Name: name,
Status: sandbox.ProbeStatusSkipped,
Summary: d + " driver not available on this host",
Fixes: []sandbox.ProbeFix{driverInstallFix(driver)},
}
}
cmd := cmdFactory(ctx)
isCanaryRead := isCanaryReadCommand(cmd)
var expected []byte
if isCanaryRead {
var readErr error
expected, readErr = os.ReadFile(canaryTargetPath)
if readErr != nil {
log.Warnf("canary probe: failed to read baseline %s: %v", canaryTargetPath, readErr)
}
}
var stdout, stderr bytes.Buffer
if cmd.Stdout == nil {
cmd.Stdout = &stdout
}
if cmd.Stderr == nil {
cmd.Stderr = &stderr
}
policy := denyAllCanaryPolicy()
result, err := sb.Execute(ctx, cmd, policy)
if err != nil {
return sandbox.ProbeResult{
Name: name,
Status: sandbox.ProbeStatusFail,
Summary: d + " sandbox setup failed",
Detail: err.Error(),
Fixes: []sandbox.ProbeFix{driverInstallFix(driver)},
}
}
defer func() { _ = result.Close() }()
if result.ShouldRun() {
err = cmd.Run()
}
if err == nil {
if isCanaryRead && len(expected) > 0 && !bytes.Equal(stdout.Bytes(), expected) {
return sandbox.ProbeResult{
Name: name,
Status: sandbox.ProbeStatusOK,
Summary: d + " masked canary read",
Detail: "the sandbox hid the contents of " + canaryTargetPath,
}
}
return sandbox.ProbeResult{
Name: name,
Status: sandbox.ProbeStatusFail,
Summary: d + " did not block canary read of " + canaryTargetPath,
Detail: "the sandbox executed `cat " + canaryTargetPath + "` without denial",
Fixes: []sandbox.ProbeFix{{
Description: "Driver appears installed but not enforcing. Re-run with PMG_LOG_LEVEL=debug to inspect the translated policy.",
}},
}
}
if errors.Is(err, context.DeadlineExceeded) {
return sandbox.ProbeResult{
Name: name,
Status: sandbox.ProbeStatusFail,
Summary: d + " canary timed out",
Detail: err.Error(),
}
}
return sandbox.ProbeResult{
Name: name,
Status: sandbox.ProbeStatusOK,
Summary: d + " correctly blocked canary read",
}
}
func isCanaryReadCommand(cmd *exec.Cmd) bool {
if cmd == nil {
return false
}
if len(cmd.Args) == 0 || filepath.Base(cmd.Args[0]) != "cat" {
return false
}
for _, arg := range cmd.Args[1:] {
if arg == canaryTargetPath {
return true
}
}
return false
}
func driverInstallFix(driver sandbox.DriverName) sandbox.ProbeFix {
switch driver {
case sandbox.DriverBubblewrap:
return bubblewrapInstallFix()
case sandbox.DriverLandlock:
return sandbox.ProbeFix{
Description: "Upgrade to Linux 5.13+ for Landlock support.",
Docs: "https://docs.kernel.org/userspace-api/landlock.html",
}
case sandbox.DriverSeatbelt:
return sandbox.ProbeFix{
Description: "sandbox-exec ships with macOS — check SIP and PATH.",
}
}
return sandbox.ProbeFix{Description: "Install the " + string(driver) + " sandbox driver."}
}
func bubblewrapInstallFix() sandbox.ProbeFix {
return sandbox.ProbeFix{
Description: "Install bubblewrap using your distribution package manager and verify unprivileged user namespaces are enabled.",
Docs: "https://github.com/containers/bubblewrap",
}
}
+29
View File
@@ -0,0 +1,29 @@
//go:build darwin
// +build darwin
package platform
import (
"context"
"github.com/safedep/pmg/sandbox"
)
type seatbeltCanaryProbe struct {
factory canarySandboxFactory
cmdFactory canaryCommandFactory
}
// NewSeatbeltCanaryProbe runs the per-driver Seatbelt smoke test.
func NewSeatbeltCanaryProbe() sandbox.Probe {
return &seatbeltCanaryProbe{
factory: func() (sandbox.Sandbox, error) { return NewSeatbeltSandbox() },
cmdFactory: defaultCanaryCommand,
}
}
func (p *seatbeltCanaryProbe) Name() string { return sandbox.ProbeSeatbeltCanary }
func (p *seatbeltCanaryProbe) Run(ctx context.Context) sandbox.ProbeResult {
return runCanary(ctx, sandbox.ProbeSeatbeltCanary, sandbox.DriverSeatbelt, p.factory, p.cmdFactory)
}
+43
View File
@@ -0,0 +1,43 @@
//go:build linux
// +build linux
package platform
import (
"context"
"github.com/safedep/pmg/sandbox"
)
type linuxCanaryProbe struct {
name string
driver sandbox.DriverName
factory canarySandboxFactory
cmdFactory canaryCommandFactory
}
// NewBwrapCanaryProbe runs the per-driver Bubblewrap smoke test.
func NewBwrapCanaryProbe() sandbox.Probe {
return &linuxCanaryProbe{
name: sandbox.ProbeBwrapCanary,
driver: sandbox.DriverBubblewrap,
factory: func() (sandbox.Sandbox, error) { return NewBubblewrapSandbox() },
cmdFactory: defaultCanaryCommand,
}
}
// NewLandlockCanaryProbe runs the per-driver Landlock smoke test.
func NewLandlockCanaryProbe() sandbox.Probe {
return &linuxCanaryProbe{
name: sandbox.ProbeLandlockCanary,
driver: sandbox.DriverLandlock,
factory: func() (sandbox.Sandbox, error) { return NewLandlockSandbox() },
cmdFactory: defaultCanaryCommand,
}
}
func (p *linuxCanaryProbe) Name() string { return p.name }
func (p *linuxCanaryProbe) Run(ctx context.Context) sandbox.ProbeResult {
return runCanary(ctx, p.name, p.driver, p.factory, p.cmdFactory)
}
+118
View File
@@ -0,0 +1,118 @@
package platform
import (
"context"
"errors"
"os/exec"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/safedep/pmg/sandbox"
)
type fakeSandbox struct {
name sandbox.DriverName
available bool
executeErr error
executedRun bool
closed bool
}
func (f *fakeSandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy *sandbox.SandboxPolicy) (*sandbox.ExecutionResult, error) {
if f.executeErr != nil {
return nil, f.executeErr
}
return sandbox.NewExecutionResult(
sandbox.WithExecutionResultSandbox(f),
sandbox.WithExecutionResultExecuted(f.executedRun),
), nil
}
func (f *fakeSandbox) Name() sandbox.DriverName { return f.name }
func (f *fakeSandbox) IsAvailable() bool { return f.available }
func (f *fakeSandbox) Close() error { f.closed = true; return nil }
func trueCmd(ctx context.Context) *exec.Cmd { return exec.CommandContext(ctx, "true") }
func falseCmd(ctx context.Context) *exec.Cmd { return exec.CommandContext(ctx, "false") }
func maskedCanaryCmd(ctx context.Context) *exec.Cmd {
cmd := exec.CommandContext(ctx, "printf", "")
cmd.Args = []string{"cat", "", canaryTargetPath}
return cmd
}
func TestRunCanary(t *testing.T) {
tests := []struct {
name string
factory canarySandboxFactory
cmd canaryCommandFactory
want sandbox.ProbeStatus
}{
{
name: "ok blocks read",
factory: func() (sandbox.Sandbox, error) { return &fakeSandbox{name: "fake", available: true}, nil },
cmd: falseCmd,
want: sandbox.ProbeStatusOK,
},
{
name: "ok masks read",
factory: func() (sandbox.Sandbox, error) { return &fakeSandbox{name: "fake", available: true}, nil },
cmd: maskedCanaryCmd,
want: sandbox.ProbeStatusOK,
},
{
name: "fail did not block",
factory: func() (sandbox.Sandbox, error) { return &fakeSandbox{name: "fake", available: true}, nil },
cmd: trueCmd,
want: sandbox.ProbeStatusFail,
},
{
name: "skip not available",
factory: func() (sandbox.Sandbox, error) { return &fakeSandbox{name: "fake", available: false}, nil },
cmd: falseCmd,
want: sandbox.ProbeStatusSkipped,
},
{
name: "fail constructor error",
factory: func() (sandbox.Sandbox, error) { return nil, errors.New("boom") },
cmd: falseCmd,
want: sandbox.ProbeStatusFail,
},
{
name: "fail execute error",
factory: func() (sandbox.Sandbox, error) {
return &fakeSandbox{name: "fake", available: true, executeErr: errors.New("setup failed")}, nil
},
cmd: falseCmd,
want: sandbox.ProbeStatusFail,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
res := runCanary(context.Background(), "canary.fake", "fake", tc.factory, tc.cmd)
assert.Equal(t, "canary.fake", res.Name)
assert.Equal(t, tc.want, res.Status)
})
}
}
func TestDenyAllCanaryPolicy(t *testing.T) {
p := denyAllCanaryPolicy()
require.NoError(t, p.ValidateResolved())
assert.Contains(t, p.Filesystem.DenyRead, canaryTargetPath)
}
func TestDriverInstallFix_BubblewrapIsDistroNeutral(t *testing.T) {
fix := driverInstallFix(sandbox.DriverBubblewrap)
assert.Contains(t, fix.Description, "distribution package manager")
assert.Empty(t, fix.Command)
assert.Equal(t, "https://github.com/containers/bubblewrap", fix.Docs)
}
func TestDefaultProbes_NotEmpty(t *testing.T) {
probes := DefaultProbes()
assert.NotEmpty(t, probes)
}
+31
View File
@@ -0,0 +1,31 @@
package platform
import (
"context"
"os"
"os/exec"
)
// probeEnv abstracts the host environment so probes can be unit-tested without
// touching the real filesystem or PATH. Real probes use defaultProbeEnv; tests
// inject fakes.
type probeEnv interface {
lookPath(name string) (string, error)
statExecutable(path string) (os.FileInfo, error)
readFile(path string) ([]byte, error)
runCommand(ctx context.Context, name string, args ...string) ([]byte, error)
}
type defaultProbeEnv struct{}
func (defaultProbeEnv) lookPath(name string) (string, error) { return exec.LookPath(name) }
func (defaultProbeEnv) statExecutable(path string) (os.FileInfo, error) {
return os.Stat(path)
}
func (defaultProbeEnv) readFile(path string) ([]byte, error) { return os.ReadFile(path) }
func (defaultProbeEnv) runCommand(ctx context.Context, name string, args ...string) ([]byte, error) {
return exec.CommandContext(ctx, name, args...).CombinedOutput()
}
+82
View File
@@ -0,0 +1,82 @@
//go:build linux
// +build linux
package platform
import (
"context"
"fmt"
llsyscall "github.com/landlock-lsm/go-landlock/landlock/syscall"
"github.com/safedep/pmg/sandbox"
)
// landlockABIDetector decouples ABI lookup from the syscall for tests.
type landlockABIDetector func() (int, error)
func defaultLandlockABIDetector() (int, error) {
v, err := llsyscall.LandlockGetABIVersion()
if err != nil {
return 0, err
}
return v, nil
}
type landlockProbe struct {
detect landlockABIDetector
}
// NewLandlockProbe returns a probe that reports the Landlock ABI level.
func NewLandlockProbe() sandbox.Probe {
return &landlockProbe{detect: defaultLandlockABIDetector}
}
func (p *landlockProbe) Name() string { return sandbox.ProbeLandlockDriver }
func (p *landlockProbe) Run(_ context.Context) sandbox.ProbeResult {
version, err := p.detect()
if err != nil {
return sandbox.ProbeResult{
Name: sandbox.ProbeLandlockDriver,
Status: sandbox.ProbeStatusFail,
Summary: "Landlock not supported by kernel",
Detail: err.Error(),
Fixes: []sandbox.ProbeFix{{
Description: "Landlock requires Linux 5.13+. Upgrade your kernel or use bwrap.",
Docs: "https://docs.kernel.org/userspace-api/landlock.html",
}},
}
}
if version <= 0 {
return sandbox.ProbeResult{
Name: sandbox.ProbeLandlockDriver,
Status: sandbox.ProbeStatusFail,
Summary: fmt.Sprintf("Landlock ABI %d (unsupported)", version),
Fixes: []sandbox.ProbeFix{{
Description: "Upgrade your kernel to 5.13 or newer.",
Docs: "https://docs.kernel.org/userspace-api/landlock.html",
}},
}
}
if version < 3 {
return sandbox.ProbeResult{
Name: sandbox.ProbeLandlockDriver,
Status: sandbox.ProbeStatusWarn,
Summary: fmt.Sprintf("Landlock ABI V%d (limited features)", version),
Detail: "ABI < 3 lacks truncate and refer support; some policies may not enforce as expected.",
Fixes: []sandbox.ProbeFix{{
Description: "Upgrade to Linux 5.19+ (ABI 3) or 6.2+ (ABI 4) for full coverage.",
Docs: "https://docs.kernel.org/userspace-api/landlock.html",
}},
}
}
return sandbox.ProbeResult{
Name: sandbox.ProbeLandlockDriver,
Status: sandbox.ProbeStatusOK,
Summary: fmt.Sprintf("Landlock ABI V%d", version),
}
}
@@ -0,0 +1,55 @@
//go:build linux
// +build linux
package platform
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/safedep/pmg/sandbox"
)
func TestLandlockProbe(t *testing.T) {
tests := []struct {
name string
detect landlockABIDetector
want sandbox.ProbeStatus
}{
{
name: "ok latest",
detect: func() (int, error) { return 4, nil },
want: sandbox.ProbeStatusOK,
},
{
name: "warn low abi",
detect: func() (int, error) { return 1, nil },
want: sandbox.ProbeStatusWarn,
},
{
name: "fail zero",
detect: func() (int, error) { return 0, nil },
want: sandbox.ProbeStatusFail,
},
{
name: "fail error",
detect: func() (int, error) { return 0, errors.New("ENOSYS") },
want: sandbox.ProbeStatusFail,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
p := &landlockProbe{detect: tc.detect}
res := p.Run(context.Background())
assert.Equal(t, sandbox.ProbeLandlockDriver, res.Name)
assert.Equal(t, tc.want, res.Status)
if tc.want != sandbox.ProbeStatusOK {
assert.NotEmpty(t, res.Fixes)
}
})
}
}
+71
View File
@@ -0,0 +1,71 @@
//go:build darwin
// +build darwin
package platform
import (
"context"
"github.com/safedep/pmg/sandbox"
)
type seatbeltProbe struct {
env probeEnv
}
// NewSeatbeltProbe returns a probe that verifies sandbox-exec is present and
// executable on this host.
func NewSeatbeltProbe() sandbox.Probe {
return &seatbeltProbe{env: defaultProbeEnv{}}
}
func (p *seatbeltProbe) Name() string { return sandbox.ProbeSeatbeltDriver }
func (p *seatbeltProbe) Run(_ context.Context) sandbox.ProbeResult {
path, err := p.env.lookPath("sandbox-exec")
if err != nil {
return sandbox.ProbeResult{
Name: sandbox.ProbeSeatbeltDriver,
Status: sandbox.ProbeStatusFail,
Summary: "sandbox-exec not found in PATH",
Detail: err.Error(),
Fixes: []sandbox.ProbeFix{{
Description: "sandbox-exec ships with macOS. Verify your PATH includes /usr/bin.",
Command: "ls -l /usr/bin/sandbox-exec",
}},
}
}
info, err := p.env.statExecutable(path)
if err != nil {
return sandbox.ProbeResult{
Name: sandbox.ProbeSeatbeltDriver,
Status: sandbox.ProbeStatusFail,
Summary: "sandbox-exec is not accessible",
Detail: err.Error(),
Fixes: []sandbox.ProbeFix{{
Description: "Inspect the binary permissions and SIP state.",
Command: "ls -l " + path,
}},
}
}
if info.Mode()&0o111 == 0 {
return sandbox.ProbeResult{
Name: sandbox.ProbeSeatbeltDriver,
Status: sandbox.ProbeStatusFail,
Summary: "sandbox-exec is not executable",
Detail: "found at " + path,
Fixes: []sandbox.ProbeFix{{
Description: "Restore execute bit on sandbox-exec or reinstall the OS toolchain.",
Command: "chmod +x " + path,
}},
}
}
return sandbox.ProbeResult{
Name: sandbox.ProbeSeatbeltDriver,
Status: sandbox.ProbeStatusOK,
Summary: "sandbox-exec available at " + path,
}
}
@@ -0,0 +1,96 @@
//go:build darwin
// +build darwin
package platform
import (
"context"
"errors"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/safedep/pmg/sandbox"
)
type fakeProbeEnv struct {
lookPathFn func(string) (string, error)
statExecutableFn func(string) (os.FileInfo, error)
readFileFn func(string) ([]byte, error)
runCommandFn func(ctx context.Context, name string, args ...string) ([]byte, error)
}
func (f *fakeProbeEnv) lookPath(name string) (string, error) { return f.lookPathFn(name) }
func (f *fakeProbeEnv) statExecutable(path string) (os.FileInfo, error) {
return f.statExecutableFn(path)
}
func (f *fakeProbeEnv) readFile(path string) ([]byte, error) { return f.readFileFn(path) }
func (f *fakeProbeEnv) runCommand(ctx context.Context, name string, args ...string) ([]byte, error) {
return f.runCommandFn(ctx, name, args...)
}
type fakeFileInfo struct {
name string
mode os.FileMode
}
func (f fakeFileInfo) Name() string { return f.name }
func (f fakeFileInfo) Size() int64 { return 0 }
func (f fakeFileInfo) Mode() os.FileMode { return f.mode }
func (f fakeFileInfo) ModTime() time.Time { return time.Time{} }
func (f fakeFileInfo) IsDir() bool { return false }
func (f fakeFileInfo) Sys() any { return nil }
func TestSeatbeltProbe(t *testing.T) {
tests := []struct {
name string
env *fakeProbeEnv
wantStat sandbox.ProbeStatus
}{
{
name: "ok",
env: &fakeProbeEnv{
lookPathFn: func(string) (string, error) { return "/usr/bin/sandbox-exec", nil },
statExecutableFn: func(string) (os.FileInfo, error) { return fakeFileInfo{mode: 0o755}, nil },
},
wantStat: sandbox.ProbeStatusOK,
},
{
name: "not in path",
env: &fakeProbeEnv{
lookPathFn: func(string) (string, error) { return "", errors.New("not found") },
},
wantStat: sandbox.ProbeStatusFail,
},
{
name: "stat fail",
env: &fakeProbeEnv{
lookPathFn: func(string) (string, error) { return "/usr/bin/sandbox-exec", nil },
statExecutableFn: func(string) (os.FileInfo, error) { return nil, errors.New("denied") },
},
wantStat: sandbox.ProbeStatusFail,
},
{
name: "not executable",
env: &fakeProbeEnv{
lookPathFn: func(string) (string, error) { return "/usr/bin/sandbox-exec", nil },
statExecutableFn: func(string) (os.FileInfo, error) { return fakeFileInfo{mode: 0o644}, nil },
},
wantStat: sandbox.ProbeStatusFail,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
p := &seatbeltProbe{env: tc.env}
res := p.Run(context.Background())
assert.Equal(t, sandbox.ProbeSeatbeltDriver, res.Name)
assert.Equal(t, tc.wantStat, res.Status)
if tc.wantStat != sandbox.ProbeStatusOK {
assert.NotEmpty(t, res.Fixes)
}
})
}
}
+14
View File
@@ -0,0 +1,14 @@
//go:build darwin
// +build darwin
package platform
import "github.com/safedep/pmg/sandbox"
// DefaultProbes returns the sandbox probes for the host platform (darwin).
func DefaultProbes() []sandbox.Probe {
return []sandbox.Probe{
NewSeatbeltProbe(),
NewSeatbeltCanaryProbe(),
}
}
+17
View File
@@ -0,0 +1,17 @@
//go:build linux
// +build linux
package platform
import "github.com/safedep/pmg/sandbox"
// DefaultProbes returns the sandbox probes for the host platform (linux).
func DefaultProbes() []sandbox.Probe {
return []sandbox.Probe{
NewBwrapProbe(),
NewLandlockProbe(),
NewAppArmorUsernsProbe(),
NewBwrapCanaryProbe(),
NewLandlockCanaryProbe(),
}
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !darwin && !linux
// +build !darwin,!linux
package platform
import "github.com/safedep/pmg/sandbox"
// DefaultProbes returns no probes on unsupported platforms.
func DefaultProbes() []sandbox.Probe { return nil }
+32
View File
@@ -0,0 +1,32 @@
// Package platform exposes Render for translating a SandboxPolicy into a
// driver-specific representation. Each driver's renderer is OS-gated (it
// imports OS-specific syscalls), so Render returns an error on a non-native
// host rather than silently producing an inaccurate result.
package platform
import (
"fmt"
"runtime"
"github.com/safedep/pmg/sandbox"
)
// Render dispatches to the OS-native renderer for driver. On a non-native
// host it returns a descriptive error so callers (e.g. `profile show
// --driver=...`) can surface it cleanly.
func Render(driver sandbox.DriverName, policy *sandbox.SandboxPolicy) ([]byte, error) {
switch driver {
case sandbox.DriverSeatbelt:
return renderSeatbelt(policy)
case sandbox.DriverBubblewrap:
return renderBubblewrap(policy)
case sandbox.DriverLandlock:
return renderLandlock(policy)
default:
return nil, fmt.Errorf("unknown sandbox driver: %s", driver)
}
}
func driverUnavailable(driver sandbox.DriverName) error {
return fmt.Errorf("driver %s is not available on %s/%s", driver, runtime.GOOS, runtime.GOARCH)
}
+18
View File
@@ -0,0 +1,18 @@
//go:build darwin
// +build darwin
package platform
import "github.com/safedep/pmg/sandbox"
func renderSeatbelt(p *sandbox.SandboxPolicy) ([]byte, error) {
return RenderSeatbelt(p)
}
func renderBubblewrap(p *sandbox.SandboxPolicy) ([]byte, error) {
return nil, driverUnavailable(sandbox.DriverBubblewrap)
}
func renderLandlock(p *sandbox.SandboxPolicy) ([]byte, error) {
return nil, driverUnavailable(sandbox.DriverLandlock)
}
+18
View File
@@ -0,0 +1,18 @@
//go:build linux
// +build linux
package platform
import "github.com/safedep/pmg/sandbox"
func renderSeatbelt(p *sandbox.SandboxPolicy) ([]byte, error) {
return nil, driverUnavailable(sandbox.DriverSeatbelt)
}
func renderBubblewrap(p *sandbox.SandboxPolicy) ([]byte, error) {
return RenderBubblewrap(p)
}
func renderLandlock(p *sandbox.SandboxPolicy) ([]byte, error) {
return RenderLandlock(p)
}
+18
View File
@@ -0,0 +1,18 @@
//go:build !darwin && !linux
// +build !darwin,!linux
package platform
import "github.com/safedep/pmg/sandbox"
func renderSeatbelt(p *sandbox.SandboxPolicy) ([]byte, error) {
return nil, driverUnavailable(sandbox.DriverSeatbelt)
}
func renderBubblewrap(p *sandbox.SandboxPolicy) ([]byte, error) {
return nil, driverUnavailable(sandbox.DriverBubblewrap)
}
func renderLandlock(p *sandbox.SandboxPolicy) ([]byte, error) {
return nil, driverUnavailable(sandbox.DriverLandlock)
}
+2 -2
View File
@@ -98,8 +98,8 @@ func (s *seatbeltSandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy *sa
}
// Name returns the name of this sandbox implementation.
func (s *seatbeltSandbox) Name() string {
return "seatbelt"
func (s *seatbeltSandbox) Name() sandbox.DriverName {
return sandbox.DriverSeatbelt
}
// IsAvailable returns true if sandbox-exec is available on this system.
@@ -29,6 +29,12 @@ const macOSUnifiedLogPath = "/usr/bin/log"
var seatbeltMessagePattern = regexp.MustCompile(`PMG_SBX\|run=([^|]+)\|kind=([^|]+)\|target=([^"\s]*)`)
// seatbeltDenyVerbPattern captures the sandbox-exec denial verb from a raw log
// line such as `Sandbox: node(123) deny(1) file-write-data /path`. The verb is
// used to recover a typed ViolationKind when a denial hit the catch-all
// `(deny default ...)` rule (which only carries `kind=default` in our marker).
var seatbeltDenyVerbPattern = regexp.MustCompile(`\bdeny\(\d+\)\s+(\S+)`)
type seatbeltLogEntry struct {
EventMessage string `json:"eventMessage"`
Process string `json:"process"`
@@ -116,14 +122,29 @@ func extractSeatbeltViolations(entries []seatbeltLogEntry, runID string) []sandb
target = payload.Target
}
kind := normalizeSeatbeltViolationKind(payload.Kind)
labelKind := payload.Kind
// When the deny hit our catch-all `(deny default ...)` rule, the
// marker only carries `kind=default`. The sandbox-exec preamble in
// the same log line names the real verb (file-write-data, etc.) so
// we recover the typed kind from there. This drives accurate
// primary-violation ranking and `--sandbox-allow` suggestions.
if kind == sandbox.ViolationKindGenericDeny {
if inferredKind, inferredLabelKind, ok := inferSeatbeltKindFromRawLog(entry.EventMessage); ok {
kind = inferredKind
labelKind = inferredLabelKind
}
}
violations = append(violations, sandbox.Violation{
Kind: normalizeSeatbeltViolationKind(payload.Kind),
Kind: kind,
RawKind: payload.Kind,
Target: target,
RuleTarget: payload.Target,
Process: process,
RawLog: strings.TrimSpace(entry.EventMessage),
RuleLabel: summarizeSeatbeltViolation(payload.Kind, target),
RuleLabel: summarizeSeatbeltViolation(labelKind, target),
})
}
@@ -145,6 +166,34 @@ func normalizeSeatbeltViolationKind(kind string) sandbox.ViolationKind {
}
}
// inferSeatbeltKindFromRawLog recovers a typed ViolationKind from the
// sandbox-exec denial verb embedded in raw. It returns the typed kind plus a
// canonical marker name (the one our own rules would emit for the same kind,
// suitable for summarizeSeatbeltViolation). Only verbs that map to kinds
// scoreViolation and suggestOverride already understand are recognized; all
// others fall back to (generic_deny, "", false) so the caller keeps the
// original classification.
func inferSeatbeltKindFromRawLog(raw string) (sandbox.ViolationKind, string, bool) {
m := seatbeltDenyVerbPattern.FindStringSubmatch(raw)
if len(m) != 2 {
return sandbox.ViolationKindGenericDeny, "", false
}
verb := m[1]
switch {
case verb == "file-write-unlink", verb == "file-write-mount", verb == "file-rename":
return sandbox.ViolationKindFSDeleteOrRename, "file-write-unlink", true
case strings.HasPrefix(verb, "file-write"), verb == "file-link", verb == "file-mknod":
return sandbox.ViolationKindFSWrite, "file-write", true
case strings.HasPrefix(verb, "file-read"):
return sandbox.ViolationKindFSRead, "file-read", true
case strings.HasPrefix(verb, "process-exec"):
return sandbox.ViolationKindExec, "process-exec", true
}
return sandbox.ViolationKindGenericDeny, "", false
}
func extractSeatbeltDeniedPath(raw string, payload *seatbeltLogPayload) string {
if payload == nil {
return ""
@@ -76,3 +76,113 @@ func TestExtractSeatbeltViolations(t *testing.T) {
assert.Equal(t, "/tmp/.env", violations[0].RuleTarget)
assert.Equal(t, "node", violations[0].Process)
}
// Regression: a denial that hits the catch-all `(deny default ...)` rule
// must still be classified as the typed kind (and get a typed label) by
// recovering the verb from the sandbox-exec preamble. Without this, the
// actual failure-causing operation (e.g. a write) gets buried below
// incidental typed reads in primaryViolation ranking.
func TestExtractSeatbeltViolationsRecoversTypedKindFromDefaultDeny(t *testing.T) {
entries := []seatbeltLogEntry{
{
EventMessage: `Sandbox: node(59437) deny(1) file-write-data /Users/dev/project/.astro/types.d.ts ` +
seatbeltLogMessage("run-1", "default", ""),
Process: "node",
},
{
EventMessage: `Sandbox: node(59438) deny(1) network-outbound 1.2.3.4:443 ` +
seatbeltLogMessage("run-1", "default", ""),
Process: "node",
},
}
violations := extractSeatbeltViolations(entries, "run-1")
require.Len(t, violations, 2)
// File write hit the catch-all deny — Kind is recovered, RawKind stays
// "default" to preserve the "catch-all match" signal, and the label
// reflects the recovered kind so users see "write access denied".
assert.Equal(t, sandbox.ViolationKindFSWrite, violations[0].Kind)
assert.Equal(t, "default", violations[0].RawKind)
assert.Equal(t, "/Users/dev/project/.astro/types.d.ts", violations[0].Target)
assert.Equal(t, "write access denied: /Users/dev/project/.astro/types.d.ts", violations[0].RuleLabel)
// Unrecognized verb (network-outbound) stays generic — no over-claiming.
assert.Equal(t, sandbox.ViolationKindGenericDeny, violations[1].Kind)
assert.Equal(t, "default", violations[1].RawKind)
}
func TestInferSeatbeltKindFromRawLog(t *testing.T) {
tests := []struct {
name string
raw string
wantKind sandbox.ViolationKind
wantLabel string
wantOK bool
}{
{
name: "file-write-data maps to fs_write",
raw: `Sandbox: node(1) deny(1) file-write-data /tmp/x`,
wantKind: sandbox.ViolationKindFSWrite,
wantLabel: "file-write",
wantOK: true,
},
{
name: "file-write-unlink maps to fs_delete_or_rename",
raw: `Sandbox: node(1) deny(1) file-write-unlink /tmp/x`,
wantKind: sandbox.ViolationKindFSDeleteOrRename,
wantLabel: "file-write-unlink",
wantOK: true,
},
{
name: "file-rename maps to fs_delete_or_rename",
raw: `Sandbox: node(1) deny(1) file-rename /tmp/a`,
wantKind: sandbox.ViolationKindFSDeleteOrRename,
wantLabel: "file-write-unlink",
wantOK: true,
},
{
name: "file-read-metadata maps to fs_read",
raw: `Sandbox: node(1) deny(1) file-read-metadata /tmp/x`,
wantKind: sandbox.ViolationKindFSRead,
wantLabel: "file-read",
wantOK: true,
},
{
name: "process-exec maps to exec",
raw: `Sandbox: node(1) deny(1) process-exec /usr/bin/curl`,
wantKind: sandbox.ViolationKindExec,
wantLabel: "process-exec",
wantOK: true,
},
{
name: "process-fork is not exec",
raw: `Sandbox: node(1) deny(1) process-fork`,
wantKind: sandbox.ViolationKindGenericDeny,
wantOK: false,
},
{
name: "network verb is not recognized",
raw: `Sandbox: node(1) deny(1) network-outbound 1.2.3.4:443`,
wantKind: sandbox.ViolationKindGenericDeny,
wantOK: false,
},
{
name: "log without deny prefix is not recognized",
raw: `something unrelated`,
wantKind: sandbox.ViolationKindGenericDeny,
wantOK: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
kind, label, ok := inferSeatbeltKindFromRawLog(tc.raw)
assert.Equal(t, tc.wantOK, ok)
assert.Equal(t, tc.wantKind, kind)
if tc.wantOK {
assert.Equal(t, tc.wantLabel, label)
}
})
}
}
@@ -0,0 +1,32 @@
//go:build darwin
// +build darwin
package platform
import (
"fmt"
"github.com/safedep/pmg/sandbox"
)
// RenderSeatbelt translates a SandboxPolicy into its native Seatbelt Profile
// Language (SBPL) source. This is a thin wrapper around the internal seatbelt
// translator and is intended for inspection use cases such as
// `pmg setup sandbox profile show --driver=seatbelt`.
//
// The output contains a per-render random log tag (PMG_SBX_<random>) used at
// runtime to correlate violations; callers comparing renders should normalize
// it.
func RenderSeatbelt(policy *sandbox.SandboxPolicy) ([]byte, error) {
if policy == nil {
return nil, fmt.Errorf("policy is nil")
}
t := newSeatbeltPolicyTranslator()
out, err := t.translate(policy)
if err != nil {
return nil, err
}
return []byte(out), nil
}
@@ -0,0 +1,70 @@
//go:build darwin
// +build darwin
package platform
import (
"os"
"path/filepath"
"regexp"
"testing"
"github.com/safedep/dry/utils"
"github.com/safedep/pmg/sandbox"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// seatbeltLogTagPattern matches the per-render random log tag so golden
// comparisons are deterministic across runs.
var seatbeltLogTagPattern = regexp.MustCompile(`PMG_SBX_[A-Za-z0-9]+`)
func normalizeSeatbeltOutput(b []byte) []byte {
return seatbeltLogTagPattern.ReplaceAll(b, []byte("PMG_SBX_GOLDENXXXXXX"))
}
func TestRenderSeatbelt_Golden(t *testing.T) {
tests := []struct {
name string
policy *sandbox.SandboxPolicy
goldenFile string
}{
{
name: "minimal allow read tmp",
policy: &sandbox.SandboxPolicy{
Name: "render-min",
Description: "minimal policy for render golden test",
PackageManagers: []string{"npm"},
Filesystem: sandbox.FilesystemPolicy{
AllowRead: []string{"/tmp"},
AllowWrite: []string{"/tmp"},
},
AllowPTY: utils.PtrTo(false),
},
goldenFile: "seatbelt_minimal.sb",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := RenderSeatbelt(tc.policy)
require.NoError(t, err)
goldenPath := filepath.Join("testdata", tc.goldenFile)
normalized := normalizeSeatbeltOutput(got)
if os.Getenv("UPDATE_GOLDEN") != "" {
require.NoError(t, os.WriteFile(goldenPath, normalized, 0o644))
}
expected, err := os.ReadFile(goldenPath)
require.NoError(t, err, "missing golden file: run with UPDATE_GOLDEN=1 to create")
assert.Equal(t, string(expected), string(normalized))
})
}
}
func TestRenderSeatbelt_NilPolicy(t *testing.T) {
_, err := RenderSeatbelt(nil)
assert.Error(t, err)
}
+61 -21
View File
@@ -122,8 +122,30 @@ func globDoubleStarAutoAllowParentDirIfNeeded(sb *strings.Builder, pattern strin
parentDir = "/"
}
sb.WriteString(fmt.Sprintf(";; Auto-allow parent directory for %s\n", pattern))
sb.WriteString(fmt.Sprintf("(allow %s (literal \"%s\"))\n", operation, parentDir))
sb.WriteString(";; Auto-allow parent directory for ")
sb.WriteString(pattern)
sb.WriteString("\n")
sb.WriteString("(allow ")
sb.WriteString(operation)
sb.WriteString(" (literal \"")
sb.WriteString(parentDir)
sb.WriteString("\"))\n")
}
func writeSeatbeltDenyRule(sb *strings.Builder, operation, matcher, value, message string) {
sb.WriteString("(deny ")
sb.WriteString(operation)
sb.WriteString("* (")
sb.WriteString(matcher)
if matcher == "regex" {
sb.WriteString(" #\"")
} else {
sb.WriteString(" \"")
}
sb.WriteString(value)
sb.WriteString("\") (with message \"")
sb.WriteString(message)
sb.WriteString("\"))\n")
}
// generateMoveBlockingRules generates deny rules for file movement (file-write-unlink) to protect paths.
@@ -398,9 +420,13 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo
globDoubleStarAutoAllowParentDirIfNeeded(sb, pattern, expanded, "file-read*")
regexPattern := util.GlobToRegex(expanded)
sb.WriteString(fmt.Sprintf("(allow file-read* (regex #\"%s\"))\n", regexPattern))
sb.WriteString("(allow file-read* (regex #\"")
sb.WriteString(regexPattern)
sb.WriteString("\"))\n")
} else {
sb.WriteString(fmt.Sprintf("(allow file-read* (subpath \"%s\"))\n", expanded))
sb.WriteString("(allow file-read* (subpath \"")
sb.WriteString(expanded)
sb.WriteString("\"))\n")
}
}
@@ -414,7 +440,9 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo
if len(tmpdirParents) > 0 {
sb.WriteString(";; Auto-allow TMPDIR parent on macOS\n")
for _, parent := range tmpdirParents {
sb.WriteString(fmt.Sprintf("(allow file-write* (subpath \"%s\"))\n", parent))
sb.WriteString("(allow file-write* (subpath \"")
sb.WriteString(parent)
sb.WriteString("\"))\n")
}
sb.WriteString("\n")
}
@@ -432,9 +460,13 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo
globDoubleStarAutoAllowParentDirIfNeeded(sb, pattern, expanded, "file-write*")
regexPattern := util.GlobToRegex(expanded)
sb.WriteString(fmt.Sprintf("(allow file-write* (regex #\"%s\"))\n", regexPattern))
sb.WriteString("(allow file-write* (regex #\"")
sb.WriteString(regexPattern)
sb.WriteString("\"))\n")
} else {
sb.WriteString(fmt.Sprintf("(allow file-write* (subpath \"%s\"))\n", expanded))
sb.WriteString("(allow file-write* (subpath \"")
sb.WriteString(expanded)
sb.WriteString("\"))\n")
}
}
@@ -452,9 +484,9 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo
// Use regex matching for glob patterns, subpath for literals
if util.ContainsGlob(expanded) {
regexPattern := util.GlobToRegex(expanded)
sb.WriteString(fmt.Sprintf("(deny file-read* (regex #\"%s\") (with message \"%s\"))\n", regexPattern, seatbeltLogMessage(t.logTag, "file-read", expanded)))
writeSeatbeltDenyRule(sb, "file-read", "regex", regexPattern, seatbeltLogMessage(t.logTag, "file-read", expanded))
} else {
sb.WriteString(fmt.Sprintf("(deny file-read* (subpath \"%s\") (with message \"%s\"))\n", expanded, seatbeltLogMessage(t.logTag, "file-read", expanded)))
writeSeatbeltDenyRule(sb, "file-read", "subpath", expanded, seatbeltLogMessage(t.logTag, "file-read", expanded))
}
expandedDenyRead = append(expandedDenyRead, expanded)
}
@@ -479,9 +511,9 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo
// Use regex matching for glob patterns, subpath for literals
if util.ContainsGlob(expanded) {
regexPattern := util.GlobToRegex(expanded)
sb.WriteString(fmt.Sprintf("(deny file-write* (regex #\"%s\") (with message \"%s\"))\n", regexPattern, seatbeltLogMessage(t.logTag, "file-write", expanded)))
writeSeatbeltDenyRule(sb, "file-write", "regex", regexPattern, seatbeltLogMessage(t.logTag, "file-write", expanded))
} else {
sb.WriteString(fmt.Sprintf("(deny file-write* (subpath \"%s\") (with message \"%s\"))\n", expanded, seatbeltLogMessage(t.logTag, "file-write", expanded)))
writeSeatbeltDenyRule(sb, "file-write", "subpath", expanded, seatbeltLogMessage(t.logTag, "file-write", expanded))
}
expandedDenyWrite = append(expandedDenyWrite, expanded)
}
@@ -522,9 +554,9 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo
if util.ContainsGlob(expanded) {
regexPattern := util.GlobToRegex(expanded)
sb.WriteString(fmt.Sprintf("(deny file-write* (regex #\"%s\") (with message \"%s\"))\n", regexPattern, seatbeltLogMessage(t.logTag, "file-write", expanded)))
writeSeatbeltDenyRule(sb, "file-write", "regex", regexPattern, seatbeltLogMessage(t.logTag, "file-write", expanded))
} else {
sb.WriteString(fmt.Sprintf("(deny file-write* (subpath \"%s\") (with message \"%s\"))\n", expanded, seatbeltLogMessage(t.logTag, "file-write", expanded)))
writeSeatbeltDenyRule(sb, "file-write", "subpath", expanded, seatbeltLogMessage(t.logTag, "file-write", expanded))
}
expandedDenyWrite = append(expandedDenyWrite, expanded)
}
@@ -537,9 +569,9 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo
if util.ContainsGlob(expanded) {
regexPattern := util.GlobToRegex(expanded)
sb.WriteString(fmt.Sprintf("(deny file-read* (regex #\"%s\") (with message \"%s\"))\n", regexPattern, seatbeltLogMessage(t.logTag, "file-read", expanded)))
writeSeatbeltDenyRule(sb, "file-read", "regex", regexPattern, seatbeltLogMessage(t.logTag, "file-read", expanded))
} else {
sb.WriteString(fmt.Sprintf("(deny file-read* (subpath \"%s\") (with message \"%s\"))\n", expanded, seatbeltLogMessage(t.logTag, "file-read", expanded)))
writeSeatbeltDenyRule(sb, "file-read", "subpath", expanded, seatbeltLogMessage(t.logTag, "file-read", expanded))
}
}
@@ -602,8 +634,12 @@ func (t *seatbeltPolicyTranslator) translateNetwork(policy *sandbox.SandboxPolic
}
for _, addr := range policy.Network.AllowBind {
sb.WriteString(fmt.Sprintf("(allow network-bind (local ip \"%s\"))\n", addr))
sb.WriteString(fmt.Sprintf("(allow network* (local ip \"%s\"))\n", addr))
sb.WriteString("(allow network-bind (local ip \"")
sb.WriteString(addr)
sb.WriteString("\"))\n")
sb.WriteString("(allow network* (local ip \"")
sb.WriteString(addr)
sb.WriteString("\"))\n")
}
if utils.SafelyGetValue(policy.AllowNetworkBind) || len(policy.Network.AllowBind) > 0 {
@@ -627,9 +663,13 @@ func (t *seatbeltPolicyTranslator) translateProcess(policy *sandbox.SandboxPolic
if util.ContainsGlob(expanded) {
// For glob patterns, use regex matching for precise control
regexPattern := util.GlobToRegex(expanded)
sb.WriteString(fmt.Sprintf("(allow process-exec* (regex #\"%s\"))\n", regexPattern))
sb.WriteString("(allow process-exec* (regex #\"")
sb.WriteString(regexPattern)
sb.WriteString("\"))\n")
} else {
sb.WriteString(fmt.Sprintf("(allow process-exec* (literal \"%s\"))\n", expanded))
sb.WriteString("(allow process-exec* (literal \"")
sb.WriteString(expanded)
sb.WriteString("\"))\n")
}
}
@@ -645,9 +685,9 @@ func (t *seatbeltPolicyTranslator) translateProcess(policy *sandbox.SandboxPolic
if util.ContainsGlob(expanded) {
// For glob patterns, use regex matching for precise control
regexPattern := util.GlobToRegex(expanded)
sb.WriteString(fmt.Sprintf("(deny process-exec* (regex #\"%s\") (with message \"%s\"))\n", regexPattern, seatbeltLogMessage(t.logTag, "process-exec", expanded)))
writeSeatbeltDenyRule(sb, "process-exec", "regex", regexPattern, seatbeltLogMessage(t.logTag, "process-exec", expanded))
} else {
sb.WriteString(fmt.Sprintf("(deny process-exec* (literal \"%s\") (with message \"%s\"))\n", expanded, seatbeltLogMessage(t.logTag, "process-exec", expanded)))
writeSeatbeltDenyRule(sb, "process-exec", "literal", expanded, seatbeltLogMessage(t.logTag, "process-exec", expanded))
}
}
+58
View File
@@ -0,0 +1,58 @@
--ro-bind-try
/usr
/usr
--ro-bind-try
/lib
/lib
--ro-bind-try
/lib64
/lib64
--ro-bind-try
/bin
/bin
--ro-bind-try
/sbin
/sbin
--ro-bind-try
/etc
/etc
--ro-bind-try
/opt
/opt
--ro-bind-try
/var/lib
/var/lib
--ro-bind-try
/sys
/sys
--dev-bind-try
/dev/null
/dev/null
--dev-bind-try
/dev/zero
/dev/zero
--dev-bind-try
/dev/random
/dev/random
--dev-bind-try
/dev/urandom
/dev/urandom
--dev-bind-try
/dev/full
/dev/full
--dev-bind-try
/dev/tty
/dev/tty
--proc
/proc
--unshare-net
--unshare-pid
--unshare-ipc
--new-session
--die-with-parent
--ro-bind-try
/tmp
/tmp
--bind
/tmp
/tmp
+110
View File
@@ -0,0 +1,110 @@
# Landlock ruleset
# policy: render-min
abi: GOLDEN (GOLDEN)
features: GOLDEN
allow_pty: false
skip_pid_namespace: false
skip_ipc_namespace: false
filesystem_rules (16):
- path: /tmp
access: execute|read_file|read_dir
- path: /tmp
access: execute|read_file|read_dir|write_file|truncate|make_reg|make_dir|make_sock|make_fifo|make_block|make_char|make_sym|remove_file|remove_dir|refer
- path: /usr/bin
access: execute|read_file
- path: /usr/sbin
access: execute|read_file
- path: /usr/lib
access: execute|read_file
- path: /usr/lib64
access: execute|read_file
- path: /bin
access: execute|read_file
- path: /sbin
access: execute|read_file
- path: /lib
access: execute|read_file
- path: /lib64
access: execute|read_file
- path: /proc
access: execute|read_file|read_dir
- path: /dev/null
access: execute|read_file|read_dir|write_file|truncate|make_reg|make_dir|make_sock|make_fifo|make_block|make_char|make_sym|remove_file|remove_dir|refer
- path: /dev/zero
access: execute|read_file|read_dir|write_file|truncate|make_reg|make_dir|make_sock|make_fifo|make_block|make_char|make_sym|remove_file|remove_dir|refer
- path: /dev/random
access: execute|read_file|read_dir|write_file|truncate|make_reg|make_dir|make_sock|make_fifo|make_block|make_char|make_sym|remove_file|remove_dir|refer
- path: /dev/urandom
access: execute|read_file|read_dir|write_file|truncate|make_reg|make_dir|make_sock|make_fifo|make_block|make_char|make_sym|remove_file|remove_dir|refer
- path: /tmp
access: write_file|truncate|make_reg|make_dir|make_sock|make_fifo|make_block|make_char|make_sym|remove_file|remove_dir|refer
deny_paths (32):
- path: /src/sandbox/platform/.env
mode: both
- path: /root/.env
mode: both
- path: /src/sandbox/platform/.aws
mode: both
- path: /root/.aws
mode: both
- path: /src/sandbox/platform/.azure
mode: both
- path: /root/.azure
mode: both
- path: /src/sandbox/platform/.gcloud
mode: both
- path: /root/.gcloud
mode: both
- path: /src/sandbox/platform/.config/gcloud
mode: both
- path: /root/.config/gcloud
mode: both
- path: /src/sandbox/platform/.kube
mode: both
- path: /root/.kube
mode: both
- path: /src/sandbox/platform/.ssh
mode: both
- path: /root/.ssh
mode: both
- path: /src/sandbox/platform/.gnupg
mode: both
- path: /root/.gnupg
mode: both
- path: /src/sandbox/platform/.docker/config.json
mode: both
- path: /root/.docker/config.json
mode: both
- path: /src/sandbox/platform/.netrc
mode: both
- path: /root/.netrc
mode: both
- path: /src/sandbox/platform/.git-credentials
mode: both
- path: /root/.git-credentials
mode: both
- path: /src/sandbox/platform/.pgpass
mode: both
- path: /root/.pgpass
mode: both
- path: /src/sandbox/platform/.config/gh
mode: both
- path: /root/.config/gh
mode: both
- path: /src/sandbox/platform/.git/config
mode: both
- path: /root/.git/config
mode: both
- path: /src/sandbox/platform/.git/hooks
mode: both
- path: /src/sandbox/platform/.git/hooks
mode: both
- path: /root/.git/hooks
mode: both
- path: /root/.git/hooks
mode: both
deny_exec_paths (1):
- /bin/sh
+265
View File
@@ -0,0 +1,265 @@
(version 1)
;; PMG Sandbox Policy: render-min
;; minimal policy for render golden test
;; Generated by PMG sandbox system
(deny default (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=default|target="))
;; Essential system permissions
;; Based on Chrome/Chromium sandbox for stable process execution
;; Process permissions
(allow process-exec)
(allow process-fork)
(allow process-info* (target same-sandbox))
(allow signal (target same-sandbox))
(allow mach-priv-task-port (target same-sandbox))
;; User preferences
(allow user-preference-read)
;; Mach IPC - specific services only
(allow mach-lookup
(global-name "com.apple.audio.systemsoundserver")
(global-name "com.apple.distributed_notifications@Uv3")
(global-name "com.apple.FSEvents")
(global-name "com.apple.FontObjectsServer")
(global-name "com.apple.fonts")
(global-name "com.apple.logd")
(global-name "com.apple.lsd.mapdb")
(global-name "com.apple.PowerManagement.control")
(global-name "com.apple.system.logger")
(global-name "com.apple.system.notification_center")
(global-name "com.apple.trustd.agent")
(global-name "com.apple.system.opendirectoryd.libinfo")
(global-name "com.apple.system.opendirectoryd.membership")
(global-name "com.apple.bsd.dirhelper")
(global-name "com.apple.securityd.xpc")
(global-name "com.apple.coreservices.launchservicesd")
)
;; POSIX IPC
(allow ipc-posix-shm) ; Shared memory
(allow ipc-posix-sem) ; Semaphores for Python multiprocessing
;; IOKit operations
(allow iokit-open
(iokit-registry-entry-class "IOSurfaceRootUserClient")
(iokit-registry-entry-class "RootDomainUserClient")
(iokit-user-client-class "IOSurfaceSendRight")
)
(allow iokit-get-properties)
;; Specific safe system socket
(allow system-socket (require-all (socket-domain AF_SYSTEM) (socket-protocol 2)))
;; sysctl - specific sysctls only
(allow sysctl-read
(sysctl-name "hw.activecpu")
(sysctl-name "hw.busfrequency_compat")
(sysctl-name "hw.byteorder")
(sysctl-name "hw.cacheconfig")
(sysctl-name "hw.cachelinesize_compat")
(sysctl-name "hw.cpufamily")
(sysctl-name "hw.cpufrequency")
(sysctl-name "hw.cpufrequency_compat")
(sysctl-name "hw.cputype")
(sysctl-name "hw.l1dcachesize_compat")
(sysctl-name "hw.l1icachesize_compat")
(sysctl-name "hw.l2cachesize_compat")
(sysctl-name "hw.l3cachesize_compat")
(sysctl-name "hw.logicalcpu")
(sysctl-name "hw.logicalcpu_max")
(sysctl-name "hw.machine")
(sysctl-name "hw.memsize")
(sysctl-name "hw.ncpu")
(sysctl-name "hw.nperflevels")
(sysctl-name "hw.packages")
(sysctl-name "hw.pagesize_compat")
(sysctl-name "hw.pagesize")
(sysctl-name "hw.physicalcpu")
(sysctl-name "hw.physicalcpu_max")
(sysctl-name "hw.tbfrequency_compat")
(sysctl-name "hw.vectorunit")
(sysctl-name "kern.argmax")
(sysctl-name "kern.bootargs")
(sysctl-name "kern.hostname")
(sysctl-name "kern.maxfiles")
(sysctl-name "kern.maxfilesperproc")
(sysctl-name "kern.maxproc")
(sysctl-name "kern.ngroups")
(sysctl-name "kern.osproductversion")
(sysctl-name "kern.osrelease")
(sysctl-name "kern.ostype")
(sysctl-name "kern.osvariant_status")
(sysctl-name "kern.osversion")
(sysctl-name "kern.secure_kernel")
(sysctl-name "kern.tcsm_available")
(sysctl-name "kern.tcsm_enable")
(sysctl-name "kern.usrstack64")
(sysctl-name "kern.version")
(sysctl-name "kern.willshutdown")
(sysctl-name "machdep.cpu.brand_string")
(sysctl-name "machdep.ptrauth_enabled")
(sysctl-name "security.mac.lockdown_mode_state")
(sysctl-name "sysctl.proc_cputype")
(sysctl-name "vm.loadavg")
(sysctl-name-prefix "hw.optional.arm")
(sysctl-name-prefix "hw.optional.arm.")
(sysctl-name-prefix "hw.optional.armv8_")
(sysctl-name-prefix "hw.perflevel")
(sysctl-name-prefix "kern.proc.all")
(sysctl-name-prefix "kern.proc.pgrp.")
(sysctl-name-prefix "kern.proc.pid.")
(sysctl-name-prefix "machdep.cpu.")
(sysctl-name-prefix "net.routetable.")
)
;; V8 thread calculations
(allow sysctl-write
(sysctl-name "kern.tcsm_enable")
)
;; Distributed notifications
(allow distributed-notification-post)
;; Specific mach-lookup for security
(allow mach-lookup (global-name "com.apple.SecurityServer"))
;; Device file I/O
(allow file-ioctl (literal "/dev/null"))
(allow file-ioctl (literal "/dev/zero"))
(allow file-ioctl (literal "/dev/random"))
(allow file-ioctl (literal "/dev/urandom"))
(allow file-ioctl (literal "/dev/dtracehelper"))
(allow file-ioctl (literal "/dev/tty"))
(allow file-ioctl file-read-data file-write-data
(require-all
(literal "/dev/null")
(vnode-type CHARACTER-DEVICE)
)
)
;; File metadata for getcwd() and similar
(allow file-read-metadata)
;; System configuration and libraries
(allow file-read* (subpath "/dev"))
(allow file-read* (subpath "/etc"))
;; Filesystem access
(allow file-read* (subpath "/tmp"))
;; Auto-allow TMPDIR parent on macOS
(allow file-write* (subpath "/var/folders/8k/n1r5cyfs0gd5044p35ymlg780000gn"))
(allow file-write* (subpath "/private/var/folders/8k/n1r5cyfs0gd5044p35ymlg780000gn"))
(allow file-write* (subpath "/tmp"))
;; Mandatory security denies (credentials, git hooks, etc.)
(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.env") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.env"))
(deny file-write* (regex #"^(.*/)?\.env$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2A%2A%2F.env"))
(deny file-write* (subpath "/Users/dev/.env") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.env"))
(deny file-write* (regex #"^/Users/dev/Work/dev/safedep/pmg/sandbox/platform/\.env\.[^/]*$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.env.%2A"))
(deny file-write* (regex #"^(.*/)?\.env\.[^/]*$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2A%2A%2F.env.%2A"))
(deny file-write* (regex #"^/Users/dev/\.env\.[^/]*$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.env.%2A"))
(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.aws") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.aws"))
(deny file-write* (regex #"^(.*/)?\.aws$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2A%2A%2F.aws"))
(deny file-write* (subpath "/Users/dev/.aws") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.aws"))
(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.azure") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.azure"))
(deny file-write* (regex #"^(.*/)?\.azure$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2A%2A%2F.azure"))
(deny file-write* (subpath "/Users/dev/.azure") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.azure"))
(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.gcloud") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.gcloud"))
(deny file-write* (regex #"^(.*/)?\.gcloud$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2A%2A%2F.gcloud"))
(deny file-write* (subpath "/Users/dev/.gcloud") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.gcloud"))
(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.config/gcloud") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.config%2Fgcloud"))
(deny file-write* (regex #"^(.*/)?\.config/gcloud$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2A%2A%2F.config%2Fgcloud"))
(deny file-write* (subpath "/Users/dev/.config/gcloud") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.config%2Fgcloud"))
(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.kube") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.kube"))
(deny file-write* (regex #"^(.*/)?\.kube$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2A%2A%2F.kube"))
(deny file-write* (subpath "/Users/dev/.kube") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.kube"))
(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.ssh") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.ssh"))
(deny file-write* (regex #"^(.*/)?\.ssh$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2A%2A%2F.ssh"))
(deny file-write* (subpath "/Users/dev/.ssh") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.ssh"))
(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.gnupg") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.gnupg"))
(deny file-write* (regex #"^(.*/)?\.gnupg$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2A%2A%2F.gnupg"))
(deny file-write* (subpath "/Users/dev/.gnupg") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.gnupg"))
(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.docker/config.json") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.docker%2Fconfig.json"))
(deny file-write* (regex #"^(.*/)?\.docker/config\.json$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2A%2A%2F.docker%2Fconfig.json"))
(deny file-write* (subpath "/Users/dev/.docker/config.json") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.docker%2Fconfig.json"))
(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.netrc") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.netrc"))
(deny file-write* (regex #"^(.*/)?\.netrc$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2A%2A%2F.netrc"))
(deny file-write* (subpath "/Users/dev/.netrc") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.netrc"))
(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.git-credentials") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.git-credentials"))
(deny file-write* (regex #"^(.*/)?\.git-credentials$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2A%2A%2F.git-credentials"))
(deny file-write* (subpath "/Users/dev/.git-credentials") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.git-credentials"))
(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.pgpass") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.pgpass"))
(deny file-write* (regex #"^(.*/)?\.pgpass$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2A%2A%2F.pgpass"))
(deny file-write* (subpath "/Users/dev/.pgpass") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.pgpass"))
(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.config/gh") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.config%2Fgh"))
(deny file-write* (regex #"^(.*/)?\.config/gh$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2A%2A%2F.config%2Fgh"))
(deny file-write* (subpath "/Users/dev/.config/gh") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.config%2Fgh"))
(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.git/config") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.git%2Fconfig"))
(deny file-write* (subpath "/Users/dev/.git/config") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.git%2Fconfig"))
(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.git/hooks") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.git%2Fhooks"))
(deny file-write* (regex #"^/Users/dev/Work/dev/safedep/pmg/sandbox/platform/\.git/hooks/.*$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.git%2Fhooks%2F%2A%2A"))
(deny file-write* (subpath "/Users/dev/.git/hooks") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.git%2Fhooks"))
(deny file-write* (regex #"^/Users/dev/\.git/hooks/.*$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.git%2Fhooks%2F%2A%2A"))
(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.env") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.env"))
(deny file-read* (regex #"^(.*/)?\.env$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2A%2A%2F.env"))
(deny file-read* (subpath "/Users/dev/.env") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.env"))
(deny file-read* (regex #"^/Users/dev/Work/dev/safedep/pmg/sandbox/platform/\.env\.[^/]*$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.env.%2A"))
(deny file-read* (regex #"^(.*/)?\.env\.[^/]*$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2A%2A%2F.env.%2A"))
(deny file-read* (regex #"^/Users/dev/\.env\.[^/]*$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.env.%2A"))
(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.aws") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.aws"))
(deny file-read* (regex #"^(.*/)?\.aws$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2A%2A%2F.aws"))
(deny file-read* (subpath "/Users/dev/.aws") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.aws"))
(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.azure") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.azure"))
(deny file-read* (regex #"^(.*/)?\.azure$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2A%2A%2F.azure"))
(deny file-read* (subpath "/Users/dev/.azure") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.azure"))
(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.gcloud") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.gcloud"))
(deny file-read* (regex #"^(.*/)?\.gcloud$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2A%2A%2F.gcloud"))
(deny file-read* (subpath "/Users/dev/.gcloud") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.gcloud"))
(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.config/gcloud") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.config%2Fgcloud"))
(deny file-read* (regex #"^(.*/)?\.config/gcloud$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2A%2A%2F.config%2Fgcloud"))
(deny file-read* (subpath "/Users/dev/.config/gcloud") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.config%2Fgcloud"))
(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.kube") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.kube"))
(deny file-read* (regex #"^(.*/)?\.kube$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2A%2A%2F.kube"))
(deny file-read* (subpath "/Users/dev/.kube") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.kube"))
(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.ssh") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.ssh"))
(deny file-read* (regex #"^(.*/)?\.ssh$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2A%2A%2F.ssh"))
(deny file-read* (subpath "/Users/dev/.ssh") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.ssh"))
(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.gnupg") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.gnupg"))
(deny file-read* (regex #"^(.*/)?\.gnupg$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2A%2A%2F.gnupg"))
(deny file-read* (subpath "/Users/dev/.gnupg") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.gnupg"))
(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.docker/config.json") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.docker%2Fconfig.json"))
(deny file-read* (regex #"^(.*/)?\.docker/config\.json$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2A%2A%2F.docker%2Fconfig.json"))
(deny file-read* (subpath "/Users/dev/.docker/config.json") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.docker%2Fconfig.json"))
(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.netrc") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.netrc"))
(deny file-read* (regex #"^(.*/)?\.netrc$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2A%2A%2F.netrc"))
(deny file-read* (subpath "/Users/dev/.netrc") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.netrc"))
(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.git-credentials") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.git-credentials"))
(deny file-read* (regex #"^(.*/)?\.git-credentials$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2A%2A%2F.git-credentials"))
(deny file-read* (subpath "/Users/dev/.git-credentials") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.git-credentials"))
(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.pgpass") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.pgpass"))
(deny file-read* (regex #"^(.*/)?\.pgpass$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2A%2A%2F.pgpass"))
(deny file-read* (subpath "/Users/dev/.pgpass") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.pgpass"))
(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.config/gh") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.config%2Fgh"))
(deny file-read* (regex #"^(.*/)?\.config/gh$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2A%2A%2F.config%2Fgh"))
(deny file-read* (subpath "/Users/dev/.config/gh") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.config%2Fgh"))
(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.git/config") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.git%2Fconfig"))
(deny file-read* (subpath "/Users/dev/.git/config") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.git%2Fconfig"))
(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.git/hooks") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.git%2Fhooks"))
(deny file-read* (regex #"^/Users/dev/Work/dev/safedep/pmg/sandbox/platform/\.git/hooks/.*$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.git%2Fhooks%2F%2A%2A"))
(deny file-read* (subpath "/Users/dev/.git/hooks") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.git%2Fhooks"))
(deny file-read* (regex #"^/Users/dev/\.git/hooks/.*$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.git%2Fhooks%2F%2A%2A"))
;; Network access
;; Process execution
+69
View File
@@ -0,0 +1,69 @@
package sandbox
import "context"
type ProbeStatus string
const (
ProbeStatusOK ProbeStatus = "ok"
ProbeStatusWarn ProbeStatus = "warn"
ProbeStatusFail ProbeStatus = "fail"
ProbeStatusSkipped ProbeStatus = "skipped"
)
// ProbeFix is a suggested remediation for a non-OK probe result.
type ProbeFix struct {
Description string
Command string
Docs string
}
// ProbeResult is the structured outcome of a probe.
type ProbeResult struct {
Name string
Status ProbeStatus
Summary string
Detail string
Fixes []ProbeFix
}
// Probe is the unit of work a diagnose runner executes.
type Probe interface {
Name() string
Run(ctx context.Context) ProbeResult
}
// Probe names. These are stable identifiers used in JSON output, the
// `--driver` filter, and by callers that want to look up a specific probe's
// result. The cmd layer maps them to friendly labels for human rendering.
const (
ProbeSeatbeltDriver = "driver.seatbelt.available"
ProbeBwrapDriver = "driver.bwrap.available"
ProbeLandlockDriver = "driver.landlock.abi"
ProbeAppArmorUserns = "linux.apparmor.userns"
ProbeSeatbeltCanary = "canary.seatbelt"
ProbeBwrapCanary = "canary.bubblewrap"
ProbeLandlockCanary = "canary.landlock"
)
// RunProbes executes probes sequentially in input order, honoring ctx cancellation
// between probes. A cancelled context short-circuits the remaining probes
// with ProbeStatusSkipped results so the caller can render a complete table.
func RunProbes(ctx context.Context, probes []Probe) []ProbeResult {
results := make([]ProbeResult, 0, len(probes))
for _, p := range probes {
if err := ctx.Err(); err != nil {
results = append(results, ProbeResult{
Name: p.Name(),
Status: ProbeStatusSkipped,
Summary: "skipped: " + err.Error(),
})
continue
}
results = append(results, p.Run(ctx))
}
return results
}
+62
View File
@@ -0,0 +1,62 @@
package sandbox
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
)
type fakeProbe struct {
name string
result ProbeResult
ran bool
}
func (f *fakeProbe) Name() string { return f.name }
func (f *fakeProbe) Run(_ context.Context) ProbeResult {
f.ran = true
if f.result.Name == "" {
f.result.Name = f.name
}
return f.result
}
func TestRunProbes_PreservesOrderAndStatus(t *testing.T) {
probes := []Probe{
&fakeProbe{name: "a", result: ProbeResult{Status: ProbeStatusOK, Summary: "a ok"}},
&fakeProbe{name: "b", result: ProbeResult{Status: ProbeStatusWarn, Summary: "b warn"}},
&fakeProbe{name: "c", result: ProbeResult{Status: ProbeStatusFail, Summary: "c fail"}},
}
results := RunProbes(context.Background(), probes)
assert.Len(t, results, 3)
assert.Equal(t, "a", results[0].Name)
assert.Equal(t, ProbeStatusOK, results[0].Status)
assert.Equal(t, "b", results[1].Name)
assert.Equal(t, ProbeStatusWarn, results[1].Status)
assert.Equal(t, "c", results[2].Name)
assert.Equal(t, ProbeStatusFail, results[2].Status)
}
func TestRunProbes_HonorsCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
a := &fakeProbe{name: "a", result: ProbeResult{Status: ProbeStatusOK}}
b := &fakeProbe{name: "b", result: ProbeResult{Status: ProbeStatusOK}}
results := RunProbes(ctx, []Probe{a, b})
assert.Len(t, results, 2)
assert.False(t, a.ran)
assert.False(t, b.ran)
assert.Equal(t, ProbeStatusSkipped, results[0].Status)
assert.Equal(t, ProbeStatusSkipped, results[1].Status)
}
func TestRunProbes_EmptySlice(t *testing.T) {
results := RunProbes(context.Background(), nil)
assert.Empty(t, results)
}
+213 -9
View File
@@ -5,9 +5,11 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"github.com/safedep/dry/log"
"gopkg.in/yaml.v3"
)
@@ -17,11 +19,22 @@ var profilesFS embed.FS
type defaultProfileRegistry struct {
mu sync.RWMutex
profiles map[string]*SandboxPolicy
builtins map[string]struct{}
builtinYAML map[string][]byte
userProfileDir string
}
func newDefaultProfileRegistry(opts ...RegistryOption) (*defaultProfileRegistry, error) {
options := &registryOptions{}
for _, opt := range opts {
opt(options)
}
func newDefaultProfileRegistry() (*defaultProfileRegistry, error) {
registry := &defaultProfileRegistry{
profiles: make(map[string]*SandboxPolicy),
builtins: make(map[string]struct{}),
builtinYAML: make(map[string][]byte),
userProfileDir: options.userProfileDir,
}
if err := registry.loadBuiltinProfiles(); err != nil {
@@ -63,6 +76,8 @@ func (r *defaultProfileRegistry) loadBuiltinProfiles() error {
r.mu.Lock()
r.profiles[policy.Name] = policy
r.builtins[policy.Name] = struct{}{}
r.builtinYAML[policy.Name] = data
r.mu.Unlock()
}
@@ -115,19 +130,85 @@ func (r *defaultProfileRegistry) resolveInheritance(child *SandboxPolicy) error
}
// GetProfile retrieves a policy by name.
// Resolution order: built-in profiles first, then user profile directory
// (by bare name, looking up <name>.yml or <name>.yaml), then a literal file path.
func (r *defaultProfileRegistry) GetProfile(name string) (*SandboxPolicy, error) {
r.mu.RLock()
if policy, exists := r.profiles[name]; exists {
if _, isBuiltin := r.builtins[name]; isBuiltin {
policy := r.profiles[name]
r.mu.RUnlock()
return policy, nil
}
r.mu.RUnlock()
path, found, err := r.findUserProfileByName(name)
if err != nil {
return nil, err
}
if found {
return r.LoadCustomProfile(path)
}
if fileExists(name) {
return r.LoadCustomProfile(name)
}
return nil, fmt.Errorf("sandbox profile not found: %s (not a built-in profile and file does not exist)", name)
return nil, fmt.Errorf("sandbox profile not found: %s (not a built-in profile, no matching user profile, and file does not exist)", name)
}
// findUserProfileByName looks for `<name>.yml` then `<name>.yaml` under
// the user profile directory. Returns the absolute path if found.
func (r *defaultProfileRegistry) findUserProfileByName(name string) (string, bool, error) {
if r.userProfileDir == "" || !isBareProfileName(name) {
return "", false, nil
}
files, err := r.userProfileFiles()
if err != nil {
return "", false, fmt.Errorf("failed to read user profile directory %s: %w", r.userProfileDir, err)
}
for _, file := range files {
if file.name == name {
return file.path, true, nil
}
}
return "", false, nil
}
func isBareProfileName(name string) bool {
return name != "" && name == filepath.Base(name) && name != "." && name != ".."
}
// UserProfileDir returns the directory scanned for user profiles.
func (r *defaultProfileRegistry) UserProfileDir() string {
return r.userProfileDir
}
// ListUserProfiles enumerates *.yml / *.yaml files under UserProfileDir().
// A missing directory returns an empty slice with no error. Profiles whose
// name collides with a built-in are marked as Shadowed.
func (r *defaultProfileRegistry) ListUserProfiles() ([]ProfileInfo, error) {
files, err := r.userProfileFiles()
if err != nil {
return nil, fmt.Errorf("failed to read user profile directory %s: %w", r.userProfileDir, err)
}
r.mu.RLock()
defer r.mu.RUnlock()
profiles := make([]ProfileInfo, 0, len(files))
for _, file := range files {
_, shadowed := r.builtins[file.name]
profiles = append(profiles, ProfileInfo{
Name: file.name,
Path: file.path,
Shadowed: shadowed,
})
}
return profiles, nil
}
// LoadCustomProfile loads a policy from a custom YAML file path.
@@ -180,17 +261,140 @@ func (r *defaultProfileRegistry) LoadCustomProfile(path string) (*SandboxPolicy,
return policy, nil
}
// ListProfiles returns the names of all built-in profiles.
func (r *defaultProfileRegistry) ListProfiles() []string {
// ListProfiles returns all discoverable profiles: built-ins first, then user
// profiles (including shadowed entries so the cmd layer can warn the user).
func (r *defaultProfileRegistry) ListProfiles() ([]ProfileSummary, error) {
r.mu.RLock()
builtinNames := make([]string, 0, len(r.builtins))
for name := range r.builtins {
builtinNames = append(builtinNames, name)
}
sort.Strings(builtinNames)
summaries := make([]ProfileSummary, 0, len(builtinNames))
for _, name := range builtinNames {
p := r.profiles[name]
summaries = append(summaries, ProfileSummary{
Name: name,
Source: ProfileSourceBuiltin,
Inherits: p.Inherits,
PackageManagers: append([]string(nil), p.PackageManagers...),
Description: p.Description,
})
}
r.mu.RUnlock()
files, err := r.userProfileFiles()
if err != nil {
return nil, fmt.Errorf("failed to read user profile directory %s: %w", r.userProfileDir, err)
}
userEntries := make([]ProfileSummary, 0, len(files))
for _, file := range files {
r.mu.RLock()
_, shadowed := r.builtins[file.name]
r.mu.RUnlock()
summary := ProfileSummary{
Name: file.name,
Source: ProfileSourceUser,
Path: file.path,
Shadowed: shadowed,
}
data, err := os.ReadFile(file.path)
if err != nil {
log.Warnf("failed to read user profile %s: %v", file.path, err)
userEntries = append(userEntries, summary)
continue
}
var parsed SandboxPolicy
if err := yaml.Unmarshal(data, &parsed); err != nil {
log.Warnf("failed to parse user profile %s: %v", file.path, err)
userEntries = append(userEntries, summary)
continue
}
summary.Inherits = parsed.Inherits
summary.PackageManagers = parsed.PackageManagers
summary.Description = parsed.Description
userEntries = append(userEntries, summary)
}
sort.Slice(userEntries, func(i, j int) bool {
return userEntries[i].Name < userEntries[j].Name
})
return append(summaries, userEntries...), nil
}
type userProfileFile struct {
name string
path string
}
func (r *defaultProfileRegistry) userProfileFiles() ([]userProfileFile, error) {
if r.userProfileDir == "" {
return nil, nil
}
entries, err := os.ReadDir(r.userProfileDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
byName := make(map[string]userProfileFile, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
ext := filepath.Ext(entry.Name())
if ext != ".yml" && ext != ".yaml" {
continue
}
name := strings.TrimSuffix(entry.Name(), ext)
if !isBareProfileName(name) {
continue
}
file := userProfileFile{
name: name,
path: filepath.Join(r.userProfileDir, entry.Name()),
}
if existing, ok := byName[name]; ok && filepath.Ext(existing.path) == ".yml" {
continue
}
byName[name] = file
}
files := make([]userProfileFile, 0, len(byName))
for _, file := range byName {
files = append(files, file)
}
sort.Slice(files, func(i, j int) bool { return files[i].name < files[j].name })
return files, nil
}
// BuiltinProfileYAML returns the embedded YAML for a built-in profile.
func (r *defaultProfileRegistry) BuiltinProfileYAML(name string) ([]byte, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
profiles := make([]string, 0, len(r.profiles))
for name := range r.profiles {
profiles = append(profiles, name)
data, ok := r.builtinYAML[name]
if !ok {
return nil, false
}
return profiles
out := make([]byte, len(data))
copy(out, data)
return out, true
}
func parsePolicy(data []byte) (*SandboxPolicy, error) {
+315
View File
@@ -0,0 +1,315 @@
package sandbox
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
// writeUserProfile writes a valid SandboxPolicy YAML named `<name>.<ext>` into
// dir. The policy is intentionally minimal but valid.
func writeUserProfile(t *testing.T, dir, name, ext string) string {
t.Helper()
policy := &SandboxPolicy{
Name: name,
Description: "user " + name,
PackageManagers: []string{"npm"},
Filesystem: FilesystemPolicy{
AllowRead: []string{"/tmp"},
AllowWrite: []string{"/tmp"},
DenyRead: []string{"/private/var"},
DenyWrite: []string{"/private/var"},
},
}
path := filepath.Join(dir, name+ext)
data, err := yaml.Marshal(policy)
require.NoError(t, err)
require.NoError(t, os.WriteFile(path, data, 0o644))
return path
}
func TestListUserProfiles(t *testing.T) {
t.Run("nil user dir returns empty", func(t *testing.T) {
registry, err := newDefaultProfileRegistry()
require.NoError(t, err)
profiles, err := registry.ListUserProfiles()
assert.NoError(t, err)
assert.Empty(t, profiles)
assert.Equal(t, "", registry.UserProfileDir())
})
t.Run("missing dir returns empty", func(t *testing.T) {
missing := filepath.Join(t.TempDir(), "does-not-exist")
registry, err := newDefaultProfileRegistry(WithUserProfileDir(missing))
require.NoError(t, err)
profiles, err := registry.ListUserProfiles()
assert.NoError(t, err)
assert.Empty(t, profiles)
assert.Equal(t, missing, registry.UserProfileDir())
})
t.Run("empty dir returns empty", func(t *testing.T) {
dir := t.TempDir()
registry, err := newDefaultProfileRegistry(WithUserProfileDir(dir))
require.NoError(t, err)
profiles, err := registry.ListUserProfiles()
assert.NoError(t, err)
assert.Empty(t, profiles)
})
t.Run("mixed yml and yaml files", func(t *testing.T) {
dir := t.TempDir()
writeUserProfile(t, dir, "alpha", ".yml")
writeUserProfile(t, dir, "beta", ".yaml")
writeUserProfile(t, dir, "gamma", ".yml")
// non-profile files should be ignored
require.NoError(t, os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("ignored"), 0o644))
require.NoError(t, os.Mkdir(filepath.Join(dir, "subdir"), 0o755))
registry, err := newDefaultProfileRegistry(WithUserProfileDir(dir))
require.NoError(t, err)
profiles, err := registry.ListUserProfiles()
require.NoError(t, err)
names := make([]string, 0, len(profiles))
for _, p := range profiles {
names = append(names, p.Name)
assert.False(t, p.Shadowed, "user-only name should not be shadowed: %s", p.Name)
}
assert.ElementsMatch(t, []string{"alpha", "beta", "gamma"}, names)
})
t.Run("deduplicates yaml and yml by name", func(t *testing.T) {
dir := t.TempDir()
ymlPath := writeUserProfile(t, dir, "dupe", ".yml")
writeUserProfile(t, dir, "dupe", ".yaml")
registry, err := newDefaultProfileRegistry(WithUserProfileDir(dir))
require.NoError(t, err)
profiles, err := registry.ListUserProfiles()
require.NoError(t, err)
require.Len(t, profiles, 1)
assert.Equal(t, "dupe", profiles[0].Name)
assert.Equal(t, ymlPath, profiles[0].Path)
})
t.Run("user file shadowed by builtin", func(t *testing.T) {
dir := t.TempDir()
writeUserProfile(t, dir, "npm-restrictive", ".yml")
writeUserProfile(t, dir, "my-custom", ".yml")
registry, err := newDefaultProfileRegistry(WithUserProfileDir(dir))
require.NoError(t, err)
profiles, err := registry.ListUserProfiles()
require.NoError(t, err)
byName := map[string]ProfileInfo{}
for _, p := range profiles {
byName[p.Name] = p
}
require.Contains(t, byName, "npm-restrictive")
assert.True(t, byName["npm-restrictive"].Shadowed)
require.Contains(t, byName, "my-custom")
assert.False(t, byName["my-custom"].Shadowed)
})
}
func TestListProfiles(t *testing.T) {
t.Run("builtins only", func(t *testing.T) {
registry, err := newDefaultProfileRegistry()
require.NoError(t, err)
summaries, err := registry.ListProfiles()
require.NoError(t, err)
require.NotEmpty(t, summaries)
for _, s := range summaries {
assert.Equal(t, ProfileSourceBuiltin, s.Source)
assert.Empty(t, s.Path)
assert.False(t, s.Shadowed)
assert.NotEmpty(t, s.PackageManagers)
assert.NotEmpty(t, s.Description)
}
})
t.Run("user only", func(t *testing.T) {
dir := t.TempDir()
writeUserProfile(t, dir, "alpha", ".yml")
registry, err := newDefaultProfileRegistry(WithUserProfileDir(dir))
require.NoError(t, err)
summaries, err := registry.ListProfiles()
require.NoError(t, err)
var alpha *ProfileSummary
for i := range summaries {
if summaries[i].Name == "alpha" {
alpha = &summaries[i]
break
}
}
require.NotNil(t, alpha)
assert.Equal(t, ProfileSourceUser, alpha.Source)
assert.NotEmpty(t, alpha.Path)
assert.False(t, alpha.Shadowed)
assert.Equal(t, "user alpha", alpha.Description)
assert.Equal(t, []string{"npm"}, alpha.PackageManagers)
})
t.Run("mixed with shadowing", func(t *testing.T) {
dir := t.TempDir()
writeUserProfile(t, dir, "npm-restrictive", ".yml")
writeUserProfile(t, dir, "my-custom", ".yml")
registry, err := newDefaultProfileRegistry(WithUserProfileDir(dir))
require.NoError(t, err)
summaries, err := registry.ListProfiles()
require.NoError(t, err)
byKey := map[string]ProfileSummary{}
for _, s := range summaries {
byKey[string(s.Source)+":"+s.Name] = s
}
bi, ok := byKey["builtin:npm-restrictive"]
require.True(t, ok)
assert.False(t, bi.Shadowed)
shadow, ok := byKey["user:npm-restrictive"]
require.True(t, ok)
assert.True(t, shadow.Shadowed)
custom, ok := byKey["user:my-custom"]
require.True(t, ok)
assert.False(t, custom.Shadowed)
})
t.Run("broken yaml user file", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "broken.yml"), []byte("not: [valid"), 0o644))
registry, err := newDefaultProfileRegistry(WithUserProfileDir(dir))
require.NoError(t, err)
summaries, err := registry.ListProfiles()
require.NoError(t, err)
var broken *ProfileSummary
for i := range summaries {
if summaries[i].Name == "broken" {
broken = &summaries[i]
break
}
}
require.NotNil(t, broken)
assert.Equal(t, ProfileSourceUser, broken.Source)
assert.Empty(t, broken.Description)
})
}
func TestGetProfileResolutionOrder(t *testing.T) {
t.Run("builtin only", func(t *testing.T) {
dir := t.TempDir()
registry, err := newDefaultProfileRegistry(WithUserProfileDir(dir))
require.NoError(t, err)
policy, err := registry.GetProfile("npm-restrictive")
require.NoError(t, err)
assert.Equal(t, "npm-restrictive", policy.Name)
})
t.Run("user only by bare name", func(t *testing.T) {
dir := t.TempDir()
writeUserProfile(t, dir, "my-user-profile", ".yml")
registry, err := newDefaultProfileRegistry(WithUserProfileDir(dir))
require.NoError(t, err)
policy, err := registry.GetProfile("my-user-profile")
require.NoError(t, err)
assert.Equal(t, "my-user-profile", policy.Name)
assert.Equal(t, "user my-user-profile", policy.Description)
})
t.Run("user only by bare name with yaml extension", func(t *testing.T) {
dir := t.TempDir()
writeUserProfile(t, dir, "yaml-ext", ".yaml")
registry, err := newDefaultProfileRegistry(WithUserProfileDir(dir))
require.NoError(t, err)
policy, err := registry.GetProfile("yaml-ext")
require.NoError(t, err)
assert.Equal(t, "yaml-ext", policy.Name)
})
t.Run("builtin wins when both exist", func(t *testing.T) {
dir := t.TempDir()
// Write a user profile whose description is unique so we can tell
// which one was resolved.
policy := &SandboxPolicy{
Name: "npm-restrictive",
Description: "USER VERSION",
PackageManagers: []string{"npm"},
Filesystem: FilesystemPolicy{
AllowRead: []string{"/tmp"},
AllowWrite: []string{"/tmp"},
DenyRead: []string{"/private/var"},
DenyWrite: []string{"/private/var"},
},
}
data, err := yaml.Marshal(policy)
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(dir, "npm-restrictive.yml"), data, 0o644))
registry, err := newDefaultProfileRegistry(WithUserProfileDir(dir))
require.NoError(t, err)
resolved, err := registry.GetProfile("npm-restrictive")
require.NoError(t, err)
assert.Equal(t, "npm-restrictive", resolved.Name)
assert.NotEqual(t, "USER VERSION", resolved.Description, "builtin should win over user file of same name")
})
t.Run("unknown name returns error", func(t *testing.T) {
dir := t.TempDir()
registry, err := newDefaultProfileRegistry(WithUserProfileDir(dir))
require.NoError(t, err)
_, err = registry.GetProfile("no-such-profile")
assert.Error(t, err)
})
t.Run("bare user profile lookup cannot escape user dir", func(t *testing.T) {
root := t.TempDir()
userDir := filepath.Join(root, "profiles")
require.NoError(t, os.Mkdir(userDir, 0o755))
outsidePath := writeUserProfile(t, root, "outside", ".yml")
registry, err := newDefaultProfileRegistry(WithUserProfileDir(userDir))
require.NoError(t, err)
_, err = registry.GetProfile("../outside")
require.Error(t, err)
policy, err := registry.GetProfile(outsidePath)
require.NoError(t, err)
assert.Equal(t, "outside", policy.Name)
})
}
+104
View File
@@ -0,0 +1,104 @@
package sandbox
import (
"fmt"
"github.com/safedep/dry/utils"
"github.com/safedep/pmg/sandbox/util"
)
// ResolveProfile loads name via the registry, resolves inheritance, and
// returns a SandboxPolicy with all path-bearing fields expanded against opts
// (or process env where opts fields are empty). The returned policy is a deep
// copy — the registry-cached policy is never mutated.
func (r *defaultProfileRegistry) ResolveProfile(name string, opts ResolveOptions) (*SandboxPolicy, error) {
policy, err := r.GetProfile(name)
if err != nil {
return nil, err
}
resolved, err := expandPolicyPaths(policy, opts)
if err != nil {
return nil, fmt.Errorf("failed to expand variables for profile %s: %w", name, err)
}
return resolved, nil
}
func expandPolicyPaths(p *SandboxPolicy, opts ResolveOptions) (*SandboxPolicy, error) {
// Shallow struct copy aliases *bool pointers with the registry-cached
// policy. Re-point them so callers mutating the result can't corrupt the
// cache. Slice fields below are rebuilt fresh so they're already isolated.
out := *p
if p.AllowGitConfig != nil {
out.AllowGitConfig = utils.PtrTo(*p.AllowGitConfig)
}
if p.AllowPTY != nil {
out.AllowPTY = utils.PtrTo(*p.AllowPTY)
}
if p.AllowNetworkBind != nil {
out.AllowNetworkBind = utils.PtrTo(*p.AllowNetworkBind)
}
allowRead, err := expandSlice(p.Filesystem.AllowRead, opts)
if err != nil {
return nil, err
}
allowWrite, err := expandSlice(p.Filesystem.AllowWrite, opts)
if err != nil {
return nil, err
}
denyRead, err := expandSlice(p.Filesystem.DenyRead, opts)
if err != nil {
return nil, err
}
denyWrite, err := expandSlice(p.Filesystem.DenyWrite, opts)
if err != nil {
return nil, err
}
out.Filesystem = FilesystemPolicy{
AllowRead: allowRead,
AllowWrite: allowWrite,
DenyRead: denyRead,
DenyWrite: denyWrite,
}
allowExec, err := expandSlice(p.Process.AllowExec, opts)
if err != nil {
return nil, err
}
denyExec, err := expandSlice(p.Process.DenyExec, opts)
if err != nil {
return nil, err
}
out.Process = ProcessPolicy{AllowExec: allowExec, DenyExec: denyExec}
// Network entries are host:port and don't carry path variables, but the
// slices are still deep-copied so the caller can safely mutate the result.
out.Network = NetworkPolicy{
AllowOutbound: append([]string(nil), p.Network.AllowOutbound...),
DenyOutbound: append([]string(nil), p.Network.DenyOutbound...),
AllowBind: append([]string(nil), p.Network.AllowBind...),
}
out.PackageManagers = append([]string(nil), p.PackageManagers...)
return &out, nil
}
func expandSlice(in []string, opts ResolveOptions) ([]string, error) {
if in == nil {
return nil, nil
}
out := make([]string, len(in))
for i, p := range in {
exp, err := util.ExpandVariablesWith(p, opts.CWD, opts.Home, opts.TmpDir)
if err != nil {
return nil, err
}
out[i] = exp
}
return out, nil
}
+122
View File
@@ -0,0 +1,122 @@
package sandbox
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResolveProfileOverrides(t *testing.T) {
dir := t.TempDir()
writeUserProfile(t, dir, "my-profile", ".yml")
registry, err := newDefaultProfileRegistry(WithUserProfileDir(dir))
require.NoError(t, err)
// Use built-in profile that has ${HOME}/${CWD} references
resolved, err := registry.ResolveProfile("npm-restrictive", ResolveOptions{
CWD: "/custom/cwd",
Home: "/custom/home",
})
require.NoError(t, err)
hasCustomHome := false
hasCustomCwd := false
for _, p := range resolved.Filesystem.AllowRead {
if strings.Contains(p, "/custom/home") {
hasCustomHome = true
}
if strings.Contains(p, "/custom/cwd") {
hasCustomCwd = true
}
assert.NotContains(t, p, "${HOME}")
assert.NotContains(t, p, "${CWD}")
}
assert.True(t, hasCustomHome)
assert.True(t, hasCustomCwd)
}
func TestResolveProfileProcessEnvFallback(t *testing.T) {
registry, err := newDefaultProfileRegistry()
require.NoError(t, err)
home, err := os.UserHomeDir()
require.NoError(t, err)
resolved, err := registry.ResolveProfile("npm-restrictive", ResolveOptions{})
require.NoError(t, err)
found := false
for _, p := range resolved.Filesystem.AllowRead {
if strings.HasPrefix(p, home) {
found = true
break
}
}
assert.True(t, found, "expected at least one AllowRead path under process home dir")
}
func TestResolveProfileInheritance(t *testing.T) {
dir := t.TempDir()
child := `name: child-profile
description: inherits npm-restrictive
inherits: npm-restrictive
package_managers:
- npm
filesystem:
allow_read:
- ${CWD}/extra
`
require.NoError(t, os.WriteFile(filepath.Join(dir, "child-profile.yml"), []byte(child), 0o644))
registry, err := newDefaultProfileRegistry(WithUserProfileDir(dir))
require.NoError(t, err)
resolved, err := registry.ResolveProfile("child-profile", ResolveOptions{
CWD: "/work",
})
require.NoError(t, err)
found := false
for _, p := range resolved.Filesystem.AllowRead {
if p == "/work/extra" {
found = true
}
assert.NotContains(t, p, "${CWD}")
}
assert.True(t, found, "expected child rule /work/extra in resolved policy")
// Inherited parent rules should also be present.
hasInherited := false
for _, p := range resolved.Filesystem.AllowRead {
if p == "/usr" || strings.HasPrefix(p, "/usr/") {
hasInherited = true
break
}
}
assert.True(t, hasInherited, "expected inherited builtin AllowRead rule")
}
func TestResolveProfileDoesNotMutateRegistry(t *testing.T) {
registry, err := newDefaultProfileRegistry()
require.NoError(t, err)
before, err := registry.GetProfile("npm-restrictive")
require.NoError(t, err)
originalAllowRead := append([]string(nil), before.Filesystem.AllowRead...)
_, err = registry.ResolveProfile("npm-restrictive", ResolveOptions{
CWD: "/x",
Home: "/y",
})
require.NoError(t, err)
after, err := registry.GetProfile("npm-restrictive")
require.NoError(t, err)
assert.Equal(t, originalAllowRead, after.Filesystem.AllowRead, "registry profile must not be mutated")
}
+91 -8
View File
@@ -5,6 +5,16 @@ import (
"os/exec"
)
// DriverName identifies a sandbox driver implementation. Returned by
// Sandbox.Name() and used wherever code needs to refer to a specific driver.
type DriverName string
const (
DriverSeatbelt DriverName = "seatbelt"
DriverBubblewrap DriverName = "bubblewrap"
DriverLandlock DriverName = "landlock"
)
// ViolationKind is PMG's normalized taxonomy for sandbox denials.
type ViolationKind string
@@ -21,7 +31,7 @@ const (
// ViolationReport is a best-effort sandbox violation summary collected from a
// sandbox implementation after command execution fails.
type ViolationReport struct {
SandboxName string
SandboxName DriverName
PolicyName string
CorrelationID string
Violations []Violation
@@ -128,8 +138,8 @@ type Sandbox interface {
// Callers must check result.ShouldRun() and only call cmd.Run() if true.
Execute(ctx context.Context, cmd *exec.Cmd, policy *SandboxPolicy) (*ExecutionResult, error)
// Name returns the sandbox implementation name (e.g., "seatbelt", "bubblewrap").
Name() string
// Name returns the sandbox driver identifier.
Name() DriverName
// IsAvailable returns true if the sandbox is available and functional on this platform.
IsAvailable() bool
@@ -139,20 +149,93 @@ type Sandbox interface {
Close() error
}
// ProfileInfo describes a user profile discovered on disk.
type ProfileInfo struct {
// Name is the profile name, derived from the file name without extension.
Name string
// Path is the absolute path to the profile file.
Path string
// Shadowed is true when a built-in profile of the same name exists and
// will win during name-based resolution.
Shadowed bool
}
// ProfileSource identifies where a profile came from.
type ProfileSource string
const (
ProfileSourceBuiltin ProfileSource = "builtin"
ProfileSourceUser ProfileSource = "user"
)
// ProfileSummary describes a discoverable profile for listing purposes.
type ProfileSummary struct {
Name string
Source ProfileSource
Path string // "" for builtins; absolute path for user profiles
Inherits string
PackageManagers []string
Description string
Shadowed bool // true when a user file is masked by a same-named builtin
}
// ResolveOptions tunes variable expansion when resolving a policy for display
// or diffing. Zero values mean "use the current process environment".
type ResolveOptions struct {
CWD string
Home string
TmpDir string
}
// ProfileRegistry manages built-in and custom sandbox policies.
type ProfileRegistry interface {
// GetProfile retrieves a policy by name.
// Name can be a built-in profile (e.g., "npm-restrictive") or a path to a custom YAML file.
// Name can be a built-in profile (e.g., "npm-restrictive"), the bare name of a
// user profile under UserProfileDir(), or a path to a custom YAML file.
// Resolution order: built-ins first, then the user profile directory.
GetProfile(name string) (*SandboxPolicy, error)
// LoadCustomProfile loads a policy from a custom YAML file path.
LoadCustomProfile(path string) (*SandboxPolicy, error)
// ListProfiles returns the names of all built-in profiles.
ListProfiles() []string
// ListProfiles returns all discoverable profiles: built-ins first, then
// user profiles (including shadowed entries).
ListProfiles() ([]ProfileSummary, error)
// ResolveProfile loads name and returns a policy with all path-bearing
// fields expanded against opts (or the process environment).
ResolveProfile(name string, opts ResolveOptions) (*SandboxPolicy, error)
// UserProfileDir returns the directory scanned for user profiles.
UserProfileDir() string
// ListUserProfiles enumerates *.yml / *.yaml files under UserProfileDir().
// A missing directory returns an empty slice with no error.
ListUserProfiles() ([]ProfileInfo, error)
// BuiltinProfileYAML returns the embedded YAML bytes for a built-in
// profile. Returns false if name is not a built-in.
BuiltinProfileYAML(name string) ([]byte, bool)
}
// RegistryOption configures a ProfileRegistry.
type RegistryOption func(*registryOptions)
type registryOptions struct {
userProfileDir string
}
// WithUserProfileDir sets the directory the registry uses to discover user
// profiles. The directory does not need to exist at construction time.
func WithUserProfileDir(dir string) RegistryOption {
return func(o *registryOptions) {
o.userProfileDir = dir
}
}
// NewProfileRegistry creates a new profile registry with built-in policies.
func NewProfileRegistry() (ProfileRegistry, error) {
return newDefaultProfileRegistry()
func NewProfileRegistry(opts ...RegistryOption) (ProfileRegistry, error) {
return newDefaultProfileRegistry(opts...)
}
+10 -7
View File
@@ -5,15 +5,18 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetTmpdirParent(t *testing.T) {
// Save original TMPDIR
originalTmpdir := os.Getenv("TMPDIR")
defer os.Setenv("TMPDIR", originalTmpdir)
defer func() {
require.NoError(t, os.Setenv("TMPDIR", originalTmpdir))
}()
t.Run("macOS pattern with /var prefix", func(t *testing.T) {
os.Setenv("TMPDIR", "/var/folders/ab/cd1234ef/T/")
require.NoError(t, os.Setenv("TMPDIR", "/var/folders/ab/cd1234ef/T/"))
parents := GetTmpdirParent()
assert.Len(t, parents, 2)
@@ -22,7 +25,7 @@ func TestGetTmpdirParent(t *testing.T) {
})
t.Run("macOS pattern with /private/var prefix", func(t *testing.T) {
os.Setenv("TMPDIR", "/private/var/folders/xy/z9876543/T/")
require.NoError(t, os.Setenv("TMPDIR", "/private/var/folders/xy/z9876543/T/"))
parents := GetTmpdirParent()
assert.Len(t, parents, 2)
@@ -31,7 +34,7 @@ func TestGetTmpdirParent(t *testing.T) {
})
t.Run("macOS pattern without trailing slash", func(t *testing.T) {
os.Setenv("TMPDIR", "/var/folders/12/abcdefgh/T")
require.NoError(t, os.Setenv("TMPDIR", "/var/folders/12/abcdefgh/T"))
parents := GetTmpdirParent()
assert.Len(t, parents, 2)
@@ -50,20 +53,20 @@ func TestGetTmpdirParent(t *testing.T) {
}
for _, tmpdir := range testCases {
os.Setenv("TMPDIR", tmpdir)
require.NoError(t, os.Setenv("TMPDIR", tmpdir))
parents := GetTmpdirParent()
assert.Empty(t, parents, "Expected empty result for TMPDIR=%s", tmpdir)
}
})
t.Run("empty TMPDIR returns empty", func(t *testing.T) {
os.Setenv("TMPDIR", "")
require.NoError(t, os.Setenv("TMPDIR", ""))
parents := GetTmpdirParent()
assert.Empty(t, parents)
})
t.Run("unset TMPDIR returns empty", func(t *testing.T) {
os.Unsetenv("TMPDIR")
require.NoError(t, os.Unsetenv("TMPDIR"))
parents := GetTmpdirParent()
assert.Empty(t, parents)
})
+55 -32
View File
@@ -6,42 +6,65 @@ import (
"strings"
)
// ExpandVariables expands known variables in a path or pattern.
// Supported variables:
// - ${HOME}: User home directory
// - ${CWD}: Current working directory
// - ${TMPDIR}: Temporary directory
func ExpandVariables(pattern string) (string, error) {
result := pattern
// Get home directory
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
// Get current working directory
cwd, err := os.Getwd()
if err != nil {
return "", err
}
// Get temp directory
tmpDir := os.TempDir()
// Replace variables
replacer := strings.NewReplacer(
"${HOME}", home,
"${CWD}", cwd,
"${TMPDIR}", tmpDir,
// Variable names recognised by ExpandVariablesWith. SupportedVariables is the
// authoritative list — callers that need to validate variable usage (e.g. the
// linter) should consume it rather than maintaining their own copy.
const (
VarHome = "${HOME}"
VarCWD = "${CWD}"
VarTMPDir = "${TMPDIR}"
)
result = replacer.Replace(result)
var SupportedVariables = []string{VarHome, VarCWD, VarTMPDir}
// Clean up path (resolve .., ., etc.)
result = filepath.Clean(result)
// IsSupportedVariable reports whether tok is one of the variables that
// ExpandVariablesWith will expand.
func IsSupportedVariable(tok string) bool {
for _, v := range SupportedVariables {
if v == tok {
return true
}
}
return false
}
return result, nil
// ExpandVariables expands known variables in a path or pattern using process
// environment values. See ExpandVariablesWith for supported variables.
func ExpandVariables(pattern string) (string, error) {
return ExpandVariablesWith(pattern, "", "", "")
}
// ExpandVariablesWith expands known variables in a path or pattern. Any of
// cwd, home, tmpDir left empty falls back to the corresponding process value.
// The set of recognised tokens is SupportedVariables.
func ExpandVariablesWith(pattern, cwd, home, tmpDir string) (string, error) {
if home == "" {
h, err := os.UserHomeDir()
if err != nil {
return "", err
}
home = h
}
if cwd == "" {
c, err := os.Getwd()
if err != nil {
return "", err
}
cwd = c
}
if tmpDir == "" {
tmpDir = os.TempDir()
}
replacer := strings.NewReplacer(
VarHome, home,
VarCWD, cwd,
VarTMPDir, tmpDir,
)
return filepath.Clean(replacer.Replace(pattern)), nil
}
// ContainsGlob returns true if the pattern contains glob wildcards.
+38
View File
@@ -258,6 +258,44 @@ func TestContainsGlob(t *testing.T) {
}
}
func TestIsSupportedVariable(t *testing.T) {
tests := []struct {
name string
token string
expected bool
}{
{"HOME is supported", "${HOME}", true},
{"CWD is supported", "${CWD}", true},
{"TMPDIR is supported", "${TMPDIR}", true},
{"unknown variable", "${USER}", false},
{"empty token", "", false},
{"bare name without braces", "HOME", false},
{"missing closing brace", "${HOME", false},
{"missing opening brace", "HOME}", false},
{"case mismatch", "${home}", false},
{"embedded variable in path is not a bare token", "${HOME}/foo", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, IsSupportedVariable(tt.token))
})
}
}
func TestSupportedVariablesCoversReplacer(t *testing.T) {
// Guard against drift: every entry in SupportedVariables must actually be
// expanded by ExpandVariablesWith, otherwise the linter and expander would
// disagree about what is "known".
for _, v := range SupportedVariables {
t.Run(v, func(t *testing.T) {
out, err := ExpandVariablesWith(v, "/cwd", "/home", "/tmp")
assert.NoError(t, err)
assert.NotEqual(t, v, out, "variable %q was not expanded", v)
})
}
}
func TestExpandVariablesWithVariableReplacement(t *testing.T) {
tests := []struct {
name string
+199
View File
@@ -0,0 +1,199 @@
package sandbox
import (
"os"
"path/filepath"
"strings"
)
// Explanation is structured data extracted from a ViolationReport. It carries
// only domain facts — the human-readable rendering and any CLI-specific
// suggestion strings (flag names, line layout) belong to the presentation
// layer (see internal/ui).
type Explanation struct {
// Primary is the violation most likely to be actionable, chosen by
// scoreViolation. Nil when the report contains no violations.
Primary *Violation
// Override carries a structured override hint for the primary violation
// (kind + target) when one can be safely suggested. Nil when no safe
// suggestion is available (glob targets, control characters, unsupported
// violation kinds). The presentation layer decides how to render this as a
// CLI flag, an API hint, etc.
Override *OverrideSuggestion
// AdditionalDenials counts violations beyond Primary so callers can show
// "+N more" without re-walking the report.
AdditionalDenials int
}
// OverrideSuggestion is the structured form of a "you can re-run with this
// allowance" hint. Kind tells the consumer which permission would unblock the
// operation; Target is the path/exec the user would whitelist.
type OverrideSuggestion struct {
Kind ViolationKind
Target string
}
// BuildExplanation produces an Explanation for the given report.
func BuildExplanation(report *ViolationReport) Explanation {
primary := primaryViolation(report)
exp := Explanation{Primary: primary}
if primary != nil {
exp.Override = overrideSuggestion(*primary)
if report != nil && len(report.Violations) > 1 {
exp.AdditionalDenials = len(report.Violations) - 1
}
}
return exp
}
func overrideSuggestion(v Violation) *OverrideSuggestion {
if !isSafeOverrideTarget(v.Target) {
return nil
}
switch v.Kind {
case ViolationKindFSRead,
ViolationKindFSWrite,
ViolationKindFSDeleteOrRename,
ViolationKindExec:
return &OverrideSuggestion{Kind: v.Kind, Target: v.Target}
default:
return nil
}
}
func primaryViolation(report *ViolationReport) *Violation {
if report == nil || len(report.Violations) == 0 {
return nil
}
cwd, _ := os.Getwd()
bestIdx := 0
bestScore := scoreViolation(report.SandboxName, report.Violations[0], cwd)
for i := 1; i < len(report.Violations); i++ {
score := scoreViolation(report.SandboxName, report.Violations[i], cwd)
if score >= bestScore {
bestIdx = i
bestScore = score
}
}
return &report.Violations[bestIdx]
}
func scoreViolation(driver DriverName, v Violation, cwd string) int {
score := 0
switch v.Kind {
case ViolationKindFSRead, ViolationKindFSWrite:
score += 120
case ViolationKindExec:
score += 110
case ViolationKindFSDeleteOrRename:
score += 100
case ViolationKindGenericDeny:
score += 10
default:
score += 30
}
if isSafeOverrideTarget(v.Target) {
score += 40
}
if v.Target != "" && v.Target != v.RuleTarget {
score += 20
}
if isProjectPath(v.Target, cwd) {
score += 80
}
if isSensitiveProjectFile(v.Target) {
score += 60
}
if isNoisySystemPath(driver, v.Target) {
score -= 120
}
if v.Kind == ViolationKindGenericDeny && v.Target == "" {
score -= 40
}
return score
}
func isSafeOverrideTarget(value string) bool {
if value == "" {
return false
}
if strings.ContainsAny(value, "*?[]") {
return false
}
for _, r := range value {
if r == 0 || r < 0x20 || r == 0x7f {
return false
}
}
return true
}
func isProjectPath(target, cwd string) bool {
if target == "" || cwd == "" {
return false
}
if strings.HasPrefix(target, ".") {
return !isParentRelativePath(target)
}
cleanTarget := filepath.Clean(target)
cleanCwd := filepath.Clean(cwd)
return cleanTarget == cleanCwd || strings.HasPrefix(cleanTarget, cleanCwd+string(filepath.Separator))
}
func isSensitiveProjectFile(target string) bool {
if target == "" || isParentRelativePath(target) {
return false
}
base := filepath.Base(target)
switch {
case strings.HasPrefix(base, ".env"):
return true
case base == ".npmrc", base == ".pypirc", base == ".netrc":
return true
case base == ".aws", base == ".ssh", base == ".kube", base == ".gnupg":
return true
default:
return strings.Contains(target, string(filepath.Separator)+".ssh") ||
strings.Contains(target, string(filepath.Separator)+".aws") ||
strings.Contains(target, string(filepath.Separator)+".kube")
}
}
func isParentRelativePath(target string) bool {
cleanTarget := filepath.Clean(target)
return cleanTarget == ".." || strings.HasPrefix(cleanTarget, ".."+string(filepath.Separator))
}
func isNoisySystemPath(driver DriverName, target string) bool {
if driver != DriverSeatbelt {
return false
}
switch target {
case "/dev/dtracehelper", "/dev/tty":
return true
default:
return strings.HasPrefix(target, "/dev/ttys")
}
}
+242
View File
@@ -0,0 +1,242 @@
package sandbox
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// ViolationCacheSchemaVersion is bumped when the on-disk JSON layout changes incompatibly.
const ViolationCacheSchemaVersion = 1
// DefaultViolationCacheRetention is the default number of reports retained in the cache.
const DefaultViolationCacheRetention = 10
const (
violationCacheFilePrefix = "violation-"
violationCacheFileSuffix = ".json"
)
// ViolationCacheRecord is the on-disk representation of a ViolationReport.
type ViolationCacheRecord struct {
SchemaVersion int `json:"schema_version"`
RecordedAt time.Time `json:"recorded_at"`
Report *ViolationReport `json:"report"`
}
// ViolationCacheEntry is a cache entry returned by readers.
type ViolationCacheEntry struct {
Path string
Record ViolationCacheRecord
}
// ViolationCache writes and reads violation reports under dir.
type ViolationCache struct {
dir string
retention int
now func() time.Time
}
// ViolationCacheOption customizes a ViolationCache.
type ViolationCacheOption func(*ViolationCache)
// WithRetention overrides the default retention.
func WithRetention(n int) ViolationCacheOption {
return func(c *ViolationCache) {
if n > 0 {
c.retention = n
}
}
}
// WithClock injects a clock for tests.
func WithClock(now func() time.Time) ViolationCacheOption {
return func(c *ViolationCache) {
if now != nil {
c.now = now
}
}
}
// NewViolationCache returns a ViolationCache rooted at dir. The directory is
// created lazily on Write.
func NewViolationCache(dir string, opts ...ViolationCacheOption) *ViolationCache {
c := &ViolationCache{
dir: dir,
retention: DefaultViolationCacheRetention,
now: time.Now,
}
for _, opt := range opts {
opt(c)
}
return c
}
// Write persists report to the cache and prunes older entries beyond
// retention. Returns the path of the written file.
func (c *ViolationCache) Write(report *ViolationReport) (string, error) {
if report == nil {
return "", errors.New("violationcache: nil report")
}
if c.dir == "" {
return "", errors.New("violationcache: empty cache directory")
}
if err := os.MkdirAll(c.dir, 0o755); err != nil {
return "", fmt.Errorf("violationcache: create dir: %w", err)
}
ts := c.now().UTC()
id, err := violationCacheShortID()
if err != nil {
return "", fmt.Errorf("violationcache: generate id: %w", err)
}
name := fmt.Sprintf("%s%s-%s%s", violationCacheFilePrefix, ts.Format("20060102T150405.000000000Z"), id, violationCacheFileSuffix)
path := filepath.Join(c.dir, name)
rec := ViolationCacheRecord{
SchemaVersion: ViolationCacheSchemaVersion,
RecordedAt: ts,
Report: report,
}
data, err := json.MarshalIndent(rec, "", " ")
if err != nil {
return "", fmt.Errorf("violationcache: marshal: %w", err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return "", fmt.Errorf("violationcache: write: %w", err)
}
if err := c.prune(); err != nil {
return path, fmt.Errorf("violationcache: prune: %w", err)
}
return path, nil
}
// List returns entries newest first. Corrupt or unreadable files are skipped.
func (c *ViolationCache) List() ([]ViolationCacheEntry, error) {
if c.dir == "" {
return nil, errors.New("violationcache: empty cache directory")
}
dirents, err := os.ReadDir(c.dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("violationcache: read dir: %w", err)
}
files := violationCacheMatchingFiles(dirents)
violationCacheSortFilesNewestFirst(files)
entries := make([]ViolationCacheEntry, 0, len(files))
for _, name := range files {
path := filepath.Join(c.dir, name)
data, err := os.ReadFile(path)
if err != nil {
continue
}
var rec ViolationCacheRecord
if err := json.Unmarshal(data, &rec); err != nil {
continue
}
if rec.SchemaVersion != ViolationCacheSchemaVersion {
continue
}
entries = append(entries, ViolationCacheEntry{Path: path, Record: rec})
}
return entries, nil
}
// Latest returns the most recent entry, or nil if the cache is empty.
func (c *ViolationCache) Latest() (*ViolationCacheEntry, error) {
entries, err := c.List()
if err != nil {
return nil, err
}
if len(entries) == 0 {
return nil, nil
}
e := entries[0]
return &e, nil
}
func (c *ViolationCache) prune() error {
dirents, err := os.ReadDir(c.dir)
if err != nil {
return err
}
files := violationCacheMatchingFiles(dirents)
if len(files) <= c.retention {
return nil
}
violationCacheSortFilesNewestFirst(files)
var firstErr error
for _, name := range files[c.retention:] {
if err := os.Remove(filepath.Join(c.dir, name)); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func violationCacheMatchingFiles(dirents []os.DirEntry) []string {
files := make([]string, 0, len(dirents))
for _, d := range dirents {
if d.IsDir() {
continue
}
name := d.Name()
if len(name) <= len(violationCacheFilePrefix)+len(violationCacheFileSuffix) {
continue
}
if !strings.HasPrefix(name, violationCacheFilePrefix) || !strings.HasSuffix(name, violationCacheFileSuffix) {
continue
}
files = append(files, name)
}
return files
}
func violationCacheSortFilesNewestFirst(files []string) {
sort.Slice(files, func(i, j int) bool { return files[i] > files[j] })
}
func violationCacheShortID() (string, error) {
var buf [4]byte
if _, err := rand.Read(buf[:]); err != nil {
return "", err
}
return hex.EncodeToString(buf[:]), nil
}
+169
View File
@@ -0,0 +1,169 @@
package sandbox
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func sampleCacheReport(label string) *ViolationReport {
return &ViolationReport{
SandboxName: "seatbelt",
PolicyName: "npm-restrictive",
CorrelationID: "corr-1",
Violations: []Violation{
{Kind: ViolationKindFSRead, Target: "/tmp/x", RuleLabel: label},
},
}
}
func newFixedClock(start time.Time) func() time.Time {
t := start
return func() time.Time {
t = t.Add(time.Millisecond)
return t
}
}
func TestViolationCacheWriteAndList(t *testing.T) {
dir := t.TempDir()
c := NewViolationCache(dir, WithClock(newFixedClock(time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC))))
path, err := c.Write(sampleCacheReport("rule-a"))
require.NoError(t, err)
assert.FileExists(t, path)
entries, err := c.List()
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Equal(t, ViolationCacheSchemaVersion, entries[0].Record.SchemaVersion)
assert.Equal(t, "rule-a", entries[0].Record.Report.Violations[0].RuleLabel)
latest, err := c.Latest()
require.NoError(t, err)
require.NotNil(t, latest)
assert.Equal(t, path, latest.Path)
}
func TestViolationCacheRotation(t *testing.T) {
dir := t.TempDir()
c := NewViolationCache(dir,
WithRetention(10),
WithClock(newFixedClock(time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC))),
)
for i := 0; i < 13; i++ {
_, err := c.Write(sampleCacheReport("rule"))
require.NoError(t, err)
}
entries, err := c.List()
require.NoError(t, err)
assert.Len(t, entries, 10)
dirents, err := os.ReadDir(dir)
require.NoError(t, err)
assert.Len(t, dirents, 10)
latest, err := c.Latest()
require.NoError(t, err)
require.NotNil(t, latest)
names := make([]string, 0, len(entries))
for _, e := range entries {
names = append(names, filepath.Base(e.Path))
}
assert.Equal(t, filepath.Base(latest.Path), names[0])
}
func TestViolationCacheSerializedSchemaVersionPresent(t *testing.T) {
dir := t.TempDir()
c := NewViolationCache(dir)
path, err := c.Write(sampleCacheReport("rule"))
require.NoError(t, err)
data, err := os.ReadFile(path)
require.NoError(t, err)
var raw map[string]any
require.NoError(t, json.Unmarshal(data, &raw))
assert.EqualValues(t, ViolationCacheSchemaVersion, raw["schema_version"])
}
func TestViolationCacheCorruptFileSkipped(t *testing.T) {
dir := t.TempDir()
c := NewViolationCache(dir)
_, err := c.Write(sampleCacheReport("good"))
require.NoError(t, err)
bad := filepath.Join(dir, "violation-bad.json")
require.NoError(t, os.WriteFile(bad, []byte("{not json"), 0o644))
entries, err := c.List()
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Equal(t, "good", entries[0].Record.Report.Violations[0].RuleLabel)
}
func TestViolationCacheWrongSchemaVersionSkipped(t *testing.T) {
dir := t.TempDir()
c := NewViolationCache(dir)
valid := ViolationCacheRecord{
SchemaVersion: ViolationCacheSchemaVersion,
RecordedAt: time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC),
Report: sampleCacheReport("valid"),
}
writeViolationCacheRecord(t, dir, "violation-20260514T100000.000000000Z-valid.json", valid)
missingVersion := valid
missingVersion.SchemaVersion = 0
missingVersion.Report = sampleCacheReport("missing-version")
writeViolationCacheRecord(t, dir, "violation-20260514T100001.000000000Z-missing.json", missingVersion)
futureVersion := valid
futureVersion.SchemaVersion = ViolationCacheSchemaVersion + 1
futureVersion.Report = sampleCacheReport("future-version")
writeViolationCacheRecord(t, dir, "violation-20260514T100002.000000000Z-future.json", futureVersion)
entries, err := c.List()
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Equal(t, "valid", entries[0].Record.Report.Violations[0].RuleLabel)
latest, err := c.Latest()
require.NoError(t, err)
require.NotNil(t, latest)
assert.Equal(t, "valid", latest.Record.Report.Violations[0].RuleLabel)
}
func TestViolationCacheWriteNilReport(t *testing.T) {
c := NewViolationCache(t.TempDir())
_, err := c.Write(nil)
assert.Error(t, err)
}
func TestViolationCacheListEmptyMissingDir(t *testing.T) {
c := NewViolationCache(filepath.Join(t.TempDir(), "missing"))
entries, err := c.List()
require.NoError(t, err)
assert.Empty(t, entries)
latest, err := c.Latest()
require.NoError(t, err)
assert.Nil(t, latest)
}
func writeViolationCacheRecord(t *testing.T, dir, name string, rec ViolationCacheRecord) {
t.Helper()
data, err := json.Marshal(rec)
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(dir, name), data, 0o644))
}
+186
View File
@@ -0,0 +1,186 @@
package sandbox
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOverrideSuggestionSkipsGlobRuleTarget(t *testing.T) {
assert.Nil(t, overrideSuggestion(Violation{
Kind: ViolationKindFSRead,
Target: "**/.env",
}))
}
func TestOverrideSuggestionUsesConcretePath(t *testing.T) {
o := overrideSuggestion(Violation{
Kind: ViolationKindFSRead,
Target: "./.env",
})
require.NotNil(t, o)
assert.Equal(t, ViolationKindFSRead, o.Kind)
assert.Equal(t, "./.env", o.Target)
}
func TestOverrideSuggestionPreservesRawTargetCharacters(t *testing.T) {
// Shell escaping is the presentation layer's job — the domain layer
// returns the raw target verbatim, special characters included.
o := overrideSuggestion(Violation{
Kind: ViolationKindFSRead,
Target: "/tmp/My Dir/it's.env",
})
require.NotNil(t, o)
assert.Equal(t, "/tmp/My Dir/it's.env", o.Target)
}
func TestOverrideSuggestionSkipsControlCharacters(t *testing.T) {
assert.Nil(t, overrideSuggestion(Violation{
Kind: ViolationKindFSRead,
Target: "/tmp/bad\npath",
}))
}
func TestOverrideSuggestionMapsAllSupportedKinds(t *testing.T) {
tests := []struct {
kind ViolationKind
want bool
}{
{ViolationKindFSRead, true},
{ViolationKindFSWrite, true},
{ViolationKindFSDeleteOrRename, true},
{ViolationKindExec, true},
{ViolationKindGenericDeny, false},
}
for _, tt := range tests {
t.Run(string(tt.kind), func(t *testing.T) {
o := overrideSuggestion(Violation{Kind: tt.kind, Target: "/tmp/x"})
if tt.want {
require.NotNil(t, o)
assert.Equal(t, tt.kind, o.Kind)
} else {
assert.Nil(t, o)
}
})
}
}
func TestPrimaryViolationPrefersConcreteProjectPathOverDefaultNoise(t *testing.T) {
cwd, err := os.Getwd()
require.NoError(t, err)
report := &ViolationReport{
SandboxName: DriverSeatbelt,
Violations: []Violation{
{
Kind: ViolationKindGenericDeny,
RawKind: "default",
Target: "/dev/dtracehelper",
RuleLabel: "sandbox denied access to /dev/dtracehelper",
},
{
Kind: ViolationKindFSRead,
RawKind: "file-read",
Target: filepath.Join(cwd, ".env"),
RuleTarget: "**/.env",
RuleLabel: "read access denied: " + filepath.Join(cwd, ".env"),
},
},
}
primary := primaryViolation(report)
require.NotNil(t, primary)
assert.Equal(t, ViolationKindFSRead, primary.Kind)
assert.Equal(t, filepath.Join(cwd, ".env"), primary.Target)
}
func TestPrimaryViolationPrefersLaterViolationOnScoreTie(t *testing.T) {
report := &ViolationReport{
SandboxName: DriverSeatbelt,
Violations: []Violation{
{
Kind: ViolationKindExec,
Target: "/tmp/first-bin",
RuleLabel: "exec denied: /tmp/first-bin",
},
{
Kind: ViolationKindExec,
Target: "/tmp/second-bin",
RuleLabel: "exec denied: /tmp/second-bin",
},
},
}
primary := primaryViolation(report)
require.NotNil(t, primary)
assert.Equal(t, "/tmp/second-bin", primary.Target)
}
func TestIsProjectPathRejectsParentRelativeTargets(t *testing.T) {
cwd, err := os.Getwd()
require.NoError(t, err)
tests := []struct {
name string
target string
want bool
}{
{name: "dot", target: ".", want: true},
{name: "dot slash", target: "./.env", want: true},
{name: "dotfile", target: ".env", want: true},
{name: "parent", target: "..", want: false},
{name: "parent slash", target: "../.env", want: false},
{name: "nested parent", target: "../../etc/passwd", want: false},
{name: "dot slash parent", target: "./../../etc/passwd", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, isProjectPath(tt.target, cwd))
})
}
}
func TestIsSensitiveProjectFileRejectsParentRelativeTargets(t *testing.T) {
assert.False(t, isSensitiveProjectFile("../.env"))
assert.False(t, isSensitiveProjectFile("../../.ssh/config"))
assert.True(t, isSensitiveProjectFile("./.env"))
assert.True(t, isSensitiveProjectFile("./.ssh/config"))
}
func TestBuildExplanationStructuredOutput(t *testing.T) {
exp := BuildExplanation(&ViolationReport{
SandboxName: "seatbelt",
PolicyName: "npm-restrictive",
CorrelationID: "run-1",
Violations: []Violation{
{
Kind: ViolationKindFSRead,
Target: "./.env",
RuleLabel: "read access denied: ./.env",
},
{
Kind: ViolationKindFSWrite,
Target: "./out.log",
RuleLabel: "write access denied: ./out.log",
},
},
})
require.NotNil(t, exp.Primary)
assert.Equal(t, ViolationKindFSRead, exp.Primary.Kind)
require.NotNil(t, exp.Override)
assert.Equal(t, ViolationKindFSRead, exp.Override.Kind)
assert.Equal(t, "./.env", exp.Override.Target)
assert.Equal(t, 1, exp.AdditionalDenials)
}
func TestBuildExplanationEmptyReport(t *testing.T) {
exp := BuildExplanation(&ViolationReport{})
assert.Nil(t, exp.Primary)
assert.Nil(t, exp.Override)
assert.Equal(t, 0, exp.AdditionalDenials)
}