From bcc446ac1a532e0afc5904d0ad6d4bd07ea9f567 Mon Sep 17 00:00:00 2001 From: BY571 Date: Sat, 5 Sep 2026 12:13:06 +0200 Subject: [PATCH 01/26] fixtures for a live knoten server and a graph worth sharing Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb --- tests/conftest.py | 55 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 7040c79..07b127a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import os import sys from pathlib import Path @@ -50,3 +51,57 @@ def read(self, nid): (tmp_path / "nodes").mkdir() return Graph().rules() + + +GIT_ISOLATION = { + # The developer's own git config must not reach these tests: a global credential + # helper would answer the server's 401 with cached credentials and pass a test that + # should fail, and a global signing setup would make commits in fixtures fail. + "GIT_CONFIG_GLOBAL": "/dev/null", + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_TERMINAL_PROMPT": "0", +} + + +@pytest.fixture +def hub(tmp_path, monkeypatch): + """A real knoten server on a random localhost port, and an isolated credential store. + + Real because the failures worth catching live in the seam between git's client, the + HTTP layer and git-http-backend, and none of them are visible to a mock. + """ + import threading + from types import SimpleNamespace + + from knoten.registry import Registry + from knoten.serve import make_server + + for k, v in GIT_ISOLATION.items(): + monkeypatch.setenv(k, v) + monkeypatch.setenv("KNOTEN_CREDENTIALS", str(tmp_path / "credentials")) + + reg = Registry(tmp_path / "data") + srv = make_server(reg, "127.0.0.1", 0) + threading.Thread(target=srv.serve_forever, daemon=True).start() + host, port = srv.server_address + yield SimpleNamespace(url=f"http://{host}:{port}", registry=reg, + secret=reg.owner_secret(), data=tmp_path / "data") + srv.shutdown() + + +@pytest.fixture +def local_graph(tmp_path, rules_yaml): + """A git repo whose root is a graph with one committed node: what a user has on disk + the moment they decide to share it.""" + import subprocess + + root = tmp_path / "trading" + (root / "nodes").mkdir(parents=True) + (root / "graph.yaml").write_text(rules_yaml, encoding="utf-8") + (root / "nodes" / "hyp-ok.md").write_text( + "---\nid: hyp-ok\ntype: hypothesis\nstatus: open\n---\n\n# a claim\n", encoding="utf-8") + env = {**os.environ, **GIT_ISOLATION} + for cmd in (["init", "-q", "-b", "master"], ["config", "user.email", "t@t.t"], + ["config", "user.name", "t"], ["add", "-A"], ["commit", "-qm", "seed"]): + subprocess.run(["git", *cmd], cwd=root, check=True, env=env, capture_output=True) + return root From 5df960ef0d1d953dfc095099175b9a80fac63018 Mon Sep 17 00:00:00 2001 From: BY571 Date: Sat, 5 Sep 2026 12:17:39 +0200 Subject: [PATCH 02/26] a server holds three things the graph cannot: tokens, invites, its owner Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb --- src/knoten/registry.py | 107 +++++++++++++++++++++++++++++++++++++++++ tests/test_registry.py | 68 ++++++++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 src/knoten/registry.py create mode 100644 tests/test_registry.py diff --git a/src/knoten/registry.py b/src/knoten/registry.py new file mode 100644 index 0000000..1ac2439 --- /dev/null +++ b/src/knoten/registry.py @@ -0,0 +1,107 @@ +"""What a server knows that the graph does not. + +A knoten server hosts graphs, and for each one it holds exactly three things the graph +itself cannot: who may connect (tokens), who has been invited but not yet arrived +(invites), and who owns the server (one secret). Everything about the graph's meaning +stays in the graph. Losing this directory loses availability, not the answer to "who +verified this". + +Files, not a database, under the same lock the graph uses for its own read-modify-write +windows. Tokens and invite codes are stored hashed: a leaked tokens.json yields nothing. +""" +from __future__ import annotations + +import hashlib +import hmac +import json +import os +import secrets +import subprocess +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from .core import GraphError, ID_RE, graph_lock, write_atomic +from .hook import install_server + +ROLES = ("read", "write", "admin") + + +def _hash(secret: str) -> str: + return hashlib.sha256((secret or "").encode()).hexdigest() + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class Registry: + def __init__(self, data: Path): + self.data = Path(data) + (self.data / "graphs").mkdir(parents=True, exist_ok=True) + + # ---------------------------------------------------------------- owner + + def owner_secret(self) -> str: + """Created on first call, 0600, printed once by `knoten serve` and never again.""" + p = self.data / "owner" + if not p.exists(): + fd = os.open(p, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w") as fh: + fh.write(secrets.token_urlsafe(32)) + return p.read_text(encoding="utf-8").strip() + + def check_owner(self, secret: str) -> bool: + return hmac.compare_digest(secret or "", self.owner_secret()) + + # ---------------------------------------------------------------- graphs + + def graph_dir(self, name: str) -> Path: + # A name becomes a directory. Checked here, at the ONE place every path is made, + # so `../etc` cannot reach mkdir through any entry point. + if not ID_RE.match(name or ""): + raise GraphError(f"'{name}' is not a valid graph name (use kebab-case: my-topic)") + return self.data / "graphs" / name + + def exists(self, name: str) -> bool: + return (self.graph_dir(name) / "repo.git").is_dir() + + def repo(self, name: str) -> Path: + r = self.graph_dir(name) / "repo.git" + if not r.is_dir(): + raise GraphError(f"no graph '{name}' on this server") + return r + + def create(self, name: str, admin: str) -> str: + """A bare repo with the gate already installed. Returns the creating admin's + token: creating a graph and being able to push to it are one act.""" + d = self.graph_dir(name) + if self.exists(name): + raise GraphError(f"graph '{name}' already exists on this server") + d.mkdir(parents=True, exist_ok=True) + repo = d / "repo.git" + subprocess.run(["git", "init", "-q", "--bare", str(repo)], check=True) + for key, value in (("http.receivepack", "true"), + ("receive.maxInputSize", "104857600")): # 100 MB per push + subprocess.run(["git", "-C", str(repo), "config", key, value], check=True) + install_server(repo) + return self.mint(name, admin, "admin") + + # ---------------------------------------------------------------- files + + def _read(self, name: str, file: str) -> dict: + p = self.graph_dir(name) / file + return json.loads(p.read_text(encoding="utf-8")) if p.exists() else {} + + def _write(self, name: str, file: str, obj: dict) -> None: + write_atomic(self.graph_dir(name) / file, json.dumps(obj, indent=1) + "\n") + + # ---------------------------------------------------------------- tokens + + def mint(self, name: str, user: str, role: str) -> str: + """Task 2 completes this. Kept minimal here so `create` has something to return.""" + token = secrets.token_urlsafe(32) + with graph_lock(self.graph_dir(name)): + tokens = self._read(name, "tokens.json") + tokens[user] = {"hash": _hash(token), "role": role} + self._write(name, "tokens.json", tokens) + return token diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..4cb6ba6 --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,68 @@ +"""What a server knows that the graph does not: who may connect, who is invited, who +owns the box. Files, hashed, locked. Losing this directory loses availability, never the +meaning of a graph.""" +import os +import stat + +import pytest + +from knoten.core import GraphError +from knoten.registry import ROLES, Registry + + +@pytest.fixture +def reg(tmp_path): + return Registry(tmp_path / "data") + + +def test_the_owner_secret_is_created_once_and_kept_private(reg): + """Printed once by `knoten serve`. If it changed between calls, the owner would be + locked out of their own server on restart.""" + first = reg.owner_secret() + + assert first == reg.owner_secret() + assert len(first) >= 32 + mode = stat.S_IMODE((reg.data / "owner").stat().st_mode) + assert mode == 0o600, f"owner secret is world-readable: {oct(mode)}" + + +def test_check_owner_accepts_only_the_secret(reg): + assert reg.check_owner(reg.owner_secret()) + assert not reg.check_owner("nope") + assert not reg.check_owner("") + + +def test_create_makes_a_gated_bare_repo(reg): + """The gate is installed at creation, not later. A remote graph that exists for even + one push without it has accepted whatever that push contained.""" + reg.create("trading", admin="seb") + + repo = reg.repo("trading") + assert (repo / "HEAD").exists(), "not a git repo" + assert not (repo / ".git").exists(), "not bare" + hook = repo / "hooks" / "pre-receive" + assert hook.exists() and os.access(hook, os.X_OK) + assert "knoten" in hook.read_text(encoding="utf-8") + + +def test_create_refuses_a_second_graph_of_the_same_name(reg): + reg.create("trading", admin="seb") + + with pytest.raises(GraphError, match="already exists"): + reg.create("trading", admin="seb") + + +@pytest.mark.parametrize("bad", ["../etc", "Trading", "a b", "", "-x", "x/y"]) +def test_graph_names_outside_id_re_are_refused_before_touching_disk(reg, bad): + """A graph name becomes a directory. `../etc` would be created outside the data + dir; the check has to run before mkdir, not after.""" + with pytest.raises(GraphError, match="not a valid graph name"): + reg.create(bad, admin="seb") + + assert sorted(p.name for p in (reg.data / "graphs").iterdir()) == [] + + +def test_repo_of_an_unknown_graph_is_an_error_not_a_path(reg): + with pytest.raises(GraphError, match="no graph 'nope'"): + reg.repo("nope") + assert not reg.exists("nope") From d5e977d2ac8b297d4f3849bfe9daefe1b6d9de8b Mon Sep 17 00:00:00 2001 From: BY571 Date: Sat, 5 Sep 2026 12:23:20 +0200 Subject: [PATCH 03/26] create() rolls back partial repos on any failure When git init, config, or install_server fail partway through, the repo directory exists on disk but lacks the pre-receive gate. Subsequent creates raise 'already exists' forever, blocking retry. Wrap the whole sequence in try/except, rollback with rmtree on any exception, and re-raise as GraphError so the invariant holds: if exists() is true, the gate is installed. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb --- src/knoten/registry.py | 26 ++++++++++++++++++-------- tests/test_registry.py | 20 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/knoten/registry.py b/src/knoten/registry.py index 1ac2439..b2d7084 100644 --- a/src/knoten/registry.py +++ b/src/knoten/registry.py @@ -16,6 +16,7 @@ import json import os import secrets +import shutil import subprocess from datetime import datetime, timedelta, timezone from pathlib import Path @@ -77,14 +78,23 @@ def create(self, name: str, admin: str) -> str: d = self.graph_dir(name) if self.exists(name): raise GraphError(f"graph '{name}' already exists on this server") - d.mkdir(parents=True, exist_ok=True) - repo = d / "repo.git" - subprocess.run(["git", "init", "-q", "--bare", str(repo)], check=True) - for key, value in (("http.receivepack", "true"), - ("receive.maxInputSize", "104857600")): # 100 MB per push - subprocess.run(["git", "-C", str(repo), "config", key, value], check=True) - install_server(repo) - return self.mint(name, admin, "admin") + try: + d.mkdir(parents=True, exist_ok=True) + repo = d / "repo.git" + subprocess.run(["git", "init", "-q", "--bare", str(repo)], check=True) + for key, value in (("http.receivepack", "true"), + ("receive.maxInputSize", "104857600")): # 100 MB per push + subprocess.run(["git", "-C", str(repo), "config", key, value], check=True) + install_server(repo) + return self.mint(name, admin, "admin") + except GraphError: + # A half-made repo that exists() calls valid would accept pushes with no gate, forever. + shutil.rmtree(d, ignore_errors=True) + raise + except Exception as e: + # A half-made repo that exists() calls valid would accept pushes with no gate, forever. + shutil.rmtree(d, ignore_errors=True) + raise GraphError(f"could not create graph '{name}': {e}") from e # ---------------------------------------------------------------- files diff --git a/tests/test_registry.py b/tests/test_registry.py index 4cb6ba6..9f0ef6b 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -66,3 +66,23 @@ def test_repo_of_an_unknown_graph_is_an_error_not_a_path(reg): with pytest.raises(GraphError, match="no graph 'nope'"): reg.repo("nope") assert not reg.exists("nope") + + +def test_create_rolls_back_on_partial_failure(reg, monkeypatch): + """A half-made repo that exists() calls valid would accept pushes with no gate, forever. + Partial creation must be rolled back atomically so a retry can succeed.""" + import knoten.registry + def failing_install(repo): + raise OSError("disk full") + monkeypatch.setattr(knoten.registry, "install_server", failing_install) + + with pytest.raises(GraphError, match="could not create"): + reg.create("trading", admin="seb") + + assert not reg.exists("trading") + assert sorted(p.name for p in (reg.data / "graphs").iterdir()) == [] + + # Restore monkeypatch and retry succeeds + monkeypatch.undo() + reg.create("trading", admin="seb") + assert reg.exists("trading") From 8bca118a14b3249f7fa4eec991bffd6766e8ac2b Mon Sep 17 00:00:00 2001 From: BY571 Date: Sat, 5 Sep 2026 12:27:13 +0200 Subject: [PATCH 04/26] tokens say who is connecting; invites are spent on first use, live or not Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb --- src/knoten/registry.py | 56 ++++++++++++++++++++- tests/test_registry.py | 109 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 1 deletion(-) diff --git a/src/knoten/registry.py b/src/knoten/registry.py index b2d7084..ce2b553 100644 --- a/src/knoten/registry.py +++ b/src/knoten/registry.py @@ -107,11 +107,65 @@ def _write(self, name: str, file: str, obj: dict) -> None: # ---------------------------------------------------------------- tokens + def _check(self, name: str, user: str, role: str) -> None: + if role not in ROLES: + raise GraphError(f"role must be one of {', '.join(ROLES)}, not '{role}'") + if not ID_RE.match(user or ""): + raise GraphError(f"'{user}' is not a valid contributor name (use kebab-case)") + self.repo(name) + def mint(self, name: str, user: str, role: str) -> str: - """Task 2 completes this. Kept minimal here so `create` has something to return.""" + self._check(name, user, role) token = secrets.token_urlsafe(32) with graph_lock(self.graph_dir(name)): tokens = self._read(name, "tokens.json") tokens[user] = {"hash": _hash(token), "role": role} self._write(name, "tokens.json", tokens) return token + + def authenticate(self, name: str, user: str, token: str) -> str | None: + """The role this token grants on this graph, or None. Never raises: an unknown + graph and a wrong token look identical to the caller, so the server does not + leak which graphs exist.""" + if not ID_RE.match(name or "") or not self.exists(name): + return None + entry = self._read(name, "tokens.json").get(user or "") + if not entry or not hmac.compare_digest(entry["hash"], _hash(token)): + return None + return entry["role"] + + def revoke(self, name: str, user: str) -> None: + with graph_lock(self.graph_dir(name)): + tokens = self._read(name, "tokens.json") + if user not in tokens: + raise GraphError(f"no contributor '{user}' on graph '{name}'") + del tokens[user] + self._write(name, "tokens.json", tokens) + + # ---------------------------------------------------------------- invites + + def invite(self, name: str, user: str, role: str, days: int = 7) -> str: + self._check(name, user, role) + code = secrets.token_urlsafe(16) + expires = (_now() + timedelta(days=days)).isoformat() + with graph_lock(self.graph_dir(name)): + invites = self._read(name, "invites.json") + invites[_hash(code)] = {"name": user, "role": role, "expires": expires} + self._write(name, "invites.json", invites) + return code + + def redeem(self, name: str, code: str) -> tuple[str, str, str]: + """One use. Returns (user, role, token). The code is removed on first try + whether or not it was still live, so an expired code cannot be retried.""" + self.repo(name) + with graph_lock(self.graph_dir(name)): + invites = self._read(name, "invites.json") + entry = invites.pop(_hash(code), None) + if entry is None: + raise GraphError("that invite code is not valid for this graph") + self._write(name, "invites.json", invites) + if datetime.fromisoformat(entry["expires"]) < _now(): + raise GraphError("that invite has expired; ask the admin for a new one") + # Outside the lock: mint takes it again, and flock on a fresh handle would wait + # on our own lock forever. + return entry["name"], entry["role"], self.mint(name, entry["name"], entry["role"]) diff --git a/tests/test_registry.py b/tests/test_registry.py index 9f0ef6b..9d395cb 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -86,3 +86,112 @@ def failing_install(repo): monkeypatch.undo() reg.create("trading", admin="seb") assert reg.exists("trading") + + +# ---------------------------------------------------------------- tokens + +def test_a_minted_token_authenticates_with_its_role(reg): + reg.create("trading", admin="seb") + tok = reg.mint("trading", "maria", "write") + + assert reg.authenticate("trading", "maria", tok) == "write" + + +def test_the_wrong_token_the_wrong_user_and_the_wrong_graph_all_fail_closed(reg): + reg.create("trading", admin="seb") + tok = reg.mint("trading", "maria", "write") + + assert reg.authenticate("trading", "maria", "not-it") is None + assert reg.authenticate("trading", "seb", tok) is None # someone else's token + assert reg.authenticate("biology", "maria", tok) is None # no such graph + assert reg.authenticate("trading", "maria", "") is None + assert reg.authenticate("trading", "", tok) is None + + +def test_tokens_are_stored_hashed(reg): + """A leaked tokens.json must be worthless.""" + reg.create("trading", admin="seb") + tok = reg.mint("trading", "maria", "write") + + on_disk = (reg.graph_dir("trading") / "tokens.json").read_text(encoding="utf-8") + assert tok not in on_disk + assert "maria" in on_disk + + +def test_create_returns_a_working_admin_token(reg): + tok = reg.create("trading", admin="seb") + + assert reg.authenticate("trading", "seb", tok) == "admin" + + +@pytest.mark.parametrize("role", ["owner", "Admin", "", "rw"]) +def test_a_role_outside_the_three_is_refused(reg, role): + reg.create("trading", admin="seb") + with pytest.raises(GraphError, match="role must be one of"): + reg.mint("trading", "maria", role) + + +def test_contributor_names_follow_id_re(reg): + """The name ends up as a JSON key and, in phase 2, as a filename in graph.yaml.""" + reg.create("trading", admin="seb") + with pytest.raises(GraphError, match="not a valid contributor name"): + reg.mint("trading", "Maria Lopez", "write") + + +def test_revoke_ends_access_and_nothing_else(reg): + reg.create("trading", admin="seb") + tok = reg.mint("trading", "maria", "write") + + reg.revoke("trading", "maria") + + assert reg.authenticate("trading", "maria", tok) is None + assert reg.exists("trading") + + +def test_revoking_a_stranger_is_an_error(reg): + reg.create("trading", admin="seb") + with pytest.raises(GraphError, match="no contributor 'ghost'"): + reg.revoke("trading", "ghost") + + +# ---------------------------------------------------------------- invites + +def test_an_invite_redeems_once_into_a_working_token(reg): + reg.create("trading", admin="seb") + code = reg.invite("trading", "maria", "write") + + user, role, tok = reg.redeem("trading", code) + + assert (user, role) == ("maria", "write") + assert reg.authenticate("trading", "maria", tok) == "write" + with pytest.raises(GraphError, match="not valid"): + reg.redeem("trading", code) # spent + + +def test_an_expired_invite_is_refused_and_spent(reg): + """Expired codes are removed when tried, so a stale invites.json does not grow + forever and a late guess cannot be retried after the clock is fixed.""" + reg.create("trading", admin="seb") + code = reg.invite("trading", "maria", "write", days=-1) + + with pytest.raises(GraphError, match="expired"): + reg.redeem("trading", code) + with pytest.raises(GraphError, match="not valid"): + reg.redeem("trading", code) + + +def test_invite_codes_are_stored_hashed(reg): + reg.create("trading", admin="seb") + code = reg.invite("trading", "maria", "write") + + assert code not in (reg.graph_dir("trading") / "invites.json").read_text(encoding="utf-8") + + +def test_a_wrong_code_and_a_wrong_graph_both_fail(reg): + reg.create("trading", admin="seb") + reg.invite("trading", "maria", "write") + + with pytest.raises(GraphError, match="not valid"): + reg.redeem("trading", "guess") + with pytest.raises(GraphError, match="no graph 'biology'"): + reg.redeem("biology", "anything") From d633aa50fe9160782881653de997eb555f00b261 Mon Sep 17 00:00:00 2001 From: BY571 Date: Sat, 5 Sep 2026 12:31:59 +0200 Subject: [PATCH 05/26] revoke needs to check the graph exists before taking the lock graph_lock tries to open a file inside the graph directory, which raises a raw FileNotFoundError if the path does not exist. Like authenticate/invite/redeem, revoke must call self.repo(name) first to convert this to a domain GraphError. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb --- src/knoten/registry.py | 1 + tests/test_registry.py | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/src/knoten/registry.py b/src/knoten/registry.py index ce2b553..85f03cb 100644 --- a/src/knoten/registry.py +++ b/src/knoten/registry.py @@ -135,6 +135,7 @@ def authenticate(self, name: str, user: str, token: str) -> str | None: return entry["role"] def revoke(self, name: str, user: str) -> None: + self.repo(name) with graph_lock(self.graph_dir(name)): tokens = self._read(name, "tokens.json") if user not in tokens: diff --git a/tests/test_registry.py b/tests/test_registry.py index 9d395cb..8e97f98 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -154,6 +154,14 @@ def test_revoking_a_stranger_is_an_error(reg): reg.revoke("trading", "ghost") +def test_revoking_on_an_unknown_graph_is_a_graph_error(reg): + """graph_lock opens a file inside the graph directory. Without an existence check + first, a well-formed name for a graph that does not exist raised a raw + FileNotFoundError instead of the one-line refusal every other method gives.""" + with pytest.raises(GraphError, match="no graph 'biology'"): + reg.revoke("biology", "maria") + + # ---------------------------------------------------------------- invites def test_an_invite_redeems_once_into_a_working_token(reg): From e5d0f2c5490425061adcb8045304488f7e7a1981 Mon Sep 17 00:00:00 2001 From: BY571 Date: Sat, 5 Sep 2026 12:35:11 +0200 Subject: [PATCH 06/26] knoten serve: an authorization layer in front of git's own HTTP backend Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb --- src/knoten/serve.py | 158 ++++++++++++++++++++++++++++++++++++++++++++ tests/conftest.py | 1 + tests/test_serve.py | 129 ++++++++++++++++++++++++++++++++++++ 3 files changed, 288 insertions(+) create mode 100644 src/knoten/serve.py create mode 100644 tests/test_serve.py diff --git a/src/knoten/serve.py b/src/knoten/serve.py new file mode 100644 index 0000000..916d726 --- /dev/null +++ b/src/knoten/serve.py @@ -0,0 +1,158 @@ +"""One job per request: decide whether this token may do this, then hand the request to +git. + +`git http-backend` ships with git and speaks the smart HTTP protocol as a CGI. It is what +nginx and Apache call to host git. knoten's server is the authorization layer in front +of it and nothing more: packfiles, refs, negotiation and the pre-receive gate are git's. + +git's own protocol separates reading from writing by endpoint (`git-upload-pack` serves +data out, `git-receive-pack` takes it in), so "read access" is a token that never gets +past the door for receive-pack. There is no finer reasoning about git's data model here. + +Binds localhost and speaks plain HTTP. TLS is a reverse proxy's or a tunnel's job. +""" +from __future__ import annotations + +import base64 +import json +import os +import re +import subprocess +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +from .core import GraphError +from .registry import Registry + +GIT_RE = re.compile(r"^/([a-z0-9][a-z0-9_-]*)\.git(/.*)$") +API_RE = re.compile(r"^/([a-z0-9][a-z0-9_-]*)/(join|invite|revoke)$") +WRITE = "git-receive-pack" + + +def make_server(reg: Registry, host: str = "127.0.0.1", port: int = 8899) -> ThreadingHTTPServer: + class Handler(_Handler): + registry = reg + return ThreadingHTTPServer((host, port), Handler) + + +class _Handler(BaseHTTPRequestHandler): + registry: Registry + + def log_message(self, *args) -> None: # one line per request is noise on a server + pass + + # ---------------------------------------------------------------- helpers + + def _basic(self) -> tuple[str, str]: + header = self.headers.get("Authorization", "") + if not header.startswith("Basic "): + return "", "" + try: + raw = base64.b64decode(header[6:]).decode() + except Exception: + return "", "" + user, _, secret = raw.partition(":") + return user, secret + + def _body(self) -> bytes: + return self.rfile.read(int(self.headers.get("Content-Length") or 0)) + + def _json_body(self) -> dict: + try: + return json.loads(self._body() or b"{}") + except json.JSONDecodeError as e: + raise GraphError(f"request body is not JSON: {e}") from None + + def _json(self, status: int, obj: dict) -> None: + data = json.dumps(obj).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + if status == 401: + self.send_header("WWW-Authenticate", 'Basic realm="knoten"') + self.end_headers() + self.wfile.write(data) + + def _refuse(self, status: int, message: str) -> None: + self._json(status, {"error": message}) + + # ---------------------------------------------------------------- routing + + def do_GET(self) -> None: + self._route() + + def do_POST(self) -> None: + self._route() + + def _route(self) -> None: + path, _, query = self.path.partition("?") + try: + if m := GIT_RE.match(path): + return self._git(m.group(1), m.group(2), query) + if path == "/graphs" and self.command == "POST": + return self._create() + if (m := API_RE.match(path)) and self.command == "POST": + return getattr(self, "_" + m.group(2))(m.group(1)) + self._refuse(404, "knoten: not found") + except GraphError as e: + self._refuse(400, f"knoten: {e}") + + # ---------------------------------------------------------------- git + + def _git(self, name: str, sub: str, query: str) -> None: + user, token = self._basic() + role = self.registry.authenticate(name, user, token) + if role is None: + # Same answer for "wrong token" and "no such graph": the server does not + # confirm which graphs exist to someone who cannot open them. + return self._refuse(401, "knoten: credentials required" if not token + else "knoten: that token is not valid here") + if role == "read" and (WRITE in query or sub.endswith("/" + WRITE)): + return self._refuse(403, f"knoten: {user} has read access to {name}, not write") + + env = { + **os.environ, + "GIT_PROJECT_ROOT": str(self.registry.graph_dir(name)), + "GIT_HTTP_EXPORT_ALL": "1", + "PATH_INFO": "/repo.git" + sub, # the URL says .git; disk says repo.git + "QUERY_STRING": query, + "REQUEST_METHOD": self.command, + "REMOTE_USER": user, + "REMOTE_ADDR": self.client_address[0], + "CONTENT_TYPE": self.headers.get("Content-Type", ""), + "CONTENT_LENGTH": self.headers.get("Content-Length", ""), + "HTTP_CONTENT_ENCODING": self.headers.get("Content-Encoding", ""), + } + r = subprocess.run(["git", "http-backend"], input=self._body(), + capture_output=True, env=env) + head, _, out = r.stdout.partition(b"\r\n\r\n") + status, headers = 200, [] + for line in head.decode(errors="replace").splitlines(): + key, _, value = line.partition(":") + if key.strip().lower() == "status": + status = int(value.strip().split()[0]) + elif key.strip(): + headers.append((key.strip(), value.strip())) + self.send_response(status) + for key, value in headers: + self.send_header(key, value) + self.send_header("Content-Length", str(len(out))) + self.end_headers() + self.wfile.write(out) + if r.returncode != 0: + print(f"knoten serve: git http-backend: {r.stderr.decode(errors='replace').strip()}", + file=sys.stderr) + + # ---------------------------------------------------------------- api (Task 4) + + def _create(self) -> None: + self._refuse(404, "knoten: not found") + + def _join(self, name: str) -> None: + self._refuse(404, "knoten: not found") + + def _invite(self, name: str) -> None: + self._refuse(404, "knoten: not found") + + def _revoke(self, name: str) -> None: + self._refuse(404, "knoten: not found") diff --git a/tests/conftest.py b/tests/conftest.py index 07b127a..5035679 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -87,6 +87,7 @@ def hub(tmp_path, monkeypatch): yield SimpleNamespace(url=f"http://{host}:{port}", registry=reg, secret=reg.owner_secret(), data=tmp_path / "data") srv.shutdown() + srv.server_close() @pytest.fixture diff --git a/tests/test_serve.py b/tests/test_serve.py new file mode 100644 index 0000000..4d1f24d --- /dev/null +++ b/tests/test_serve.py @@ -0,0 +1,129 @@ +"""knoten's server does one thing per request: decide whether this token may do this, +then hand the request to `git http-backend`, which ships with git and speaks the smart +HTTP protocol. Everything hard (packfiles, refs, negotiation, hooks) is git's. These +tests therefore drive real git clients at a real server. +""" +import json +import os +import subprocess +import urllib.error +import urllib.request + +import pytest + +ALIVE_NO_GATE = "---\nid: hyp-x\ntype: hypothesis\nstatus: alive\n---\n\n# x\n" + + +def git(*args, cwd, env=None): + return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, + env={**os.environ, **(env or {})}) + + +def clone_url(hub, graph, user, token): + return f"http://{user}:{token}@{hub.url.removeprefix('http://')}/{graph}.git" + + +def commit_node(work, name, text): + (work / "nodes" / name).write_text(text, encoding="utf-8") + git("add", "-A", cwd=work) + git("commit", "-qm", name, cwd=work) + + +def auth_refused(r): + """git phrases a 401 three ways: `401` when it cannot retry, `Authentication failed` + when the credentials it sent were rejected, `could not read Username` when it had + none and prompts are off. All three are the server saying no.""" + return r.returncode != 0 and any( + s in r.stderr for s in ("401", "Authentication failed", "Username")) + + +@pytest.fixture +def trading(hub, local_graph): + """A graph on the server with its admin's token, pushed once from `local_graph`.""" + admin = hub.registry.create("trading", admin="seb") + git("remote", "add", "origin", clone_url(hub, "trading", "seb", admin), cwd=local_graph) + r = git("push", "-q", "origin", "master", cwd=local_graph) + assert r.returncode == 0, r.stderr + return {"admin": admin, "work": local_graph} + + +# ---------------------------------------------------------------- reading and writing + +def test_an_admin_can_push_and_the_server_holds_it(hub, trading): + repo = hub.registry.repo("trading") + assert "seed" in git("log", "--oneline", cwd=repo).stdout + + +def test_a_read_token_can_clone_but_not_push(hub, trading, tmp_path): + """git's own protocol separates reading from writing by endpoint. Read access is + never getting past the door for git-receive-pack, nothing subtler.""" + tok = hub.registry.mint("trading", "reader", "read") + dest = tmp_path / "reader-clone" + + r = git("clone", "-q", clone_url(hub, "trading", "reader", tok), str(dest), cwd=tmp_path) + assert r.returncode == 0, r.stderr + + git("config", "user.email", "r@r.r", cwd=dest); git("config", "user.name", "r", cwd=dest) + commit_node(dest, "hyp-r.md", "---\nid: hyp-r\ntype: hypothesis\nstatus: open\n---\n\n# r\n") + r = git("push", "origin", "master", cwd=dest) + + assert r.returncode != 0 + assert "403" in r.stderr + assert "hyp-r" not in git("log", "--oneline", cwd=hub.registry.repo("trading")).stdout + + +def test_no_token_is_a_401_challenge(hub, trading, tmp_path): + r = git("clone", "-q", f"{hub.url}/trading.git", str(tmp_path / "x"), cwd=tmp_path) + + assert auth_refused(r), r.stderr + + +def test_a_wrong_token_is_refused(hub, trading, tmp_path): + r = git("clone", "-q", clone_url(hub, "trading", "seb", "wrong"), str(tmp_path / "x"), + cwd=tmp_path) + + assert auth_refused(r), r.stderr + + +def test_an_unknown_graph_looks_like_a_wrong_token(hub, trading, tmp_path): + """The server must not confirm which graphs exist to someone without a token.""" + r = git("clone", "-q", clone_url(hub, "biology", "seb", trading["admin"]), + str(tmp_path / "x"), cwd=tmp_path) + + assert auth_refused(r), r.stderr + + +# ---------------------------------------------------------------- the gate, over HTTP + +def test_the_rule_gate_refuses_a_broken_push_over_http(hub, trading): + """PR #32's pre-receive hook, unchanged, reached through git-http-backend. The + rule's own message must reach the pusher.""" + work = trading["work"] + commit_node(work, "hyp-x.md", ALIVE_NO_GATE) + + r = git("push", "origin", "master", cwd=work) + + assert r.returncode != 0 + assert "live-claims-must-cite-their-gates" in r.stderr + assert "hyp-x" not in git("log", "--oneline", cwd=hub.registry.repo("trading")).stdout + + +def test_a_clean_push_after_a_refused_one_lands(hub, trading): + work = trading["work"] + commit_node(work, "hyp-x.md", ALIVE_NO_GATE) + git("push", "origin", "master", cwd=work) + git("reset", "-q", "--hard", "HEAD~1", cwd=work) + commit_node(work, "hyp-y.md", "---\nid: hyp-y\ntype: hypothesis\nstatus: open\n---\n\n# y\n") + + r = git("push", "origin", "master", cwd=work) + + assert r.returncode == 0, r.stderr + assert "hyp-y" in git("log", "--oneline", cwd=hub.registry.repo("trading")).stdout + + +def test_a_second_clone_sees_what_the_first_pushed(hub, trading, tmp_path): + tok = hub.registry.mint("trading", "maria", "write") + dest = tmp_path / "maria" + git("clone", "-q", clone_url(hub, "trading", "maria", tok), str(dest), cwd=tmp_path) + + assert (dest / "nodes" / "hyp-ok.md").exists() From 3f7bef51983a2089e961f074610ea1cb740fd026 Mon Sep 17 00:00:00 2001 From: BY571 Date: Sat, 5 Sep 2026 12:40:42 +0200 Subject: [PATCH 07/26] serve: a crashed backend answers 500, not a silent 200 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git http-backend can exit non-zero with no CGI header block at all — a genuine internal error, not a gate refusal (those travel the sideband with exit 0). The relay used to fall back to status 200 in that case, handing the client an empty 200 OK while the real failure sat only in the server's own stderr. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb --- src/knoten/serve.py | 15 ++++++++++----- tests/test_serve.py | 24 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/knoten/serve.py b/src/knoten/serve.py index 916d726..248efbe 100644 --- a/src/knoten/serve.py +++ b/src/knoten/serve.py @@ -126,22 +126,27 @@ def _git(self, name: str, sub: str, query: str) -> None: r = subprocess.run(["git", "http-backend"], input=self._body(), capture_output=True, env=env) head, _, out = r.stdout.partition(b"\r\n\r\n") - status, headers = 200, [] + status, headers, saw_status = 200, [], False for line in head.decode(errors="replace").splitlines(): key, _, value = line.partition(":") if key.strip().lower() == "status": - status = int(value.strip().split()[0]) + status, saw_status = int(value.strip().split()[0]), True elif key.strip(): headers.append((key.strip(), value.strip())) + if r.returncode != 0: + print(f"knoten serve: git http-backend: {r.stderr.decode(errors='replace').strip()}", + file=sys.stderr) + if not saw_status: + # A pre-receive refusal travels the sideband with exit 0, so a non-zero + # exit with no Status line can only be the backend itself dying — a + # crashed backend used to relay to the client as a silent 200. + return self._refuse(500, "knoten: git http-backend failed on the server; see its log") self.send_response(status) for key, value in headers: self.send_header(key, value) self.send_header("Content-Length", str(len(out))) self.end_headers() self.wfile.write(out) - if r.returncode != 0: - print(f"knoten serve: git http-backend: {r.stderr.decode(errors='replace').strip()}", - file=sys.stderr) # ---------------------------------------------------------------- api (Task 4) diff --git a/tests/test_serve.py b/tests/test_serve.py index 4d1f24d..4b36b9b 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -3,6 +3,7 @@ HTTP protocol. Everything hard (packfiles, refs, negotiation, hooks) is git's. These tests therefore drive real git clients at a real server. """ +import base64 import json import os import subprocess @@ -127,3 +128,26 @@ def test_a_second_clone_sees_what_the_first_pushed(hub, trading, tmp_path): git("clone", "-q", clone_url(hub, "trading", "maria", tok), str(dest), cwd=tmp_path) assert (dest / "nodes" / "hyp-ok.md").exists() + + +def test_a_crashed_backend_is_a_500_not_a_silent_200(hub, trading, monkeypatch): + """A refusal by the gate arrives in the sideband with exit 0, so a non-zero exit + with no Status line can only be the backend dying. That used to relay as 200 with + an empty body. Real git cannot be made to crash headerless on demand, which is why + this one test fakes the subprocess: it is testing the relay, not git.""" + import subprocess as sp + from knoten import serve as serve_mod + + def dead(*args, **kwargs): + return sp.CompletedProcess(args, 128, stdout=b"", stderr=b"fatal: boom") + monkeypatch.setattr(serve_mod.subprocess, "run", dead) + + req = urllib.request.Request( + f"{hub.url}/trading.git/info/refs?service=git-upload-pack", + headers={"Authorization": "Basic " + base64.b64encode( + f"seb:{trading['admin']}".encode()).decode()}) + with pytest.raises(urllib.error.HTTPError) as e: + urllib.request.urlopen(req) + + assert e.value.code == 500 + assert "http-backend failed" in json.loads(e.value.read())["error"] From 5db3afcb99d1aa162718fe97981c4acfe6645484 Mon Sep 17 00:00:00 2001 From: BY571 Date: Sat, 5 Sep 2026 12:45:14 +0200 Subject: [PATCH 08/26] four endpoints: the owner creates, an admin invites and revokes, a code joins Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb --- src/knoten/serve.py | 34 +++++++++++++--- tests/test_serve.py | 97 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 5 deletions(-) diff --git a/src/knoten/serve.py b/src/knoten/serve.py index 248efbe..39132b2 100644 --- a/src/knoten/serve.py +++ b/src/knoten/serve.py @@ -148,16 +148,40 @@ def _git(self, name: str, sub: str, query: str) -> None: self.end_headers() self.wfile.write(out) - # ---------------------------------------------------------------- api (Task 4) + # ---------------------------------------------------------------- api def _create(self) -> None: - self._refuse(404, "knoten: not found") + user, secret = self._basic() + if user != "owner" or not self.registry.check_owner(secret): + return self._refuse(401, "knoten: the owner secret is required to create a graph") + body = self._json_body() + token = self.registry.create(body.get("name", ""), body.get("admin", "")) + self._json(201, {"token": token}) def _join(self, name: str) -> None: - self._refuse(404, "knoten: not found") + body = self._json_body() + user, role, token = self.registry.redeem(name, body.get("code", "")) + self._json(200, {"name": user, "role": role, "token": token}) + + def _admin(self, name: str) -> str | None: + """The calling admin's name, or None after having refused the request.""" + user, token = self._basic() + if self.registry.authenticate(name, user, token) != "admin": + self._refuse(403, f"knoten: only an admin of {name} can do that") + return None + return user def _invite(self, name: str) -> None: - self._refuse(404, "knoten: not found") + if not self._admin(name): + return + body = self._json_body() + code = self.registry.invite(name, body.get("name", ""), body.get("role", "write"), + int(body.get("days", 7))) + self._json(200, {"code": code}) def _revoke(self, name: str) -> None: - self._refuse(404, "knoten: not found") + if not self._admin(name): + return + body = self._json_body() + self.registry.revoke(name, body.get("name", "")) + self._json(200, {"revoked": body.get("name", "")}) diff --git a/tests/test_serve.py b/tests/test_serve.py index 4b36b9b..0ef441e 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -151,3 +151,100 @@ def dead(*args, **kwargs): assert e.value.code == 500 assert "http-backend failed" in json.loads(e.value.read())["error"] + + +# ---------------------------------------------------------------- the api + +def api(hub, path, body, auth=None): + """Raw urllib, deliberately not the knoten client: the server is being tested.""" + req = urllib.request.Request(hub.url + path, data=json.dumps(body).encode(), + method="POST", headers={"Content-Type": "application/json"}) + if auth: + cred = base64.b64encode(f"{auth[0]}:{auth[1]}".encode()).decode() + req.add_header("Authorization", "Basic " + cred) + try: + with urllib.request.urlopen(req) as r: + return r.status, json.loads(r.read()) + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read()) + + +def test_the_owner_secret_creates_a_graph_and_gets_the_admin_token(hub): + status, body = api(hub, "/graphs", {"name": "biology", "admin": "seb"}, ("owner", hub.secret)) + + assert status == 201 + assert hub.registry.authenticate("biology", "seb", body["token"]) == "admin" + assert (hub.registry.repo("biology") / "hooks" / "pre-receive").exists() + + +def test_creating_a_graph_without_the_owner_secret_is_refused(hub): + status, body = api(hub, "/graphs", {"name": "biology", "admin": "seb"}, ("owner", "nope")) + assert status == 401 + assert not hub.registry.exists("biology") + + status, _ = api(hub, "/graphs", {"name": "biology", "admin": "seb"}) + assert status == 401 + + +def test_a_bad_graph_name_is_a_400_and_creates_nothing(hub): + status, body = api(hub, "/graphs", {"name": "../etc", "admin": "seb"}, ("owner", hub.secret)) + + assert status == 400 + assert "not a valid graph name" in body["error"] + assert list((hub.data / "graphs").iterdir()) == [] + + +def test_an_admin_can_invite_and_the_invitee_can_join(hub, trading): + status, body = api(hub, "/trading/invite", {"name": "maria", "role": "write", "days": 7}, + ("seb", trading["admin"])) + assert status == 200, body + + status, joined = api(hub, "/trading/join", {"code": body["code"]}) + + assert status == 200, joined + assert joined["name"] == "maria" and joined["role"] == "write" + assert hub.registry.authenticate("trading", "maria", joined["token"]) == "write" + + +def test_a_write_token_cannot_invite(hub, trading): + tok = hub.registry.mint("trading", "maria", "write") + + status, body = api(hub, "/trading/invite", {"name": "x", "role": "write"}, ("maria", tok)) + + assert status == 403 + assert "only an admin" in body["error"] + + +def test_joining_twice_with_one_code_fails_the_second_time(hub, trading): + _, inv = api(hub, "/trading/invite", {"name": "maria", "role": "read"}, ("seb", trading["admin"])) + api(hub, "/trading/join", {"code": inv["code"]}) + + status, body = api(hub, "/trading/join", {"code": inv["code"]}) + + assert status == 400 + assert "not valid" in body["error"] + + +def test_revoke_ends_a_contributors_access(hub, trading, tmp_path): + tok = hub.registry.mint("trading", "maria", "write") + + status, _ = api(hub, "/trading/revoke", {"name": "maria"}, ("seb", trading["admin"])) + assert status == 200 + + r = git("clone", "-q", clone_url(hub, "trading", "maria", tok), str(tmp_path / "x"), cwd=tmp_path) + assert auth_refused(r), r.stderr + + +def test_malformed_json_is_a_400_not_a_traceback(hub, trading): + req = urllib.request.Request(hub.url + "/trading/join", data=b"{not json", + method="POST", headers={"Content-Type": "application/json"}) + with pytest.raises(urllib.error.HTTPError) as e: + urllib.request.urlopen(req) + + assert e.value.code == 400 + assert "not JSON" in json.loads(e.value.read())["error"] + + +def test_unknown_paths_are_404(hub): + status, _ = api(hub, "/trading/steal", {}) + assert status == 404 From d218a762f247f00f8008deea212ec7ac4837c710 Mon Sep 17 00:00:00 2001 From: BY571 Date: Sat, 5 Sep 2026 12:51:06 +0200 Subject: [PATCH 09/26] guard json body parsing and days field conversion _json_body() now validates that parsed JSON is a dict, not a list or other type, preventing AttributeError on .get(). _invite() wraps int(days) in try-except to catch non-numeric values. Both now raise GraphError for 400 instead of uncaught exceptions. Added comments to _create() and _admin() explaining security and response sequencing rationale. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb --- src/knoten/serve.py | 20 ++++++++++++++++---- tests/test_serve.py | 15 +++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/knoten/serve.py b/src/knoten/serve.py index 39132b2..3199977 100644 --- a/src/knoten/serve.py +++ b/src/knoten/serve.py @@ -59,9 +59,13 @@ def _body(self) -> bytes: def _json_body(self) -> dict: try: - return json.loads(self._body() or b"{}") + result = json.loads(self._body() or b"{}") except json.JSONDecodeError as e: raise GraphError(f"request body is not JSON: {e}") from None + # A list like [1, 2, 3] parses fine but then crashes the thread on .get(). + if not isinstance(result, dict): + raise GraphError("request body must be a JSON object") + return result def _json(self, status: int, obj: dict) -> None: data = json.dumps(obj).encode() @@ -152,6 +156,7 @@ def _git(self, name: str, sub: str, query: str) -> None: def _create(self) -> None: user, secret = self._basic() + # Distinguishing "wrong username" from "wrong secret" would tell an attacker which half they got right. if user != "owner" or not self.registry.check_owner(secret): return self._refuse(401, "knoten: the owner secret is required to create a graph") body = self._json_body() @@ -164,7 +169,11 @@ def _join(self, name: str) -> None: self._json(200, {"name": user, "role": role, "token": token}) def _admin(self, name: str) -> str | None: - """The calling admin's name, or None after having refused the request.""" + """The calling admin's name, or None after having refused the request. + + If auth fails, _admin writes the 403 response itself. Callers check the return + value and must return without writing anything, or the client gets two responses + on one connection.""" user, token = self._basic() if self.registry.authenticate(name, user, token) != "admin": self._refuse(403, f"knoten: only an admin of {name} can do that") @@ -175,8 +184,11 @@ def _invite(self, name: str) -> None: if not self._admin(name): return body = self._json_body() - code = self.registry.invite(name, body.get("name", ""), body.get("role", "write"), - int(body.get("days", 7))) + try: + days = int(body.get("days", 7)) + except (TypeError, ValueError): + raise GraphError("days must be a whole number") from None + code = self.registry.invite(name, body.get("name", ""), body.get("role", "write"), days) self._json(200, {"code": code}) def _revoke(self, name: str) -> None: diff --git a/tests/test_serve.py b/tests/test_serve.py index 0ef441e..fdd66a4 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -245,6 +245,21 @@ def test_malformed_json_is_a_400_not_a_traceback(hub, trading): assert "not JSON" in json.loads(e.value.read())["error"] +def test_a_json_body_that_is_not_an_object_is_a_400(hub, trading): + """`[1, 2, 3]` parses as JSON and then crashed the thread on `.get`. The client saw + a closed connection, not a refusal.""" + status, body = api(hub, "/trading/invite", [1, 2, 3], ("seb", trading["admin"])) + assert status == 400 + assert "JSON object" in body["error"] + + +def test_a_non_numeric_days_is_a_400(hub, trading): + status, body = api(hub, "/trading/invite", + {"name": "maria", "role": "write", "days": "banana"}, ("seb", trading["admin"])) + assert status == 400 + assert "whole number" in body["error"] + + def test_unknown_paths_are_404(hub): status, _ = api(hub, "/trading/steal", {}) assert status == 404 From 370e942c1e49b18ef06561d79b2eb0fe58ddec8b Mon Sep 17 00:00:00 2001 From: BY571 Date: Sat, 5 Sep 2026 12:56:02 +0200 Subject: [PATCH 10/26] knoten serve, and the owner secret is shown exactly once Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb --- src/knoten/cli.py | 32 ++++++++++++++++++++++++++++++++ tests/test_serve.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/src/knoten/cli.py b/src/knoten/cli.py index 42f43b6..37c7af6 100644 --- a/src/knoten/cli.py +++ b/src/knoten/cli.py @@ -15,6 +15,8 @@ from .commit import commit from .core import GraphError, ID_RE, LOCK, find_root, node_path, today from .hook import install as install_hook, install_server +from .registry import ROLES, Registry +from .serve import make_server from .validate import _csv, applies, load_config # Keyed by the uppercase word `ops` puts in `verdict` — not by raw status, which is @@ -226,6 +228,29 @@ def server_hook(repo, force) -> int: return 0 +def serve_cmd(data, bind) -> int: + """Run on the server. Prints the owner secret the first time a data directory is + used, because that is the one moment the owner is certainly at the keyboard.""" + host, _, port = bind.rpartition(":") + host = host or "127.0.0.1" + reg = Registry(Path(data)) + first = not (reg.data / "owner").exists() + secret = reg.owner_secret() + srv = make_server(reg, host, int(port)) + if first: + print(f" owner secret (shown once, keep it somewhere safe): {secret}") + if host not in ("127.0.0.1", "localhost"): + print(" warning: plain HTTP on a non-local address. Put TLS in front (a reverse " + "proxy or a tunnel) before anyone outside this machine connects.", + file=sys.stderr) + print(f" serving {reg.data} on http://{host}:{srv.server_address[1]} (ctrl-c to stop)") + try: + srv.serve_forever() + except KeyboardInterrupt: + pass + return 0 + + def render_get(payload: dict) -> None: print(f"{payload['id']} [{MARK.get(payload['verdict'], payload['verdict'])}] " f"type={payload['type']}\n") @@ -597,6 +622,10 @@ def _parser() -> argparse.ArgumentParser: s.add_argument("--force", action="store_true", help="overwrite a hook knoten did not write") + s = sub.add_parser("serve", help="host remote graphs (run this on the server)") + s.add_argument("--data", required=True, metavar="DIR", help="where graphs and tokens live") + s.add_argument("--bind", default="127.0.0.1:8899", metavar="HOST:PORT") + s = sub.add_parser("show", help="the node, its edges and its attachments") s.add_argument("node") s.add_argument("--json", action="store_true", help="emit the raw payload") @@ -648,6 +677,9 @@ def main(argv=None) -> int: if args.cmd == "hook" and args.server is not None: return server_hook(args.server, args.force) + if args.cmd == "serve": + return serve_cmd(args.data, args.bind) + root = find_root() return { "query": lambda: query(root, args.term, args.json), diff --git a/tests/test_serve.py b/tests/test_serve.py index fdd66a4..b465c7e 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -263,3 +263,34 @@ def test_a_non_numeric_days_is_a_400(hub, trading): def test_unknown_paths_are_404(hub): status, _ = api(hub, "/trading/steal", {}) assert status == 404 + + +# ---------------------------------------------------------------- the cli + +def test_serve_prints_the_owner_secret_exactly_once(tmp_path, monkeypatch, capsys): + """Shown on first run, when the directory is created, and never again: the secret + on a terminal scrollback is one thing, in every restart's log is another.""" + from http.server import ThreadingHTTPServer + from knoten.cli import main + + monkeypatch.setattr(ThreadingHTTPServer, "serve_forever", lambda self: None) + + assert main(["serve", "--data", str(tmp_path / "d"), "--bind", "127.0.0.1:0"]) == 0 + first = capsys.readouterr().out + assert main(["serve", "--data", str(tmp_path / "d"), "--bind", "127.0.0.1:0"]) == 0 + second = capsys.readouterr().out + + secret = (tmp_path / "d" / "owner").read_text().strip() + assert secret in first + assert secret not in second + assert "serving" in second + + +def test_serve_warns_when_bound_to_a_non_local_address(tmp_path, monkeypatch, capsys): + from http.server import ThreadingHTTPServer + from knoten.cli import main + + monkeypatch.setattr(ThreadingHTTPServer, "serve_forever", lambda self: None) + main(["serve", "--data", str(tmp_path / "d"), "--bind", "0.0.0.0:0"]) + + assert "plain HTTP" in capsys.readouterr().err From d4678baa61e5b02db6524307104d0f198c4652aa Mon Sep 17 00:00:00 2001 From: BY571 Date: Sat, 5 Sep 2026 13:02:39 +0200 Subject: [PATCH 11/26] fix serve_cmd port validation and socket cleanup validate the --bind port before writing the owner secret, so a typo like --bind localhost:abc fails early without orphaning the secret on disk. also call server_close() in a finally block to close the socket when serve_forever() returns, fixing ResourceWarning on exit. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb --- src/knoten/cli.py | 13 +++++++++++-- tests/test_serve.py | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/knoten/cli.py b/src/knoten/cli.py index 37c7af6..921f091 100644 --- a/src/knoten/cli.py +++ b/src/knoten/cli.py @@ -231,12 +231,19 @@ def server_hook(repo, force) -> int: def serve_cmd(data, bind) -> int: """Run on the server. Prints the owner secret the first time a data directory is used, because that is the one moment the owner is certainly at the keyboard.""" - host, _, port = bind.rpartition(":") + host, _, port_str = bind.rpartition(":") host = host or "127.0.0.1" + # Parse and validate the port before any file is written; a crash after the owner + # secret exists orphans it, shown to nobody, forever. + try: + port = int(port_str) + except ValueError: + raise GraphError(f"--bind wants HOST:PORT with a numeric port, got '{bind}'") from None reg = Registry(Path(data)) + srv = make_server(reg, host, port) + # Only now, after the server socket is open, check and display the owner secret. first = not (reg.data / "owner").exists() secret = reg.owner_secret() - srv = make_server(reg, host, int(port)) if first: print(f" owner secret (shown once, keep it somewhere safe): {secret}") if host not in ("127.0.0.1", "localhost"): @@ -248,6 +255,8 @@ def serve_cmd(data, bind) -> int: srv.serve_forever() except KeyboardInterrupt: pass + finally: + srv.server_close() return 0 diff --git a/tests/test_serve.py b/tests/test_serve.py index b465c7e..5195439 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -294,3 +294,29 @@ def test_serve_warns_when_bound_to_a_non_local_address(tmp_path, monkeypatch, ca main(["serve", "--data", str(tmp_path / "d"), "--bind", "0.0.0.0:0"]) assert "plain HTTP" in capsys.readouterr().err + + +def test_serve_refuses_a_non_numeric_port_before_creating_the_owner_secret(tmp_path, capsys): + """The secret used to be written before the bind was parsed, so a typo in --bind + created it, crashed, and never showed it; every later run then saw an existing + file and stayed silent. Fail before writing anything.""" + from knoten.cli import main + + assert main(["serve", "--data", str(tmp_path / "d"), "--bind", "127.0.0.1:abc"]) == 1 + err = capsys.readouterr().err + assert "numeric port" in err and "Traceback" not in err + assert not (tmp_path / "d" / "owner").exists() + + +def test_serve_closes_its_socket_when_it_stops(tmp_path, monkeypatch): + """`serve_forever` returning is not the socket closing. Left open, the port stays + bound until the interpreter exits.""" + import warnings + from http.server import ThreadingHTTPServer + from knoten.cli import main + + monkeypatch.setattr(ThreadingHTTPServer, "serve_forever", lambda self: None) + with warnings.catch_warnings(): + warnings.simplefilter("error", ResourceWarning) + assert main(["serve", "--data", str(tmp_path / "d"), "--bind", "127.0.0.1:0"]) == 0 + import gc; gc.collect() From d0bcc6b88eb744c923d91072bf1f95b7cb948585 Mon Sep 17 00:00:00 2001 From: BY571 Date: Sat, 5 Sep 2026 13:06:35 +0200 Subject: [PATCH 12/26] a credential store git asks through its own helper protocol Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb --- src/knoten/cli.py | 10 ++++++ src/knoten/remote.py | 72 ++++++++++++++++++++++++++++++++++++++++ tests/test_remote.py | 78 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 src/knoten/remote.py create mode 100644 tests/test_remote.py diff --git a/src/knoten/cli.py b/src/knoten/cli.py index 921f091..4deb1fb 100644 --- a/src/knoten/cli.py +++ b/src/knoten/cli.py @@ -17,6 +17,7 @@ from .hook import install as install_hook, install_server from .registry import ROLES, Registry from .serve import make_server +from . import remote from .validate import _csv, applies, load_config # Keyed by the uppercase word `ops` puts in `verdict` — not by raw status, which is @@ -635,6 +636,10 @@ def _parser() -> argparse.ArgumentParser: s.add_argument("--data", required=True, metavar="DIR", help="where graphs and tokens live") s.add_argument("--bind", default="127.0.0.1:8899", metavar="HOST:PORT") + # git runs this; nobody types it. `!knoten credential` is set in every clone's config. + s = sub.add_parser("credential", help=argparse.SUPPRESS) + s.add_argument("action", nargs="?") + s = sub.add_parser("show", help="the node, its edges and its attachments") s.add_argument("node") s.add_argument("--json", action="store_true", help="emit the raw payload") @@ -689,6 +694,11 @@ def main(argv=None) -> int: if args.cmd == "serve": return serve_cmd(args.data, args.bind) + if args.cmd == "credential": + if args.action == "get": + sys.stdout.write(remote.credential_helper(sys.stdin.read())) + return 0 # store/erase: git manages nothing here; knoten does + root = find_root() return { "query": lambda: query(root, args.term, args.json), diff --git a/src/knoten/remote.py b/src/knoten/remote.py new file mode 100644 index 0000000..e263f13 --- /dev/null +++ b/src/knoten/remote.py @@ -0,0 +1,72 @@ +"""The client side of a remote graph. + +A remote is a git URL plus a token. git already knows how to push, pull and clone over +HTTP and how to ask a helper program for credentials, so this module is that helper plus +a handful of commands that wrap git and make four JSON calls. It imports nothing from the +server: a client and a server never share code paths, so a bug in one cannot hide in the +other. + +Tokens live in one file, mode 0600, one line per remote: `/ `. +The owner secret for a server is stored under the bare host with user `owner`. +""" +from __future__ import annotations + +import base64 +import json +import os +import subprocess +import sys +import urllib.error +import urllib.request +from pathlib import Path +from urllib.parse import urlsplit + +from .core import GraphError, ID_RE + + +# ---------------------------------------------------------------- credentials + +def cred_path() -> Path: + return Path(os.environ.get("KNOTEN_CREDENTIALS") + or Path.home() / ".config" / "knoten" / "credentials") + + +def _key(url: str) -> str: + u = urlsplit(url) + return f"{u.netloc}{u.path}".rstrip("/") + + +def cred_store(url: str, user: str, secret: str) -> None: + p = cred_path() + p.parent.mkdir(parents=True, exist_ok=True) + key = _key(url) + lines = [l for l in (p.read_text(encoding="utf-8").splitlines() if p.exists() else []) + if not l.startswith(key + " ")] + lines.append(f"{key} {user} {secret}") + fd = os.open(p, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write("\n".join(lines) + "\n") + + +def cred_lookup(url: str) -> tuple[str, str] | None: + p = cred_path() + if not p.exists(): + return None + key = _key(url) + for line in p.read_text(encoding="utf-8").splitlines(): + parts = line.split(" ", 2) + if len(parts) == 3 and parts[0] == key: + return parts[1], parts[2] + return None + + +def credential_helper(request: str) -> str: + """git's credential protocol: key=value lines in, username and password out. + + Empty output for an unknown remote, never an error: git then falls through to its + next helper, so every non-knoten remote on the machine keeps working. + """ + fields = dict(line.split("=", 1) for line in request.splitlines() if "=" in line) + url = f"{fields.get('protocol', 'https')}://{fields.get('host', '')}/{fields.get('path', '')}" + found = cred_lookup(url) + return f"username={found[0]}\npassword={found[1]}\n" if found else "" diff --git a/tests/test_remote.py b/tests/test_remote.py new file mode 100644 index 0000000..08b9e64 --- /dev/null +++ b/tests/test_remote.py @@ -0,0 +1,78 @@ +"""The client side of a remote graph: a credential store git calls through its own +credential-helper protocol, and commands that wrap git plus four JSON calls.""" +import os +import stat +import subprocess + +import pytest + +from knoten.cli import main +from knoten.core import GraphError +from knoten.remote import cred_lookup, cred_path, cred_store, credential_helper + + +def git(*args, cwd, env=None): + return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, + env={**os.environ, **(env or {})}) + + +@pytest.fixture(autouse=True) +def isolated_credentials(tmp_path, monkeypatch): + monkeypatch.setenv("KNOTEN_CREDENTIALS", str(tmp_path / "creds")) + + +# ---------------------------------------------------------------- the store + +def test_store_and_lookup_round_trip_per_remote(): + cred_store("https://h.example/trading.git", "seb", "tok-1") + cred_store("https://h.example/biology.git", "seb", "tok-2") + + assert cred_lookup("https://h.example/trading.git") == ("seb", "tok-1") + assert cred_lookup("https://h.example/biology.git") == ("seb", "tok-2") + assert cred_lookup("https://h.example/nope.git") is None + + +def test_storing_the_same_remote_again_replaces_it(): + cred_store("https://h.example/trading.git", "seb", "old") + cred_store("https://h.example/trading.git", "seb", "new") + + assert cred_lookup("https://h.example/trading.git") == ("seb", "new") + assert cred_path().read_text().count("trading.git") == 1 + + +def test_the_store_is_private_to_the_user(): + cred_store("https://h.example/trading.git", "seb", "tok") + + assert stat.S_IMODE(cred_path().stat().st_mode) == 0o600 + + +def test_lookup_with_no_store_is_none_not_an_error(): + assert cred_lookup("https://h.example/trading.git") is None + + +# ---------------------------------------------------------------- git's protocol + +def test_the_helper_answers_gits_request_for_a_known_remote(): + """git writes key=value lines and reads username/password back. `path` is only + sent when credential.useHttpPath is on, which the client sets, so one host can + hold many graphs with different tokens.""" + cred_store("https://h.example/trading.git", "maria", "tok") + + out = credential_helper("protocol=https\nhost=h.example\npath=trading.git\n") + + assert out == "username=maria\npassword=tok\n" + + +def test_the_helper_says_nothing_for_an_unknown_remote(): + """Empty output tells git to fall through to its next helper or to prompt. Anything + else, including an error, would break every non-knoten remote on the machine.""" + assert credential_helper("protocol=https\nhost=h.example\npath=other.git\n") == "" + + +def test_the_cli_exposes_the_helper_on_stdin(monkeypatch, capsys): + import io + cred_store("https://h.example/trading.git", "maria", "tok") + monkeypatch.setattr("sys.stdin", io.StringIO("protocol=https\nhost=h.example\npath=trading.git\n")) + + assert main(["credential", "get"]) == 0 + assert capsys.readouterr().out == "username=maria\npassword=tok\n" From 4ef011f3a8f4a85954d2463a1fb499a602dd798b Mon Sep 17 00:00:00 2001 From: BY571 Date: Sat, 5 Sep 2026 13:12:35 +0200 Subject: [PATCH 13/26] enforce mode 0600 on credential file even when it already exists os.open's mode argument only applies when creating a new file; existing files keep their original permissions. A credentials file pre-existing with looser bits would leak tokens to other local users on every write. Call os.fchmod after open to enforce secure permissions regardless of file age. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb --- src/knoten/remote.py | 2 ++ tests/test_remote.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/knoten/remote.py b/src/knoten/remote.py index e263f13..c19a013 100644 --- a/src/knoten/remote.py +++ b/src/knoten/remote.py @@ -44,6 +44,8 @@ def cred_store(url: str, user: str, secret: str) -> None: if not l.startswith(key + " ")] lines.append(f"{key} {user} {secret}") fd = os.open(p, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + # os.open's mode applies only when the file is created; existing files keep their bits. + os.fchmod(fd, 0o600) with os.fdopen(fd, "w", encoding="utf-8") as fh: fh.write("\n".join(lines) + "\n") diff --git a/tests/test_remote.py b/tests/test_remote.py index 08b9e64..5f5ce3a 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -46,6 +46,20 @@ def test_the_store_is_private_to_the_user(): assert stat.S_IMODE(cred_path().stat().st_mode) == 0o600 +def test_the_store_is_made_private_again_if_it_was_not(): + """os.open's mode applies only when the file is created. A credentials file that + already existed with looser bits (a manual copy, a bad umask) kept them on every + later write, leaking every token to other local users.""" + p = cred_path() + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("h.example/old.git seb tok\n", encoding="utf-8") + os.chmod(p, 0o644) + + cred_store("https://h.example/trading.git", "seb", "tok") + + assert stat.S_IMODE(p.stat().st_mode) == 0o600 + + def test_lookup_with_no_store_is_none_not_an_error(): assert cred_lookup("https://h.example/trading.git") is None From c9aded085eec494276728c7dde1d30092e384871 Mon Sep 17 00:00:00 2001 From: BY571 Date: Sat, 5 Sep 2026 13:24:46 +0200 Subject: [PATCH 14/26] remote create, push, pull: one command each, git underneath Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb --- src/knoten/cli.py | 28 ++++++++++ src/knoten/remote.py | 125 +++++++++++++++++++++++++++++++++++++++++++ tests/test_remote.py | 117 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 270 insertions(+) diff --git a/src/knoten/cli.py b/src/knoten/cli.py index 4deb1fb..051b32f 100644 --- a/src/knoten/cli.py +++ b/src/knoten/cli.py @@ -229,6 +229,18 @@ def server_hook(repo, force) -> int: return 0 +def remote_cmd(root, args) -> int: + if args.remote_cmd == "create": + url = remote.remote_create(root, args.name, args.on, admin=args.admin, + owner_secret=args.owner_secret) + print(f" ✓ {url}") + print(" invite someone: knoten invite --role write") + return 0 + remote.remote_add(root, args.url) + print(" ✓ origin set. `knoten pull` and `knoten push` now use it.") + return 0 + + def serve_cmd(data, bind) -> int: """Run on the server. Prints the owner secret the first time a data directory is used, because that is the one moment the owner is certainly at the keyboard.""" @@ -640,6 +652,19 @@ def _parser() -> argparse.ArgumentParser: s = sub.add_parser("credential", help=argparse.SUPPRESS) s.add_argument("action", nargs="?") + s = sub.add_parser("remote", help="connect this graph to a knoten server") + rs = s.add_subparsers(dest="remote_cmd", required=True) + c = rs.add_parser("create", help="create this graph on a server and push it") + c.add_argument("name") + c.add_argument("--on", required=True, metavar="URL", help="the server, e.g. https://graphs.example") + c.add_argument("--as", dest="admin", metavar="NAME", help="your contributor name (default: git user.name)") + c.add_argument("--owner-secret", help="the server's owner secret (asked for if not stored)") + a = rs.add_parser("add", help="point this clone at an existing remote graph") + a.add_argument("url", help="the graph's URL, e.g. https://graphs.example/trading") + + sub.add_parser("push", help="push this graph to its remote, through the gate") + sub.add_parser("pull", help="fetch what collaborators pushed") + s = sub.add_parser("show", help="the node, its edges and its attachments") s.add_argument("node") s.add_argument("--json", action="store_true", help="emit the raw payload") @@ -720,6 +745,9 @@ def main(argv=None) -> int: "hook": lambda: hook(root, args.force), "attach": lambda: attach(root, args.node, args.files), "detach": lambda: detach(root, args.node, args.file), + "remote": lambda: remote_cmd(root, args), + "push": lambda: remote.push(root), + "pull": lambda: remote.pull(root), }[args.cmd]() except (GraphError, OSError) as e: # OSError: a typo'd --frontmatter/--body/--append path is ordinary user error, diff --git a/src/knoten/remote.py b/src/knoten/remote.py index c19a013..d7409cc 100644 --- a/src/knoten/remote.py +++ b/src/knoten/remote.py @@ -72,3 +72,128 @@ def credential_helper(request: str) -> str: url = f"{fields.get('protocol', 'https')}://{fields.get('host', '')}/{fields.get('path', '')}" found = cred_lookup(url) return f"username={found[0]}\npassword={found[1]}\n" if found else "" + + +# ---------------------------------------------------------------- git and http + +def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run(["git", "-C", str(cwd), *args], capture_output=True, text=True) + + +def _toplevel(root: Path) -> Path: + r = _git(root, "rev-parse", "--show-toplevel") + if r.returncode != 0: + raise GraphError(f"{root} is not in a git repository; run `git init` there first") + return Path(r.stdout.strip()) + + +def _origin(repo: Path) -> str: + r = _git(repo, "remote", "get-url", "origin") + if r.returncode != 0: + raise GraphError("this graph has no remote; run `knoten remote create` or " + "`knoten remote add` first") + return r.stdout.strip() + + +def _wire(repo: Path, git_url: str) -> None: + """Point origin at the remote and make git ask knoten for the token.""" + has = _git(repo, "remote", "get-url", "origin").returncode == 0 + _git(repo, "remote", "set-url" if has else "add", "origin", git_url) + for key, value in (("credential.helper", "!knoten credential"), + ("credential.useHttpPath", "true"), + # Large pushes would otherwise go chunked, which the stdlib + # server does not read. 500 MB keeps them Content-Length. + ("http.postBuffer", "524288000")): + _git(repo, "config", key, value) + + +def _api(url: str, body: dict, auth: tuple[str, str] | None = None) -> dict: + req = urllib.request.Request(url, data=json.dumps(body).encode(), method="POST", + headers={"Content-Type": "application/json"}) + if auth: + cred = base64.b64encode(f"{auth[0]}:{auth[1]}".encode()).decode() + req.add_header("Authorization", "Basic " + cred) + try: + with urllib.request.urlopen(req, timeout=30) as r: + return json.loads(r.read() or b"{}") + except urllib.error.HTTPError as e: + try: + message = json.loads(e.read()).get("error", e.reason) + except Exception: + message = e.reason + raise GraphError(str(message).removeprefix("knoten: ")) from None + except urllib.error.URLError as e: + raise GraphError(f"cannot reach {url}: {e.reason}") from None + + +def _explain(stderr: str) -> str: + """git prints only a status code for an HTTP refusal. Say what it means.""" + if any(m in stderr for m in ("401", "Authentication failed", "terminal prompts disabled")): + return "credentials refused; the token may have been revoked. Ask for a new invite." + if "403" in stderr: + return "this token has read access, not write" + if "pre-receive hook declined" in stderr: + return "the server refused the push; fix the violations above and push again" + lines = [l for l in stderr.strip().splitlines() if l.strip()] + return lines[-1] if lines else "git failed" + + +def _relay(stderr: str) -> None: + """The gate's own output arrives as `remote:` lines. Show those, and only those.""" + for line in stderr.splitlines(): + if line.startswith("remote:"): + print(line, file=sys.stderr) + + +# ---------------------------------------------------------------- commands + +def remote_create(root: Path, name: str, on: str, admin: str | None = None, + owner_secret: str | None = None) -> str: + repo = _toplevel(root) + on = on.rstrip("/") + u = urlsplit(on) + host_url = f"{u.scheme}://{u.netloc}" + secret = owner_secret or (cred_lookup(host_url) or ("", ""))[1] + if not secret: + import getpass + secret = getpass.getpass(f"owner secret for {u.netloc}: ") + if admin is None: + admin = _git(repo, "config", "user.name").stdout.strip().lower().replace(" ", "-") + if not ID_RE.match(admin or ""): + raise GraphError(f"'{admin}' is not a valid contributor name; pass --as NAME (kebab-case)") + + token = _api(f"{on}/graphs", {"name": name, "admin": admin}, ("owner", secret))["token"] + cred_store(host_url, "owner", secret) + git_url = f"{on}/{name}.git" + cred_store(git_url, admin, token) + _wire(repo, git_url) + r = _git(repo, "push", "-u", "origin", "HEAD") + _relay(r.stderr) + if r.returncode != 0: + raise GraphError(_explain(r.stderr)) + return f"{on}/{name}" + + +def remote_add(root: Path, url: str) -> None: + _wire(_toplevel(root), url.rstrip("/") + ".git") + + +def push(root: Path) -> int: + repo = _toplevel(root) + _origin(repo) + r = _git(repo, "push", "origin", "HEAD") + _relay(r.stderr) + if r.returncode != 0: + raise GraphError(_explain(r.stderr)) + print(" ✓ pushed") + return 0 + + +def pull(root: Path) -> int: + repo = _toplevel(root) + _origin(repo) + r = _git(repo, "pull", "-q", "--ff-only", "origin") + if r.returncode != 0: + raise GraphError(_explain(r.stderr)) + print(" ✓ up to date") + return 0 diff --git a/tests/test_remote.py b/tests/test_remote.py index 5f5ce3a..fcac2dc 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -90,3 +90,120 @@ def test_the_cli_exposes_the_helper_on_stdin(monkeypatch, capsys): assert main(["credential", "get"]) == 0 assert capsys.readouterr().out == "username=maria\npassword=tok\n" + + +# ---------------------------------------------------------------- create, push, pull + +def commit_node(work, name, text): + (work / "nodes" / name).write_text(text, encoding="utf-8") + git("add", "-A", cwd=work) + git("commit", "-qm", name, cwd=work) + + +@pytest.fixture +def shared(hub, local_graph, monkeypatch): + """`local_graph` created on `hub` by its admin through the CLI, cwd inside it.""" + monkeypatch.chdir(local_graph) + assert main(["remote", "create", "trading", "--on", hub.url, "--as", "seb", + "--owner-secret", hub.secret]) == 0 + return local_graph + + +def test_remote_create_puts_the_graph_on_the_server_and_wires_the_clone(capsys, hub, shared): + """One command: the graph exists on the server, the admin's token is stored, origin + points at it, and the seed commit is already there.""" + assert hub.registry.exists("trading") + assert "seed" in git("log", "--oneline", cwd=hub.registry.repo("trading")).stdout + assert cred_lookup(f"{hub.url}/trading.git")[0] == "seb" + assert cred_lookup(hub.url) == ("owner", hub.secret) + assert git("remote", "get-url", "origin", cwd=shared).stdout.strip() == f"{hub.url}/trading.git" + assert git("config", "credential.helper", cwd=shared).stdout.strip() == "!knoten credential" + assert f"{hub.url}/trading" in capsys.readouterr().out + + +def test_remote_create_remembers_the_owner_secret_for_the_next_graph(hub, local_graph, tmp_path, monkeypatch): + monkeypatch.chdir(local_graph) + main(["remote", "create", "trading", "--on", hub.url, "--as", "seb", "--owner-secret", hub.secret]) + + second = tmp_path / "biology" + (second / "nodes").mkdir(parents=True) + (second / "graph.yaml").write_text("name: biology\n", encoding="utf-8") + for c in (["init", "-q", "-b", "master"], ["config", "user.email", "t@t.t"], + ["config", "user.name", "t"], ["add", "-A"], ["commit", "-qm", "seed"]): + git(*c, cwd=second) + monkeypatch.chdir(second) + + assert main(["remote", "create", "biology", "--on", hub.url, "--as", "seb"]) == 0 + assert hub.registry.exists("biology") + + +def test_remote_create_outside_a_git_repo_says_git_init(hub, tmp_path, monkeypatch, capsys): + g = tmp_path / "g" + (g / "nodes").mkdir(parents=True) + (g / "graph.yaml").write_text("name: g\n", encoding="utf-8") + monkeypatch.chdir(g) + + assert main(["remote", "create", "g", "--on", hub.url, "--as", "seb", "--owner-secret", hub.secret]) == 1 + assert "git init" in capsys.readouterr().err + + +def test_remote_create_with_the_wrong_owner_secret_is_one_line(hub, local_graph, monkeypatch, capsys): + monkeypatch.chdir(local_graph) + + assert main(["remote", "create", "trading", "--on", hub.url, "--as", "seb", "--owner-secret", "no"]) == 1 + err = capsys.readouterr().err + assert "owner secret" in err and "Traceback" not in err + + +def test_push_goes_through_the_gate(hub, shared, capsys): + commit_node(shared, "hyp-x.md", "---\nid: hyp-x\ntype: hypothesis\nstatus: alive\n---\n\n# x\n") + + assert main(["push"]) == 1 + err = capsys.readouterr().err + assert "live-claims-must-cite-their-gates" in err + assert "hyp-x" not in git("log", "--oneline", cwd=hub.registry.repo("trading")).stdout + + git("reset", "-q", "--hard", "HEAD~1", cwd=shared) + commit_node(shared, "hyp-y.md", "---\nid: hyp-y\ntype: hypothesis\nstatus: open\n---\n\n# y\n") + + assert main(["push"]) == 0 + assert "hyp-y" in git("log", "--oneline", cwd=hub.registry.repo("trading")).stdout + + +def test_pull_brings_a_collaborators_node_down(hub, shared, tmp_path, monkeypatch): + tok = hub.registry.mint("trading", "maria", "write") + other = tmp_path / "maria" + url = f"http://maria:{tok}@{hub.url.removeprefix('http://')}/trading.git" + git("clone", "-q", url, str(other), cwd=tmp_path) + git("config", "user.email", "m@m.m", cwd=other); git("config", "user.name", "maria", cwd=other) + commit_node(other, "hyp-m.md", "---\nid: hyp-m\ntype: hypothesis\nstatus: open\n---\n\n# m\n") + assert git("push", "-q", "origin", "master", cwd=other).returncode == 0 + + monkeypatch.chdir(shared) + assert main(["pull"]) == 0 + assert (shared / "nodes" / "hyp-m.md").exists() + + +def test_push_with_a_read_token_is_explained_not_dumped(hub, shared, tmp_path, monkeypatch, capsys): + """git says `The requested URL returned error: 403`. The user should read `read + access, not write`.""" + tok = hub.registry.mint("trading", "reader", "read") + cred_store(f"{hub.url}/trading.git", "reader", tok) + commit_node(shared, "hyp-z.md", "---\nid: hyp-z\ntype: hypothesis\nstatus: open\n---\n\n# z\n") + + assert main(["push"]) == 1 + assert "read access, not write" in capsys.readouterr().err + + +def test_push_without_a_remote_says_so(local_graph, monkeypatch, capsys): + monkeypatch.chdir(local_graph) + assert main(["push"]) == 1 + assert "remote create" in capsys.readouterr().err + + +def test_remote_add_points_an_existing_clone_at_a_remote(hub, local_graph, monkeypatch): + monkeypatch.chdir(local_graph) + assert main(["remote", "add", f"{hub.url}/trading"]) == 0 + + assert git("remote", "get-url", "origin", cwd=local_graph).stdout.strip() == f"{hub.url}/trading.git" + assert git("config", "credential.useHttpPath", cwd=local_graph).stdout.strip() == "true" From f07ad974d7d3ba52acb4e64d6a1fc946307952c0 Mon Sep 17 00:00:00 2001 From: BY571 Date: Sat, 5 Sep 2026 13:29:36 +0200 Subject: [PATCH 15/26] secrets handed to a human on a command line must not start with a dash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit token_urlsafe's alphabet includes '-', and a value beginning with '-' reads to argparse as a flag, not an option's value — knoten remote create failed one run in five with a perfectly valid --owner-secret. owner_secret() and invite() now use token_hex; mint()'s tokens travel only as a git HTTP password, never argv, so they keep token_urlsafe. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb --- src/knoten/registry.py | 12 ++++++++++-- tests/test_registry.py | 10 ++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/knoten/registry.py b/src/knoten/registry.py index 85f03cb..3ed7958 100644 --- a/src/knoten/registry.py +++ b/src/knoten/registry.py @@ -31,6 +31,14 @@ def _hash(secret: str) -> str: return hashlib.sha256((secret or "").encode()).hexdigest() +# Every secret below that a human ever passes on a command line (owner_secret, +# invite codes) is generated with token_hex, not token_urlsafe: token_urlsafe's +# alphabet includes '-', and a secret that begins with '-' reads to argparse as +# a flag, not a value — `knoten remote create ... --owner-secret ` then +# failed one run in five with a perfectly valid secret. mint()'s tokens travel +# only as a git HTTP password, never argv, so they keep token_urlsafe. + + def _now() -> datetime: return datetime.now(timezone.utc) @@ -48,7 +56,7 @@ def owner_secret(self) -> str: if not p.exists(): fd = os.open(p, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(fd, "w") as fh: - fh.write(secrets.token_urlsafe(32)) + fh.write(secrets.token_hex(32)) return p.read_text(encoding="utf-8").strip() def check_owner(self, secret: str) -> bool: @@ -147,7 +155,7 @@ def revoke(self, name: str, user: str) -> None: def invite(self, name: str, user: str, role: str, days: int = 7) -> str: self._check(name, user, role) - code = secrets.token_urlsafe(16) + code = secrets.token_hex(16) expires = (_now() + timedelta(days=days)).isoformat() with graph_lock(self.graph_dir(name)): invites = self._read(name, "invites.json") diff --git a/tests/test_registry.py b/tests/test_registry.py index 8e97f98..f14afea 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -203,3 +203,13 @@ def test_a_wrong_code_and_a_wrong_graph_both_fail(reg): reg.redeem("trading", "guess") with pytest.raises(GraphError, match="no graph 'biology'"): reg.redeem("biology", "anything") + + +def test_secrets_that_travel_on_a_command_line_never_start_with_a_dash(reg): + """`--owner-secret VALUE` and `--invite CODE` are argv. A value beginning with `-` + is a flag to argparse, and token_urlsafe produced one about one run in five.""" + reg.create("trading", admin="seb") + for _ in range(64): + assert not reg.invite("trading", "maria", "write").startswith("-") + assert not reg.owner_secret().startswith("-") + assert all(c in "0123456789abcdef" for c in reg.owner_secret()) From 0e649d0369218c9dd6e2064e378e1fd576d6ba86 Mon Sep 17 00:00:00 2001 From: BY571 Date: Sat, 5 Sep 2026 13:56:14 +0200 Subject: [PATCH 16/26] explain the push failure git actually gave, not a false match getpass.getpass raised an uncaught EOFError with no terminal to prompt on (cron, CI, a pipe), producing a traceback instead of a one-line refusal. And _explain matched "401"/"403" against relayed remote: lines too, so a rule violation naming a node id like hyp-401-alive read as a credentials problem instead of the actual gate failure. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb --- src/knoten/remote.py | 18 ++++++++++++++---- tests/test_remote.py | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/knoten/remote.py b/src/knoten/remote.py index d7409cc..55a5201 100644 --- a/src/knoten/remote.py +++ b/src/knoten/remote.py @@ -12,6 +12,7 @@ from __future__ import annotations import base64 +import getpass import json import os import subprocess @@ -128,9 +129,13 @@ def _api(url: str, body: dict, auth: tuple[str, str] | None = None) -> dict: def _explain(stderr: str) -> str: """git prints only a status code for an HTTP refusal. Say what it means.""" - if any(m in stderr for m in ("401", "Authentication failed", "terminal prompts disabled")): + # A relayed `remote:` line can itself contain "401" or "403" (a node id, a rule + # message) — match only git's own lines, or a rule violation reads as a credential + # problem. + own = "\n".join(l for l in stderr.splitlines() if not l.startswith("remote:")) + if any(m in own for m in ("401", "Authentication failed", "terminal prompts disabled")): return "credentials refused; the token may have been revoked. Ask for a new invite." - if "403" in stderr: + if "403" in own: return "this token has read access, not write" if "pre-receive hook declined" in stderr: return "the server refused the push; fix the violations above and push again" @@ -155,8 +160,13 @@ def remote_create(root: Path, name: str, on: str, admin: str | None = None, host_url = f"{u.scheme}://{u.netloc}" secret = owner_secret or (cred_lookup(host_url) or ("", ""))[1] if not secret: - import getpass - secret = getpass.getpass(f"owner secret for {u.netloc}: ") + try: + secret = getpass.getpass(f"owner secret for {u.netloc}: ") + except EOFError: + # No terminal to prompt on (cron, CI, a pipe): a raw traceback here would + # break "no tracebacks for user error", so this is a refusal like any other. + raise GraphError(f"no owner secret for {u.netloc}; pass --owner-secret, or " + "run this where a prompt can be answered") from None if admin is None: admin = _git(repo, "config", "user.name").stdout.strip().lower().replace(" ", "-") if not ID_RE.match(admin or ""): diff --git a/tests/test_remote.py b/tests/test_remote.py index fcac2dc..4cb5f72 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -155,6 +155,18 @@ def test_remote_create_with_the_wrong_owner_secret_is_one_line(hub, local_graph, assert "owner secret" in err and "Traceback" not in err +def test_remote_create_with_no_secret_and_no_terminal_is_one_line(hub, local_graph, monkeypatch, capsys): + """getpass raises EOFError when stdin is not a terminal. From a script or CI that + was a traceback instead of the one line every other refusal gives.""" + import io + monkeypatch.chdir(local_graph) + monkeypatch.setattr("sys.stdin", io.StringIO("")) + + assert main(["remote", "create", "trading", "--on", hub.url, "--as", "seb"]) == 1 + err = capsys.readouterr().err + assert "owner secret" in err and "Traceback" not in err + + def test_push_goes_through_the_gate(hub, shared, capsys): commit_node(shared, "hyp-x.md", "---\nid: hyp-x\ntype: hypothesis\nstatus: alive\n---\n\n# x\n") @@ -195,6 +207,19 @@ def test_push_with_a_read_token_is_explained_not_dumped(hub, shared, tmp_path, m assert "read access, not write" in capsys.readouterr().err +def test_a_rule_message_containing_401_is_not_mistaken_for_a_credential_problem(hub, shared, capsys): + """The gate's remote: lines share stderr with git's own. A node id with 401 in it + used to make the summary say "credentials refused" for a plain rule violation.""" + commit_node(shared, "hyp-401-alive.md", + "---\nid: hyp-401-alive\ntype: hypothesis\nstatus: alive\n---\n\n# x\n") + + assert main(["push"]) == 1 + err = capsys.readouterr().err + assert "live-claims-must-cite-their-gates" in err + assert "credentials refused" not in err + assert "refused the push" in err + + def test_push_without_a_remote_says_so(local_graph, monkeypatch, capsys): monkeypatch.chdir(local_graph) assert main(["push"]) == 1 From 453e3eee087ce2477a39c5e7ed22523facab69cf Mon Sep 17 00:00:00 2001 From: BY571 Date: Sat, 5 Sep 2026 14:02:27 +0200 Subject: [PATCH 17/26] stub the prompt, not stdin, so a missing terminal test stays quiet Pointing sys.stdin at an empty StringIO made getpass.fallback_getpass print a GetPassWarning about not controlling terminal echo on every run. Stubbing knoten.remote.getpass.getpass to raise EOFError directly gets the same no-terminal behaviour without the warning, keeping test output pristine. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb --- tests/test_remote.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_remote.py b/tests/test_remote.py index 4cb5f72..22f753d 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -157,10 +157,13 @@ def test_remote_create_with_the_wrong_owner_secret_is_one_line(hub, local_graph, def test_remote_create_with_no_secret_and_no_terminal_is_one_line(hub, local_graph, monkeypatch, capsys): """getpass raises EOFError when stdin is not a terminal. From a script or CI that - was a traceback instead of the one line every other refusal gives.""" - import io + was a traceback instead of the one line every other refusal gives. The prompt itself + is stubbed rather than pointing stdin at a StringIO, which makes getpass warn about + not being able to control echo on the terminal.""" + def no_terminal(*_a, **_k): + raise EOFError monkeypatch.chdir(local_graph) - monkeypatch.setattr("sys.stdin", io.StringIO("")) + monkeypatch.setattr("knoten.remote.getpass.getpass", no_terminal) assert main(["remote", "create", "trading", "--on", hub.url, "--as", "seb"]) == 1 err = capsys.readouterr().err From c7a32eadabd3e3b07af0e80505242ec055906404 Mon Sep 17 00:00:00 2001 From: BY571 Date: Sat, 5 Sep 2026 14:20:21 +0200 Subject: [PATCH 18/26] invite, join, revoke: a friend goes from nothing to pushing in two commands Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb --- src/knoten/cli.py | 34 +++++++++++++++++ src/knoten/remote.py | 37 ++++++++++++++++++ tests/conftest.py | 9 ++++- tests/test_remote.py | 90 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 168 insertions(+), 2 deletions(-) diff --git a/src/knoten/cli.py b/src/knoten/cli.py index 051b32f..cffb009 100644 --- a/src/knoten/cli.py +++ b/src/knoten/cli.py @@ -241,6 +241,19 @@ def remote_cmd(root, args) -> int: return 0 +def invite_cmd(root, name, role, days) -> int: + code = remote.invite(root, name, role, days) + print(f" ✓ {name} may join as {role} for {days} day(s). Send them this code, once:") + print(f" {code}") + return 0 + + +def revoke_cmd(root, name) -> int: + remote.revoke(root, name) + print(f" ✓ {name} can no longer connect. What they already pushed stays.") + return 0 + + def serve_cmd(data, bind) -> int: """Run on the server. Prints the owner secret the first time a data directory is used, because that is the one moment the owner is certainly at the keyboard.""" @@ -665,6 +678,19 @@ def _parser() -> argparse.ArgumentParser: sub.add_parser("push", help="push this graph to its remote, through the gate") sub.add_parser("pull", help="fetch what collaborators pushed") + s = sub.add_parser("invite", help="admin: let someone in (prints a one-time code)") + s.add_argument("name", help="their contributor name, kebab-case") + s.add_argument("--role", default="write", choices=ROLES) + s.add_argument("--expires", type=int, default=7, metavar="DAYS") + + s = sub.add_parser("join", help="redeem an invite and clone the graph") + s.add_argument("url", help="the graph's URL, e.g. https://graphs.example/trading") + s.add_argument("--invite", required=True, metavar="CODE") + s.add_argument("--dest", metavar="DIR", help="where to clone (default: the graph's name)") + + s = sub.add_parser("revoke", help="admin: remove a contributor's access") + s.add_argument("name") + s = sub.add_parser("show", help="the node, its edges and its attachments") s.add_argument("node") s.add_argument("--json", action="store_true", help="emit the raw payload") @@ -724,6 +750,12 @@ def main(argv=None) -> int: sys.stdout.write(remote.credential_helper(sys.stdin.read())) return 0 # store/erase: git manages nothing here; knoten does + if args.cmd == "join": + clone, name, role = remote.join(args.url, args.invite, args.dest) + print(f" ✓ joined as {name} ({role}), cloned to {clone}/") + print(f" cd {clone} && knoten frontier") + return 0 + root = find_root() return { "query": lambda: query(root, args.term, args.json), @@ -748,6 +780,8 @@ def main(argv=None) -> int: "remote": lambda: remote_cmd(root, args), "push": lambda: remote.push(root), "pull": lambda: remote.pull(root), + "invite": lambda: invite_cmd(root, args.name, args.role, args.expires), + "revoke": lambda: revoke_cmd(root, args.name), }[args.cmd]() except (GraphError, OSError) as e: # OSError: a typo'd --frontmatter/--body/--append path is ordinary user error, diff --git a/src/knoten/remote.py b/src/knoten/remote.py index 55a5201..b91f8c8 100644 --- a/src/knoten/remote.py +++ b/src/knoten/remote.py @@ -207,3 +207,40 @@ def pull(root: Path) -> int: raise GraphError(_explain(r.stderr)) print(" ✓ up to date") return 0 + + +def _graph_api(root: Path) -> tuple[str, tuple[str, str]]: + """The graph's API base URL and the caller's credentials for it.""" + repo = _toplevel(root) + git_url = _origin(repo) + auth = cred_lookup(git_url) + if not auth: + raise GraphError("no credentials stored for this remote; are you a contributor here?") + return git_url.removesuffix(".git"), auth + + +def invite(root: Path, name: str, role: str = "write", days: int = 7) -> str: + base, auth = _graph_api(root) + return _api(f"{base}/invite", {"name": name, "role": role, "days": days}, auth)["code"] + + +def revoke(root: Path, name: str) -> None: + base, auth = _graph_api(root) + _api(f"{base}/revoke", {"name": name}, auth) + + +def join(url: str, code: str, dest: str | None = None) -> tuple[Path, str, str]: + """Redeem the code, remember the token, clone. Nothing is written to disk until the + server has accepted the code, so a wrong code leaves no half-made clone behind.""" + url = url.rstrip("/") + git_url = url + ".git" + got = _api(f"{url}/join", {"code": code}) + cred_store(git_url, got["name"], got["token"]) + target = Path(dest or url.rsplit("/", 1)[-1]) + r = subprocess.run(["git", "-c", "credential.helper=!knoten credential", + "-c", "credential.useHttpPath=true", + "clone", "-q", git_url, str(target)], capture_output=True, text=True) + if r.returncode != 0: + raise GraphError(_explain(r.stderr)) + _wire(target, git_url) + return target, got["name"], got["role"] diff --git a/tests/conftest.py b/tests/conftest.py index 5035679..0022035 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -93,10 +93,15 @@ def hub(tmp_path, monkeypatch): @pytest.fixture def local_graph(tmp_path, rules_yaml): """A git repo whose root is a graph with one committed node: what a user has on disk - the moment they decide to share it.""" + the moment they decide to share it. + + Nested under "admin/", not directly in tmp_path: a friend joining a graph named + "trading" clones to tmp_path/"trading" by default, and that must never collide with + the admin's own on-disk checkout of the same graph, which happens to share this + tmp_path in tests.""" import subprocess - root = tmp_path / "trading" + root = tmp_path / "admin" / "trading" (root / "nodes").mkdir(parents=True) (root / "graph.yaml").write_text(rules_yaml, encoding="utf-8") (root / "nodes" / "hyp-ok.md").write_text( diff --git a/tests/test_remote.py b/tests/test_remote.py index 22f753d..d2596ff 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -235,3 +235,93 @@ def test_remote_add_points_an_existing_clone_at_a_remote(hub, local_graph, monke assert git("remote", "get-url", "origin", cwd=local_graph).stdout.strip() == f"{hub.url}/trading.git" assert git("config", "credential.useHttpPath", cwd=local_graph).stdout.strip() == "true" + + +# ---------------------------------------------------------------- the friend's journey + +def test_the_whole_journey(hub, shared, tmp_path, monkeypatch, capsys): + """You create a remote and invite Maria. Maria joins, adds a node, pushes. You pull + and it is there. She pushes a broken one and it is not. Every step through the CLI.""" + assert main(["invite", "maria", "--role", "write"]) == 0 + code = capsys.readouterr().out.strip().split()[-1] + + monkeypatch.chdir(tmp_path) + assert main(["join", f"{hub.url}/trading", "--invite", code]) == 0 + clone = tmp_path / "trading" + assert (clone / "nodes" / "hyp-ok.md").exists() + assert "maria" in capsys.readouterr().out + + git("config", "user.email", "m@m.m", cwd=clone); git("config", "user.name", "maria", cwd=clone) + commit_node(clone, "hyp-m.md", "---\nid: hyp-m\ntype: hypothesis\nstatus: open\n---\n\n# m\n") + monkeypatch.chdir(clone) + assert main(["push"]) == 0 + + monkeypatch.chdir(shared) + assert main(["pull"]) == 0 + assert (shared / "nodes" / "hyp-m.md").exists() + + commit_node(clone, "hyp-bad.md", "---\nid: hyp-bad\ntype: hypothesis\nstatus: alive\n---\n\n# b\n") + monkeypatch.chdir(clone) + assert main(["push"]) == 1 + assert "live-claims-must-cite-their-gates" in capsys.readouterr().err + + +def test_join_with_a_read_invite_can_pull_but_not_push(hub, shared, tmp_path, monkeypatch, capsys): + main(["invite", "reader", "--role", "read"]) + code = capsys.readouterr().out.strip().split()[-1] + monkeypatch.chdir(tmp_path) + main(["join", f"{hub.url}/trading", "--invite", code, "--dest", "r"]) + clone = tmp_path / "r" + git("config", "user.email", "r@r.r", cwd=clone); git("config", "user.name", "r", cwd=clone) + commit_node(clone, "hyp-r.md", "---\nid: hyp-r\ntype: hypothesis\nstatus: open\n---\n\n# r\n") + monkeypatch.chdir(clone) + + assert main(["push"]) == 1 + assert "read access, not write" in capsys.readouterr().err + assert main(["pull"]) == 0 + + +def test_revoke_locks_a_contributor_out_on_their_next_push(hub, shared, tmp_path, monkeypatch, capsys): + main(["invite", "maria"]) + code = capsys.readouterr().out.strip().split()[-1] + admin = cred_lookup(f"{hub.url}/trading.git") # admin's own token, before maria's join overwrites it + + monkeypatch.chdir(tmp_path) + main(["join", f"{hub.url}/trading", "--invite", code]) + clone = tmp_path / "trading" + git("config", "user.email", "m@m.m", cwd=clone); git("config", "user.name", "maria", cwd=clone) + maria = cred_lookup(f"{hub.url}/trading.git") + + # The credential store is one machine's, keyed by remote URL: admin and maria are on + # separate machines in reality, each with their own store for this same URL. Restore + # each in turn to simulate that, since the test runs both in one shared file. + cred_store(f"{hub.url}/trading.git", *admin) + monkeypatch.chdir(shared) + assert main(["revoke", "maria"]) == 0 + + cred_store(f"{hub.url}/trading.git", *maria) + commit_node(clone, "hyp-m.md", "---\nid: hyp-m\ntype: hypothesis\nstatus: open\n---\n\n# m\n") + monkeypatch.chdir(clone) + assert main(["push"]) == 1 + assert "credentials refused" in capsys.readouterr().err + + +def test_only_an_admin_can_invite(hub, shared, tmp_path, monkeypatch, capsys): + main(["invite", "maria"]) + code = capsys.readouterr().out.strip().split()[-1] + monkeypatch.chdir(tmp_path) + main(["join", f"{hub.url}/trading", "--invite", code]) + monkeypatch.chdir(tmp_path / "trading") + + assert main(["invite", "friend-of-maria"]) == 1 + assert "only an admin" in capsys.readouterr().err + + +def test_a_spent_or_wrong_code_is_one_readable_line(hub, shared, tmp_path, monkeypatch, capsys): + monkeypatch.chdir(tmp_path) + + assert main(["join", f"{hub.url}/trading", "--invite", "nope"]) == 1 + err = capsys.readouterr().err + assert "not valid" in err and "Traceback" not in err + assert not (tmp_path / "trading").exists() + From 6199ae37b7bbf903ea8fd9834de2a978f8d95ce0 Mon Sep 17 00:00:00 2001 From: BY571 Date: Sat, 5 Sep 2026 14:35:49 +0200 Subject: [PATCH 19/26] join: say the invite is spent when clone fails after redemption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server consumes the code before git clone runs, so a clone failure left the user only git's error and a stored credential nobody explained — they retried the same code and got refused for what looked like an unrelated reason. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb --- src/knoten/remote.py | 8 +++++++- tests/test_remote.py | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/knoten/remote.py b/src/knoten/remote.py index b91f8c8..d4206fe 100644 --- a/src/knoten/remote.py +++ b/src/knoten/remote.py @@ -241,6 +241,12 @@ def join(url: str, code: str, dest: str | None = None) -> tuple[Path, str, str]: "-c", "credential.useHttpPath=true", "clone", "-q", git_url, str(target)], capture_output=True, text=True) if r.returncode != 0: - raise GraphError(_explain(r.stderr)) + # The server already consumed the code in the _api call above, one line up. git's + # own error alone reads like the code is still good and worth retrying — it is + # not, so say what actually happened and how to finish without it. + raise GraphError( + f"clone failed: {_explain(r.stderr)}. The invite is spent but your credentials " + f"are saved, so finish by hand: git clone {git_url} && cd && " + f"knoten remote add {url}") _wire(target, git_url) return target, got["name"], got["role"] diff --git a/tests/test_remote.py b/tests/test_remote.py index d2596ff..b59111f 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -325,3 +325,21 @@ def test_a_spent_or_wrong_code_is_one_readable_line(hub, shared, tmp_path, monke assert "not valid" in err and "Traceback" not in err assert not (tmp_path / "trading").exists() + +def test_a_clone_failure_after_redemption_says_the_invite_is_spent(hub, shared, tmp_path, monkeypatch, capsys): + """The server consumes the code before git clone runs. When the clone then failed, + the user saw only git's error, retried the same code, and was refused for a reason + that looked unrelated. Now the message says the credentials are saved and how to + finish by hand.""" + main(["invite", "maria"]) + code = capsys.readouterr().out.strip().split()[-1] + blocker = tmp_path / "taken" + (blocker / "not-empty").mkdir(parents=True) # git refuses a non-empty dest + monkeypatch.chdir(tmp_path) + + assert main(["join", f"{hub.url}/trading", "--invite", code, "--dest", "taken"]) == 1 + err = capsys.readouterr().err + assert "invite is spent" in err and "credentials are saved" in err + assert "Traceback" not in err + assert cred_lookup(f"{hub.url}/trading.git")[0] == "maria" + From e886c5978dec942a83e517158f38d21fbfc8d6d3 Mon Sep 17 00:00:00 2001 From: BY571 Date: Sat, 5 Sep 2026 14:46:17 +0200 Subject: [PATCH 20/26] one server, many graphs, no cross-talk, and eight joins at once lose none Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb --- tests/test_serve.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/test_serve.py b/tests/test_serve.py index 5195439..48c842b 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -308,6 +308,50 @@ def test_serve_refuses_a_non_numeric_port_before_creating_the_owner_secret(tmp_p assert not (tmp_path / "d" / "owner").exists() +# ---------------------------------------------------------------- isolation and races + +def test_two_graphs_on_one_server_do_not_share_tokens(hub, trading, tmp_path): + """A token for `trading` opens nothing on `biology`, including reading. One server, + many graphs, no cross-talk, or the invite model means nothing.""" + hub.registry.create("biology", admin="seb") + + r = git("clone", "-q", clone_url(hub, "biology", "seb", trading["admin"]), + str(tmp_path / "x"), cwd=tmp_path) + + assert auth_refused(r), r.stderr + + +def test_concurrent_joins_do_not_lose_each_other(hub, trading): + """Eight invites redeemed at the same moment. A read-modify-write on tokens.json + without the lock drops some of them, silently, and the file still parses.""" + import concurrent.futures + + codes = [hub.registry.invite("trading", f"user-{i}", "write") for i in range(8)] + + def redeem(code): + _, body = api(hub, "/trading/join", {"code": code}) + return body["name"], body["token"] + + with concurrent.futures.ThreadPoolExecutor(8) as pool: + results = list(pool.map(redeem, codes)) + + for name, token in results: + assert hub.registry.authenticate("trading", name, token) == "write", f"{name} lost" + on_disk = json.loads((hub.registry.graph_dir("trading") / "tokens.json").read_text()) + assert len(on_disk) == 9 # seb + eight + + +def test_a_traversal_in_the_url_is_404_not_a_file(hub, trading): + """`/../../etc.git` must never reach GIT_PROJECT_ROOT. The regex refuses it before + the registry sees it; this pins that the regex stays strict.""" + req = urllib.request.Request(hub.url + "/../../etc.git/info/refs?service=git-upload-pack") + with pytest.raises(urllib.error.HTTPError) as e: + urllib.request.urlopen(req) + + assert e.value.code in (401, 404) + e.value.close() # unread, it leaves the socket for the GC to warn about later + + def test_serve_closes_its_socket_when_it_stops(tmp_path, monkeypatch): """`serve_forever` returning is not the socket closing. Left open, the port stays bound until the interpreter exits.""" From b99f74bb29d3ce4968952cc43b130d5be5cca2f2 Mon Sep 17 00:00:00 2001 From: BY571 Date: Sat, 5 Sep 2026 14:53:05 +0200 Subject: [PATCH 21/26] say that a graph can be shared, and stop saying we would never host one Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb --- README.md | 61 +++++++++++++++++++++++++++++++------------------------ SKILL.md | 10 +++++---- SPEC.md | 8 +++++--- 3 files changed, 46 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index f2403b9..c0bbb3c 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,8 @@ knoten attach hyp-idea run.py accuracy.png # the code and the plot knoten validate # enforce this graph's rules knoten hook # make `git commit` refuse a broken graph -knoten hook --server g.git # ...and `git push`, for every contributor +knoten remote create g --on https://graphs.example # share it +knoten invite maria --role write # let someone in knoten viz --open # the whole graph as one HTML file ``` @@ -137,35 +138,43 @@ tags: [decoding, reasoning, prompting, evaluation] ## A shared graph -A graph is a folder in git, so a whole lab can work in one, and "has this been tried?" -stops quietly meaning "have *I* tried this?". Sharing one needs no knoten at all: +One graph, several people, one set of rules enforced for all of them. A remote is a +`knoten serve` process on any machine you can reach over HTTPS: a box you own behind a +reverse proxy or a tunnel, a small VPS, or a hosted knoten. ```bash -git init --bare lab-graph.git # on any box you can all reach -git clone you@box:lab-graph.git # everyone else, human or agent +# you, once, in your graph +knoten remote create trading --on https://graphs.example +knoten invite maria --role write # prints a one-time code + +# maria, anywhere +knoten join https://graphs.example/trading --invite 7f3a-... +knoten frontier # her clone; the loop is unchanged from here +knoten push # over HTTPS, through the gate ``` -What knoten adds is the gate. `knoten hook` protects the person who ran it, in the clone -they ran it in, and `git commit --no-verify` walks past it. `knoten hook --server -lab-graph.git` installs a `pre-receive` hook on the repo everyone pushes *to*: it unpacks -each pushed tree, finds every graph in it, runs `knoten validate` on each and refuses the -push if any fails. No CI, no runner, no minutes, and nobody can skip it from a laptop. - -That gate matters more here than it looks. The parser refuses rather than skips, on -purpose, so one malformed node does not quietly vanish from the graph. On one machine -that is a good trade. On a shared one it means a single bad push breaks `frontier`, -`index` and `gates` for **everyone** until somebody fixes a file they did not write. - -Two people working at once do not collide: an edge is declared once, on the subject, and -back-links are generated at load, so adding connections touches two different files and -git merges them. Pull before you work, or your frontier will recommend something a -collaborator killed on Tuesday. - -Reading needs nothing installed. Nodes are markdown, so any forge renders them, and -`knoten viz` writes the graph as one self-contained HTML file you can hand to someone who -has never heard of knoten. For invitations, per-user permissions or required approvals, -put the repo on a forge such as [Forgejo](https://forgejo.org) and let it handle the -people; the gate above still does the part no forge can. +Every push runs `knoten validate` on the server before the ref moves, so a node that +breaks the graph's rules is refused for everyone, including whoever wrote the rules, and +including anyone who never installed `knoten hook`. That matters more here than it +looks: the parser refuses rather than skips, on purpose, so on one machine a malformed +node is your problem and on a shared one it would be everybody's. + +`read` can clone and pull. `write` can push. `admin` can invite and revoke. Tokens say +who is connecting and nothing else; what a token can do is the role the admin gave it. + +Reading needs nothing installed. Nodes are markdown, and `knoten viz` writes the graph as +one self-contained HTML file you can hand to someone who has never heard of knoten. + +To run the server: + +```bash +knoten serve --data ~/knoten-remotes # prints the owner secret once; keep it +``` + +It binds localhost and speaks plain HTTP. Put TLS in front before anyone outside the +machine connects. For a graph that lives in a bare repo you administer yourself, without +a server, `knoten hook --server ` installs the same gate as a `pre-receive` +hook. ## For agents diff --git a/SKILL.md b/SKILL.md index 9c4d2e7..4f92d8d 100644 --- a/SKILL.md +++ b/SKILL.md @@ -70,9 +70,9 @@ or retracts the old one, never an edit to it. ## Before you work -If the graph is shared, `git pull` FIRST. Every read below answers from the files on -disk, so a stale clone reports work a collaborator settled days ago as still open. That -is the exact failure this graph exists to prevent, arriving through the back door. +If the graph has a remote, `knoten pull` FIRST. Every read below answers from the files +on disk, so a stale clone reports work a collaborator settled days ago as still open. +That is the exact failure this graph exists to prevent, arriving through the back door. 1. `knoten frontier` — what is worth doing next: open work, dead ends whose stated reopen condition may now hold, and gates nothing has been through. @@ -91,7 +91,9 @@ is the exact failure this graph exists to prevent, arriving through the back doo graph and the one that would otherwise be lost. Use `knoten update --status dead --append --field cause=` instead if you opened the node earlier. 6. `knoten attach ` — the script that ran it and the plot that shows it. - A claim nobody can re-run is a claim nobody trusts in six months. + A claim nobody can re-run is a claim nobody trusts in six months. If the graph has a + remote, `knoten push` when the node is filed; the server runs the same rules and + refuses what this clone would have. ## When you run out of ideas diff --git a/SPEC.md b/SPEC.md index 391d542..1f3de83 100644 --- a/SPEC.md +++ b/SPEC.md @@ -51,8 +51,10 @@ A system that stores only conclusions would have preserved ~15% of that. ## 2. Non-goals -- **We do not build a Git host.** Git already provides versioning, branching, blame, - diffs, PRs (peer review!) and hosting. Reimplementing any of it is madness. +- **We do not build a general git host.** We host knoten graphs, because the permission + model (who may write, who verified what) is part of the graph and no general host can + enforce it. Git still provides versioning, branching, blame, diffs and history; we + write none of that. - **We do not build a UI first.** A static site generator over the graph is a phase-3 nicety, and it can emit its own JSON when it exists. - **We do not invent a vocabulary.** Micropublications and nanopublications already @@ -377,7 +379,7 @@ body the day a graph outgrows a tag-filtered index — which the 1k–5k node ca | **2** | **Tool-protocol server** | ✅ done — later demoted to a fallback, then removed (§8) | | **2.5** | CLI becomes the primary agent surface: `ops.py` as the one implementation behind every read, `--json` on every read, `commit`/`update` on the CLI, `SKILL.md` | ✅ done | | 3 | Static-site graph viewer → GitHub Pages | free hosting | -| 4 | Hosted multi-graph service | probably never needed | +| 4 | **Remote graphs**: `knoten serve`, invites, roles, the gate on push | ✅ transport done; signed identity and verification follow | Phase 0 **validated the schema against real content** — including retractions, structural blockers, and prose that no JSON schema could hold. From 3ab922fa7ec698a44f1f2747126476ddfa05cb22 Mon Sep 17 00:00:00 2001 From: BY571 Date: Sat, 5 Sep 2026 15:09:51 +0200 Subject: [PATCH 22/26] _explain matches git's own phrasing, not bare digits in the url A bare "401"/"403" substring search also matched git's own `fatal: unable to access '...'` line, so a port or graph name containing those three digits flipped the verdict -- the hub fixture binds port 0, and plenty of ephemeral ports contain "401". Match `returned error: 401/403` and `HTTP 401/403` instead. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb --- src/knoten/remote.py | 15 ++++++++++++--- tests/test_remote.py | 21 ++++++++++++++++++++- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/knoten/remote.py b/src/knoten/remote.py index d4206fe..6c0cb09 100644 --- a/src/knoten/remote.py +++ b/src/knoten/remote.py @@ -15,6 +15,7 @@ import getpass import json import os +import re import subprocess import sys import urllib.error @@ -128,14 +129,22 @@ def _api(url: str, body: dict, auth: tuple[str, str] | None = None) -> dict: def _explain(stderr: str) -> str: - """git prints only a status code for an HTTP refusal. Say what it means.""" + """git prints only a status code for an HTTP refusal. Say what it means. + + A bare substring search for "401"/"403" also matched git's own URL line + (`fatal: unable to access 'http://127.0.0.1:36605/trading.git/': The requested + URL returned error: 403`), so any host, port or graph name that happened to + contain those three digits flipped the verdict — the `hub` fixture binds + port 0, and plenty of ephemeral ports contain "401". Match git's phrasing, + not the digits. + """ # A relayed `remote:` line can itself contain "401" or "403" (a node id, a rule # message) — match only git's own lines, or a rule violation reads as a credential # problem. own = "\n".join(l for l in stderr.splitlines() if not l.startswith("remote:")) - if any(m in own for m in ("401", "Authentication failed", "terminal prompts disabled")): + if re.search(r"returned error: 401\b|HTTP 401\b|Authentication failed|terminal prompts disabled", own): return "credentials refused; the token may have been revoked. Ask for a new invite." - if "403" in own: + if re.search(r"returned error: 403\b|HTTP 403\b", own): return "this token has read access, not write" if "pre-receive hook declined" in stderr: return "the server refused the push; fix the violations above and push again" diff --git a/tests/test_remote.py b/tests/test_remote.py index b59111f..e51d0f7 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -8,7 +8,7 @@ from knoten.cli import main from knoten.core import GraphError -from knoten.remote import cred_lookup, cred_path, cred_store, credential_helper +from knoten.remote import _explain, cred_lookup, cred_path, cred_store, credential_helper def git(*args, cwd, env=None): @@ -223,6 +223,25 @@ def test_a_rule_message_containing_401_is_not_mistaken_for_a_credential_problem( assert "refused the push" in err +def test_explain_is_not_fooled_by_digits_in_gits_own_url_line(): + """`_explain` used to bare-substring-search for "401"/"403", which also matched + git's own `fatal: unable to access '...'` line whenever the port or graph name in + the URL happened to contain those three digits. A port of 34012 turned a real 403 + into "credentials refused"; a port of 35403 turned a real 401 into "read access, + not write". Neither port carries the phrase git actually uses for the error.""" + read_only = ( + "fatal: unable to access 'http://127.0.0.1:34012/trading.git/': " + "The requested URL returned error: 403" + ) + assert "read access, not write" in _explain(read_only) + + bad_token = ( + "fatal: unable to access 'http://127.0.0.1:35403/trading.git/': " + "The requested URL returned error: 401" + ) + assert "credentials refused" in _explain(bad_token) + + def test_push_without_a_remote_says_so(local_graph, monkeypatch, capsys): monkeypatch.chdir(local_graph) assert main(["push"]) == 1 From dbe51ddf5e12a60b2a638f35d9b9f0ed5bd28e78 Mon Sep 17 00:00:00 2001 From: BY571 Date: Sat, 5 Sep 2026 15:10:42 +0200 Subject: [PATCH 23/26] /join no longer confirms which graphs exist /join takes no credentials, so it must answer a wrong code and an unknown graph identically -- refusing to touch registry.redeem for a graph that does not exist keeps /join from being a name oracle, matching what /git already does for git-receive-pack and git-upload-pack. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb --- src/knoten/serve.py | 4 ++++ tests/test_serve.py | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/knoten/serve.py b/src/knoten/serve.py index 3199977..f908fbb 100644 --- a/src/knoten/serve.py +++ b/src/knoten/serve.py @@ -165,6 +165,10 @@ def _create(self) -> None: def _join(self, name: str) -> None: body = self._json_body() + if not self.registry.exists(name): + # /join needs no credentials, so it must not become a name oracle: an + # unknown graph gets the same 400 a wrong code gets, not "no graph 'x'". + return self._refuse(400, "knoten: that invite code is not valid for this graph") user, role, token = self.registry.redeem(name, body.get("code", "")) self._json(200, {"name": user, "role": role, "token": token}) diff --git a/tests/test_serve.py b/tests/test_serve.py index 48c842b..3a35a4d 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -225,6 +225,16 @@ def test_joining_twice_with_one_code_fails_the_second_time(hub, trading): assert "not valid" in body["error"] +def test_join_on_an_unknown_graph_looks_like_a_wrong_code(hub, trading): + """/join needs no credentials, so it must not confirm which graphs exist: a + nonexistent graph and a wrong code get byte-identical 400 bodies.""" + status_unknown, body_unknown = api(hub, "/biology/join", {"code": "x"}, None) + status_known, body_known = api(hub, "/trading/join", {"code": "x"}, None) + + assert status_unknown == 400 and status_known == 400 + assert body_unknown == body_known + + def test_revoke_ends_a_contributors_access(hub, trading, tmp_path): tok = hub.registry.mint("trading", "maria", "write") From 4a5992087e19b5a9889f3c219ac7da30cd20d88d Mon Sep 17 00:00:00 2001 From: BY571 Date: Sat, 5 Sep 2026 15:11:59 +0200 Subject: [PATCH 24/26] guard against a malformed or negative content-length "Content-Length: abc" raised ValueError unguarded, escaping _route's except GraphError and killing the thread with no response to the client. "Content-Length: -1" reached rfile.read(-1), which reads until the socket closes -- an unauthenticated thread-exhaustion primitive on a connection the client never closes. Both are now a 400 GraphError. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb --- src/knoten/serve.py | 15 ++++++++++++++- tests/test_serve.py | 21 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/knoten/serve.py b/src/knoten/serve.py index f908fbb..41269cc 100644 --- a/src/knoten/serve.py +++ b/src/knoten/serve.py @@ -55,7 +55,20 @@ def _basic(self) -> tuple[str, str]: return user, secret def _body(self) -> bytes: - return self.rfile.read(int(self.headers.get("Content-Length") or 0)) + raw = self.headers.get("Content-Length") + try: + length = int(raw) if raw else 0 + except ValueError: + # "Content-Length: abc" raised here unguarded, escaping _route's except + # GraphError: the thread died with no response and a traceback on the + # server's stderr. GraphError instead turns into an ordinary 400. + raise GraphError("request body length is invalid") from None + if not (0 <= length <= 104857600): # matches receive.maxInputSize (100 MB) + # A negative length reaches rfile.read(-1), which reads until EOF -- on a + # socket the client never closes, that parks the thread forever: an + # unauthenticated way to exhaust the server's thread pool one request at a time. + raise GraphError("request body length is invalid") + return self.rfile.read(length) def _json_body(self) -> dict: try: diff --git a/tests/test_serve.py b/tests/test_serve.py index 3a35a4d..dcc144d 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -4,6 +4,7 @@ tests therefore drive real git clients at a real server. """ import base64 +import http.client import json import os import subprocess @@ -263,6 +264,26 @@ def test_a_json_body_that_is_not_an_object_is_a_400(hub, trading): assert "JSON object" in body["error"] +def test_a_malformed_or_negative_content_length_is_a_400_not_a_hang(hub, trading): + """"Content-Length: abc" used to raise ValueError inside _body, unguarded, killing + the thread with no response. "Content-Length: -1" used to reach rfile.read(-1), + which reads until the socket closes -- on a connection the client never closes, + a thread parked forever with no credentials required to trigger it.""" + host, port = hub.url.removeprefix("http://").rsplit(":", 1) + for bad in ("abc", "-1"): + conn = http.client.HTTPConnection(host, int(port), timeout=5) + conn.putrequest("POST", "/trading/join") + conn.putheader("Content-Type", "application/json") + conn.putheader("Content-Length", bad) + conn.endheaders() + r = conn.getresponse() + body = json.loads(r.read()) + conn.close() + + assert r.status == 400, (bad, body) + assert "invalid" in body["error"] + + def test_a_non_numeric_days_is_a_400(hub, trading): status, body = api(hub, "/trading/invite", {"name": "maria", "role": "write", "days": "banana"}, ("seb", trading["admin"])) From d1d1f702942274acef6e156ad6899568ce8eae8d Mon Sep 17 00:00:00 2001 From: BY571 Date: Sat, 5 Sep 2026 15:13:10 +0200 Subject: [PATCH 25/26] refuse chunked push bodies instead of relaying them as empty _body only reads Content-Length bytes; a Transfer-Encoding: chunked push (git goes chunked above http.postBuffer, default 1 MiB) handed http-backend an empty stdin, which died and left the client with an inscrutable bare 500. A plain `git clone` of a hosted graph, which the README treats as normal, hit this on any push over 1 MiB. Refuse with a 411 that names the fix. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb --- src/knoten/serve.py | 7 +++++++ tests/test_serve.py | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/knoten/serve.py b/src/knoten/serve.py index 41269cc..bf0276a 100644 --- a/src/knoten/serve.py +++ b/src/knoten/serve.py @@ -126,6 +126,13 @@ def _git(self, name: str, sub: str, query: str) -> None: else "knoten: that token is not valid here") if role == "read" and (WRITE in query or sub.endswith("/" + WRITE)): return self._refuse(403, f"knoten: {user} has read access to {name}, not write") + if "chunked" in self.headers.get("Transfer-Encoding", "").lower(): + # _body only ever reads Content-Length bytes, so a chunked push (git goes + # chunked above http.postBuffer, default 1 MiB) hands http-backend an empty + # stdin; http-backend dies and the client sees a bare, unexplained 500. + return self._refuse(411, "knoten: chunked uploads are not supported; set " + "http.postBuffer to at least the push size (knoten remote " + "add does this) and push again") env = { **os.environ, diff --git a/tests/test_serve.py b/tests/test_serve.py index dcc144d..ba15bd8 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -154,6 +154,27 @@ def dead(*args, **kwargs): assert "http-backend failed" in json.loads(e.value.read())["error"] +def test_a_chunked_push_is_refused_not_dumped_as_a_bare_500(hub, trading): + """_body only ever reads Content-Length bytes. A chunked push (git goes chunked + above http.postBuffer, default 1 MiB) used to hand http-backend an empty stdin, + which died, leaving the client with an inscrutable 500 and no idea why.""" + host, port = hub.url.removeprefix("http://").rsplit(":", 1) + cred = base64.b64encode(f"seb:{trading['admin']}".encode()).decode() + conn = http.client.HTTPConnection(host, int(port), timeout=5) + conn.putrequest("POST", "/trading.git/git-receive-pack") + conn.putheader("Content-Type", "application/x-git-receive-pack-request") + conn.putheader("Authorization", "Basic " + cred) + conn.putheader("Transfer-Encoding", "chunked") + conn.endheaders() + conn.send(b"0\r\n\r\n") + r = conn.getresponse() + body = json.loads(r.read()) + conn.close() + + assert r.status == 411, body + assert "http.postBuffer" in body["error"] + + # ---------------------------------------------------------------- the api def api(hub, path, body, auth=None): From c78347768c4a44464115cfc293d88a4f5737d2aa Mon Sep 17 00:00:00 2001 From: BY571 Date: Sat, 5 Sep 2026 15:14:18 +0200 Subject: [PATCH 26/26] three trivial hardening minors from the review - serve.py: skip a relayed content-length header before appending knoten's own, so a dumb-protocol response does not carry two. - registry.py: entry.get("hash", "") instead of entry["hash"], so a hand-edited or truncated tokens.json fails closed, not with a traceback. - README.md: the example invite code is pure lowercase hex (token_hex), not dash-separated. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb --- README.md | 2 +- src/knoten/registry.py | 4 +++- src/knoten/serve.py | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c0bbb3c..cd873d8 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ knoten remote create trading --on https://graphs.example knoten invite maria --role write # prints a one-time code # maria, anywhere -knoten join https://graphs.example/trading --invite 7f3a-... +knoten join https://graphs.example/trading --invite 7f3a9c... knoten frontier # her clone; the loop is unchanged from here knoten push # over HTTPS, through the gate ``` diff --git a/src/knoten/registry.py b/src/knoten/registry.py index 3ed7958..b1a55ff 100644 --- a/src/knoten/registry.py +++ b/src/knoten/registry.py @@ -138,7 +138,9 @@ def authenticate(self, name: str, user: str, token: str) -> str | None: if not ID_RE.match(name or "") or not self.exists(name): return None entry = self._read(name, "tokens.json").get(user or "") - if not entry or not hmac.compare_digest(entry["hash"], _hash(token)): + # .get, not entry["hash"]: a hand-edited or truncated tokens.json then fails + # closed (no entry matches) instead of a traceback on every request for that user. + if not entry or not hmac.compare_digest(entry.get("hash", ""), _hash(token)): return None return entry["role"] diff --git a/src/knoten/serve.py b/src/knoten/serve.py index bf0276a..1e91192 100644 --- a/src/knoten/serve.py +++ b/src/knoten/serve.py @@ -155,6 +155,8 @@ def _git(self, name: str, sub: str, query: str) -> None: key, _, value = line.partition(":") if key.strip().lower() == "status": status, saw_status = int(value.strip().split()[0]), True + elif key.strip().lower() == "content-length": + pass # knoten sends its own Content-Length below; relaying http-backend's too would duplicate the header elif key.strip(): headers.append((key.strip(), value.strip())) if r.returncode != 0: