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 <noreply@anthropic.com>
This commit is contained in:
Abhisek Datta
2026-07-02 18:49:31 +05:30
committed by GitHub
co-authored by Claude
parent 0fb6faa951
commit 648adcbda4
21 changed files with 717 additions and 7 deletions
+28 -1
View File
@@ -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..."
+1
View File
@@ -156,6 +156,7 @@ PMG supports the tools you already use:
| | `pipx` | `pipx run <pkg>` |
| | `poetry` | `poetry add <pkg>` |
| | `uv` | `uv add <pkg>` |
| | `uvx` | `uvx <pkg>` |
## Installation
+61
View File
@@ -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)
}
+1
View File
@@ -240,6 +240,7 @@ var legacyProfileAliases = map[string]map[string]string{
"pipx": "pipx",
"poetry": "poetry",
"uv": "uv",
"uvx": "uvx",
},
}
+4
View File
@@ -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:
+7
View File
@@ -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"}},
+1 -1
View File
@@ -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
+1
View File
@@ -67,6 +67,7 @@ Legacy variables `PMG_PROXY_MODE` and `PMG_PROXY_INSTALL_ONLY` (for the old flat
| `yarn` | ✅ |
| `pip` | ✅ |
| `uv` | ✅ |
| `uvx` | ✅ |
| `poetry` | ✅ |
## References
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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,
}
}
+5
View File
@@ -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)
}
+1 -1
View File
@@ -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
+1
View File
@@ -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},
}
+1
View File
@@ -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())
+12 -1
View File
@@ -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
}
+204
View File
@@ -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] <command> [args...]`.
//
// uvx always runs a tool, so the package(s) to audit are:
// - the --from <spec> value when provided. In that case the positional
// <command> 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 <spec> 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
}
+329
View File
@@ -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))
})
}
}
+1 -1
View File
@@ -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
+1
View File
@@ -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)
+55
View File
@@ -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.*
+1
View File
@@ -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"}},
}