0078: A project's committed file and its trust
On this page
Status: accepted
Context
A project can't tell Short Leash what it needs. Its PHP and Node versions, document root, extra names, HTTPS, services, background processes and environment live only in one computer's config.toml, so every teammate sets them up again by hand, and a fresh clone runs on Short Leash's defaults. A file in the repository fixes that, but it's code someone else wrote: a clone or a pull could make Short Leash run a command, install and start software, or run Git hooks without anyone choosing to. Secrets must stay out of anything committed (invariant 9), and chat must never be the one that lets something run (invariant 15).
Decision
The files. A site's folder can hold leash.toml, meant to be committed, and leash.local.toml for one person's changes (meant to stay out of Git). Both are TOML, read only from the site's folder itself, never searched for in parent folders. The names and limits live in config::defaults (PROJECT_FILE, PROJECT_LOCAL_FILE, PROJECT_FILE_MAX_BYTES).
php = "8.4" # a PHP minor Short Leash offers (runtimes::php::SUPPORTED)
node = "22" # a Node major ("v22.3.0" is read as 22)
docroot = "public" # relative to the folder, checked with config::clean_docroot
aliases = ["shop-admin"] # more names, cleaned with sites::clean_name
secure = true
[services]
mysql = "8.4" # catalog id = catalog version or its prefix
postgres = { version = "17", extensions = ["postgis"] }
[processes.queue]
name = "Queue worker" # display only; defaults to the id
command = "php artisan queue:work"
keep_running = true
[processes.migrate]
command = "php artisan migrate"
once = true
[env]
APP_ENV = "local"
- A process takes
name,command,keep_running,onceandstart_on_visit, as a process added in Short Leash does. A trusted file'sstart_on_visitprocess starts on the site's first request exactly as Short Leash's own does; it is in the fingerprint, so turning it on asks for trust again, whilestart_on_visit = falseand no key fingerprint the same. - Unknown keys are listed as problems and ignored; they never fail the whole file, so an older version of Short Leash still reads a newer file. There's no schema version key.
- A file that doesn't parse applies nothing and says why; the site keeps being served with Short Leash's own settings. A file that's a symbolic link, larger than 64 KiB or not UTF-8 isn't read, with one problem saying which. Nor is one with an invisible or direction-changing character (Unicode's format characters, line and paragraph separators and its other invisible characters, such as U+202E, U+200B, variation selectors and Hangul fillers;
project::file::hidden_char) in any key or string: what the person trusts must look like what runs, so the problem names the key and the character instead. The app's trust dialog escapes the same list, plus control characters, and a test keeps its list equal to Short Leash's. Any of these makes the files unreadable for trust (below), so nothing that needs trust applies from either file until both read again and someone trusts the result. - Short Leash re-reads the files when they change on disk, so what applies without trust (below) takes effect at once.
- Not in the file: a
commandfor command sites, a Python version, ports, and anything else Short Leash's own settings cover that no project asked for yet.
Merging the two files. leash.local.toml goes over leash.toml: single values replace, aliases add up, and services, processes and env merge per key, the local entry replacing the whole entry with the same key.
Precedence, highest first:
- Short Leash's own setting for the site (
config.toml'sSiteSettings). leash.local.toml.leash.toml.- The project's own version files:
.nvmrcor.node-version,composer.json'srequire.php(0073),.python-version,go.mod,.ruby-version. - Short Leash's defaults.
- Short Leash's setting wins because every control in the app must do what it says: Short Leash's setting and the local file are both one person's, and a control that silently does nothing is worse than a visible override. When Short Leash's setting differs from the file, the setting says so ("leash.toml asks for 8.4") with Use leash.toml's, which clears Short Leash's setting ("Using leash.toml's PHP 8.4"), and the site's Needs a look lists it.
- Lists and maps add up across all three: aliases are the union, and env and processes merge per key, Short Leash's entry winning on the same variable or process id.
secureis a plainboolinconfig.toml, so Short Leash's setting counts only when it'strueor the newSiteSettings.secure_chosenis set, which every change to HTTPS in Short Leash sets. An existing site whosesecure = falsewas never touched takes the file's value.- Where the file's PHP sits in 0073's order: the site's pin; then
leash.local.toml'sphp, thenleash.toml's; then the default whenrequire.phpallows it; then the newest installed, released versionrequire.phpallows; then the default. The Overview says "from leash.toml" or "from leash.local.toml", never "picked for this site", and a file's version thatrequire.phpdoesn't allow gets the same Needs a look check as a pin that doesn't fit. A version the file names is one version chosen on purpose, like a pin, so when it isn't installed Short Leash installs it the first time the site needs it, as it does for a pin and for.nvmrc, from verified downloads (invariant 7).composer.json's range still never makes Short Leash download anything.
What applies without trust. Reading is safe. php, node, docroot, aliases and secure apply as soon as they're read: they choose how Short Leash serves code it already serves.
env applies at once only as PHP's per-request parameters: the FastCGI parameters the proxy sends with each PHP request, not the environment of any process. The request runs the repository's own PHP code, and once the names php-fpm treats as settings are refused (PHP_VALUE and PHP_ADMIN_VALUE, below), a parameter is only a variable that request's code can read, so it gives the file nothing that code doesn't already have. Everything else that uses a site's environment starts a program that isn't necessarily the project's: a command or Python site's server goes through /bin/sh, which runs code from SHELLOPTS and PS4; a shell, Git, Composer, Node or Python each read variables (PROMPT_COMMAND, GIT_CONFIG_*, COMPOSER_HOME, NODE_OPTIONS, HOME) that can make them run the repository's code with no request involved. So until the folder is trusted the file's env reaches none of them, and while it's trusted it goes everywhere Short Leash's own site environment goes:
| Where a site's environment is used | The file's env |
|---|---|
PHP requests (the proxy's FastCGI parameters), except PHP_VALUE and PHP_ADMIN_VALUE |
Always |
The site's server: command, Python and other app servers (runtimes::apps) |
Only while trusted |
| Background processes and terminals | Only while trusted |
The CLI's leash php and leash composer shims |
Only while trusted |
The Monitor agent's merges (NODE_OPTIONS, PYTHONPATH, RUBYOPT) for servers and processes |
Only while trusted |
| Scratchpads and Git hooks | Only while trusted |
| The Overview and site settings (display only) | Always shown; marked "Not applied until trusted" where it doesn't apply yet |
-
Env rules. Names go through
config::env_key_problem. These are refused from either file, trusted or not, on every system:- Short Leash's reserved names (
RESERVED_ENV) and everyLEASH_*name, because Short Leash's own programs read them (its terminals' shell setup, and its PHP prepend, which readsLEASH_SCRATCH_*withgetenv).LEASH_*is refused from every source of a site's environment, Short Leash's ownconfig.tomlincluded, byconfig::env_key_problemitself. - Names shells read at startup:
SHELLOPTS,BASHOPTS,PS4,IFS,ENV,BASH_ENV,ZDOTDIR,PROMPT_COMMAND,PATHEXTandCOMSPEC. - Names that load code into programs:
LD_PRELOAD,LD_LIBRARY_PATHand anyDYLD_*. PHP_VALUEandPHP_ADMIN_VALUE. php-fpm applies them as php.ini settings that stay in the pool worker after the request, and Short Leash runs one pool per PHP version shared by every site, so they would reach other sites' requests (replacing Short Leash's ownauto_prepend_file, for example);PHP_ADMIN_VALUEcan also load an extension. Short Leash never forwards these two names as FastCGI parameters from any site's environment, its ownconfig.tomlincluded.
Trust doesn't make these safe, because they'd change how Short Leash's own shells, helpers and shared PHP pools behave. Apart from the two PHP names, the list is a second line of defense: keeping an untrusted file's env to PHP's request parameters is what makes reading safe. Values that look like secrets are refused with a problem naming the key: names that say secret (
*_PASSWORD,*_TOKEN,*_SECRET,APP_KEYand the like), URLs with a password in them, private keys, tokens with a known prefix (sk_live_,ghp_,AKIAand others), Laravel'sbase64:keys and JSON web tokens. An empty value is fine (DB_PASSWORD = ""). A value with a control character other than a tab (an escape sequence, a line break) is refused with a problem, since it could change how the trust dialog shows it. Long random-looking values are applied with a problem asking you to check them. The same rules apply to both files.project_fileand the API name a refused value by its key and the reason only, never the value. - Short Leash's reserved names (
-
Alias rules. An alias from a file that is a Short Leash service's name (
reverb, forreverb.test;services::SERVICE_NAMES), another site's name or alias, or that a site earlier in name order already claimed from its own file, is dropped for this site with a problem; the service or the other site keeps its address. A site's own name in its aliases is dropped quietly. A site has at most 20 aliases in all, as in Short Leash, counted after those dropped, so a dropped alias doesn't take a free one's place. -
Document root. A file's
docrootmoves only a site served from a folder (PHP or static files). On a command, port or Python site it's a problem and unused.
What needs trust. Keys that run a command, install and start a service, or reach programs that aren't the project's act, so they need the folder trusted:
processesare merged into the site only while its folder is trusted. A process from the file shows "From leash.toml"; to change it, change the file. A process Short Leash already has with the same id (inconfig.toml) wins, but adding a process in Short Leash never takes a file process's id, trusted or not, so one can't hide a file's by accident. Likewise an env variable from the file shows where it comes from in the site's Environment and can't be removed there (the file would set it again); setting Short Leash's own value for it wins.- The file's
envanywhere but PHP's request parameters (the table above). servicesare acted on only by Apply (Trust and apply → "Applied leash.toml"). Apply first lists everything it will do and waits for a yes, in the app's dialog and inleash project apply's prompt: the commands and services, and each Short Leash setting it will replace with the file's value, showing both values. Only then does it clear those settings (as Use leash.toml's does for each), install the PHP and Node versions that apply and act onservices. Nothing Short Leash set is replaced without being listed first.- Running the repository's Git hooks from Changes (ADR 0081) needs the repository's root folder trusted.
The fingerprint. A grant is for a folder and a fingerprint of the acting keys in the merged files:
sha256_hex("leash-project-trust/1\n" + canonical JSON {
processes: { id: { command, keep_running, once, start_on_visit } },
services: { kind: { version, extensions (sorted) } },
env: { name: value },
hooks: bool,
unreadable: bool
})
Canonical JSON has keys in sorted order and no whitespace. A process's name is display only and not part of it. env holds the variables that apply from the files after the env rules, and is in it because a trusted env reaches terminals and processes, so changing a variable asks again. hooks is true when the folder is a Git repository's or worktree's root (it has a .git folder or file), because the grant then lets the repository's hooks run: a folder trusted before it was a repository's root asks again once it becomes one, such as after git init. unreadable is true when either file exists but isn't read or doesn't parse (a symbolic link, too large, not UTF-8, not valid TOML, a hidden character, or a read error), so a file that breaks never matches an earlier grant and the other file's acting keys don't run under it. The processes and env Short Leash uses come from the same read of the files that was fingerprinted, so a checkout between two reads can't pair a trusted fingerprint with new commands. A folder with no project file can be trusted too (that's how Trust and run hooks works); its fingerprint is that of empty files.
Trusted: a record for the folder with the same identity and fingerprint.Changed: a record whose identity or fingerprint differs.Untrusted: no record.Changedcounts as untrusted everywhere: the file's processes stop, its env goes back to PHP's request parameters only, nothing from the new file runs, and the Overview says what changed, until someone trusts it again. A grant also keeps a hash of each of the fingerprint's three parts (what the files run, their env, and the folder'shooksandunreadableflags), so aChangedfolder says why (ChangedBecause:runs,envorfolder, which also covers a new identity); the dialog, the Overview andleash projectword it by cause. The fingerprint itself doesn't change, and a grant from before the parts were kept falls back to wording by what the files contain.- A folder is keyed by its canonical path (
std::fs::canonicalize), so a moved or renamed folder is asked about again. The grant also records the folder's identity: device and inode on macOS, volume serial number and file index on Windows (throughwinapi-util). A folder deleted and cloned again at the same path has a new identity, so it'sChangedat once, without waiting for leashd to prune anything. A folder Short Leash can't canonicalize or identify is untrusted. - One grant covers processes, services, env beyond PHP's request parameters and Git hooks. Hook scripts aren't hashed: trusting a folder trusts its repository's code, which Short Leash already serves. For a repository's root, the grant also records the digest of which hooks of its own Short Leash's Git writes can run and which of its Git settings need trust (ADR 0081's
hooks::digest, names only, so a hook manager rewriting a script doesn't change it). Git actions count the folder asChangedwhen the digest now differs, so a hook or setting that arrives after the grant (a pull, a branch that adds.husky/pre-push) asks again; the project file's own processes and env don't depend on it. A grant written before Short Leash kept the digest has none, and Git actions ask once. project::trust::inherit(paths, from, to)lets a branch workspace (ADR 0082) start trusted: it grantsto, withto's own identity, only whenfromisTrustedandto's fingerprint equals the one trusted forfrom. Otherwise the workspace asks like any folder.
Who trusts. A person, in the app's trust dialog or at a terminal. The dialog lists exactly what the grant allows: each command, with whether it keeps running, runs on a visit or runs when you choose Run; each service Short Leash would install and start; the env variables the site's server, processes and terminals would get; and, when the folder is a Git repository's root, "Run this repository's Git hooks when you commit from Short Leash". Trust and run hooks in Changes opens the same dialog, so trusting a repository root that is also a site grants its file's processes, services and env too, and lists them. The dialog carries the fingerprint of what it shows, read with it, and the grant is for that fingerprint: when the files no longer match it, the grant is refused and the dialog shows them again, so what's trusted is exactly what the person saw. Grants and removals are written to trust.json one at a time. leash project trust refuses without a terminal and has no --yes. leash changes commit|push --trust and leash workspace merge --hooks trust share one prompt: they list what the grant covers, with the folder and fingerprint, ask Trust … and run its hooks? [y/N], and refuse without a terminal, so no flag grants trust silently. leash project apply on a folder that isn't trusted asks first, and refuses without a terminal whatever its flags. On a trusted folder it asks before replacing Short Leash settings; without a terminal it refuses and lists the settings it would replace, unless --yes is given, which confirms only those replacements and never grants trust. MCP and chat have no tool that trusts or applies; the one tool, project_file, is a Read that shows the files, their problems and the trust state (invariant 15). Stop trusting ("No longer trusted") removes the grant, stops the file's processes and keeps its env to PHP's request parameters; services Apply added stay, as ordinary Short Leash services other sites may use.
Git hooks and trust. Hooks run only when the repository's root folder is trusted (and not Changed). In an untrusted repository that has hooks, committing from Changes offers Trust and run hooks, Commit without hooks, or cancel. Commit without hooks points core.hooksPath at an empty folder for that command rather than passing --no-verify, which would still run prepare-commit-msg and post-commit; it's shown disabled, with the reason, when the repository's own config names programs Git would run. For a site in a subfolder of a repository, the dialog trusts the repository's root and says so ("Trust shop's repository"), because the hooks belong to the repository, not the site. ADR 0081 records the rest.
Where trust is kept. <Short Leash home>/trust.json, 0600, written with fsx::write_private_atomic:
{"version":1,"folders":[{"path":"…","identity":"…","fingerprint":"…","parts":{"runs":"…","env":"…","folder":"…"},"grantedAt":1760000000000}]}
- Not
config.toml: people edit it by hand and sync it with their dotfiles, and its contents go into the API's state. - Not Short Leash's database (0029): it's a handful of records leashd needs while it builds the site list, kept until you stop trusting a folder or the folder is gone (records for missing folders are pruned when leashd starts), so invariant 11 needs no new table or retention rule.
- A
trust.jsonthat can't be read is set aside (trust.json.unreadable-…) rather than overwritten, and every folder falls back to untrusted.
Services from the file. A version matches a catalog version equal to it or starting with "<version>." ("17" → 17.x, "8.4" → 8.4.x), through services::resolve_version. An existing instance of that kind at a matching version is reused, and started if it's off; otherwise Apply adds one. valkey on Windows and garnet on macOS mean that system's Redis-compatible cache at its recommended version, with a note. A kind or version Short Leash doesn't offer, or a second version of a kind Short Leash runs only one of, is a problem that names what Short Leash offers or runs. Extensions: pgvector, which every Postgres already has, and postgis, added to that Postgres service as Add PostGIS would. CREATE EXTENSION stays with the project's migrations.
Offering to apply. A site whose file asks for something not in effect (acting keys not trusted, a Short Leash setting that differs, a service missing or stopped) shows "<domain> has leash.toml" with Review and Not now. Not now hides it until the merged files change, remembered only while leashd runs. leash link and leash park print the same hint.
Writing the file. leash init writes leash.toml from a site's settings in Short Leash, its resolved PHP and Node versions and the services its .env uses, leaving out anything that looks like a secret, and any of Short Leash's own values holding a hidden or control character Short Leash wouldn't read back, and naming what it left out. It doesn't replace an existing file without --force (in the app: "Replace leash.toml in …?"). When the folder is in a Git repository and Git doesn't already ignore leash.local.toml (asked through 0034's hardened Git, as Changes reads a repository), leash init adds it to the .gitignore in the site's folder (the repository's own when the site is the repository's root) and says so. The Overview warns when Git tracks a leash.local.toml, since it's meant to be one person's. A committed local file gets no extra trust: its acting keys need trust like the committed file's.
Alternatives
- The file over Short Leash's setting. Teams would get one source of truth, but a control in the app would then do nothing whenever the file set the same thing. A visible override with Use leash.toml's keeps both honest.
- Trust for the whole file. Hashing every key would ask again on each PHP bump or new alias, and people learn to click through prompts that come too often. Only keys that make Short Leash run or install something ask.
- Untrusted env everywhere, with a longer deny list. Shells, Git, Node, Python and others each read variables that make them run programs, and no list stays complete. Keeping an untrusted file's env to PHP's request parameters doesn't depend on one.
- Hashing the hook scripts too. Hook managers rewrite them on install and branch changes, so a grant would rarely hold, and the repository's code already runs when Short Leash serves the site.
- A grant per command or per service. More precise, but a dialog per key for one project; one grant per folder and fingerprint lists everything once.
- Searching parent folders. Would let a monorepo share one file, but which file applies would depend on where a site happens to sit. A subfolder site has its own file.
- YAML or JSON. TOML has comments, matches
config.toml, and Short Leash already reads it.
Consequences
- A pull can change a site's versions, docroot, names and HTTPS without asking, and the env PHP requests get. Changes to processes, services or env ask again before they reach anything else, so a trusted folder whose file changes a variable is asked about again, like a changed command. The Overview always says where each setting comes from.
- Short Leash's own setting can hide the file's value. The site's settings and Needs a look show it, so it isn't silent, but a teammate who set PHP by hand before the file existed keeps their version until they choose Use leash.toml's.
- A command or Python site from a fresh clone runs its server without the file's env until the folder is trusted; only PHP requests get it at once. The Overview shows the variables waiting for trust.
- A moved or renamed folder asks again, and so does one deleted and cloned again at the same path, or whose file stops parsing.
- Whatever triggers a reload (the files changing, a request for an unknown name, a change in Short Leash's settings), processes that no site lists any more are stopped: a file's processes after its folder stops being trusted or its file drops them, and every process of a site missing from the reload, such as one whose parked folder was deleted or whose volume isn't mounted. Each is looked at again just before it's stopped, so one a later reload brought back keeps running.
- Once a repository's root is trusted, its existing hook scripts can change without asking again, but a new hook of its own or a new Git setting that needs trust asks again before a Git action runs it. Everyone upgrading is asked once on their first commit or push in a repository that has hooks.
- Trust is per folder: a site in a repository's subfolder and the repository's root are separate grants. The site's grant covers its file's processes, services and env; the root's covers the hooks.
- A file's PHP or Node version that isn't installed is downloaded without asking, which
composer.jsonnever causes. - Trust protects against files that arrive with a clone or a pull. It doesn't protect against a program already running as you, which could edit
trust.jsonor call Short Leash's API, as it could editconfig.toml. That includes an AI coding agent with a shell, which could also fake a terminal: the terminal check stops accidents and other tools, not a determined program on your computer. - Secret-looking values never apply from either file, so a team keeps its secrets in
.env(or Short Leash's own site environment, on one computer), never inleash.toml.