Security
Running tsr build in a repository is running that repository's code — the same as npm run build, make, or cargo test. tsr does not sandbox the programs it spawns, and it is not designed to make an untrusted repository safe to build. For that, use a container.
What it does guard is the part with no process boundary around it: the things tsr performs itself. That is a narrow surface, but it is the surface nothing else can protect.
tsr does this itself | Why nothing else can guard it | Guard |
|---|---|---|
Runs in-process builtins (rm, cp, mv, …) | There is no /bin/rm to audit, deny, or leave off PATH — the builtin is tsr | Workspace confinement |
| Builds the environment every child inherits | The child is already running by the time anything could inspect it | Guarded variables |
Chooses which tasks.toml gets to run commands | The choice happens before any config is read | Bounded discovery |
| Owns the lifetime of every process it spawns | Only the parent knows what it started | Process-tree containment |
Every guard below is on by default. A rejected guard is a runner-level error — exit 64 — raised before the first child is spawned.
Threat model
The guards exist for three concrete situations. Being explicit about which is which matters, because they call for different defences.
| Situation | Example | What helps |
|---|---|---|
| Accident | A dir = "../.." left over from a refactor; a glob that reaches further than intended | Workspace confinement — a config may widen it, which is fine, because nobody is trying to defeat it |
| Untrusted config | You cloned a repo, or you are running a fork's PR branch in CI | The env guards (no config key can lift them), bounded discovery, and --dry-run to read the config first |
| Shared machine | Another local user plants a tasks.toml where you will cd | Bounded discovery + the world-writable refusal |
A tasks.toml that simply runs a malicious command (run = "curl … | sh") is not stopped by any of this, and is not meant to be. Running the repo's commands is the entire purpose of a task runner. The guards narrow what a config can do beyond the commands it visibly declares.
Two tiers of guard
| Relaxed by | Defends against | |
|---|---|---|
| Workspace confinement | [security] allow_paths — a config key | Accidents |
| Guarded env variables | --allow-unsafe-env — a CLI flag, no config equivalent | A config you don't trust |
The asymmetry is deliberate. A guard a tasks.toml can widen is no defence against a tasks.toml you are wary of — it would simply widen itself. So the guards that exist for that case have no config-side switch at all.
Workspace confinement
Every path tsr resolves itself must stay inside the workspace: the directory holding tasks.toml.
What it stops
[tasks.clean] run = "rm -rf ../../build"
rm: refusing to touch '/home/you/build': outside the workspace at '/home/you/project' — add it to `[security] allow_paths` if that is intended
The builtin case is the one that matters most. rm inside a run string is tsr itself — the builtins always win over a binary of the same name, so that rm -rf dist behaves identically on Linux, macOS and Windows. The consequence is that there is no PATH to adjust, no sandbox to deny it, and no audit trail. A boundary check inside tsr is the only guard that can exist for it.
Confined builtins: rm, cp, mv, mkdir, touch, cat. (echo, pwd, true and false touch no files.)
Rejected at load time
These fail before anything runs, so a config that would step outside never gets halfway through a build first:
| Field | Rejected when |
|---|---|
dir | It resolves outside the workspace |
env_file | It resolves outside the workspace |
packages | Its literal prefix is outside |
workspace.members | Its literal prefix is outside |
A glob is judged by its literal prefix — the part with a fixed location. apps/* cannot escape apps/ however it expands; ../* has already left before the wildcard is considered.
Resolution is physical, not textual
The check follows symlinks as far as the filesystem actually goes: the longest existing prefix of a path is canonicalized, and only a not-yet-created tail is joined textually — where, by definition, there is no symlink left to follow.
ln -s /etc config-link # inside the workspace…
[tasks.oops] run = "rm -rf config-link/hosts" # …still rejected: it lands in /etc
A purely textual check would have seen a path starting inside the workspace and allowed it.
The check follows the operation, not just the operand:
cp -randmvwalk a tree, and a symlink found inside one is a second way out — copying follows it. Every link met on the walk is checked in its own right, socp -r tree outwheretree/link → /etc/passwdis refused even thoughtreeitself is perfectly legal.rm -rdoes not follow directory symlinks at all: the link is removed, never the thing it points at.env_fileis re-checked when it is read, not only when it is validated, so a link created in between is not followed.
Widening it
For a build that genuinely writes outside its own tree:
[security] allow_paths = ["../shared-cache", "/tmp/build"]
Relative entries resolve against the workspace root. Each entry admits that directory and its contents — not its parent, not its siblings.
Reaching outside the repo is a legitimate thing for a build to do, and the workspace owner is the one who knows whether it is intended. This guard is aimed at the stale ../.., not at an adversary — an adversarial config would just add the allow_paths entry itself.
Guarded environment variables
A config may not set a variable whose purpose is to decide what code some other program loads.
The list
| Group | Variables |
|---|---|
| Dynamic-loader injection | LD_PRELOAD, LD_AUDIT, DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH |
| Interpreter startup hooks | NODE_OPTIONS, BASH_ENV, PYTHONSTARTUP, PERL5OPT, RUBYOPT, PHP_INI_SCAN_DIR |
| JVM injection | JAVA_TOOL_OPTIONS, JDK_JAVA_OPTIONS, _JAVA_OPTIONS |
| Module search paths | PYTHONPATH, PERL5LIB, RUBYLIB |
| Toolchain flags that name a program | GOFLAGS (-toolexec), RUSTC_WRAPPER, RUSTC_WORKSPACE_WRAPPER |
| Programs git & ssh shell out to | GIT_SSH, GIT_SSH_COMMAND, GIT_EXTERNAL_DIFF, GIT_PROXY_COMMAND, SSH_ASKPASS, SUDO_ASKPASS |
tsr's own namespace | anything prefixed TSR_ |
Every toolchain ships some way to make its compiler or interpreter load extra code, and new ones arrive with new tools. Variables that are commonly set on purpose — CC, CLASSPATH, GOPATH, PYTHONHOME — are deliberately left out, because a guard that fires on ordinary configuration gets switched off wholesale, which is worse than not having it. Treat this as a guard against the well-known vectors, not as a boundary.
What it stops
Without this, a config that appears to run one thing can execute something else entirely, inside a process it never names:
# rejected — this runs evil.js inside every `node` the build touches, # including ones started by tools tsr never invoked directly [env] NODE_OPTIONS = "--require ./evil.js"
# rejected — replaces the binary `git` shells out to for every fetch and push [env] GIT_SSH_COMMAND = "./harvest-keys.sh"
The .env variant is the one to watch. It is the file people read least and commit most often, and it is loaded automatically from the workspace root:
✗ config error: the root '.env' sets 'LD_PRELOAD', which decides what code an unrelated program loads — pass `--allow-unsafe-env` if that is intended
TSR_ is reserved for the same reason: a config should not be able to reconfigure a nested tsr invocation, least of all to talk it out of these checks.
PATH gets rules, not a ban
Extending PATH is ordinary and useful, so it is not forbidden. Two rules apply instead:
[env] PATH = "./bin:$PATH" # fine — written out, and it augments PATH = "/only/mine" # rejected — replaces the inherited PATH PATH = ":$PATH" # rejected — the empty entry *is* the working directory
It must still reference
$PATH. A replacedPATHis an injection vector wearing ordinary clothes: it silently changes which binary every unqualified command in the run resolves to. Requiring the reference is exactly the "merged, never wiped" principle the environment model already follows everywhere else.No entry may be empty or a bare
.. Every shell reads both as the working directory, so they put whatever folder a task happens to run in ahead of the realPATH— and in apackagesfan-out that is a different folder each time. An explicit relative entry is fine; the objection is to the invisible form, since":$PATH"and"$PATH:"look like nothing at all in a diff.
Scope: config sources only
| Source | Checked? |
|---|---|
[env] | ✅ |
Task env | ✅ |
env_file | ✅ |
Root .env | ✅ |
| Process environment | ❌ — passed through untouched |
The process environment belongs to whoever invoked tsr. A runner that refused the environment it was handed would be broken rather than safe — and the user who exported LD_PRELOAD in their own shell has already made that decision.
Only the tasks that will actually run are checked, so an unrelated task elsewhere in tasks.toml cannot block a run that never touches it.
Lifting it
tsr build --allow-unsafe-env
There is deliberately no [security] equivalent. These guards exist for the case where the tasks.toml is what you are wary of, and a guard the config could switch off would not survive that case. The decision has to be made by the person typing the command.
NODE_OPTIONS has legitimate uses (--max-old-space-size=4096) and is guarded anyway, because it is also the classic --require injection. If your repo needs it, pass the flag — the error message names it.
Bounded config discovery
tsr finds the workspace root by walking up from your working directory to the nearest tasks.toml. Which file it lands on decides what gets to run commands on your machine, so the walk is bounded. It stops at the first of:
the repository root — a directory holding
.git, checked after that directory itself, since a workspace anchored at the repo root is the norm;your home directory;
a filesystem boundary — the same rule
gitapplies to its own discovery.
Without a bound, a tasks.toml left in /tmp — or in a home directory, or on a mounted share — silently governs every project beneath it, and tsr test in an unrelated checkout runs whatever it says.
World-writable configs are refused
✗ config error: '/tmp/shared/tasks.toml' is world-writable, and it decides what commands run — `chmod o-w '/tmp/shared/tasks.toml'` before using it
The check covers the file and the directory it sits in — a config you cannot rewrite is no protection if the directory lets it be replaced. Unix only; there is no cheap equivalent of the mode bits on Windows.
Two deliberate narrowings, because a guard that fires on ordinary setups gets worked around rather than heeded:
Group-writable is accepted. A
umaskof002with a per-user group is a common default; rejecting it would fail on almost every checkout.Sticky directories are accepted. That is what the bit means:
/tmpis world-writable, but only a file's owner may replace it.Ownership is not checked. A file another user owns is only reachable through a directory they can write to, which the above already catches — and checking it would reproduce git's "dubious ownership" friction on every CI checkout that runs as a different uid.
The same check covers the root .env and every env_file a reachable task loads: those set the environment each child inherits, so whoever can write one chooses what the build sees. Only writability is checked, never readability — a world-readable .env is exactly what umask 022 produces, and failing on it would fire on nearly every repo while telling you nothing you can act on.
Process-tree containment
Killing the process tsr spawned is not the same as stopping the work. npm run dev is a launcher: the Node process it starts spawns vite, and killing the launcher leaves vite holding the port.
So a child that a run may have to kill is spawned into its own process group (unix) or job object (windows), and the whole group is torn down — SIGTERM first, then SIGKILL after a 2s grace, long enough for a dev server to close its listeners.
Why isolation is conditional
Isolation is not free on unix. A process group outside the terminal's foreground group is stopped with SIGTTIN the moment it reads stdin — which would break every interactive task (tsr dev, tsr test -- --watch).
So it is withheld in exactly one case — both of these must hold:
stdin is a terminal. Under CI, a pipe or
< /dev/nullthere is no foreground group to be outside of, so isolation costs nothing and always applies.No parallelism. Nothing in a sequential run can abort a child that is already running, because there is no sibling to fail.
A lone interactive tsr dev therefore keeps the inherited group and stays interactive — and that same run in CI is fully contained.
Ctrl-C
SIGINT and SIGTERM (and CTRL_C_EVENT on Windows) abort through the same path a task failure uses: stop launching, tear down what is running, exit 130.
--no-baildoes not override it. You asked to stop.A second interrupt exits immediately, so a wedged child can never trap your terminal.
Previously
tsrdied mid-wait()and left its children to init.
Reading a config before you run it
tsr <task> --dry-run
Walks the dependency graph and prints every unit of work the run would perform — label, directory, command — without running any of it.
· lint dir: . cmd: eslint . · build (packages/ui) dir: packages/ui cmd: vite build
Two properties make it safe to use on a config you don't trust:
Commands print as written, before
$VARexpansion. A plan pasted into an issue or captured in a CI log cannot carry what your.envholds.The walk is always sequential, even for
parallel = truebatches, so the order you read is the order things would happen.
A config that cannot be resolved still fails with the same error a real run would give — a dry run is an inspection, not a bypass. See --dry-run.
Working with a repo you haven't read
tsr ci --dry-run # what would run, and where cat tasks.toml .env # what the plan resolved from tsr ci # only then
Guard reference
| Guard | Default | Relaxed by | Failure |
|---|---|---|---|
| Builtin operands confined to the workspace | on | [security] allow_paths | Builtin exits 1 |
dir / env_file / packages / members confined | on | [security] allow_paths | Exit 64 at load |
| Guarded env variables rejected | on | --allow-unsafe-env | Exit 64 at load |
PATH must extend, and hide no cwd entry | on | --allow-unsafe-env | Exit 64 at load |
Discovery bounded at repo / $HOME / filesystem | on | — | Task not found, exit 64 |
World-writable config / .env / env_file refused | on (unix) | chmod o-w | Exit 64 at load |
| Process-tree teardown | parallel runs, and any non-tty run | — | — |
Ctrl-C aborts and exits 130 | on | — | — |
What is not guarded
Stated plainly, so the boundary is not mistaken for more than it is:
Spawned programs. Once a child starts it has your full privileges.
tsrcannot and does not sandbox it.delegateandruntargets. Naming a binary to execute is the feature;tsrdoes not decide which binaries are allowed.node_modules/.binonPATH. A repo-local binary shadowing a global one is npm's own behaviour, and what makesrun = "vite"work (§9.2).A child's output. Children inherit stdio; whatever they print — including secrets — is theirs.
tsritself never prints an environment value:--dry-runprints commands before expansion and no reporter event carries env, so there is nothing for it to mask.Resource exhaustion. There are no CPU, memory or file-descriptor limits on a task. Limits declared in
tasks.tomlwould be no defence against a config that simply omits them — usesystemd-run,ulimitor a container.A local attacker racing the run. The path checks resolve, then act; they are not TOCTOU-hardened. Someone who can create symlinks inside your workspace while a build runs already controls the repository.
allow_pathsagainst a hostile config. It is a config key, so a config can widen it. It guards accidents, by design.Supply chain of what you install. Release archives ship a
checksums.txtthat both installers verify, and carry build provenance attestations, but whatnpm,cargooruvthen fetch is between you and them.
Reporting a vulnerability
Privately, through GitHub's private vulnerability reporting — not a public issue. Include your version, platform, and a tasks.toml that reproduces it. Full policy: SECURITY.md.
The full normative model is SPEC §12.