0081: Changes can write to Git
On this page
Status: accepted
Context
A site's Changes page shows its uncommitted changes and only reads them (0034). People review a coding agent's work there, then switch to a terminal or an editor to stage, commit and push it. Writing to Git is a different risk from reading: a repository is untrusted input, and committing or pushing runs its hooks and any program its config names, as the person, with their keys. A push can also overwrite a remote, and a discard can lose work that was never committed.
Decision
- Reads don't change. Every rule of 0034 stays, and reads still go through its
Git::command. Only its read-only rule is replaced. - What Changes can write. Set up Git (
git init, with an optional first commit), Stage, Unstage, Stage all, Unstage all, Discard, Commit, Commit & push and Push. No force push, rebase, branch switching, merge, amend or stash. - A separate write runner (
changes::write), so the read runner's rules stay as they are. It:- removes the same
GIT_*variables as reads, and keepsGIT_CEILING_DIRECTORIESat the home folder andsafe.bareRepository=explicit; - sets
GIT_TERMINAL_PROMPT=0,GCM_INTERACTIVE=never,GIT_EDITOR=:and no pager, so Git never asks for anything or opens an editor. It never runs an askpass program either:GIT_ASKPASSis empty (so Git skipscore.askPassandSSH_ASKPASS),core.askPassis emptied with-c, andSSH_ASKPASS_REQUIRE=never; - drops the variables that change how Git reads a pathspec (
GIT_ICASE_PATHSPECSand the like) and passes every path as:(literal), so a path means only that path. Long lists are split into several Git calls of under 8,000 characters of paths each, for Windows' command-line limit, stopping at the first that fails; - puts the site's runtimes and the login shell's
PATHon thePATH, as the site's processes get them (runtimes::apps::login_shell_path), so hooks findphp,pintandnpx. Only absolute folders are kept, sossh,gpgor a hook's tools are never found inside the repository. Reads drop relativePATHentries too; - passes on an
SSH_AUTH_SOCKsent by the app or the CLI that asked, because leashd on macOS runs from launchd without one. leashd accepts it on macOS only when it is a socket owned by the user, and on Windows only an agent's named pipe or an existing absolute path; - has no time limit, streams Git's output to the page as it comes (
Event::ChangesOutput) and can be stopped with Stop. The output, which can hold anything a hook prints, is kept in memory only, up to 1 MB per write, and never stored; - always sets
GIT_PROTOCOL_FROM_USER=0andGIT_ALLOW_PROTOCOL=https:http:ssh:git:fileand passes-c protocol.ext.allow=never, soext::and other transports stay closed whatever a repository's config says, as a second guard behind the trust rules below. This holds in trusted repositories too: a remote that needs a remote helper fails with "This remote needs a Git helper Short Leash doesn't run. Push from a terminal." Without hooks,fileis dropped as well, because Git doesn't passcore.hooksPathon to a repository it reaches as a folder, so pushing into one would run that repository's hooks; - never goes into a submodule, whatever the person's own config says: every write passes
-c submodule.recurse=false,-c push.recurseSubmodules=noand-c fetch.recurseSubmodules=false, and push adds--no-recurse-submodules. A submodule's own config sits inside the repository, where no trust check reads it. Git passes-csettings on to the Git commands a hook runs, so a trusted repository's hooks don't go into submodules by default either. Reads pass-c submodule.recurse=falsetoo (0034); - never starts Git's housekeeping (
-c gc.auto=0,-c maintenance.auto=false), which would go on in the background after the write, outside its lock and Stop; - never fetches from a remote unless it's a push: in a partial clone, Git would otherwise fetch missing objects while staging, committing or discarding (
GIT_NO_LAZY_FETCH=1). A push the person starts keeps lazy fetch, unlike reads (0034), so in a partial clone it may download objects the clone left on its remote, through the transports above; - takes Git's locks, unlike reads. When another Git command holds
index.lock, the write stops before changing anything and says Git is busy; - runs one write at a time.
- removes the same
- Trust. A folder is trusted through a project file's trust (ADR 0078, read with
Engine::folder_trust). A folder whoseleash.tomlchanged since it was trusted counts as untrusted, and one grant covers its processes, services and hooks. Git actions use the trust of the repository's top folder. The grant records the digest of the repository's hooks and settings that need trust (below), and Git actions count the folder as untrusted (Changed) when that digest differs now, so a hook or setting added after the grant (by a pull, or a branch that adds.husky/pre-push) asks again rather than running on the earlier grant (project::trust::git_state). A grant written before Short Leash kept the digest has none, and asks once. For a site in a subfolder of a bigger repository, the trust dialog in Changes trusts the repository's top folder and says that it trusts the whole repository, not only the site's folder. - Settings that need trust. These settings make Git run a program, change where it connects or how it checks the connection, change the content it stores, or move the files it changes. They count when they are set in the repository's own config, its worktree config, or any file those include (
include.path,includeIf), in every scope, URL-scoped forms such ashttp.<url>.*included. The person's global and system config are their own and don't count, except that a remote configured there is a remote. Reading the scopes apart needsgit config --show-scope, so Changes' Git actions need Git 2.26 or later; with an older Git every write is refused with "Changes' Git actions need Git 2.26 or later. Update Git, then try again." and reads work as before. The rules are prefixes, not single keys (changes::hooks::needs_trust, tested rule by rule):filter.*,diff.*.textconv,diff.*.commandandmerge.*.driver;core.fsmonitor,core.hooksPath,core.sshCommand,core.gitProxy,core.editor,core.pager,core.askPassandsequence.editor;core.worktree, which moves the working tree, and with it what stage and discard change, somewhere else;hook.*,credential.*andgpg.*;- any
protocol.*key, the top-levelprotocol.allowincluded; - any
url.*key; - any
http.*key in any scope, which coverssslVerify,sslCAInfo,sslCAPath,sslCert,sslKey,proxy,curloptResolveandcookieFile; remote.*.uploadpack,remote.*.receivepack,remote.*.proxyandremote.*.vcs;- a remote URL (
remote.*.url,remote.*.pushurl) that's a folder on this computer (.andfile://included), uses a<helper>::transport, or has a scheme other than HTTPS, HTTP, SSH and Git, each of which makes Git run a program or reach another repository's hooks; - a remote a branch pushes to (
branch.*.remote,branch.*.pushRemote,remote.pushDefault) that isn't a remote with aremote.<name>.urlin any config, unless it's a URL as above: Git takes any other name as a folder; - any file in the repository's
.git/remotesor.git/branchesfolder, old ways of defining remotes that Git's config never shows, named asremotes/<name>orbranches/<name>; submodule.*.update,submodule.recurseandpush.recurseSubmodules. Othersubmodule.*keys don't count:git submodule initwrites a submodule'surlandactive,ext::URLs are closed by the runner, and writes never go into submodules anyway (above).- A repository whose own config sets
core.hooksPath, such as one with a committed hooks folder, needs trust for every write too. This is intended: the setting points Git at programs from the repository, and Short Leash treats it like the others rather than as a hook it could skip.
- Fail closed. In an untrusted repository with any of those settings, Changes refuses every write action (stage, unstage, discard, commit and push; Set up Git only runs where there's no repository yet) with a message that names the settings it found as Git prints them, section and name in lower case, such as "This repository's Git settings (filter.crypt.clean, http.sslverify) run programs or change where Git connects. Trust the repository to use Git actions here." The button is Trust and run hooks for a commit or push in a repository with hooks, and Trust repository otherwise. A stage, unstage or discard runs no hooks, so its button is always Trust repository, and the dialog names the hooks that trusting lets a later commit or push run. That one name per case is used in the dialog, the docs and the CLI. Once the folder is trusted, writes run with the repository's config as the person's own
gitwould. Writes never turn a repository's filters off: that would store unfiltered content, such as a git-crypt secret in plain text, a notebook's outputs or an LFS file's raw bytes, and a discard would write the stored blob over the real file. Reads keep 0034's hardening, filters off included. - Hooks. Stage, unstage, discard and Set up Git never run hooks, even in a trusted folder:
core.hooksPathpoints at a folder that can't exist (/dev/null, orNULon Windows), and everyhook.*setting in effect is emptied for that command (throughGIT_CONFIG_COUNT, so any name fits). When those settings can't be read, the write doesn't run. Commit and push run hooks only in a trusted folder.- Untrusted means no hooks at all. In a folder that isn't trusted, every write runs that way, whatever Short Leash found: no repository hook runs, the person's own global hooks don't either (one rule, with nothing to explain), and no local folder is reachable as a remote. Finding hooks only decides when to ask; it's never what keeps them from running. An untrusted push also passes
-c push.pushOption=(empty), so the repository's own push options don't reach the server. - Whose hooks. A repository's own hooks are those in
.git/hooksthat Git would run (on Unix only with an execute bit), without.samplefiles, or in acore.hooksPathset in its own config (which needs trust anyway, above). A relativecore.hooksPathfrom the person's own config is resolved against the repository, as Git does, so it names the repository's files and counts as its hooks. The hooks counted are a commit's (pre-commit,prepare-commit-msg,commit-msg,post-commit,post-index-change,reference-transaction, andpre-auto-gc) and a push's (pre-push,reference-transaction). A trust question's digest covers every hook any of Short Leash's Git writes runs (changes::hooks::write_hooks): these, and a branch workspace'sworktree addandmergehooks (0082), since one grant runs them all. - The question. In an untrusted folder, a commit or push with the repository's hooks asks first: Trust and run hooks, Commit without hooks (Push without hooks), or cancel. One with nothing found goes ahead without asking, and without any hook: finding nothing isn't trust. Commit without hooks points
core.hooksPathat a folder that can't exist rather than passing--no-verify, because--no-verifystill runsprepare-commit-msgandpost-commit, and the button promises no hooks at all. When the repository also has settings that need trust, skipping hooks can't help, and the question is the refusal above, without Commit without hooks. One grant covers more than hooks, so the question (and the refusal's 409 answer) lists all of it, as 0078's trust dialog does: the top folder'sleash.tomlprocesses, services and env (or that a project file there can't be read), then the hooks and settings found. It carries the fingerprint of the project files it shows and a digest of the repository's hooks (every write's, since one grant runs them all) and the settings that need trust. Trust and run hooks hands both back with the commit, and Trust repository after a refused stage, unstage or discard hands them toPOST /v1/sites/{name}/changes/trust, which trusts the repository's top folder even for a site in one of its folders, so the write can be tried again. The digest covers the hooks' and settings' names, as the question shows them, not a hook script's contents or a setting's value: trusting the repository trusts its code, as 0078 says. The grant keeps it, and every later Git action compares it with the repository's digest then, so new names ask again while a hook manager rewriting a script doesn't. The question also names the folder it would trust, and a top folder that doesn't hold the site (a repository's owncore.worktreecan point Git anywhere) is never trusted from it. When the project files, hooks or settings changed since, nothing is trusted, no hook runs and nothing is written: "~/Sites/shop changed since you looked. Look at it again, then trust it." Like 0078's grant, this is the person's alone: no MCP or chat tool calls the trust route or sendstrustAndRun(invariant 15).
- Untrusted means no hooks at all. In a folder that isn't trusted, every write runs that way, whatever Short Leash found: no repository hook runs, the person's own global hooks don't either (one rule, with nothing to explain), and no local folder is reachable as a remote. Finding hooks only decides when to ask; it's never what keeps them from running. An untrusted push also passes
- Every discard can be recovered from the Trash. Discard asks first and names what will be lost, file by file, and says where each file's copy goes:
- before Git restores a tracked file, Short Leash writes a copy of its current contents into a temporary folder inside Short Leash's own
discardedfolder, outside the working tree, named after the file with "(discarded)", such asapp.php (discarded), and moves that copy to the Trash (macOS) or Recycle Bin (Windows). If the move fails, the temporary copy is removed and that file's discard doesn't happen, so its working tree is untouched. Then unstaged changes go back to the staged version, so a partly staged file keeps its staged part and the dialog says so, and a fully staged file goes back toHEAD. A file that's gone from disk has nothing to copy: it just comes back, and the dialog says so rather than promising a copy; - untracked and newly added files, and folders, move to the bin themselves;
- a staged rename gets its old name and committed content back, and the new file moves to the bin;
- a staged version that would otherwise be lost (a new file changed or deleted since it was staged, or a staged rename's) goes to the bin as its own copy, such as
app.php (discarded, staged). It's the staged blob exactly as Git stores it, so for a file Git stores through a filter, such as git-crypt or Git LFS, it's the encrypted file or the LFS pointer; - a file with a merge conflict is refused.
- The dialog's plan carries a fingerprint of each file; if a file changed after the dialog showed it, nothing is discarded and the page asks the person to look again.
- Moves use the
trashcrate (MIT), which keepsunsafeout of Short Leash's code. On macOS it uses the file manager API (NSFileManager trashItemAtURL) rather than Finder, so no Automation permission is asked for. When the bin can't take an item, such as on a volume without a Trash, discard fails and deletes nothing. On Windows, network folders, which Windows never recycles, are refused first, and for an item too big for the Recycle Bin Windows asks whether to delete it permanently: declining fails the discard, and deleting is the person's own choice in Windows' prompt. - If leashd stops between making a copy and moving it, the copy stays in Short Leash's
discardedfolder; at the next start, leashd moves copies older than a day to the bin, never deleting one. - Several files are discarded one at a time, stopping at the first failure, and the result lists which files were discarded and which weren't. For each file the bin comes first, so a single file is either discarded with its copy in the bin or left exactly as it was.
- before Git restores a tracked file, Short Leash writes a copy of its current contents into a temporary folder inside Short Leash's own
- Commit reads the message from standard input and never opens an editor. Draft message fills the box with the local model (0080); the message stays editable and nothing is committed without a click. Commit refuses an empty message, nothing staged, and files staged outside the site's folder when the site is one folder of a bigger repository, since those were never on the page. Afterwards it shows the final message, which a
prepare-commit-msgorcommit-msghook may have rewritten. The answer says whether a hook ran (hooks were on, and a hook file Git would run or ahook.*setting was there), and the page says a hook changed the message only then, comparing the two the way Git cleans up a message (trailing spaces and extra blank lines), so Git's own tidying never reads as a hook's change. Signing set in the person's own config uses their key and agent; when signing fails, or Git doesn't know their name and email, the error says what to run in a terminal. - Push sends
HEADto its upstream with an explicit refspec,HEAD:refs/heads/<branch>, to the remote and branch the branch's own config names (branch.<name>.remoteandbranch.<name>.merge), which can differ from the local name. A remote without aremote.<name>.urlis refused, because Git would take its name for a folder. Push refspecs in the repository's config, forced ones included, never apply, and a detachedHEADis refused. It passes--no-follow-tags, so only the branch is pushed, never tags. It never passes--force,--force-with-leaseor a+refspec, and when the remote has moved on it says to pull in a terminal first. With no upstream it offers to push with--set-upstream origin <branch>. The page offers Push whenever the branch has commits to push (ahead of its upstream, or with an upstream Git can't count against until it's fetched), with uncommitted changes too, and on a clean branch that isn't on a remote yet. It uses the person's Git and its credential helper and never prompts for or sees credentials: when Git can't find any, it says "Sign in to this remote once in a terminal, then push again." - While a write runs, the page shows a starting lamp with a word and three dots, and Stop: Committing and Pushing at once, and Staging, Unstaging, Discarding, Setting up Git and Trusting only when they take longer than 400 ms, so a quick one doesn't flash. What Git and hooks print is shown as plain text, never as markup.
- CLI and MCP.
leash changes stage|unstage|discard|commit|push.--truston a commit or push shows what trusting covers and asks at a terminal, and refuses without one (0078);--no-verifygoes on without hooks.leash changes trusttrusts on its own, for a stage, unstage or discard that's refused, or a repository whose hooks changed: it reads what trusting covers fromGET /v1/sites/{name}/changes/trust(the refusal's fields andtrusted; reading trusts nothing), asks with the same terminal prompt, and posts what it showed to the trust route. It has no MCP tool. Text from leashd and the repository is printed with hidden characters as codes. The MCP toolschanges_stage,changes_unstageandchanges_commitareAct;changes_discard(it destroys work) andchanges_push(it sends data out) areNeverInChat(invariant 15, 0062).changes_commit'sno_verifyargument, which commits without hooks, is hidden from chat, so chat's commits can't skip hooks.
Consequences
- This is the first time Short Leash changes a person's repository. Every write goes through one runner with its own tests, and every discard leaves a copy in the Trash or Recycle Bin, which fills with "(discarded)" copies until it's emptied.
- In an untrusted folder, hooks behave differently from a terminal: Short Leash asks first, and Commit without hooks also stops
prepare-commit-msgandpost-commit, which--no-verifyin a terminal wouldn't. - Trusting a folder now also lets its hooks and its Git settings run when you use Changes' Git actions; the trust dialog says so.
- A repository whose own config has a setting that needs trust can't use any Git action in Changes until it's trusted. That includes common setups: hooks in a committed folder set with
core.hooksPath, git-crypt, and Git LFS installed for one repository. - The person's own global hooks don't run in an untrusted folder either, unlike in a terminal, until they trust it.
- Put Back on a "(discarded)" copy in the Trash points at the temporary folder it was made in, which is gone by then; the copy has to be dragged back into the site instead. Files that moved to the bin from the site's folder go back with Put Back (Restore on Windows) as usual.
- The
trashcrate is a new dependency, recorded inTHIRD_PARTY_NOTICES.md. - Pushing over SSH needs an agent the app or CLI can see; the macOS daemon has none of its own.
- Branch workspaces (ADR 0082) add worktrees, merge and push through the same runner and the same trust rules.
- Force push, rebase and branch switching are left for later.