fix: complete PR clone multi-value, git-status auth spoofing, CLI robustness

Addresses review findings from a merge-time audit.

- **Complete the headline multi-value `clone` fix for PRs** (was applied only to
  kind:30617). GitPullRequestEvent (1618) and GitPullRequestUpdateEvent (1619)
  carry `clone` with the same spec shape but still emitted repeated single-value
  tags and read only the first value — so the exact interop bug this branch set
  out to kill was still live for PRs, both directions (ngit keeps only the last
  repeated tag; we lost every URL after the first from ngit's multi-value tag).
  Now both emit one multi-value `["clone", …]` tag and read both forms. Verified
  on the wire + GitNip34InteropTest + CLI harness (40 checks).

- **Android git-status spoofing (GitStatusIndex)**: newest-status-wins with no
  author check meant anyone could publish a kind-1632 and make someone else's
  issue render closed. Now filter statuses to the repository owner (from the
  status's own `a` tag), declared maintainers (from the cached announcement), or
  the target item's author — matching NIP-34 and the CLI's derivation. Pre-existing
  on main; this branch made the CLI/Android divergence visible.

- **CLI robustness**: `git comment`/`git patch` no longer block forever reading
  stdin on an interactive TTY (amy is non-interactive — error instead). The local
  `git` subprocesses in `git init`/`git apply` now drain stdout on a side thread
  under a bounded `waitFor` + `destroyForcibly`, so a wedged git can't hang the
  CLI.

Left as a follow-up (cosmetic): GitBrowseCommands.candidateUrls duplicates
GitRepositoryBrowserViewModel's — worth lifting to shared code, not worth the
cross-module coupling here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
This commit is contained in:
Claude
2026-07-21 03:37:58 +00:00
parent 4e0ac212ee
commit 5f5356c0fe
11 changed files with 114 additions and 16 deletions
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.model.LocalCache.observeEvents
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent
@@ -71,6 +72,7 @@ object GitStatusIndex {
val latest = HashMap<HexKey, GitStatusEvent>()
for (event in events) {
val target = event.rootEventId() ?: continue
if (!isAuthoritative(event, target)) continue
val current = latest[target]
if (current == null || event.createdAt > current.createdAt) {
latest[target] = event
@@ -79,6 +81,29 @@ object GitStatusIndex {
return latest
}
/**
* NIP-34: only the repository owner, a declared maintainer, or the target
* item's own author may set its status — "the newest Status from the root
* author or a maintainer is valid". Without this filter any pubkey could
* publish a kind-1632 and make someone else's issue render closed.
*
* The owner is read from the status's own `a` tag (so it holds even before
* the announcement is cached); the maintainer set needs the cached kind-30617
* (falling back to owner/author-only until it arrives — the safe default is to
* treat an unverifiable status as absent, i.e. the item stays open).
*/
private fun isAuthoritative(
status: GitStatusEvent,
targetId: HexKey,
): Boolean {
status.repositoryAddress()?.let { repoAddress ->
if (status.pubKey == repoAddress.pubKeyHex) return true
val repo = LocalCache.getAddressableNoteIfExists(repoAddress)?.event as? GitRepositoryEvent
if (repo != null && status.pubKey in repo.maintainers()) return true
}
return status.pubKey == LocalCache.getNoteIfExists(targetId)?.event?.pubKey
}
/**
* Whether the latest status for [targetId] marks it as closed (kind 1632)
* or applied/resolved/merged (kind 1631). Items with no status event, or
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import java.io.File
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
/**
@@ -38,6 +39,9 @@ import kotlin.concurrent.thread
* This shells out to `git`, like `git init` — it operates on the local checkout.
*/
object GitApplyCommand {
/** Safety ceiling for the local `git am`/`git apply` invocation so a wedged git can't hang the CLI. */
private const val GIT_TIMEOUT_SEC = 60L
suspend fun apply(
dataDir: DataDir,
rest: Array<String>,
@@ -101,17 +105,18 @@ object GitApplyCommand {
vararg gitArgs: String,
): Pair<Int, String> {
var writer: Thread? = null
var reader: Thread? = null
return try {
val proc =
ProcessBuilder(listOf("git", *gitArgs))
.directory(repoDir)
.redirectErrorStream(true)
.start()
// Feed stdin on a separate thread while we drain stdout on this one: a
// large patch (bigger than the OS pipe buffer) would otherwise deadlock
// — git blocks writing output we haven't read, we block writing stdin.
// The patch was decoded as UTF-8, so write it back as UTF-8 (not the JVM
// default charset, which would corrupt non-ASCII filenames/messages).
// Feed stdin AND drain stdout on side threads: a large patch (bigger than
// the OS pipe buffer) would otherwise deadlock, and reading on this thread
// would block an unbounded wait. The patch was decoded as UTF-8, so write
// it back as UTF-8 (not the JVM default charset, which would corrupt
// non-ASCII filenames/messages).
writer =
if (input != null) {
thread(name = "git-stdin") { runCatching { proc.outputStream.use { it.write(input.toByteArray(Charsets.UTF_8)) } } }
@@ -119,14 +124,22 @@ object GitApplyCommand {
proc.outputStream.close()
null
}
val out = proc.inputStream.readBytes().decodeToString()
proc.waitFor() to out
val sb = StringBuilder()
reader = thread(name = "git-out") { runCatching { sb.append(proc.inputStream.readBytes().decodeToString()) } }
if (!proc.waitFor(GIT_TIMEOUT_SEC, TimeUnit.SECONDS)) {
proc.destroyForcibly()
124 to "git timed out after ${GIT_TIMEOUT_SEC}s"
} else {
reader.join()
proc.exitValue() to sb.toString()
}
} catch (e: Exception) {
1 to (e.message ?: "could not run git (is it installed and is this a git repo?)")
} finally {
// Always join so a non-daemon stdin thread can't outlive the call (e.g.
// Always join so the non-daemon side threads can't outlive the call (e.g.
// under the in-process test harness, which doesn't exitProcess).
writer?.join()
writer?.join(1_000)
reader?.join(1_000)
}
}
}
@@ -44,7 +44,13 @@ object GitCommentCommand {
): Int {
val args = Args(rest)
val targetRef = args.positional(0, "target-event-or-repo")
val body = (args.positionalOrNull(1) ?: System.`in`.readBytes().decodeToString()).trim()
val bodyArg = args.positionalOrNull(1)
// Never block on an interactive TTY: amy is non-interactive, so require the
// body as an argument unless it's actually being piped in.
if (bodyArg == null && System.console() != null) {
return Output.error("bad_args", "comment body required as an argument (or piped on stdin)")
}
val body = (bodyArg ?: System.`in`.readBytes().decodeToString()).trim()
if (body.isBlank()) return Output.error("bad_args", "empty comment (pass BODY as an argument or on stdin)")
args.rejectUnknown("relay")
@@ -29,6 +29,8 @@ import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import com.vitorpamplona.quartz.nip34Git.state.GitRepositoryStateEvent
import com.vitorpamplona.quartz.nip34Git.state.tags.RefTag
import java.io.File
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
/**
* `amy git init` — bootstrap a NIP-34 repository from the local git checkout,
@@ -42,6 +44,9 @@ import java.io.File
* about the local working tree, exactly like the `ngit`/`nak git` `init`.
*/
object GitInitCommand {
/** Safety ceiling for a single local `git` invocation; a normal read-only op finishes in milliseconds. */
private const val GIT_TIMEOUT_SEC = 60L
suspend fun init(
dataDir: DataDir,
rest: Array<String>,
@@ -171,8 +176,18 @@ object GitInitCommand {
.directory(repoDir)
.redirectError(ProcessBuilder.Redirect.DISCARD)
.start()
val out = proc.inputStream.readBytes().decodeToString()
if (proc.waitFor() == 0) out.trim().ifEmpty { null } else null
// Drain stdout on a side thread and bound the wait: a wedged git (a
// hung filter/hook, a credential prompt) must not hang the CLI forever.
val sb = StringBuilder()
val reader = thread(name = "git-out") { runCatching { sb.append(proc.inputStream.readBytes().decodeToString()) } }
if (!proc.waitFor(GIT_TIMEOUT_SEC, TimeUnit.SECONDS)) {
proc.destroyForcibly()
reader.join(1_000)
null
} else {
reader.join()
if (proc.exitValue() == 0) sb.toString().trim().ifEmpty { null } else null
}
} catch (_: Exception) {
null
}
@@ -126,6 +126,8 @@ object GitPatchCommands {
File(file).takeIf { it.isFile }?.readText()
?: throw IllegalArgumentException("--file not found: $file")
} else {
// Non-interactive: don't block waiting for a human to type a patch.
require(System.console() == null) { "no patch given: pass --file PATH or pipe `git format-patch` to stdin" }
System.`in`.readBytes().decodeToString()
}.trim()
}
+5
View File
@@ -177,6 +177,11 @@ assert_eq "$(M git show "$IADDR" --relay "$RELAY_URL" | jq -r '.clone | length')
IISS="$(M git issue "$IADDR" --subject "interop" "b" --relay "$RELAY_URL" | jq -r '.event_id')"
IOWNER="$(echo "$IADDR" | cut -d: -f2)"
assert_eq "$(M fetch --id "$IISS" --relay "$RELAY_URL" | jq -r "[.events[0].tags[] | select(.[0]==\"p\" and .[1]==\"$IOWNER\")] | length")" "1" interop.issue_ptag "issue carries the repo owner p tag"
# A PR (1618) also carries clone URLs as ONE multi-value tag (same fix as 30617).
IPR="$(M git pr "$IADDR" --commit tip1 --clone https://c1.git,https://c2.git --subject s --relay "$RELAY_URL" | jq -r '.event_id')"
IPRTAGS="$(M fetch --id "$IPR" --relay "$RELAY_URL")"
assert_eq "$(echo "$IPRTAGS" | jq -r '[.events[0].tags[] | select(.[0]=="clone")] | length')" "1" interop.pr_clone_single "PR clone is one tag"
assert_eq "$(echo "$IPRTAGS" | jq -r '.events[0].tags[] | select(.[0]=="clone") | length')" "3" interop.pr_clone_multivalue "PR clone tag carries both URLs"
# GRASP server list (10317) round-trip.
GRASP="$(M git grasp set "wss://grasp.example.com,wss://grasp2.example.com" --relay "$RELAY_URL")"
@@ -87,7 +87,9 @@ class GitPullRequestEvent(
fun currentCommit(): String? = tags.firstNotNullOfOrNull(CurrentCommitTag::parse)
fun cloneUrls(): List<String> = tags.mapNotNull(CloneTag::parse)
// Tolerant of both the NIP-34 spec form (one multi-value `["clone", a, b]` tag,
// which ngit emits) and the legacy repeated form; deduped.
fun cloneUrls(): List<String> = tags.flatMap(CloneTag::parseAll).distinct()
fun subject(): String? = tags.firstNotNullOfOrNull(SubjectTag::parse)
@@ -138,7 +140,7 @@ class GitPullRequestEvent(
pTag(repository.event.pubKey, repository.authorHomeRelay)
if (notify.isNotEmpty()) pTags(notify)
currentCommit(currentCommit)
cloneUrls.forEach { cloneUrl(it) }
if (cloneUrls.isNotEmpty()) cloneUrls(cloneUrls)
subject?.let { subject(it) }
if (labels.isNotEmpty()) hashtags(labels)
branchName?.let { branchName(it) }
@@ -81,7 +81,8 @@ class GitPullRequestUpdateEvent(
fun currentCommit(): String? = tags.firstNotNullOfOrNull(CurrentCommitTag::parse)
fun cloneUrls(): List<String> = tags.mapNotNull(CloneTag::parse)
// Tolerant of both the multi-value and legacy repeated `clone` forms; deduped.
fun cloneUrls(): List<String> = tags.flatMap(CloneTag::parseAll).distinct()
fun earliestUniqueCommit(): String? = tags.firstOrNull { it.size > 1 && it[0] == "r" && it[1].isNotEmpty() }?.get(1)
@@ -119,7 +120,7 @@ class GitPullRequestUpdateEvent(
pTag(repository.event.pubKey, repository.authorHomeRelay)
if (notify.isNotEmpty()) pTags(notify)
currentCommit(currentCommit)
cloneUrls.forEach { cloneUrl(it) }
if (cloneUrls.isNotEmpty()) cloneUrls(cloneUrls)
mergeBase?.let { mergeBase(it) }
initializer()
}
@@ -46,6 +46,9 @@ fun TagArrayBuilder<GitPullRequestEvent>.currentCommit(commit: String) = addUniq
fun TagArrayBuilder<GitPullRequestEvent>.cloneUrl(url: String) = add(CloneTag.assemble(url))
/** Emit all clone URLs as one NIP-34 multi-value `["clone", url1, url2, …]` tag (the spec/ngit form). */
fun TagArrayBuilder<GitPullRequestEvent>.cloneUrls(urls: List<String>) = addUnique(CloneTag.assemble(urls))
fun TagArrayBuilder<GitPullRequestEvent>.subject(subject: String) = addUnique(SubjectTag.assemble(subject))
fun TagArrayBuilder<GitPullRequestEvent>.branchName(name: String) = addUnique(BranchNameTag.assemble(name))
@@ -43,6 +43,9 @@ fun TagArrayBuilder<GitPullRequestUpdateEvent>.currentCommit(commit: String) = a
fun TagArrayBuilder<GitPullRequestUpdateEvent>.cloneUrl(url: String) = add(CloneTag.assemble(url))
/** Emit all clone URLs as one NIP-34 multi-value `["clone", url1, url2, …]` tag (the spec/ngit form). */
fun TagArrayBuilder<GitPullRequestUpdateEvent>.cloneUrls(urls: List<String>) = addUnique(CloneTag.assemble(urls))
fun TagArrayBuilder<GitPullRequestUpdateEvent>.mergeBase(commit: String) = addUnique(MergeBaseTag.assemble(commit))
/** Adds the NIP-22 `E` tag pointing at the parent Pull Request. */
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip34Git
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -102,6 +103,28 @@ class GitNip34InteropTest {
assertTrue(owner in pTags, "issue must p-tag the repository owner for maintainer routing")
}
@Test
fun pullRequestEmitsAndReadsMultiValueClone() {
val repoEvent = repo(arrayOf(arrayOf("d", "x"), arrayOf("r", "root", "euc")))
val tmpl =
GitPullRequestEvent.build(
description = "desc",
repository = EventHintBundle(repoEvent),
earliestUniqueCommit = "root",
currentCommit = "tip",
cloneUrls = listOf("https://a.git", "https://b.git"),
)
// One multi-value clone tag (ngit/spec form), not repeated single-value tags.
assertEquals(1, tmpl.tags.count { it[0] == "clone" }, "PR clone must be a single tag")
assertEquals(listOf("clone", "https://a.git", "https://b.git"), tmpl.tags.first { it[0] == "clone" }.toList())
// Reader flattens both the multi-value and the legacy repeated forms.
val multi = GitPullRequestEvent("00", owner, 0, arrayOf(arrayOf("clone", "https://a.git", "https://b.git")), "", "00")
val repeated = GitPullRequestEvent("00", owner, 0, arrayOf(arrayOf("clone", "https://a.git"), arrayOf("clone", "https://b.git")), "", "00")
assertEquals(listOf("https://a.git", "https://b.git"), multi.cloneUrls())
assertEquals(listOf("https://a.git", "https://b.git"), repeated.cloneUrls())
}
@Test
fun patchRTagIsPlainWithoutEucMarker() {
val repoEvent = repo(arrayOf(arrayOf("d", "x"), arrayOf("r", "rootcommit", "euc")))