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 keeps GIT_CEILING_DIRECTORIES at the home folder and safe.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_ASKPASS is empty (so Git skips core.askPass and SSH_ASKPASS), core.askPass is emptied with -c, and SSH_ASKPASS_REQUIRE=never;
    • drops the variables that change how Git reads a pathspec (GIT_ICASE_PATHSPECS and 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 PATH on the PATH, as the site's processes get them (runtimes::apps::login_shell_path), so hooks find php, pint and npx. Only absolute folders are kept, so ssh, gpg or a hook's tools are never found inside the repository. Reads drop relative PATH entries too;
    • passes on an SSH_AUTH_SOCK sent 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=0 and GIT_ALLOW_PROTOCOL=https:http:ssh:git:file and passes -c protocol.ext.allow=never, so ext:: 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, file is dropped as well, because Git doesn't pass core.hooksPath on 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=no and -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 -c settings 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=false too (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.
  • Trust. A folder is trusted through a project file's trust (ADR 0078, read with Engine::folder_trust). A folder whose leash.toml changed 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 as http.<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 needs git 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.*.command and merge.*.driver;
    • core.fsmonitor, core.hooksPath, core.sshCommand, core.gitProxy, core.editor, core.pager, core.askPass and sequence.editor;
    • core.worktree, which moves the working tree, and with it what stage and discard change, somewhere else;
    • hook.*, credential.* and gpg.*;
    • any protocol.* key, the top-level protocol.allow included;
    • any url.* key;
    • any http.* key in any scope, which covers sslVerify, sslCAInfo, sslCAPath, sslCert, sslKey, proxy, curloptResolve and cookieFile;
    • remote.*.uploadpack, remote.*.receivepack, remote.*.proxy and remote.*.vcs;
    • a remote URL (remote.*.url, remote.*.pushurl) that's a folder on this computer (. and file:// 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 a remote.<name>.url in any config, unless it's a URL as above: Git takes any other name as a folder;
    • any file in the repository's .git/remotes or .git/branches folder, old ways of defining remotes that Git's config never shows, named as remotes/<name> or branches/<name>;
    • submodule.*.update, submodule.recurse and push.recurseSubmodules. Other submodule.* keys don't count: git submodule init writes a submodule's url and active, 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 git would. 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.hooksPath points at a folder that can't exist (/dev/null, or NUL on Windows), and every hook.* setting in effect is emptied for that command (through GIT_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/hooks that Git would run (on Unix only with an execute bit), without .sample files, or in a core.hooksPath set in its own config (which needs trust anyway, above). A relative core.hooksPath from 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, and pre-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's worktree add and merge hooks (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.hooksPath at a folder that can't exist rather than passing --no-verify, because --no-verify still runs prepare-commit-msg and post-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's leash.toml processes, 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 to POST /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 own core.worktree can 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 sends trustAndRun (invariant 15).
  • 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 discarded folder, outside the working tree, named after the file with "(discarded)", such as app.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 to HEAD. 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 trash crate (MIT), which keeps unsafe out 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 discarded folder; 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.
  • 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-msg or commit-msg hook may have rewritten. The answer says whether a hook ran (hooks were on, and a hook file Git would run or a hook.* 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 HEAD to its upstream with an explicit refspec, HEAD:refs/heads/<branch>, to the remote and branch the branch's own config names (branch.<name>.remote and branch.<name>.merge), which can differ from the local name. A remote without a remote.<name>.url is refused, because Git would take its name for a folder. Push refspecs in the repository's config, forced ones included, never apply, and a detached HEAD is refused. It passes --no-follow-tags, so only the branch is pushed, never tags. It never passes --force, --force-with-lease or 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. --trust on a commit or push shows what trusting covers and asks at a terminal, and refuses without one (0078); --no-verify goes on without hooks. leash changes trust trusts on its own, for a stage, unstage or discard that's refused, or a repository whose hooks changed: it reads what trusting covers from GET /v1/sites/{name}/changes/trust (the refusal's fields and trusted; 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 tools changes_stage, changes_unstage and changes_commit are Act; changes_discard (it destroys work) and changes_push (it sends data out) are NeverInChat (invariant 15, 0062). changes_commit's no_verify argument, 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-msg and post-commit, which --no-verify in 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 trash crate is a new dependency, recorded in THIRD_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.