Remote Sources via git clone
Sharing a team ULIS config should not require every user to clone a repository by hand and then point ULIS at the checkout. --source and preset refs accept a git repository URL over HTTPS or SSH. One primitive does the work: shallow-clone the repository into a temporary directory, then hand that directory to the existing code paths, so build, validate, install, prune, and the ownership manifest are unchanged and nothing after the clone knows the source was remote.
We clone rather than download. An HTTPS archive download unpacked with tar was rejected because it needs a streaming download, byte caps against decompression bombs, and a tarball path-traversal guard, and it supports neither private repositories nor SSH remotes. A per-file API crawl was rejected as host-specific and slow. A preset registry was rejected as far more product than the problem requires. Cloning deletes all of that machinery and inherits the user's existing git credentials.
GitHub and GitLab web URLs additionally parse a <ref> and a <subdir> out of the path, and a #<ref> fragment works on any host. Refs are branches and tags only; a commit SHA is rejected with an explicit message, because SHA checkout needs init plus fetch plus checkout rather than clone --branch. This is a known weakness, not merely an unbuilt feature: a branch is mutable, so a source reviewed yesterday and a source that runs today can differ without anything in the consent machinery noticing. The trust gate re-shows the commands on every run, which bounds the damage to what a command list reveals, but it does not make a remote source reproducible. A branch name shaped like a short SHA (seven or more hex characters) is refused along with real SHAs, since nothing local can tell the two apart before the clone.
Remote sources execute code the user did not write, so a trust gate stands between a pasted URL and the destination. It prints every command a remote source will run, exactly as it will be spawned, plus what it installs that a host agent later executes on its own, and requires confirmation. What that second list is read from moved: the first versions enumerated the parsed source - declared hooks, declared MCP servers, raw/ files matched by basename - and every bypass found in review lived in the gap between what a source declares and what a generator emits. A hook derived from a security policy, an unrecognised key a looseObject carried verbatim into YAML, a TOML table header spliced out of an mcp.yaml server name: none exists in the source as a hook or a server, all exist in the generated file. So the gate now generates the configs and reads the bytes that are about to be installed - reporting anything carrying a command, whatever format it is in - plus the trees copied through untouched, judged by destination effect rather than by filename, plus the approval settings that decide what runs unattended. Two consequences are deliberate: structural output is never built by concatenating source-controlled text (keys and table headers are quoted, so a value cannot end its own construct), and the enumeration is treated as best-effort, so the gate is armed by the presence of a remote source rather than by a non-empty list and says "nothing recognised" rather than "nothing to run". The gate turned out to need wider scope than a single CLI prompt. The TUI owns the terminal, so it cannot answer a prompt on stdin; instead its install review screen lists the same planned commands, generated by the same planner and sanitizer, and proceeding from that screen is the consent.
The gate has exactly one escape hatch, and it is deliberate: -y/--yes accepts it along with the overwrite prompt, because a non-interactive run has nobody to ask and a prompt that cannot be answered would only be a hang. A piped y does not answer it — the prompt requires a real TTY — so the hatch is a flag the user typed, never something stdin can supply. It does conflate two different consents (overwrite my files, run this repo's code) into one flag; the alternative, a second --trust-remote flag, was judged not worth the surface for a case where the user is already naming a URL on the command line.
Binding that consent to what actually runs is the part worth stating precisely. The review is bound to the settings it was generated from, so changing platforms or install options after reviewing invalidates it. That check alone would only be as good as the list of settings someone remembered to include in it, so the screen also hands the installer the exact command list it displayed, and the installer re-plans from the real install options at the point of execution and refuses to run anything that differs. Settings binding catches a changed source or destination, which a command list does not describe; the command comparison catches everything else, including drift nobody anticipated.
Two smaller decisions follow from the same "remote input is hostile" premise. Remote text is escaped at the sinks it can reach before the trust gate runs, because those logs are what the user reads while deciding: the name from a remote preset.yaml is sanitized where it enters, since it flows into fields no logger sees, and everything else — copied file names, alias names, child process output — is escaped in the logger itself rather than at each call site, so a build path that never touches the installer's own wrappers is still covered. And argv containing shell metacharacters or spaces is refused outright on Windows: .cmd shims force shell: true, where Node concatenates arguments instead of escaping them, so a manifest entry named pkg & calc would run a second command that the preview renders as a single harmless argument.
Handling credentials safely also took more than the plan anticipated. A URL may carry user:password@, and git echoes the remote back verbatim in its own error output, so every displayed or persisted form of a URL is redacted, and URL shapes that redaction cannot reliably strip — whitespace in the authority, an empty authority, a comma inside userinfo where preset lists are comma-separated — are rejected outright rather than printed. build --source <url> is rejected too: build writes generated output into the source tree, which is then discarded.
Consequences
gitbecomes a hard runtime dependency for remote sources. ULIS previously shelled out only tonpx/bunx. There is no archive fallback, because a fallback would resurrect everything this decision deletes.- A failed clone of a
github.comrepository is retried once throughgh repo clone, whenghis onPATH.ghcarries a GitHub token that plaingitmay not see, which is the common reason a private-repo clone fails on a machine where the user is otherwise signed in. It is optional, not a second hard dependency: withoutghthe original failure stands. The retry fires on any non-zero exit rather than on parsed stderr, so an unrelated failure costs one extra clone attempt. - Refs are limited to branches and tags. A branch name containing
/needs the#<ref>fragment form, because the web-URL form takes the first path segment as the ref. - Nothing is cached. Every run clones, and the temporary directory is removed on success, on failure, and on interrupt, so there is no cache invalidation question and no diverged local copy.
- Clones are shallow, single-branch, and time-limited, with terminal and ssh prompts disabled so an unauthenticated private repository fails fast instead of hanging on a credential prompt. Symlinks in a cloned tree are rejected, since a committed symlink could otherwise point outside the clone.
- A remote source installs to the current directory, or to the home directory with
--global, because a temporary directory has no meaningful parent to install alongside. - A skill or extension argument containing a shell metacharacter or a space is refused on Windows rather than escaped. Quoting for
cmd.exeis hard to get right; a package name has no legitimate use for a metacharacter, and a spaced argument is written as separate entries instead. - Byte caps were deliberately not added. A hostile repository can still fill a disk within the clone timeout, which is equally true of the
git clonethe user would otherwise run by hand.
Revisit if
- Git-less environments need remote sources, which would require reopening the archive-download path along with its size caps and traversal guard.
- Users need to pin a source to an exact commit. This is the weakness above rather than a request to wait for: it would replace
clone --branchwithinitplusfetchpluscheckout, and would also give the SHA-shaped-branch check something better to do than refuse.