mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
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:
@@ -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..."
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -240,6 +240,7 @@ var legacyProfileAliases = map[string]map[string]string{
|
||||
"pipx": "pipx",
|
||||
"poetry": "poetry",
|
||||
"uv": "uv",
|
||||
"uvx": "uvx",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"}},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.*
|
||||
@@ -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"}},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user