From 648adcbda44d9981e52f86dd29f19b9dcfdd46a4 Mon Sep 17 00:00:00 2001 From: Abhisek Datta Date: Thu, 2 Jul 2026 18:49:31 +0530 Subject: [PATCH] feat: add uvx (uv tool run) package executor (#357) * feat(uvx): add uvx (uv tool run) package executor Adds support for `uvx`, implemented as a PyPI Executor alongside pipx. uvx is an alias for `uv tool run`: it installs a tool into an ephemeral environment and runs it, so it has no install/list subcommand and the first positional argument (or --from) is the package to audit. Parsing highlights: - --from overrides the positional command as the package to audit - --with packages are audited as additional environment dependencies - name@version shorthand (ruff@0.3.0, ruff@latest) is normalized - flag parsing stops at the tool name so the tool's own flags are not misread as uvx options; uvx's value/boolean flags are registered so none greedily consume the package positional - VCS/URL/local-path specs are skipped for registry auditing Wires up command registration, analytics, shell alias/shim, cloud audit mapping, a dedicated `uvx` sandbox profile (UV_*/PIP_* env, uv cache and tool dirs), config policy, docs, unit tests and an E2E workflow step. Closes #326 https://claude.ai/code/session_011hyLxq7oWJX5Dp4tCEfG19 * chore(uvx): align docs and base profile with uvx support Incorporates the low-risk, non-parser improvements from the community PR #345 (author non-responsive) into our implementation: - list uvx (and the previously-missing pipx) as PyPI managers in the pypi-restrictive base profile package_managers and its README, so the base profile applies directly when selected via --sandbox-profile - document uvx in docs/github-action.md and docs/proxy-mode.md - add version / IsExplicitVersion assertions to the uvx parser tests Our pflag-based parser is kept as-is: unlike #345 it audits --with packages and handles all uvx short flags (e.g. -w), both of which the community PR misses. * fix(uvx): skip interpreter requests; use require in tests Addresses review feedback on PR #357: - uvx interpreter requests (`uvx python`, `uvx python@3.12`, `uvx pypy`, ...) launch an isolated interpreter rather than installing a PyPI tool. Treating the positional as a package made the guard flow resolve/analyze pkg:pypi/python (and python==3.12), which could wrongly block or fail a valid invocation. Skip these for the positional; --with packages on the same command are still audited. - Use require.NoError / require.Len for fatal assertions in the uvx tests, matching the repo's testing convention, so a failure stops the subtest before a nil dereference instead of panicking. * docs(uvx): document fail-open and --with-requirements trade-offs Record the two deliberate parsing decisions raised in review as in-code trade-off comments (no behavior change): - unknown flags are tolerated (fail open), consistent with the other executors; the residual gap only affects non-proxy guard mode since the default proxy flow intercepts every registry download. - --with-requirements / --with-editable values are consumed but not expanded into audit targets; expanding them needs manifest-extractor and guard changes, tracked as follow-up. Proxy mode still covers them. * docs(uvx): drop --with-requirements limitation note Per maintainer review: guard mode is being deprecated and auditing the contents of an existing requirements file is a scanner's responsibility, not PMG's. Remove the "known limitation / follow-up" note; the flags stay registered only so their values are not mistaken for the tool positional. --------- Co-authored-by: Claude --- .github/workflows/pmg-e2e.yml | 29 +- README.md | 1 + cmd/executors/uvx.go | 61 +++++ config/config.go | 1 + config/config.template.yml | 4 + config/sandbox_policy_for_test.go | 7 + docs/github-action.md | 2 +- docs/proxy-mode.md | 1 + docs/sandbox.md | 2 +- internal/alias/alias.go | 2 +- internal/analytics/event.go | 5 + internal/audit/cloud_translate.go | 2 +- internal/audit/cloud_translate_test.go | 1 + main.go | 1 + packagemanager/pypi_executor.go | 13 +- packagemanager/pypi_uvx_executor.go | 204 ++++++++++++++ packagemanager/pypi_uvx_executor_test.go | 329 +++++++++++++++++++++++ sandbox/profiles/README.md | 2 +- sandbox/profiles/pypi-restrictive.yml | 1 + sandbox/profiles/uvx.yml | 55 ++++ sandbox/profiles_env_test.go | 1 + 21 files changed, 717 insertions(+), 7 deletions(-) create mode 100644 cmd/executors/uvx.go create mode 100644 packagemanager/pypi_uvx_executor.go create mode 100644 packagemanager/pypi_uvx_executor_test.go create mode 100644 sandbox/profiles/uvx.yml diff --git a/.github/workflows/pmg-e2e.yml b/.github/workflows/pmg-e2e.yml index 11277f6..7d223cf 100644 --- a/.github/workflows/pmg-e2e.yml +++ b/.github/workflows/pmg-e2e.yml @@ -83,7 +83,7 @@ jobs: run: | test -f $HOME/.pmg.rc test -d $HOME/.pmg/bin - for shim in npm pip pip3 pnpm bun uv yarn poetry npx pnpx; do + for shim in npm pip pip3 pnpm bun uv uvx yarn poetry npx pnpx; do test -x $HOME/.pmg/bin/$shim || { echo "Missing shim: $shim"; exit 1; } done @@ -416,6 +416,33 @@ jobs: cd - && rm -rf "$PNPX_TESTDIR" + - name: Test UVX - Package Execution + run: | + echo "Testing UVX package execution (uv tool run)..." + UVX_TESTDIR=$(mktemp -d) && cd "$UVX_TESTDIR" + + echo "Testing uvx with a simple tool in proxy mode (default)..." + pmg uvx ruff@0.6.9 --version | tee uvx-output.txt + # Verification: pinned version is resolved and executed via the proxy + grep -q "0.6.9" uvx-output.txt + + echo "Testing uvx version pin via @ syntax (non-proxy)..." + pmg --proxy-mode=false uvx ruff@0.6.9 --version | tee uvx-pin-output.txt + grep -q "0.6.9" uvx-pin-output.txt + + echo "Testing uvx with --from (command name differs from package)..." + pmg --proxy-mode=false uvx --from cowsay cowsay -t "Hello from pmg uvx" | tee uvx-from-output.txt + # Verification: cowsay output contains our message and cow art + grep -q "Hello from pmg uvx" uvx-from-output.txt + grep -q '\^__\^' uvx-from-output.txt + + echo "Testing uvx dry-run mode..." + pmg --proxy-mode=false --dry-run uvx --from cowsay cowsay -t "This should not execute" | tee uvx-dry-output.txt + # Verification: dry-run should NOT produce cowsay ASCII art (cow face ^__^) + ! grep -q '\^__\^' uvx-dry-output.txt + + cd - && rm -rf "$UVX_TESTDIR" + - name: Test Pip - Single Package & Manifest run: | echo "Testing Pip single package installation..." diff --git a/README.md b/README.md index 0ae87a8..e54ec0b 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,7 @@ PMG supports the tools you already use: | | `pipx` | `pipx run ` | | | `poetry` | `poetry add ` | | | `uv` | `uv add ` | +| | `uvx` | `uvx ` | ## Installation diff --git a/cmd/executors/uvx.go b/cmd/executors/uvx.go new file mode 100644 index 0000000..e2138fe --- /dev/null +++ b/cmd/executors/uvx.go @@ -0,0 +1,61 @@ +package executors + +import ( + "context" + "fmt" + + "github.com/safedep/pmg/config" + "github.com/safedep/pmg/internal/analytics" + "github.com/safedep/pmg/internal/flows" + "github.com/safedep/pmg/internal/ui" + "github.com/safedep/pmg/packagemanager" + "github.com/spf13/cobra" +) + +func NewUvxCommand() *cobra.Command { + return &cobra.Command{ + Use: "uvx [package] [args]", + Short: "Guard uvx package executor", + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + err := executeUvxFlow(cmd.Context(), args) + if err != nil { + ui.ExitFromCommandError(err) + } + + return nil + }, + } +} + +func executeUvxFlow(ctx context.Context, args []string) error { + analytics.TrackCommandUvx() + + packageExecutor, err := packagemanager.NewPypiPackageExecutor(packagemanager.DefaultUvxPackageExecutorConfig()) + if err != nil { + return fmt.Errorf("failed to create uvx package executor proxy: %w", err) + } + + config := config.Get() + parsedCommand, err := packageExecutor.ParseCommand(args) + if err != nil { + return fmt.Errorf("failed to parse command: %w", err) + } + + packageResolverConfig := packagemanager.NewDefaultPypiDependencyResolverConfig() + packageResolverConfig.IncludeTransitiveDependencies = config.Config.Transitive + packageResolverConfig.TransitiveDepth = config.Config.TransitiveDepth + packageResolverConfig.IncludeDevDependencies = config.Config.IncludeDevDependencies + packageResolverConfig.PackageInstallTargets = parsedCommand.InstallTargets + + packageResolver, err := packagemanager.NewPypiDependencyResolver(packageResolverConfig) + if err != nil { + return fmt.Errorf("failed to create dependency resolver: %w", err) + } + + if !config.IsProxyModeEnabled() { + return flows.Common(packageExecutor, packageResolver).Run(ctx, args, parsedCommand) + } + + return flows.ProxyFlow(packageExecutor, packageResolver).Run(ctx, args, parsedCommand) +} diff --git a/config/config.go b/config/config.go index 1b552f1..15a92b3 100644 --- a/config/config.go +++ b/config/config.go @@ -240,6 +240,7 @@ var legacyProfileAliases = map[string]map[string]string{ "pipx": "pipx", "poetry": "poetry", "uv": "uv", + "uvx": "uvx", }, } diff --git a/config/config.template.yml b/config/config.template.yml index c45e612..069a78b 100644 --- a/config/config.template.yml +++ b/config/config.template.yml @@ -174,6 +174,10 @@ sandbox: enabled: true profile: uv + uvx: + enabled: true + profile: uvx + # Dependency cooldown blocks installation of package versions published within # a configurable time window. dependency_cooldown: diff --git a/config/sandbox_policy_for_test.go b/config/sandbox_policy_for_test.go index 6f2f7b0..e39e825 100644 --- a/config/sandbox_policy_for_test.go +++ b/config/sandbox_policy_for_test.go @@ -92,6 +92,13 @@ func TestSandboxConfigPolicyFor(t *testing.T) { wantProfile: "uv", wantExists: true, }, + { + name: "legacy pypi-restrictive re-mapped for uvx", + policies: map[string]SandboxPolicyRef{"uvx": {Enabled: true, Profile: "pypi-restrictive"}}, + pmName: "uvx", + wantProfile: "uvx", + wantExists: true, + }, { name: "template override disables re-mapping", policies: map[string]SandboxPolicyRef{"npm": {Enabled: true, Profile: "npm-restrictive"}}, diff --git a/docs/github-action.md b/docs/github-action.md index bade02f..12f91b3 100644 --- a/docs/github-action.md +++ b/docs/github-action.md @@ -12,7 +12,7 @@ That's it. Out of the box you get: - malware blocking against [SafeDep's real-time threat intelligence](https://docs.safedep.io/cloud/malware-analysis) - a 5-day dependency cooldown (blocks freshly-published versions) -- proxy-based interception of npm/pip/pnpm/yarn/bun/poetry/uv/npx/pnpx +- proxy-based interception of npm/pip/pnpm/yarn/bun/poetry/uv/uvx/npx/pnpx ## Quick start diff --git a/docs/proxy-mode.md b/docs/proxy-mode.md index 54e8f94..c7526b7 100644 --- a/docs/proxy-mode.md +++ b/docs/proxy-mode.md @@ -67,6 +67,7 @@ Legacy variables `PMG_PROXY_MODE` and `PMG_PROXY_INSTALL_ONLY` (for the old flat | `yarn` | ✅ | | `pip` | ✅ | | `uv` | ✅ | +| `uvx` | ✅ | | `poetry` | ✅ | ## References diff --git a/docs/sandbox.md b/docs/sandbox.md index 1d0d998..6c5ca1c 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -63,7 +63,7 @@ see what was removed. The shared base profiles (`npm-restrictive`, `pypi-restrictive`) allow no environment variables. Each package manager's leaf profile (`npm`, `yarn`, `bun`, `pnpm`, `npx`, `pip`, `pipx`, `uv`, -`poetry`) re-allows only the variables that package manager legitimately needs via an +`uvx`, `poetry`) re-allows only the variables that package manager legitimately needs via an `environment.allow` block, so package managers keep working: ```yaml diff --git a/internal/alias/alias.go b/internal/alias/alias.go index 4a4a4b9..86292a4 100644 --- a/internal/alias/alias.go +++ b/internal/alias/alias.go @@ -97,7 +97,7 @@ func DefaultConfig() AliasConfig { return AliasConfig{ RcFileName: ".pmg.rc", - PackageManagers: []string{"npm", "pip", "pip3", "pipx", "pnpm", "bun", "uv", "yarn", "poetry", "npx", "pnpx"}, + PackageManagers: []string{"npm", "pip", "pip3", "pipx", "pnpm", "bun", "uv", "uvx", "yarn", "poetry", "npx", "pnpx"}, Shells: shells, } } diff --git a/internal/analytics/event.go b/internal/analytics/event.go index 7f881ae..35883ea 100644 --- a/internal/analytics/event.go +++ b/internal/analytics/event.go @@ -11,6 +11,7 @@ const ( eventCommandUv = "pmg_command_uv" eventCommandPoetry = "pmg_command_poetry" eventCommandPipx = "pmg_command_pipx" + eventCommandUvx = "pmg_command_uvx" eventCommandNpx = "pmg_command_npx" eventCommandPnpx = "pmg_command_pnpx" @@ -68,6 +69,10 @@ func TrackCommandPipx() { TrackEvent(eventCommandPipx) } +func TrackCommandUvx() { + TrackEvent(eventCommandUvx) +} + func TrackCommandGenerateEnvDocker() { TrackEvent(eventPmgGenerateEnvDocker) } diff --git a/internal/audit/cloud_translate.go b/internal/audit/cloud_translate.go index fa04bce..c5dd4d8 100644 --- a/internal/audit/cloud_translate.go +++ b/internal/audit/cloud_translate.go @@ -201,7 +201,7 @@ func mapPackageManager(name string) controltowerv1.PmgPackageManager { return controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_PIP case "poetry": return controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_POETRY - case "uv": + case "uv", "uvx": return controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_UV default: return controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_UNSPECIFIED diff --git a/internal/audit/cloud_translate_test.go b/internal/audit/cloud_translate_test.go index 55d1b0f..a096de7 100644 --- a/internal/audit/cloud_translate_test.go +++ b/internal/audit/cloud_translate_test.go @@ -249,6 +249,7 @@ func TestMapPackageManager(t *testing.T) { {"pipx", "pipx", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_PIP}, {"poetry", "poetry", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_POETRY}, {"uv", "uv", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_UV}, + {"uvx", "uvx", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_UV}, {"unknown", "cargo", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_UNSPECIFIED}, {"empty", "", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_UNSPECIFIED}, } diff --git a/main.go b/main.go index b183c5b..8550a12 100644 --- a/main.go +++ b/main.go @@ -160,6 +160,7 @@ func main() { cmd.AddCommand(pypi.NewUvCommand()) cmd.AddCommand(pypi.NewPoetryCommand()) cmd.AddCommand(executors.NewPipxCommand()) + cmd.AddCommand(executors.NewUvxCommand()) cmd.AddCommand(proxyCmd.NewProxyCommand()) cmd.AddCommand(version.NewVersionCommand()) cmd.AddCommand(setup.NewSetupCommand()) diff --git a/packagemanager/pypi_executor.go b/packagemanager/pypi_executor.go index eb66e5d..7d3eba1 100644 --- a/packagemanager/pypi_executor.go +++ b/packagemanager/pypi_executor.go @@ -12,6 +12,11 @@ type PypiPackageExecutorConfig struct { CommandName string InstallCommands []string NonDownloadCommands []string + + // ImplicitRun marks executors that always run a tool with no install + // subcommand (uvx, i.e. `uv tool run`). For these the first positional + // argument (or --from) is the package to audit. See parseUvxCommand. + ImplicitRun bool } func DefaultPipxPackageExecutorConfig() PypiPackageExecutorConfig { @@ -45,11 +50,17 @@ func (p *pypiPackageExecutor) Ecosystem() packagev1.Ecosystem { } func (p *pypiPackageExecutor) ParseCommand(args []string) (*ParsedCommand, error) { - if len(args) > 0 && args[0] == "pipx" { + if len(args) > 0 && args[0] == p.Config.CommandName { args = args[1:] } command := Command{Exe: p.Config.CommandName, Args: args} + + // uvx (uv tool run) has no install subcommand; the tool is always run. + if p.Config.ImplicitRun { + return p.parseUvxCommand(command, args) + } + if len(args) < 1 { return &ParsedCommand{Command: command}, nil } diff --git a/packagemanager/pypi_uvx_executor.go b/packagemanager/pypi_uvx_executor.go new file mode 100644 index 0000000..8df4a1a --- /dev/null +++ b/packagemanager/pypi_uvx_executor.go @@ -0,0 +1,204 @@ +package packagemanager + +import ( + "io" + "regexp" + "strings" + + "github.com/safedep/dry/log" + "github.com/spf13/pflag" +) + +// uvxInterpreterRequestRe matches the interpreter requests uv understands as a +// tool command (python, python3, python3.12, pypy, cpython, graalpy, ...). uv +// launches an isolated interpreter for these instead of installing a PyPI +// package, so there is nothing to audit. +var uvxInterpreterRequestRe = regexp.MustCompile(`^(python|cpython|pypy|graalpy)(\d+(\.\d+)?)?$`) + +// DefaultUvxPackageExecutorConfig returns the config for the uvx executor. +// uvx is an alias for `uv tool run`: it installs a tool into an ephemeral +// environment and runs it. It shares the PyPI executor machinery but parses +// commands differently (there is no install/list subcommand). +func DefaultUvxPackageExecutorConfig() PypiPackageExecutorConfig { + return PypiPackageExecutorConfig{ + CommandName: "uvx", + ImplicitRun: true, + } +} + +// parseUvxCommand handles `uvx [flags] [args...]`. +// +// uvx always runs a tool, so the package(s) to audit are: +// - the --from value when provided. In that case the positional +// is just the executable name within that package, not a package +// to audit (e.g. `uvx --from httpie http`). +// - otherwise the first positional argument, since the command name doubles +// as the package name (e.g. `uvx ruff`). +// - plus any --with values, which are extra packages added to the +// ephemeral environment. +func (p *pypiPackageExecutor) parseUvxCommand(command Command, args []string) (*ParsedCommand, error) { + if len(args) == 0 { + return &ParsedCommand{Command: command}, nil + } + + flagSet := pflag.NewFlagSet("uvx", pflag.ContinueOnError) + // Tolerate unknown flags (fail open) rather than refusing to run, matching + // the pipx/pip/uv executors. uv adds flags frequently; failing closed on an + // unrecognized flag would break otherwise-valid uvx invocations after a uv + // upgrade. The residual gap — a future value-taking flag consuming the tool + // positional and yielding no audit target — only affects non-proxy guard + // mode; the default proxy flow still intercepts every registry download. + flagSet.ParseErrorsAllowlist.UnknownFlags = true + flagSet.SetOutput(io.Discard) + + // uvx only accepts options before the tool name; everything after the tool + // is passed through to it. Stopping at the first positional ensures a tool's + // own flags (e.g. `uvx ruff --fix` or `uvx mytool --with x`) are never parsed + // as uvx options. + flagSet.SetInterspersed(false) + + fromSpec, withSpecs := setupUvxFlags(flagSet) + + if err := flagSet.Parse(args); err != nil { + return &ParsedCommand{Command: command}, nil + } + + var specs []string + if *fromSpec != "" { + specs = append(specs, *fromSpec) + } else if positional := flagSet.Args(); len(positional) > 0 && !uvxIsInterpreterRequest(positional[0]) { + // `uvx python`, `uvx python@3.12`, `uvx pypy` etc. launch an isolated + // interpreter rather than installing a PyPI tool, so there is nothing to + // audit for the positional. --with packages are still audited below. + specs = append(specs, positional[0]) + } + specs = append(specs, *withSpecs...) + + return p.buildUvxInstallTargets(command, specs) +} + +// buildUvxInstallTargets normalizes uvx specifiers and builds audit targets, +// skipping specs that cannot be resolved against the PyPI registry. +func (p *pypiPackageExecutor) buildUvxInstallTargets(command Command, specs []string) (*ParsedCommand, error) { + normalized := make([]string, 0, len(specs)) + for _, spec := range specs { + if !uvxIsAuditableSpec(spec) { + log.Debugf("uvx: skipping non-registry spec %q for audit", spec) + continue + } + normalized = append(normalized, uvxNormalizeSpec(spec)) + } + + return p.buildInstallTargets(command, normalized) +} + +// uvxNormalizeSpec converts uvx's `name@version` shorthand (e.g. ruff@0.3.0, +// ruff@latest) into a standard PEP 508 specifier so the shared PyPI parser can +// extract the name and version. `@latest` (or a bare `@`) means no constraint. +// It is only called for registry specs (see uvxIsAuditableSpec), so the `@` is +// always the version separator and never part of a URL. +func uvxNormalizeSpec(spec string) string { + at := strings.Index(spec, "@") + if at == -1 { + return spec + } + + name, version := spec[:at], spec[at+1:] + if version == "" || version == "latest" { + return name + } + + // Keep an explicit operator (e.g. ruff@>=0.3.0); otherwise pin exactly. + if strings.ContainsAny(version[:1], "=<>~!") { + return name + version + } + + return name + "==" + version +} + +// uvxIsInterpreterRequest reports whether a uvx positional command is an +// interpreter request (e.g. `python`, `python@3.12`, `python3.11`, `pypy`) +// rather than a PyPI tool. The version suffix (`@...`) is ignored for matching. +func uvxIsInterpreterRequest(spec string) bool { + name := spec + if at := strings.Index(name, "@"); at != -1 { + name = name[:at] + } + + return uvxInterpreterRequestRe.MatchString(name) +} + +// uvxIsAuditableSpec reports whether a uvx specifier can be resolved against the +// PyPI registry. VCS, URL and local-path specifiers cannot, so we skip auditing +// them here; the proxy still guards any registry traffic they trigger. +func uvxIsAuditableSpec(spec string) bool { + if spec == "" { + return false + } + + if strings.Contains(spec, "://") || strings.HasPrefix(spec, "git+") || strings.HasPrefix(spec, "file:") { + return false + } + + if strings.HasPrefix(spec, ".") || strings.HasPrefix(spec, "~") || strings.Contains(spec, "/") { + return false + } + + for _, ext := range []string{".whl", ".tar.gz", ".tar.bz2", ".zip"} { + if strings.HasSuffix(spec, ext) { + return false + } + } + + return true +} + +// setupUvxFlags registers uvx's options on flagSet and returns the --from and +// --with values. Every value-taking option (e.g. --with-requirements, +// --with-editable, --python) is registered so its value is never mistaken for +// the tool positional, and every boolean option is registered so it does not +// greedily consume the following argument (pflag treats an unknown flag's next +// token as its value). The set mirrors `uvx --help`; unrecognized future flags +// are tolerated via the UnknownFlags allowlist. +func setupUvxFlags(flagSet *pflag.FlagSet) (fromSpec *string, withSpecs *[]string) { + fromSpec = flagSet.String("from", "", "") + withSpecs = flagSet.StringArrayP("with", "w", nil, "") + + stringFlags := []struct{ name, short string }{ + {"with-editable", ""}, {"with-requirements", ""}, {"python-platform", ""}, + {"default-index", ""}, {"index-url", "i"}, {"index-strategy", ""}, + {"keyring-provider", ""}, {"resolution", ""}, {"prerelease", ""}, + {"fork-strategy", ""}, {"exclude-newer", ""}, {"link-mode", ""}, + {"cache-dir", ""}, {"python", "p"}, {"color", ""}, {"directory", ""}, + {"project", ""}, {"config-file", ""}, + } + for _, f := range stringFlags { + flagSet.StringP(f.name, f.short, "", "") + } + + arrayFlags := []struct{ name, short string }{ + {"constraints", "c"}, {"build-constraints", "b"}, {"overrides", ""}, + {"env-file", ""}, {"index", ""}, {"extra-index-url", ""}, {"find-links", "f"}, + {"upgrade-package", "P"}, {"exclude-newer-package", ""}, {"reinstall-package", ""}, + {"config-setting", "C"}, {"config-settings-package", ""}, + {"no-build-isolation-package", ""}, {"no-build-package", ""}, + {"no-binary-package", ""}, {"refresh-package", ""}, {"allow-insecure-host", ""}, + } + for _, f := range arrayFlags { + flagSet.StringArrayP(f.name, f.short, nil, "") + } + + boolFlags := []struct{ name, short string }{ + {"isolated", ""}, {"no-env-file", ""}, {"version", "V"}, {"no-index", ""}, + {"upgrade", "U"}, {"no-sources", ""}, {"reinstall", ""}, {"compile-bytecode", ""}, + {"no-build-isolation", ""}, {"no-build", ""}, {"no-binary", ""}, {"no-cache", "n"}, + {"refresh", ""}, {"managed-python", ""}, {"no-managed-python", ""}, + {"no-python-downloads", ""}, {"quiet", "q"}, {"verbose", "v"}, {"native-tls", ""}, + {"offline", ""}, {"no-progress", ""}, {"no-config", ""}, {"help", "h"}, + } + for _, f := range boolFlags { + flagSet.BoolP(f.name, f.short, false, "") + } + + return fromSpec, withSpecs +} diff --git a/packagemanager/pypi_uvx_executor_test.go b/packagemanager/pypi_uvx_executor_test.go new file mode 100644 index 0000000..5c70ae7 --- /dev/null +++ b/packagemanager/pypi_uvx_executor_test.go @@ -0,0 +1,329 @@ +package packagemanager + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUvxExecutorParseCommand(t *testing.T) { + pm, err := NewPypiPackageExecutor(DefaultUvxPackageExecutorConfig()) + require.NoError(t, err) + + cases := []struct { + name string + args []string + expectedTargets int + expectedPackages []string + }{ + { + name: "simple tool", + args: []string{"ruff"}, + expectedTargets: 1, + expectedPackages: []string{"ruff"}, + }, + { + name: "tool with its own args is not a package", + args: []string{"ruff", "check", "."}, + expectedTargets: 1, + expectedPackages: []string{"ruff"}, + }, + { + name: "tool with its own flags is not parsed as uvx flags", + args: []string{"ruff", "--fix", "--no-cache"}, + expectedTargets: 1, + expectedPackages: []string{"ruff"}, + }, + { + name: "version pin via @ syntax", + args: []string{"ruff@0.3.0"}, + expectedTargets: 1, + expectedPackages: []string{"ruff"}, + }, + { + name: "version @latest drops constraint", + args: []string{"ruff@latest"}, + expectedTargets: 1, + expectedPackages: []string{"ruff"}, + }, + { + name: "version pin via == specifier", + args: []string{"ruff==0.3.0"}, + expectedTargets: 1, + expectedPackages: []string{"ruff"}, + }, + { + name: "extras with version", + args: []string{"mypy[faster-cache]@1.0.0"}, + expectedTargets: 1, + expectedPackages: []string{"mypy"}, + }, + { + name: "--from overrides positional command", + args: []string{"--from", "httpie", "http"}, + expectedTargets: 1, + expectedPackages: []string{"httpie"}, + }, + { + name: "--from with version and positional command", + args: []string{"--from", "ruff==0.3.0", "ruff", "--check", "."}, + expectedTargets: 1, + expectedPackages: []string{"ruff"}, + }, + { + name: "--from with @ version", + args: []string{"--from", "ruff@0.3.0", "ruff"}, + expectedTargets: 1, + expectedPackages: []string{"ruff"}, + }, + { + name: "--with adds extra packages", + args: []string{"--with", "rich", "mkdocs"}, + expectedTargets: 2, + expectedPackages: []string{"mkdocs", "rich"}, + }, + { + name: "repeated --with collects all", + args: []string{"--with", "rich", "--with", "pygments", "mkdocs"}, + expectedTargets: 3, + expectedPackages: []string{"mkdocs", "rich", "pygments"}, + }, + { + name: "--with shorthand -w", + args: []string{"-w", "rich", "mkdocs"}, + expectedTargets: 2, + expectedPackages: []string{"mkdocs", "rich"}, + }, + { + name: "--from combined with --with", + args: []string{"--from", "mkdocs-material", "--with", "mkdocs", "mkdocs"}, + expectedTargets: 2, + expectedPackages: []string{"mkdocs-material", "mkdocs"}, + }, + { + name: "value flag does not consume the package", + args: []string{"--python", "3.12", "ruff"}, + expectedTargets: 1, + expectedPackages: []string{"ruff"}, + }, + { + name: "boolean flag does not consume the package", + args: []string{"--isolated", "ruff"}, + expectedTargets: 1, + expectedPackages: []string{"ruff"}, + }, + { + name: "reinstall boolean does not consume the package", + args: []string{"--reinstall", "ruff"}, + expectedTargets: 1, + expectedPackages: []string{"ruff"}, + }, + { + name: "no-cache shorthand does not consume the package", + args: []string{"-n", "ruff"}, + expectedTargets: 1, + expectedPackages: []string{"ruff"}, + }, + { + name: "index url value flag", + args: []string{"--index-url", "https://example.com/simple", "ruff"}, + expectedTargets: 1, + expectedPackages: []string{"ruff"}, + }, + { + name: "git+ spec is skipped", + args: []string{"--from", "git+https://github.com/foo/bar@v1", "bar"}, + expectedTargets: 0, + }, + { + name: "url spec is skipped", + args: []string{"https://example.com/pkg.whl"}, + expectedTargets: 0, + }, + { + name: "local path spec is skipped", + args: []string{"./local-tool"}, + expectedTargets: 0, + }, + { + name: "python interpreter request is not audited", + args: []string{"python", "-c", "print(1)"}, + expectedTargets: 0, + }, + { + name: "versioned python interpreter request is not audited", + args: []string{"python@3.12", "script.py"}, + expectedTargets: 0, + }, + { + name: "pythonX.Y interpreter request is not audited", + args: []string{"python3.11"}, + expectedTargets: 0, + }, + { + name: "python interpreter with --with still audits the extra package", + args: []string{"--with", "rich", "python"}, + expectedTargets: 1, + expectedPackages: []string{"rich"}, + }, + { + name: "tool with python-like prefix is still audited", + args: []string{"python-dotenv"}, + expectedTargets: 1, + expectedPackages: []string{"python-dotenv"}, + }, + { + name: "bare uvx invocation", + args: []string{}, + expectedTargets: 0, + }, + { + name: "uvx prefix is stripped", + args: []string{"uvx", "ruff"}, + expectedTargets: 1, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result, err := pm.ParseCommand(tc.args) + require.NoError(t, err) + assert.Equal(t, tc.expectedTargets, len(result.InstallTargets), "number of install targets mismatch") + + for i, expectedPkg := range tc.expectedPackages { + if i < len(result.InstallTargets) { + assert.Equal(t, expectedPkg, result.InstallTargets[i].PackageVersion.Package.Name, "package name mismatch for target %d", i) + } + } + }) + } +} + +// TestUvxExecutorParseCommandVersions pins the resolved name/version and the +// explicit-version flag for pinned specs. These cases use exact versions so no +// registry lookup is needed. +func TestUvxExecutorParseCommandVersions(t *testing.T) { + pm, err := NewPypiPackageExecutor(DefaultUvxPackageExecutorConfig()) + require.NoError(t, err) + + cases := []struct { + name string + args []string + expectedName string + expectedVersion string + }{ + { + name: "positional @ version", + args: []string{"ruff@0.3.0", "check"}, + expectedName: "ruff", + expectedVersion: "0.3.0", + }, + { + name: "positional == version", + args: []string{"ruff==0.3.0"}, + expectedName: "ruff", + expectedVersion: "0.3.0", + }, + { + name: "--from with == version", + args: []string{"--from", "httpie==3.2.2", "http"}, + expectedName: "httpie", + expectedVersion: "3.2.2", + }, + { + name: "--from= with extras and version", + args: []string{"--from=mypy[faster-cache,reports]==1.13.0", "mypy", "--xml-report", "report"}, + expectedName: "mypy", + expectedVersion: "1.13.0", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result, err := pm.ParseCommand(tc.args) + require.NoError(t, err) + require.Len(t, result.InstallTargets, 1) + + target := result.InstallTargets[0] + assert.Equal(t, tc.expectedName, target.PackageVersion.Package.Name) + assert.Equal(t, tc.expectedVersion, target.PackageVersion.Version) + assert.True(t, target.IsExplicitVersion, "pinned spec should be marked as explicit version") + }) + } +} + +func TestUvxExecutorProxyBehavior(t *testing.T) { + pm, err := NewPypiPackageExecutor(DefaultUvxPackageExecutorConfig()) + require.NoError(t, err) + + cases := []struct { + name string + command string + }{ + {name: "uvx tool run downloads", command: "uvx ruff"}, + {name: "uvx --from run downloads", command: "uvx --from httpie http"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + parsed, err := pm.ParseCommand(strings.Split(tc.command, " ")) + require.NoError(t, err) + + // uvx always runs a tool, so it always may download packages and is + // never a known non-download command. + assert.False(t, parsed.IsKnownNonDownloadCommand) + assert.True(t, parsed.MayDownloadPackages()) + assert.True(t, parsed.IsInstallationCommand()) + }) + } +} + +func TestUvxNormalizeSpec(t *testing.T) { + cases := []struct { + input string + expected string + }{ + {"ruff", "ruff"}, + {"ruff@0.3.0", "ruff==0.3.0"}, + {"ruff@latest", "ruff"}, + {"ruff@", "ruff"}, + {"ruff@>=0.3.0", "ruff>=0.3.0"}, + {"ruff==0.3.0", "ruff==0.3.0"}, + {"mypy[faster-cache]@1.0.0", "mypy[faster-cache]==1.0.0"}, + } + + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + assert.Equal(t, tc.expected, uvxNormalizeSpec(tc.input)) + }) + } +} + +func TestUvxIsAuditableSpec(t *testing.T) { + cases := []struct { + input string + auditable bool + }{ + {"ruff", true}, + {"ruff@0.3.0", true}, + {"mypy[faster-cache]", true}, + {"", false}, + {"git+https://github.com/foo/bar", false}, + {"https://example.com/pkg.whl", false}, + {"file:///tmp/pkg", false}, + {"./local", false}, + {"../local", false}, + {"~/tool", false}, + {"/abs/path", false}, + {"dist/pkg.tar.gz", false}, + {"pkg.whl", false}, + } + + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + assert.Equal(t, tc.auditable, uvxIsAuditableSpec(tc.input)) + }) + } +} diff --git a/sandbox/profiles/README.md b/sandbox/profiles/README.md index f35ffe8..17ec525 100644 --- a/sandbox/profiles/README.md +++ b/sandbox/profiles/README.md @@ -10,7 +10,7 @@ Restrictive policy for the npm ecosystem (npm, pnpm, yarn, bun). ### pypi-restrictive -Restrictive policy for the PyPI ecosystem (pip, pip3, poetry, uv). +Restrictive policy for the PyPI ecosystem (pip, pip3, pipx, poetry, uv, uvx). ## Custom Policies diff --git a/sandbox/profiles/pypi-restrictive.yml b/sandbox/profiles/pypi-restrictive.yml index 217aa6f..518d768 100644 --- a/sandbox/profiles/pypi-restrictive.yml +++ b/sandbox/profiles/pypi-restrictive.yml @@ -6,6 +6,7 @@ package_managers: - pipx - poetry - uv + - uvx # Optional security settings (uncomment to enable) # allow_git_config: false # Allow package managers to modify .git/config (default: false, blocks for security) diff --git a/sandbox/profiles/uvx.yml b/sandbox/profiles/uvx.yml new file mode 100644 index 0000000..ae7de92 --- /dev/null +++ b/sandbox/profiles/uvx.yml @@ -0,0 +1,55 @@ +name: uvx +description: Profile for uvx executor (uv tool run) with write access to current directory +inherits: pypi-restrictive + +package_managers: + - uvx + +# uvx runs tools in ephemeral environments and often needs PTY access, e.g. +# `uvx cowsay -t hello`. Set explicitly so it stays enabled even if a parent +# profile turns it off in the future. +allow_pty: true + +# uvx-executed tools may need to bind to localhost ports (e.g. dev servers). +allow_network_bind: true + +environment: + # The pypi-restrictive base allows no environment variables. uvx is driven by + # uv, so it needs uv's own config namespace (UV_*) and honors pip's index/TLS + # conventions (PIP_*). Sibling tool credentials (POETRY_*) and TWINE_* stay + # scrubbed. + # + # Accepted trade-off: UV_* re-allows UV_PUBLISH_TOKEN, uv's publishing + # credential, even though uvx does not publish. + allow: + - UV_* + - PIP_* + +filesystem: + allow_read: + # uv cache: ephemeral tool environments are created here. ~/.cache/uv is the + # Linux default; uv honors UV_CACHE_DIR / XDG_CACHE_HOME otherwise. + - ${HOME}/.cache/uv/** + - ${HOME}/Library/Caches/uv/** + + # uv data dir: managed Python interpreters and installed tools. + - ${HOME}/.local/share/uv/** + - ${HOME}/Library/Application Support/uv/** + + # uv config and the tool bin directory. + - ${HOME}/.config/uv/** + - ${HOME}/.local/bin/** + + allow_write: + - ${CWD}/** + - ${HOME}/.cache/uv/** + - ${HOME}/Library/Caches/uv/** + - ${HOME}/.local/share/uv/** + - ${HOME}/Library/Application Support/uv/** + - ${HOME}/.config/uv/** + - ${HOME}/.local/bin/** + + # Additional deny rules for extra security + deny_write: + - ${CWD}/.env + - ${CWD}/.env.* diff --git a/sandbox/profiles_env_test.go b/sandbox/profiles_env_test.go index 6eee58e..f21508f 100644 --- a/sandbox/profiles_env_test.go +++ b/sandbox/profiles_env_test.go @@ -49,6 +49,7 @@ func TestProfileEnvContract(t *testing.T) { {profile: "pip", wantKept: []string{}}, {profile: "pipx", wantKept: []string{}}, {profile: "uv", wantKept: []string{"UV_PUBLISH_TOKEN"}}, + {profile: "uvx", wantKept: []string{"UV_PUBLISH_TOKEN"}}, {profile: "poetry", wantKept: []string{"POETRY_PYPI_TOKEN_PYPI"}}, }