From 7263f85936f973c4df68194e5b9d5b5d120a2e79 Mon Sep 17 00:00:00 2001 From: Torben Nehmer Date: Tue, 1 Sep 2026 18:06:55 +0200 Subject: [PATCH] feat: Publish-Remote Gate und die Anleitung fuer eine private Instanz (2.2.0) Files changed: - .gitignore - AGENTS.md - CHANGES.md - VERSION - instructions/gates.md - instructions/private-instance.md - tools/chemenu/commands/doctor.py - tools/chemenu/commands/git_publish.py - tools/chemenu/config.py - tools/chemenu/tests/test_git_publish.py --- .gitignore | 7 ++ AGENTS.md | 11 ++- CHANGES.md | 58 ++++++++++- VERSION | 2 +- instructions/gates.md | 45 ++++++++- instructions/private-instance.md | 125 ++++++++++++++++++++++++ tools/chemenu/commands/doctor.py | 39 +++++++- tools/chemenu/commands/git_publish.py | 100 +++++++++++++++++++ tools/chemenu/config.py | 16 +++ tools/chemenu/tests/test_git_publish.py | 113 +++++++++++++++++++++ 10 files changed, 506 insertions(+), 10 deletions(-) create mode 100644 instructions/private-instance.md diff --git a/.gitignore b/.gitignore index e3ab2d0..60ef0d5 100644 --- a/.gitignore +++ b/.gitignore @@ -110,6 +110,13 @@ npm-debug.log* # `dist export`; this anchored pattern deliberately does not match it. /ENVIRONMENT.md +# Publish-Remote Gate allowlist (see instructions/gates.md). Names the push +# URLs *this* checkout may publish to, so it is per-checkout for exactly the +# reason ENVIRONMENT.md above is: a committed copy would tell a private clone +# that the public upstream is a legitimate target for its own content. Absent +# means unrestricted; `doctor` reports which. +/.wikitool-remotes.json + # Coverage output from `pytest --cov` (see .gitea/workflows/ci.yml). Derived, # like reports/: recomputable from any commit, and `publish` runs `git add -A`, # so an unignored htmlcov/ would commit itself on the next content publish. diff --git a/AGENTS.md b/AGENTS.md index 62d6d2c..77b7490 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,9 @@ tools/wikitool instructions sync Full procedure, including the tool environment: [instructions/bootstrap.md](instructions/bootstrap.md). Setting up a brand-new, empty instance instead of cloning this one: `tools/wikitool dist export` and [instructions/setup-instance.md](instructions/setup-instance.md) - see -[INSTALL.md](INSTALL.md). +[INSTALL.md](INSTALL.md). A *private* instance that keeps taking stack updates from a public +upstream is a third shape, with a safeguard the other two do not need: +[instructions/private-instance.md](instructions/private-instance.md). ## Invariants @@ -177,16 +179,19 @@ tools/wikitool search --field entity_type=system --field 'confidence<0.6' ## Gates -Two limits are enforced in code rather than by instruction, because a prompt-level limit is +Three limits are enforced in code rather than by instruction, because a prompt-level limit is one an agent can talk itself past. - **Mass-Update Gate.** `publish` exits **42** on a change touching too many files, printing the file list and the `--confirm ` line that publishes it once the user approves. The threshold and the rule live in [instructions/gates.md](instructions/gates.md). +- **Publish-Remote Gate.** `publish` exits **42** on a push to a URL this checkout has not + declared in `.wikitool-remotes.json`. It has no token and no flag: the way past it is a + deliberate edit by the user, never by an agent. - **Iteration Budget Gate / Loop-Breaker.** Past 60 `wikitool` calls in a session, or after 3 identical calls in a row, further calls are refused. -Both refuse with exit 1. **Do not retry, and do not open the gate.** Stop, summarize the +The last refuses with exit 1. **Do not retry, and do not open a gate.** Stop, summarize the situation to the user, and get explicit approval. The full procedure - including why `budget reset` is not the escape hatch - is [instructions/gates.md](instructions/gates.md). diff --git a/CHANGES.md b/CHANGES.md index 1091bec..4593bb3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -20,6 +20,62 @@ their date-only headings. --- +## 2.2.0 - 2026-09-01 - Publish-Remote Gate: publish schreibt nur an erklaerte Ziele + +**Author:** Torben Nehmer + +Der Stack bekommt sein drittes Gate. Die beiden bestehenden fragen, ob eine Änderung zu groß +ist und ob ein Rebase gefährlich ist. Dieses fragt, was darunter liegt: **ob das überhaupt das +richtige Repository ist.** + +**Das Problem entsteht erst durch die private Instanz.** Ein Checkout mit eigenem Inhalt hat +typischerweise zwei Remotes — sein eigenes und das öffentliche Upstream, von dem er +Stack-Updates zieht. Git unterscheidet die beim Push nicht, also legt ein falsches `--remote` +einen privaten Korpus auf ein öffentliches Repository. Das ist nicht billig rückholbar, und +zwar nachweislich: Beim Veröffentlichen dieses Repos blieb die gesamte alte History nach dem +Force-Push per SHA abrufbar, bis auf dem Server die Reflogs verfielen und `git gc --prune=now` +lief. Ein Force-Push bewegt den Branch, nicht die Objekte. + +**`.wikitool-remotes.json` nennt die erlaubten Push-URLs.** Nicht die Remote-*Namen*: Eine +Namensliste ließe ein `publish` durch, dessen `origin` umgebogen wurde, und genau das ist der +Fall, den das Gate fangen soll. Gelesen wird die `pushurl`, wenn der Remote eine setzt, denn +dorthin schreibt `git push` tatsächlich. + +**Pro Checkout und gitignored**, aus demselben Grund wie `ENVIRONMENT.md`: Zwei Klone pushen an +zwei verschiedene Orte, eine committete Kopie würde einem privaten Klon also mitteilen, das +öffentliche Upstream sei ein legitimes Ziel für seinen eigenen Inhalt. **Fehlt die Datei, gilt +keine Beschränkung** — ein Checkout mit einem Remote und ohne Privates hat nichts zu schützen, +und eine Pflichtdatei würde aus einer Sicherung Papierkram machen. Eine *kaputte* Datei ist +dagegen ein Fehler und kein „keine Beschränkung": Eine beschädigte Sicherung darf sich nicht +wie eine abgeschaltete verhalten. + +**Kein Token, keine Flagge.** Die anderen beiden Gates lösen sich mit einem `--confirm `, +weil ihre Frage („ist diese Änderung richtig?") für genau ein Changeset beantwortbar ist. Dieses +fragt „gehört dieser Inhalt in jenes Repository?", und das ist eine stehende Eigenschaft des +Checkouts, kein Einzelfallurteil. Der Weg daran vorbei ist ein bewusster Edit des Nutzers. +Ein Agent, der die Datei anfasst, um an einer Verweigerung vorbeizukommen, öffnet ein Gate aus +eigenem Antrieb — Invariante 6. + +**`doctor` meldet den Zustand** statt ihn zu erzwingen: OK mit Anzahl der Ziele, OK bei +Abwesenheit mit einem Remote, und WARN bei mehr als einem Remote ohne Allowlist — also genau in +der Form, die eine private Instanz annimmt, sobald sie das Upstream hinzufügt. + +**Und die Prozedur, für die das Gate gebaut wurde.** `instructions/private-instance.md` (neu) +beschreibt die dritte Instanz-Form neben „frisch aufsetzen" und „Repo klonen": eine private +Arbeitsinstanz, die Stack-Updates von einem öffentlichen Upstream per `git merge` zieht und +deren eigener Inhalt nie zurückwandert. Der Grund, warum das dem Tarball-Weg vorzuziehen ist, +steht dort ausformuliert — `cp -r` hat keinen Drei-Wege-Merge und keine Konflikterkennung. +Schritt 4 der Anleitung ist das Gate, und zwar ausdrücklich **vor** dem ersten `publish`: +später hinzugefügt schützt es das Fenster nicht, das es schließen soll. + +**Dateien:** `config.PUBLISH_REMOTES_FILENAME`, `git_publish.read_allowed_push_urls()`, +`push_url_for()`, `publish_remote_refusal()` und die Prüfung vor dem Reconcile-Schritt, +`doctor.check_publish_remotes()`, `.gitignore`, `instructions/gates.md`, +`instructions/private-instance.md` (neu), `AGENTS.md` (Gate-Liste und Bootstrap-Routing), +12 neue Tests in `test_git_publish.py`. + +--- + ## 2.1.1 - 2026-09-01 - raw_dir-Fixture kappt config.ROOT; letzte private Fixture-Namen ersetzt **Author:** Torben Nehmer @@ -47,7 +103,7 @@ Nachgewiesen, indem `raw/documents/` lokal entfernt und die Suite erneut gefahre Provenance-Tests grün ohne das Verzeichnis, vorher rot. **Fixture-Namen.** Die Suite benutzte weiterhin reale Systemnamen der Ursprungsinstanz als -Fixture-Bezeichner (`atlantis`, `abydos`, `Nathan`, `hermes`, `ecodms`, `Opa Hasso`). In einem +Fixture-Bezeichner. In einem öffentlichen Repo beschreiben sie nichts, verraten aber die Namensgebung einer privaten Umgebung. Ersetzt durch `aurora`, `almanac`, `Borealis`, `gateway`, `docstore`. `gdeploy` bleibt: die Seite existiert im öffentlichen Korpus. diff --git a/VERSION b/VERSION index 3e3c2f1..ccbccc3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.1.1 +2.2.0 diff --git a/instructions/gates.md b/instructions/gates.md index c98c5dc..625105b 100644 --- a/instructions/gates.md +++ b/instructions/gates.md @@ -22,10 +22,11 @@ Read the exit code first - it says which of these applies: ## Exit 42: user clearance required A `wikitool` command that exits **42** is not reporting an error. It is refusing to act until a -human has *read its output*. Two gates use it today - the Mass-Update Gate (`publish`, on a -change touching 10 or more counted files) and the rebase-review gate (`sync` and `publish`, on -a rebase whose incoming commits touch a file this session is also changing) - but the rule is -about the exit code, not the command: +human has *read its output*. Three gates use it today - the Mass-Update Gate (`publish`, on a +change touching 10 or more counted files), the rebase-review gate (`sync` and `publish`, on +a rebase whose incoming commits touch a file this session is also changing), and the +Publish-Remote Gate (`publish`, on a push to a target this checkout has not declared) - but the +rule is about the exit code, not the command: > **Copy the command's output into your reply - the substance of it, not a description of it - > and stop.** Run no further commands in that turn. @@ -62,6 +63,42 @@ clearance. Background: [[Mass-Update Gate]] (`kb/concepts/Mass-Update Gate.md`). +### Publish-Remote Gate + +The Mass-Update Gate asks whether a change is too large to publish. This one asks the question +underneath it: **whether this is the right repository to publish to at all.** + +A checkout that holds private content usually has two remotes - its own, and the public upstream +it takes stack updates from. Git does not distinguish them at push time, so one wrong `--remote` +puts a private corpus on a public repository. That is not cheaply reversible: a force-push moves +the branch, but the objects stay fetchable by SHA until someone expires the server's reflogs and +runs `git gc --prune=now` on the bare repo. + +`.wikitool-remotes.json` names the push URLs a checkout permits: + +```json +{ "schema": 1, "allowed_push_urls": ["ssh://git@example.net:22/you/your-wiki.git"] } +``` + +It pins **URLs, not remote names** - a name-based list would wave through a `publish` whose +`origin` had been repointed, which is the failure it exists to catch. It reads the remote's +`pushurl` when one is set, because that is where `git push` actually writes. + +The file is per-checkout and gitignored, for the same reason `ENVIRONMENT.md` is: two clones push +to two different places, so a committed copy would tell a private clone that the public upstream +is a legitimate target for its own content. **Absent means unrestricted** - a single-remote +checkout with nothing private in it has nothing to protect, and `doctor` reports which state a +checkout is in, WARNing only when there is more than one remote and no allowlist. A malformed +file is an error rather than "no restriction": a corrupted safeguard must not read as a disabled +one. + +**This gate has no `--confirm` token, on purpose.** The other two clear with a token because the +question they ask ("is this change right?") is one the agent can put to the user and the user can +answer for that one changeset. This one asks "does this content belong to that repository?", which +is a standing property of the checkout, not a per-push judgment. The way past it is for the user +to add the URL to the file. **An agent must never edit `.wikitool-remotes.json` to get past a +refusal** - that is opening a gate on your own initiative, which AGENTS.md invariant 6 forbids. + ## Iteration Budget Gate and loop-breaker Every `wikitool` call is counted per session. Calls are refused past **60 in a session**, or diff --git a/instructions/private-instance.md b/instructions/private-instance.md new file mode 100644 index 0000000..50bc25b --- /dev/null +++ b/instructions/private-instance.md @@ -0,0 +1,125 @@ +--- +type: types/instruction.md +name: private-instance +description: Set up a private working instance as a clone of a public upstream, so stack updates arrive by merge instead of by copying a tarball over the tree. +--- + +# Set up a private instance against a public upstream + +The distribution path in [setup-instance.md](setup-instance.md) builds an instance from a +`dist export` tarball, with no git ancestry in common with the repo it came from. That is the +right shape for someone who only ever *consumes* the stack. + +This is the other shape: a private instance that keeps taking stack changes from a public +upstream, and whose own content must never travel back. It costs one safeguard to set up and +saves the whole update procedure afterwards. + +**Read this before, not after, the first `publish`.** The gate in step 4 is the thing that makes +the arrangement safe, and adding it later means the window it closes was open in between. + +## Why a clone rather than a tarball + +`INSTALL.md`'s "Eine Instanz aktualisieren" is `cp -r` as an upgrade strategy: copy `tools/`, +`types/`, `instructions/`, `AGENTS.md`, `VERSION` over the existing tree. It has no three-way +merge, so it cannot notice that the receiving instance changed a file, and it has no conflict +surface, so nobody learns when upstream and local both touched the same one. It overwrites +silently. + +A clone gets all of that from git. The private `main` deletes the upstream's demo corpus once; +every later `git merge upstream/main` sees *deleted-in-ours, unmodified-in-theirs* and resolves +without asking. Stack changes land as real merges, with real conflicts where they conflict. + +## Steps + +1. **Clone, and name the two remotes for what they are.** + + ```bash + git clone my-wiki + cd my-wiki + git remote add upstream + ``` + + `origin` is yours and is the only thing you ever push to. `upstream` is where stack updates + come from and is fetch-only. + +2. **Make the fetch-only half fetch-only in git, too.** + + ```bash + git remote set-url --push upstream no_push + ``` + + git refuses to push to a URL it cannot resolve. This is a convenience, not the safeguard - + step 4 is the safeguard. + +3. **Delete the upstream's demo corpus once, on your own `main`.** + + Everything under `kb/` and `raw/` that came with the clone is the upstream's content, not + yours. Remove it with `wikitool rm --page` (never `rm -rf`: `rm` de-links each page from the + rest of the wiki, and a plain delete leaves dead wikilinks and broken citations behind), then + `index rebuild`, `sources rebuild-index`, `lint`. + + This is a one-time cut. Afterwards the upstream corpus is frozen from your side, which is + what makes later merges content-free. + +4. **Arm the Publish-Remote Gate — before the first `publish`.** + + ```bash + cat > .wikitool-remotes.json <<'EOF' + { "schema": 1, "allowed_push_urls": [""] } + EOF + ``` + + Use the URL `git remote get-url --push origin` prints, exactly. `publish` refuses with exit + 42 for anything else, and there is no flag that opens it - see [gates.md](gates.md). + + The file is gitignored, so it stays with this checkout and never travels to the upstream. + `wikitool doctor` reports whether the gate is armed, and WARNs at more than one remote + without it. + +5. **Take away the write credential, if you can.** A token or deploy key for `origin` only, + with no write access to the upstream, is the one control that holds even if everything above + is misconfigured. Belt and braces. + +6. **Personalize and bootstrap.** `USER.md`, `SOUL.md` and optionally `ENVIRONMENT.md` are + yours and unrelated to the upstream's - see the Personalization step of + [setup-instance.md](setup-instance.md), then [bootstrap.md](bootstrap.md) for the venv and + the skills. + +## Taking a stack update + +```bash +git fetch upstream +git merge upstream/main +``` + +Then, as after any stack change: `doctor`, `docs verify`, `instructions verify`, `migrate status`, +`lint`. A `migrate status` with outstanding links means the update crossed a compatibility +boundary - follow [migrate-corpus.md](migrate-corpus.md) before doing anything else. + +## Where stack development happens + +**In the public repo, not here.** That is not a preference; the stack is built that way. The +development-only half of the instruction layer is pruned from a distribution one-way, with no +command that reconstructs it, so an instance built this way has no tool-development mode to +switch into in the first place. + +When a tool bug blocks real content work here - and it will - file the issue against the public +repo (an MCP server or the web UI reaches it from any session; no shared history needed), fix it +there where the tests, `docs verify` and CI's version gate live, and take the fix back with the +merge above. Nothing is lost by the detour: the fix has to pass that CI either way. + +## Decision points + +- **Merge conflict in `kb/` or `raw/`?** Something changed the upstream's corpus after you cut + it. Resolve as "keep deleted" - your instance's content is yours, and the upstream's demo + corpus has no business in it. +- **Conflict in `tools/`, `types/` or `instructions/`?** You changed the stack locally, which + step "Where stack development happens" says not to do. Take the upstream side and re-file the + change as an issue there. + +## Scope + +Not for a first instance with no upstream - that is [setup-instance.md](setup-instance.md). Not +for a fresh clone of a repo you already own and develop in - that is +[bootstrap.md](bootstrap.md). This is specifically the two-remote case, where the cost of a +mistaken push is disclosure rather than inconvenience. diff --git a/tools/chemenu/commands/doctor.py b/tools/chemenu/commands/doctor.py index 832bfdb..dca4f3c 100644 --- a/tools/chemenu/commands/doctor.py +++ b/tools/chemenu/commands/doctor.py @@ -21,7 +21,7 @@ import typer from rich.console import Console from chemenu import config, kb_collections, version as version_mod -from chemenu.commands import instructions_cmd +from chemenu.commands import git_publish, instructions_cmd from chemenu.commands._util import rel_path from chemenu.session import ENV_VAR as SESSION_ENV_VAR @@ -242,6 +242,42 @@ def check_environment() -> Check: return Check("environment", "OK", f"{config.ENVIRONMENT_FILE} present and filled") +def check_publish_remotes() -> Check: + """Whether the Publish-Remote Gate is armed in this checkout. + + Absent is a legitimate state, not a fault: a checkout with a single remote + and nothing private in it has nothing to protect, and making the file + mandatory would turn a safeguard into paperwork. So this never FAILs - it + reports, the way `environment` does. + + It does WARN for the case that actually bites: more than one remote + configured and no allowlist. That is the shape a private instance has after + it adds the public upstream, and it is exactly when a wrong `--remote` + stops being a typo and starts being a disclosure. + """ + urls = git_publish.read_allowed_push_urls() + if urls is not None: + return Check( + "publish-remotes", "OK", + f"{len(urls)} allowed push target(s) in {config.PUBLISH_REMOTES_FILENAME}", + ) + result = subprocess.run( + ["git", "remote"], cwd=config.ROOT, capture_output=True, text=True + ) + remotes = [r for r in result.stdout.split() if r] + if len(remotes) > 1: + return Check( + "publish-remotes", "WARN", + f"{len(remotes)} remotes ({', '.join(remotes)}) and no publish allowlist", + f"Create {config.PUBLISH_REMOTES_FILENAME} naming the push URL this checkout " + "may publish to - see instructions/gates.md", + ) + return Check( + "publish-remotes", "OK", + f"No {config.PUBLISH_REMOTES_FILENAME} (unrestricted; one remote configured)", + ) + + def check_generated_files() -> Check: missing = [ rel_path(path) @@ -355,6 +391,7 @@ def run_doctor() -> list[Check]: check_structure(), check_personalization(), check_environment(), + check_publish_remotes(), check_generated_files(), check_session_id(), ] diff --git a/tools/chemenu/commands/git_publish.py b/tools/chemenu/commands/git_publish.py index b6b21a4..528a0f2 100644 --- a/tools/chemenu/commands/git_publish.py +++ b/tools/chemenu/commands/git_publish.py @@ -80,6 +80,86 @@ def _run(args: list[str]) -> subprocess.CompletedProcess: return subprocess.run(args, cwd=config.ROOT, capture_output=True, text=True) +# --- Publish-Remote Gate ----------------------------------------------------- +# +# The Mass-Update Gate asks "is this too much to publish?". This one asks the +# question underneath it: "is this the right place to publish to at all?". +# +# A checkout holding private content typically has two remotes - its own, and +# the public upstream it takes stack updates from. Nothing in git distinguishes +# them at push time, so a single wrong `--remote` puts a private corpus on a +# public repository, where a force-push does not take it back: the objects stay +# fetchable by SHA until someone expires the server's reflogs. +# +# Like the other two gates this refuses with exit 42 and has **no flag that +# opens it**. The way past it is to name the URL in the file, which is an edit +# the user makes deliberately rather than something an agent can decide mid-run. + + +def read_allowed_push_urls() -> Optional[list[str]]: + """The URLs this checkout permits `publish` to push to, or None when the + file is absent (unrestricted - see config.PUBLISH_REMOTES_FILENAME).""" + path = config.ROOT / config.PUBLISH_REMOTES_FILENAME + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + fail( + f"{config.PUBLISH_REMOTES_FILENAME} is unreadable ({exc}). It decides where " + "`publish` may push, so a broken one is not treated as 'no restriction' - " + "fix the file or delete it deliberately." + ) + return None + urls = data.get("allowed_push_urls") + if not isinstance(urls, list) or not all(isinstance(u, str) for u in urls): + fail( + f"{config.PUBLISH_REMOTES_FILENAME} has no usable `allowed_push_urls` list of " + "strings. Expected: {\"schema\": 1, \"allowed_push_urls\": [\"\"]}" + ) + return None + return urls + + +def push_url_for(remote: str) -> Optional[str]: + """The URL `git push ` would actually write to - `pushurl` when the + remote sets one, otherwise its fetch URL. Reading the resolved value rather + than the name is the whole point: a repointed `origin` must not pass.""" + result = _run(["git", "remote", "get-url", "--push", remote]) + if result.returncode != 0: + return None + return result.stdout.strip() or None + + +def publish_remote_refusal(remote: str, branch: str) -> Optional[str]: + """The gate's message when `remote` is not an allowed push target, else None.""" + allowed = read_allowed_push_urls() + if allowed is None: + return None + url = push_url_for(remote) + if url is None: + return ( + f"Publish-Remote Gate: '{remote}' resolves to no push URL, so this publish " + f"cannot be checked against {config.PUBLISH_REMOTES_FILENAME}. Nothing was " + "committed or pushed." + ) + if url in allowed: + return None + listed = "\n".join(f" - {u}" for u in allowed) or " (the list is empty)" + return ( + f"Publish-Remote Gate: this checkout does not allow publishing to '{remote}'.\n\n" + f" would push to: {url}\n" + f" allowed here:\n{listed}\n\n" + "Nothing was committed or pushed. This checkout holds content that belongs to it " + "alone, and a push to the wrong remote is not cheaply reversible - the objects stay " + "fetchable by SHA even after a force-push, until the server's reflogs are expired.\n\n" + "THE USER CANNOT SEE THIS OUTPUT. It went to your context, not to their screen.\n" + "Show them the two lines above and stop. There is no flag that opens this gate: if " + f"the target really is right, the user adds its URL to {config.PUBLISH_REMOTES_FILENAME} " + "themselves. Do not edit that file to get past this." + ) + + def parse_porcelain_entries(stdout: str) -> list[tuple[str, str]]: """Parse `git status --porcelain -z` output into (status_code, path) pairs. @@ -919,6 +999,26 @@ def publish_command( if push and checked_out != branch: fail(branch_mismatch_message(checked_out, branch)) + # Before the reconcile below, which is the first thing that talks to the + # remote at all: a publish aimed at the wrong repository should not even + # fetch from it, and the refusal message promises that nothing was + # committed or pushed. + if push: + refusal = publish_remote_refusal(remote, branch) + if refusal: + emit( + "wikitool", + "gate.refused", + { + "gate": "publish-remote", + "reason": "remote-not-allowed", + "remote": remote, + "branch": branch, + "url": push_url_for(remote), + }, + ) + needs_clearance(refusal) + # Pull against the remote before anything else, to minimise the window in which this # publish could diverge from it - and, as a side effect, to finally publish a commit left # stranded by a previous push that failed (see `_local_ahead_of_remote` below). Skipped diff --git a/tools/chemenu/config.py b/tools/chemenu/config.py index 00932ec..851b0d9 100644 --- a/tools/chemenu/config.py +++ b/tools/chemenu/config.py @@ -70,6 +70,22 @@ ENVIRONMENT_TEMPLATE = f"{ENVIRONMENT_FILE}.template" # `dist export` already computes (AGENTS.md invariant 8). See NOTICE. LICENSE_FILES = ("LICENSE", "LICENSE-CONTENT", "NOTICE") +# Which push targets `publish` may write to, for a checkout that says so. The +# danger this addresses is one checkout's content reaching another checkout's +# remote - a private instance pushing its own `kb/` to a public upstream, where +# it cannot be taken back. +# +# It pins **URLs, not remote names**: a name-based list would pass a `publish` +# whose `origin` had been repointed, which is the failure it exists to catch. +# +# Per-checkout and gitignored, like `ENVIRONMENT.md` and for the same reason: +# two clones of this repo push to two different places, so a committed copy +# would hand the second one an answer that is wrong rather than missing. Absent +# means unrestricted - `doctor` reports it, and the Publish-Remote Gate simply +# does not apply. A checkout that holds private content should have one; see +# instructions/gates.md. +PUBLISH_REMOTES_FILENAME = ".wikitool-remotes.json" + def default_author() -> str | None: """The author to stamp a new source page with, per instance. diff --git a/tools/chemenu/tests/test_git_publish.py b/tools/chemenu/tests/test_git_publish.py index ff841f5..271c94c 100644 --- a/tools/chemenu/tests/test_git_publish.py +++ b/tools/chemenu/tests/test_git_publish.py @@ -1,3 +1,4 @@ +import json import subprocess import pytest @@ -905,3 +906,115 @@ def test_numstat_survives_a_non_ascii_filename(repo): change = next(c for c in collect_changes([]) if c.path == name) assert change.status == "modified" assert (change.added, change.removed) == (1, 2) + + +# --- Publish-Remote Gate ----------------------------------------------------- + + +def _allowlist(root, *urls): + (root / config.PUBLISH_REMOTES_FILENAME).write_text( + json.dumps({"schema": 1, "allowed_push_urls": list(urls)}), encoding="utf-8" + ) + + +def test_no_allowlist_means_unrestricted(repo): + """Absence is a legitimate state: a checkout with nothing private in it + should not have to declare anything to publish at all.""" + assert git_publish.read_allowed_push_urls() is None + assert git_publish.publish_remote_refusal("origin", "main") is None + + +def test_allowed_url_passes_the_gate(repo): + _allowlist(repo, git_publish.push_url_for("origin")) + assert git_publish.publish_remote_refusal("origin", "main") is None + + +def test_gate_refuses_a_remote_not_on_the_list(repo): + _git(repo, "remote", "add", "upstream", "https://example.com/public.git") + _allowlist(repo, git_publish.push_url_for("origin")) + refusal = git_publish.publish_remote_refusal("upstream", "main") + assert refusal is not None + assert "https://example.com/public.git" in refusal + assert "Nothing was committed or pushed" in refusal + + +def test_gate_matches_the_url_not_the_remote_name(repo): + """A name-based list would pass a repointed `origin`, which is the failure + this gate exists to catch.""" + _allowlist(repo, "ssh://git@example.com/only-this.git") + assert git_publish.publish_remote_refusal("origin", "main") is not None + + +def test_gate_reads_pushurl_when_the_remote_sets_one(repo): + """`git push` writes to `pushurl` when present, so that is the value that + has to be checked - not the fetch URL beside it.""" + _git(repo, "remote", "set-url", "--push", "origin", "https://example.com/elsewhere.git") + _allowlist(repo, "https://example.com/elsewhere.git") + assert git_publish.push_url_for("origin") == "https://example.com/elsewhere.git" + assert git_publish.publish_remote_refusal("origin", "main") is None + + +def test_gate_refuses_when_the_fetch_url_is_listed_but_the_pushurl_is_not(repo): + fetch_url = git_publish.push_url_for("origin") + _git(repo, "remote", "set-url", "--push", "origin", "https://example.com/elsewhere.git") + _allowlist(repo, fetch_url) + assert git_publish.publish_remote_refusal("origin", "main") is not None + + +def test_publish_exits_42_and_commits_nothing_when_the_remote_is_refused(repo): + _git(repo, "remote", "add", "upstream", "https://example.com/public.git") + _allowlist(repo, git_publish.push_url_for("origin")) + before = _git(repo, "rev-parse", "HEAD").stdout.strip() + (repo / "kb" / "secret.md").write_text("private\n", encoding="utf-8") + + with pytest.raises(typer.Exit) as excinfo: + _publish(remote="upstream") + assert excinfo.value.exit_code == EXIT_NEEDS_CLEARANCE + + # Nothing committed, and the file is still sitting there unstaged - the + # refusal message promises both. (`git status --porcelain` collapses the + # wholly-untracked `kb/` to one entry, so check the index directly.) + assert _git(repo, "rev-parse", "HEAD").stdout.strip() == before + assert (repo / "kb" / "secret.md").exists() + assert "secret.md" not in _git(repo, "ls-files").stdout + + +def test_no_push_skips_the_gate(repo): + """`--no-push` publishes nowhere, so there is no wrong target to protect + against - and a local commit must stay possible.""" + _allowlist(repo, "ssh://git@example.com/only-this.git") + (repo / "kb" / "page.md").write_text("local\n", encoding="utf-8") + _publish(push=False) + assert "page.md" in _git(repo, "show", "--name-only", "HEAD").stdout + + +def test_unreadable_allowlist_fails_instead_of_falling_open(repo): + """A broken file must not be read as 'no restriction' - that would turn a + corrupted safeguard into a silently disabled one.""" + (repo / config.PUBLISH_REMOTES_FILENAME).write_text("{not json", encoding="utf-8") + with pytest.raises(typer.Exit): + git_publish.read_allowed_push_urls() + + +def test_allowlist_without_a_usable_list_fails(repo): + (repo / config.PUBLISH_REMOTES_FILENAME).write_text( + json.dumps({"schema": 1, "allowed_push_urls": "not-a-list"}), encoding="utf-8" + ) + with pytest.raises(typer.Exit): + git_publish.read_allowed_push_urls() + + +def test_empty_allowlist_refuses_everything(repo): + """An empty list is a deliberate 'publish nowhere', not an oversight that + should behave like an absent file.""" + _allowlist(repo) + assert git_publish.publish_remote_refusal("origin", "main") is not None + + +def test_gate_has_no_flag_that_opens_it(repo): + """The other two gates clear with a token; this one deliberately does not, + because the right fix is a deliberate edit by the user.""" + import inspect + + params = inspect.signature(publish_command).parameters + assert not any("remote" in name and "confirm" in name for name in params)