diff --git a/.loom/event-log.md b/.loom/event-log.md index 14f19c2..ff4fc8c 100644 --- a/.loom/event-log.md +++ b/.loom/event-log.md @@ -875,3 +875,64 @@ still true.* *Recorded because the code changed in a way that quietly moved a boundary somebody might rely on.* + +## 2026-09-08 — measured: the private path works, and nothing is unexercised now `rowan` + +**Supersedes the entry that recorded the git-over-HTTPS assumption as unexercised. +It has been run.** *A read-scoped token, issued for one session and destroyed +after.* + +``` +git clone --filter=blob:none --depth=1 over HTTPS + token, private repo OK, 124KB +loomctl external list jeffry/homelab-impl OK, authenticated +short raw URL + 303, authenticated 404 anon -> 200, resolved branch +If-None-Match across the redirect, authenticated 304 +``` + +**So a private repository can adopt from another private one**, *by name and by +URL, with `check` working off the lock afterwards.* **Every path in the tool has +now been run at least once.** + +*Incidentally confirmed: `homelab-impl` publishes nothing and adopts six documents +from `homelab-cluster`, each with a `.usages.md`.* **Consuming inward, which is +the arrangement that made the confidentiality hazard impossible in that shape.** + +## 2026-09-08 — the tool is a mast, not a lock `rowan` + +**Recorded because it decides a question that keeps coming back, and nothing in +the code says it.** + +> **`loomctl` grants no access.** *It reads what your credentials already let you +> read.* **Anything it does, a person could do with copy and paste** — *and the +> locks mean nothing outside the tool and the discipline of the agreement.* + +**So it warns and does not refuse.** *The point is not to make the wrong thing +impossible; it is to make it deliberate* — **the same move as annotating a file +you could simply edit.** + +**Which is also why `add ` stays**, *even though it can reach outside a +publisher's `.loom/published/` and is, in that form, a small general-purpose file +fetcher.* **A general-purpose tool is not the aim**, *so the escape hatch is kept +and made to say what it is, rather than removed.* + +**What the tool adds over copy and paste is not restriction. It is the record** — +*origin, resolved branch, `ETag`.* **A pasted document has no provenance**, *so +nobody can later ask where it came from or whether we were allowed to have it* — +**not because the question is hard, but because the evidence is gone.** + +## 2026-09-08 — the confidentiality check is coarse, deliberately `rowan` + +**`add` now asks whether *this* repository is readable anonymously**, *by resolving +`origin` and making one unauthenticated request.* **So the warning fires when the +source is private and the destination is public, rather than on every adoption a +private repository performs** — *which was the previous behaviour and would have +been noise in exactly the workflow that is legitimate.* + +**Belief that could be shown wrong, and it is a known blind spot rather than a +guess:** *the signal distinguishes public from not-public and nothing finer.* +**Two repositories private to different people is the case where private-to-private +genuinely widens access, and this check cannot see it** — *so the tool says so, in +the message, instead of implying a verdict it has not earned.* + +**Failing open is deliberate:** *no origin, or an unparseable one, reports "I +cannot tell" and warns.* **"Cannot tell" must never read as "not public."** diff --git a/internal/external/external.go b/internal/external/external.go index 80ff9b5..882263b 100644 --- a/internal/external/external.go +++ b/internal/external/external.go @@ -168,7 +168,8 @@ func Add(root, raw, override string, out io.Writer) error { fmt.Fprintf(out, "adopted %s\n", rel) fmt.Fprintf(out, " from %s\n", body.url) - warnIfNotPublic(cfg, body.url, out) + warnIfNotPublic(cfg, root, body.url, out) + notePublishedSurface(body.url, out) if etag == "" { fmt.Fprintf(out, " etag (none served — check cannot ask conditionally)\n") } else { @@ -185,7 +186,7 @@ func Add(root, raw, override string, out io.Writer) error { // moment of adoption. The tool can see half of that — whether this fetch needed // a credential — and cannot see the other half, which is who can read the // repository the copy is landing in. It reports the half it knows. -func warnIfNotPublic(cfg *config.Config, raw string, out io.Writer) { +func warnIfNotPublic(cfg *config.Config, root, raw string, out io.Writer) { req, err := http.NewRequest(http.MethodHead, raw, nil) if err != nil { return @@ -201,10 +202,27 @@ func warnIfNotPublic(cfg *config.Config, raw string, out io.Writer) { if resp.StatusCode == http.StatusOK { return } - fmt.Fprintf(out, " WARN this needed a credential — anonymously it is %s.\n", resp.Status) - fmt.Fprintf(out, " Confidentiality does not travel with the copy: this now lives in\n") - fmt.Fprintf(out, " .loom/externals/ and is readable by anyone who can read THIS\n") - fmt.Fprintf(out, " repository, which I cannot see. Do not adopt from a source less\n") + + // The source is private. Whether that matters depends on where it is + // landing, and a warning on every adoption a private repository performs is + // noise in exactly the workflow that is legitimate. + public, known := selfVisibility(root) + if known && !public { + fmt.Fprintf(out, " NOTE private source, and this repository is not public either.\n") + fmt.Fprintf(out, " Access is not widened by that alone — but this check only\n") + fmt.Fprintf(out, " tells public from not-public, so it cannot see two repositories\n") + fmt.Fprintf(out, " private to different people. That case does widen it.\n") + return + } + if !known { + fmt.Fprintf(out, " WARN this needed a credential, and I cannot tell who may read this\n") + fmt.Fprintf(out, " repository — no usable origin. Check before you commit.\n") + return + } + fmt.Fprintf(out, " WARN this needed a credential — anonymously it is %s — and THIS\n", resp.Status) + fmt.Fprintf(out, " repository is public.\n") + fmt.Fprintf(out, " Confidentiality does not travel with the copy: adopting this\n") + fmt.Fprintf(out, " publishes it to everyone. Do not adopt from a source less\n") fmt.Fprintf(out, " readable than the repository you are adopting into.\n") fmt.Fprintf(out, " Two ways out: ask them to publish it — usually the thing you\n") fmt.Fprintf(out, " needed was not the confidential part — or keep no copy and\n") diff --git a/internal/external/visibility.go b/internal/external/visibility.go new file mode 100644 index 0000000..212d560 --- /dev/null +++ b/internal/external/visibility.go @@ -0,0 +1,97 @@ +package external + +import ( + "bytes" + "fmt" + "net/http" + "os/exec" + "strings" +) + +// selfVisibility reports whether the repository we are adopting into can be read +// anonymously. +// +// This is the half of the confidentiality rule the tool was previously blind to. +// Knowing only that a source is private makes the warning fire on every adoption +// a private repository performs, which is the legitimate case — and a warning +// that always fires is a warning nobody reads. +// +// The signal is coarse on purpose. It distinguishes public from not-public and +// nothing finer, so it cannot see that two repositories are private to different +// groups. That case widens access and this check will miss it. +func selfVisibility(root string) (public bool, known bool) { + out, err := exec.Command("git", "-C", root, "remote", "get-url", "origin").Output() + if err != nil { + return false, false + } + host, ownerRepo, ok := splitRemote(strings.TrimSpace(string(out))) + if !ok { + return false, false + } + req, err := http.NewRequest(http.MethodHead, "https://"+host+"/"+ownerRepo, nil) + if err != nil { + return false, false + } + resp, err := client.Do(req) + if err != nil { + return false, false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK, true +} + +// splitRemote pulls a host and owner/repo out of a git remote, whether it is +// ssh, ssh:// or https. +func splitRemote(remote string) (host, ownerRepo string, ok bool) { + s := remote + if i := strings.Index(s, "://"); i >= 0 { + s = s[i+3:] + } + if at := strings.Index(s, "@"); at >= 0 { + s = s[at+1:] + } + // scp-style "host:owner/repo.git" and url-style "host:port/owner/repo.git" + var rest string + if i := strings.IndexAny(s, ":/"); i >= 0 { + host, rest = s[:i], s[i+1:] + } else { + return "", "", false + } + if j := strings.Index(rest, "/"); j >= 0 && isPort(rest[:j]) { + rest = rest[j+1:] + } + rest = strings.TrimSuffix(strings.Trim(rest, "/"), ".git") + if host == "" || strings.Count(rest, "/") != 1 { + return "", "", false + } + return host, rest, true +} + +func isPort(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// notePublishedSurface says when an adopted document did not come from the +// publisher's published surface. +// +// What is not exported is not hidden — the rest of a repository is there to read. +// It is simply not what you depend on, and a lock against it records a dependency +// on something that was never a contract. +func notePublishedSurface(rawURL string, out interface{ Write([]byte) (int, error) }) { + if strings.Contains(rawURL, "/"+PublishedDir+"/") { + return + } + var b bytes.Buffer + fmt.Fprintf(&b, " NOTE not from %s — what is not exported is not hidden, but it is\n", PublishedDir) + fmt.Fprintf(&b, " not what you depend on. Nothing promises this path will still\n") + fmt.Fprintf(&b, " be there, or still mean this, tomorrow.\n") + out.Write(b.Bytes()) +} diff --git a/internal/external/visibility_test.go b/internal/external/visibility_test.go new file mode 100644 index 0000000..543abc2 --- /dev/null +++ b/internal/external/visibility_test.go @@ -0,0 +1,34 @@ +package external + +import "testing" + +func TestSplitRemote(t *testing.T) { + for _, tc := range []struct { + remote, host, ownerRepo string + ok bool + }{ + {"ssh://git@git.hypertheory-labs.dev:2222/loom/loom-cli.git", "git.hypertheory-labs.dev", "loom/loom-cli", true}, + {"ssh://git@git.hypertheory-labs.dev/loom/loom-cli.git", "git.hypertheory-labs.dev", "loom/loom-cli", true}, + {"git@github.com:octocat/Hello-World.git", "github.com", "octocat/Hello-World", true}, + {"https://gitlab.com/gitlab-org/gitlab-svgs.git", "gitlab.com", "gitlab-org/gitlab-svgs", true}, + {"https://gitlab.com/gitlab-org/gitlab-svgs", "gitlab.com", "gitlab-org/gitlab-svgs", true}, + // A nested group is not owner/repo, and guessing would produce a URL + // that answers about the wrong repository. + {"https://gitlab.com/group/sub/project.git", "", "", false}, + {"/srv/git/bare.git", "", "", false}, + {"", "", "", false}, + } { + host, or, ok := splitRemote(tc.remote) + if ok != tc.ok || host != tc.host || or != tc.ownerRepo { + t.Errorf("splitRemote(%q) = (%q, %q, %v), want (%q, %q, %v)", + tc.remote, host, or, ok, tc.host, tc.ownerRepo, tc.ok) + } + } +} + +func TestSelfVisibilityUnknownOutsideARepo(t *testing.T) { + // Failing open matters: "cannot tell" must not read as "not public". + if _, known := selfVisibility(t.TempDir()); known { + t.Error("selfVisibility in a non-repository reported a known answer") + } +} diff --git a/main.go b/main.go index 120147e..78e72d8 100644 --- a/main.go +++ b/main.go @@ -33,9 +33,12 @@ become — and somebody decides. Its exits are apply or discard; nothing there drifts into being kept. With no cart open, check says what moved and stages nothing, because opening a round is somebody's act and not a side effect. -add warns when a document could only be fetched with a credential: adopting is -copying, and confidentiality does not travel with the copy. It cannot see who -may read the repository the copy lands in, so it reports the half it knows. +loomctl grants no access. It reads what your credentials already let you read, +and records where the copy came from — which is the part copy-and-paste loses. +It warns rather than refuses: adopting from a source less readable than the +repository you are adopting into is yours to decide, and the check only tells +public from not-public, so it cannot see two repositories private to different +people. add adopts what is not here yet, and refuses what is already adopted. Given a document that is present but unlocked — fetched by hand before this existed —