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 itselfWhy nothing else can guard itGuard
Runs in-process builtins (rm, cp, mv, …)There is no /bin/rm to audit, deny, or leave off PATH — the builtin is tsrWorkspace confinement
Builds the environment every child inheritsThe child is already running by the time anything could inspect itGuarded variables
Chooses which tasks.toml gets to run commandsThe choice happens before any config is readBounded discovery
Owns the lifetime of every process it spawnsOnly the parent knows what it startedProcess-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.

SituationExampleWhat helps
AccidentA dir = "../.." left over from a refactor; a glob that reaches further than intendedWorkspace confinement — a config may widen it, which is fine, because nobody is trying to defeat it
Untrusted configYou cloned a repo, or you are running a fork's PR branch in CIThe env guards (no config key can lift them), bounded discovery, and --dry-run to read the config first
Shared machineAnother local user plants a tasks.toml where you will cdBounded discovery + the world-writable refusal
What no guard here covers

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 byDefends against
Workspace confinement[security] allow_paths — a config keyAccidents
Guarded env variables--allow-unsafe-env — a CLI flag, no config equivalentA 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

TOML
[tasks.clean]
run = "rm -rf ../../build"
TEXT
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:

FieldRejected when
dirIt resolves outside the workspace
env_fileIt resolves outside the workspace
packagesIts literal prefix is outside
workspace.membersIts 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.

Shell
ln -s /etc config-link          # inside the workspace…
TOML
[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 -r and mv walk 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, so cp -r tree out where tree/link → /etc/passwd is refused even though tree itself is perfectly legal.

  • rm -r does not follow directory symlinks at all: the link is removed, never the thing it points at.

  • env_file is 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:

TOML
[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.

Why this one is config-relaxable

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

GroupVariables
Dynamic-loader injectionLD_PRELOAD, LD_AUDIT, DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH
Interpreter startup hooksNODE_OPTIONS, BASH_ENV, PYTHONSTARTUP, PERL5OPT, RUBYOPT, PHP_INI_SCAN_DIR
JVM injectionJAVA_TOOL_OPTIONS, JDK_JAVA_OPTIONS, _JAVA_OPTIONS
Module search pathsPYTHONPATH, PERL5LIB, RUBYLIB
Toolchain flags that name a programGOFLAGS (-toolexec), RUSTC_WRAPPER, RUSTC_WORKSPACE_WRAPPER
Programs git & ssh shell out toGIT_SSH, GIT_SSH_COMMAND, GIT_EXTERNAL_DIFF, GIT_PROXY_COMMAND, SSH_ASKPASS, SUDO_ASKPASS
tsr's own namespaceanything prefixed TSR_
The list is not exhaustive — and cannot be

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:

TOML
# 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"
TOML
# 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:

TEXT
✗ 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:

TOML
[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
  1. It must still reference $PATH. A replaced PATH is 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.

  2. 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 real PATH — and in a packages fan-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

SourceChecked?
[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

Shell
tsr build --allow-unsafe-env
A flag, never a config key

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 git applies 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

TEXT
✗ 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 umask of 002 with 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: /tmp is 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/null there 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-bail does not override it. You asked to stop.

  • A second interrupt exits immediately, so a wedged child can never trap your terminal.

  • Previously tsr died mid-wait() and left its children to init.


Reading a config before you run it

Shell
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.

TEXT
· 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 $VAR expansion. A plan pasted into an issue or captured in a CI log cannot carry what your .env holds.

  • The walk is always sequential, even for parallel = true batches, 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

Shell
tsr ci --dry-run          # what would run, and where
cat tasks.toml .env       # what the plan resolved from
tsr ci                    # only then

Guard reference

GuardDefaultRelaxed byFailure
Builtin operands confined to the workspaceon[security] allow_pathsBuiltin exits 1
dir / env_file / packages / members confinedon[security] allow_pathsExit 64 at load
Guarded env variables rejectedon--allow-unsafe-envExit 64 at load
PATH must extend, and hide no cwd entryon--allow-unsafe-envExit 64 at load
Discovery bounded at repo / $HOME / filesystemonTask not found, exit 64
World-writable config / .env / env_file refusedon (unix)chmod o-wExit 64 at load
Process-tree teardownparallel runs, and any non-tty run
Ctrl-C aborts and exits 130on

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. tsr cannot and does not sandbox it.

  • delegate and run targets. Naming a binary to execute is the feature; tsr does not decide which binaries are allowed.

  • node_modules/.bin on PATH. A repo-local binary shadowing a global one is npm's own behaviour, and what makes run = "vite" work (§9.2).

  • A child's output. Children inherit stdio; whatever they print — including secrets — is theirs. tsr itself never prints an environment value: --dry-run prints 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.toml would be no defence against a config that simply omits them — use systemd-run, ulimit or 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_paths against 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.txt that both installers verify, and carry build provenance attestations, but what npm, cargo or uv then 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.