The core decides, plugins apply
A dependency tool has two dangerous properties at once. It holds a write token for many repositories. And it runs package managers that read code from exactly those repositories.
So pinup draws a sharp line: the core decides everything that changes. A task only carries out, without the platform's credentials and within a scope it may not leave.
01 — The opinion in one sentence
A task regenerates a lock file or runs an allowed command, and nothing else.
Which dependency changes, which version comes and which file is affected is in the plan. The task gets a checkout and an order. A command from a rule looks like this:
packageRules:
- matchDatasources: [git-refs]
postUpgradeTasks:
commands: ["node tools/update-expected-commit.mjs {{{packageFile}}}"]
fileFilters: ["*.yaml"]
That lives in the repository's .pinup.yaml. Whether the command may run, however, is not the repository's decision. The list of allowed commands in the runner decides that, the central job that runs pinup across all repositories.
02 — The alternative: isolation as an operations task
Renovate runs post-upgrade tasks in the environment it runs in itself. The operator sets a list of allowed commands. Isolation Renovate largely leaves to that environment: a container per run, a runner of its own, a token with few rights.
That is a reasonable cut. A tool for many platforms cannot know which isolation is possible there. So it becomes an operations task.
In my estate one job runs across many repositories, with one token and one signing key. A task from repository A would have seen the checkouts of B and C, moments before pinup commits them under its signature. I did not want to leave that to operations.
03 — The price
Linux only. The sandbox uses user namespaces. Where they do not exist or a profile forbids them, pinup refuses every task and holds the branch instead of running unprotected.
Caches per repository. A shared package cache would let one repository poison the next one's lock refresh. Separate caches cost npm a few seconds per repository.
The network stays open. A package manager has to fetch packages. Whatever the job reaches, the task reaches. That is the gap, named on purpose.
More work in the core. pinup composes the lock-refresh commands itself, with switches that turn off the package managers' scripts and plugins.
04 — The occasion
Two incidents from my own estate.
The command that silently dropped out. Under Renovate a command was not on the list of allowed commands. Renovate skipped it and pushed the branch anyway. So for weeks the lock refreshes for my own packages were missing, without a red job. pinup holds such a branch with the reason taskRefused and names the command.
The token under the wrong name. From the start pinup filtered the platform credentials by name. Still, for a while the write token reached the tasks in my runner. The runner configuration had passed it to tasks under other names. Since then pinup checks the value too: if a variable carries the token under any name, the task does not start.
05 — The enforcement
- No platform credentials, checked twice. A task sees a fixed baseline (
PATH,LANG,LC_ALL,TMPDIR,TZ) and whatever the runner explicitly releases. The platform token and the signing key are kept out by name and by value. - The sandbox. Every task runs in namespaces of its own, without capabilities and with hidden directories: temporary checkouts, keys, agent sockets, pinup's own cache.
- Fail closed. Before the first task pinup checks the sandbox from inside. If the check fails, no task runs. Tests of their own in pinup's pipeline prove this inside the production images that tasks run in.
- The scope. pinup compares what a task changed with its allowed files. A single path outside discards the whole result.
Frequently asked questions
How does a task reach private packages then?+
Through read-only credentials the runner explicitly releases: a .netrc in the task's temporary home for Go modules, read tokens as variables for Composer and npm. None of them holds the platform's write token, and pinup checks that by value.
Why not simply a container per task?+
In the specification that is even the default variant, with a digest-pinned image per task. It needs Docker in the runner, though. Production runs the variant without containers, with the in-process sandbox, and it is proven there.
Can I switch the sandbox off?+
Yes, with PINUP_TASK_ISOLATION=off. Tasks then run without isolation, and pinup says so on every run. Without that switch no task runs unisolated: if the sandbox is missing, pinup refuses the tasks and holds the branches.
Conclusion
A task that only carries out can do less. It can also break less, and that is the point. The price is Linux, separate caches and a gap in the network that is named openly.
Next week: the places where pinup deliberately does not decide differently, compatibility with Renovate. The overview of all parts and the terms that recur are in the opening of the series.
What do the tasks in your dependency runner see? I will check.
A look at your runner: which credentials tasks reach, which commands are allowed and what one repository learns about the next.
About the author

Kai Ole Hartwig
Programming since 2002 – self-taught, set up my own business with KO-Web in 2012. Over 100 projects, with a focus on security, performance, automation and quality. Today freelance: DevSecOps consulting, training and software development.
