diff --git a/docs/scratchpads/t_301e4e3b-pr-merge-gitea-empty-uid.md b/docs/scratchpads/t_301e4e3b-pr-merge-gitea-empty-uid.md new file mode 100644 index 0000000..01540bb --- /dev/null +++ b/docs/scratchpads/t_301e4e3b-pr-merge-gitea-empty-uid.md @@ -0,0 +1,31 @@ +# Scratchpad: t_301e4e3b pr-merge.sh Gitea empty-uid fallback + +## Task + +Implement a narrow hardening in `packages/mosaic/framework/tools/git/pr-merge.sh` so Gitea merges recover from the known non-interactive `tea pr merge` identity failure: `user does not exist [uid: 0, name: ]`. + +## Constraints + +- Preserve Mosaic policy gates: squash-only, base branch `main`, queue guard unless explicitly skipped. +- Preserve the existing authenticated Gitea API fallback when no tea login exists. +- Do not fallback on arbitrary tea failures. +- Do not expose tokens or credential-bearing remotes. +- Scope is limited to the merge wrapper plus focused test/support/scratchpad files. + +## External issue + +- Gitea issue #520: Harden pr-merge.sh Gitea empty-uid fallback + +## Plan + +1. Add a focused shell regression harness with mocked `tea` and `curl` proving the known empty uid/name failure must fall back to Gitea API. +2. Watch the harness fail on current code. +3. Implement helper functions in `pr-merge.sh` for redacted command display, known failure classification, and authenticated Gitea API merge fallback. +4. Keep unknown `tea` failures blocking by replaying stderr and exiting non-zero. +5. Run syntax, shellcheck if available, focused regression, and repo quality gates before push/PR. + +## Session log + +- 2026-05-22: Read Kanban context, Mosaic global/repo instructions, created isolated branch `fix/t_301e4e3b-pr-merge-gitea-empty-uid`, and opened Gitea issue #520 using the Mosaic issue wrapper/API fallback. +- 2026-05-22: Added regression harness and watched it fail on current behavior with `user does not exist [uid: 0, name: ]`; implemented narrow fallback and verified known-empty-identity fallback, arbitrary tea failure blocking, and no-tea-login API fallback paths. +- 2026-05-22: Validation passed for `bash -n`, `shellcheck -x`, focused shell harness, `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, and `pnpm --filter @mosaicstack/mosaic test`. Full `pnpm test` exposed an out-of-scope gateway DB setup failure (`relation "messages" does not exist`) in `apps/gateway/src/__tests__/cross-user-isolation.test.ts`. diff --git a/docs/scratchpads/t_5aab9cc8-pr-merge-eval-injection.md b/docs/scratchpads/t_5aab9cc8-pr-merge-eval-injection.md new file mode 100644 index 0000000..23ce55c --- /dev/null +++ b/docs/scratchpads/t_5aab9cc8-pr-merge-eval-injection.md @@ -0,0 +1,48 @@ +# t_5aab9cc8 — pr-merge.sh eval injection remediation + +## Objective + +Remediate PR #521 review blocker: `packages/mosaic/framework/tools/git/pr-merge.sh` must reject non-numeric PR numbers before metadata lookup/merge and must not use `eval` for GitHub merge execution. + +## Scope + +- Shell wrapper only: `packages/mosaic/framework/tools/git/pr-merge.sh` +- Focused regression harness: `packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh` +- No API/frontend/infra surfaces. + +## Acceptance Criteria + +- AC1: `PR_NUMBER` is validated as digits-only immediately after required-argument parsing, before metadata lookup. +- AC2: GitHub merge path uses a quoted argv array, not command-string construction plus `eval`. +- AC3: Focused tests prove PR-number metacharacters are rejected and cannot execute injected shell commands on GitHub path. +- AC4: Focused tests prove PR-number metacharacters are rejected on Gitea path before tea/curl merge calls. +- AC5: Existing Gitea empty-uid fallback behavior remains green. +- AC6: Syntax, shellcheck where available, focused harness, and relevant repo gates are rerun or absence documented. + +## Plan + +1. Add failing regression tests for GitHub eval injection and Gitea invalid PR rejection. +2. Implement fail-closed PR number validation before metadata lookup. +3. Replace GitHub `eval` command with argv array execution. +4. Run required validation and update this scratchpad with evidence. +5. Commit, queue-guard, push branch, update PR #521. + +## TDD Log + +- RED: `AGENT_WORK_ROOT="$HERMES_KANBAN_WORKSPACE/work" bash packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh` failed on vulnerable code with `Expected GitHub metacharacter PR number to be rejected` and showed the injected PR number reached the GitHub merge path. +- GREEN: Added digits-only validation before metadata lookup and replaced GitHub `eval` with an argv array. The focused harness now passes and verifies invalid PR numbers are rejected before GitHub `gh` calls and before Gitea `tea`/`curl` calls. + +## Validation Evidence + +- PASS: `AGENT_WORK_ROOT="$HERMES_KANBAN_WORKSPACE/work" bash -n packages/mosaic/framework/tools/git/pr-merge.sh packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh` +- PASS: `shellcheck -x packages/mosaic/framework/tools/git/pr-merge.sh packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh` +- PASS: `AGENT_WORK_ROOT="$HERMES_KANBAN_WORKSPACE/work" bash packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh` +- PASS: `pnpm --filter @mosaicstack/mosaic... build` +- PASS: `pnpm --filter @mosaicstack/mosaic lint` +- PASS: `pnpm --filter @mosaicstack/mosaic typecheck` +- PASS: `pnpm --filter @mosaicstack/mosaic test` — 32 files / 291 tests passed. +- REVIEW: `/home/hermes/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` could not run due Codex 401 Unauthorized. Independent delegate review completed read-only with PASS / no blockers; non-blocking suggestion to assert GitHub mock log remains empty was applied. + +## Risks / Blockers + +- No active blockers. diff --git a/packages/mosaic/framework/tools/git/pr-merge.sh b/packages/mosaic/framework/tools/git/pr-merge.sh index ad8c318..dd94d14 100755 --- a/packages/mosaic/framework/tools/git/pr-merge.sh +++ b/packages/mosaic/framework/tools/git/pr-merge.sh @@ -2,9 +2,10 @@ # pr-merge.sh - Merge pull requests on Gitea or GitHub # Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--skip-queue-guard] -set -e +set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=packages/mosaic/framework/tools/git/detect-platform.sh source "$SCRIPT_DIR/detect-platform.sh" # Default values @@ -69,6 +70,11 @@ if [[ -z "$PR_NUMBER" ]]; then usage fi +if [[ ! "$PR_NUMBER" =~ ^[0-9]+$ ]]; then + echo "Error: Invalid PR number '$PR_NUMBER'. PR number must contain digits only." >&2 + exit 1 +fi + if [[ "$MERGE_METHOD" != "squash" ]]; then echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2 exit 1 @@ -92,21 +98,138 @@ PLATFORM=$(detect_platform) OWNER=$(get_repo_owner) REPO=$(get_repo_name) +find_tea_login_for_host() { + local host="$1" + local logins_json + + command -v tea >/dev/null 2>&1 || return 1 + logins_json=$(tea login list --output json 2>/dev/null) || return 1 + TEA_LOGINS_JSON="$logins_json" python3 - "$host" <<'PY' +import json +import os +import sys + +host = sys.argv[1] +try: + logins = json.loads(os.environ.get("TEA_LOGINS_JSON", "[]")) +except Exception: + raise SystemExit(1) + +for login in logins if isinstance(logins, list) else []: + url = str(login.get("url") or login.get("URL") or "") + name = str(login.get("name") or login.get("Name") or "") + if url.rstrip("/").endswith(host) and name: + print(name) + raise SystemExit(0) + +raise SystemExit(1) +PY +} + +is_known_tea_empty_identity_failure() { + local error_file="$1" + + python3 - "$error_file" <<'PY' +import re +import sys + +with open(sys.argv[1], encoding="utf-8", errors="replace") as handle: + error = handle.read() + +known_empty_identity = re.search( + r"user does not exist.*\[.*uid:\s*0,\s*name:\s*\]", + error, + flags=re.IGNORECASE | re.DOTALL, +) +raise SystemExit(0 if known_empty_identity else 1) +PY +} + +merge_gitea_with_api() { + local host="$1" + local api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge" + local token body_file payload + + token=$(get_gitea_token "$host" || true) + if [[ -z "$token" ]]; then + echo "Error: No Gitea API token available for authenticated merge fallback on $host." >&2 + return 1 + fi + + mkdir -p "${AGENT_WORK_ROOT:-/home/hermes/agent-work}" + body_file=$(mktemp "${AGENT_WORK_ROOT:-/home/hermes/agent-work}/pr-merge-api-response.XXXXXX") + payload='{"Do":"squash"}' + + if curl -fsS \ + -X POST \ + -H "Authorization: token $token" \ + -H 'Content-Type: application/json' \ + -d "$payload" \ + "$api_url" > "$body_file"; then + rm -f "$body_file" + return 0 + fi + + python3 - "$body_file" <<'PY' >&2 +import json +import sys + +path = sys.argv[1] +try: + with open(path, encoding="utf-8", errors="replace") as handle: + raw = handle.read(500) + data = json.loads(raw) if raw else {} + message = data.get("message") or data.get("error") or raw or "empty response" +except Exception: + try: + with open(path, encoding="utf-8", errors="replace") as handle: + message = handle.read(500) or "empty response" + except Exception: + message = "unreadable response" + +print(f"Error: Gitea API merge fallback failed: {message}") +PY + rm -f "$body_file" + return 1 +} + case "$PLATFORM" in github) - CMD="gh pr merge $PR_NUMBER --squash" - [[ "$DELETE_BRANCH" == true ]] && CMD="$CMD --delete-branch" - eval "$CMD" + cmd=(gh pr merge "$PR_NUMBER" --squash) + [[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch) + "${cmd[@]}" ;; gitea) - CMD="tea pr merge $PR_NUMBER --style squash --repo $OWNER/$REPO --login ${GITEA_LOGIN:-mosaicstack}" + HOST=$(get_remote_host) || { + echo "Error: Cannot determine host from origin remote URL" >&2 + exit 1 + } + TEA_LOGIN="${GITEA_LOGIN:-$(find_tea_login_for_host "$HOST" || true)}" # Delete branch after merge if requested if [[ "$DELETE_BRANCH" == true ]]; then echo "Note: Branch deletion after merge may need to be done separately with tea" >&2 fi - eval "$CMD" + if [[ -n "$TEA_LOGIN" ]]; then + mkdir -p "${AGENT_WORK_ROOT:-/home/hermes/agent-work}" + TEA_ERROR_FILE=$(mktemp "${AGENT_WORK_ROOT:-/home/hermes/agent-work}/pr-merge-tea-error.XXXXXX") + if tea pr merge "$PR_NUMBER" --style squash --repo "$OWNER/$REPO" --login "$TEA_LOGIN" 2> "$TEA_ERROR_FILE"; then + rm -f "$TEA_ERROR_FILE" + elif is_known_tea_empty_identity_failure "$TEA_ERROR_FILE"; then + cat "$TEA_ERROR_FILE" >&2 + echo "Known tea empty identity failure detected; using authenticated Gitea API merge fallback." >&2 + rm -f "$TEA_ERROR_FILE" + merge_gitea_with_api "$HOST" + else + cat "$TEA_ERROR_FILE" >&2 + rm -f "$TEA_ERROR_FILE" + exit 1 + fi + else + echo "No tea login configured for $HOST; using authenticated Gitea API merge fallback." >&2 + merge_gitea_with_api "$HOST" + fi ;; *) echo "Error: Could not detect git platform" >&2 diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh b/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh new file mode 100755 index 0000000..d64aa0e --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh @@ -0,0 +1,216 @@ +#!/bin/bash +# Regression harness for pr-merge.sh Gitea non-interactive tea empty identity fallback. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORK_ROOT="${AGENT_WORK_ROOT:-/home/hermes/agent-work}" +SANDBOX="$WORK_ROOT/pr-merge-empty-uid-test-$$" +MOCK_BIN="$SANDBOX/bin" +REPO_DIR="$SANDBOX/repo" +LOG_FILE="$SANDBOX/mock.log" + +cleanup() { + rm -rf "$SANDBOX" +} +trap cleanup EXIT + +mkdir -p "$MOCK_BIN" "$REPO_DIR" +: > "$LOG_FILE" + +cat > "$MOCK_BIN/tea" <<'EOF' +#!/bin/bash +set -euo pipefail +printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG" +printf '\n' >> "$PR_MERGE_TEST_LOG" +if [[ "$*" == *"pr merge"* ]]; then + echo 'user does not exist [uid: 0, name: ]' >&2 + exit 1 +fi +exit 0 +EOF +chmod +x "$MOCK_BIN/tea" + +cat > "$MOCK_BIN/curl" <<'EOF' +#!/bin/bash +set -euo pipefail +printf 'curl %q ' "$@" >> "$PR_MERGE_TEST_LOG" +printf '\n' >> "$PR_MERGE_TEST_LOG" +args=" $* " +if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123"* && "$args" != *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then + cat <<'JSON' +{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock"},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true} +JSON + exit 0 +fi +if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then + cat <<'JSON' +{"merged":true,"message":"mock merge complete"} +JSON + exit 0 +fi +echo "unexpected curl invocation: $*" >&2 +exit 97 +EOF +chmod +x "$MOCK_BIN/curl" + +cd "$REPO_DIR" +git init -q +git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git + +export PATH="$MOCK_BIN:$PATH" +export PR_MERGE_TEST_LOG="$LOG_FILE" +export GITEA_LOGIN="git.mosaicstack.dev" +export GITEA_TOKEN="redacted-test-token" + +OUTPUT="$SANDBOX/output.log" +if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then + echo "Expected pr-merge.sh to recover via Gitea API fallback." >&2 + echo "--- output ---" >&2 + sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2 + echo "--- mock log ---" >&2 + sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2 + exit 1 +fi + +if ! grep -q '/api/v1/repos/mosaicstack/stack/pulls/123/merge' "$LOG_FILE"; then + echo "Expected authenticated Gitea merge API endpoint to be called." >&2 + sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2 + exit 1 +fi + +if grep -q 'redacted-test-token' "$OUTPUT"; then + echo "Token leaked to pr-merge.sh output." >&2 + exit 1 +fi + +cat > "$MOCK_BIN/tea" <<'EOF' +#!/bin/bash +set -euo pipefail +printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG" +printf '\n' >> "$PR_MERGE_TEST_LOG" +if [[ "$*" == *"pr merge"* ]]; then + echo 'tea network timeout' >&2 + exit 2 +fi +exit 0 +EOF +chmod +x "$MOCK_BIN/tea" +: > "$LOG_FILE" +if "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then + echo "Expected arbitrary tea failure to remain blocking." >&2 + exit 1 +fi +if grep -q '/api/v1/repos/mosaicstack/stack/pulls/123/merge' "$LOG_FILE"; then + echo "Arbitrary tea failure unexpectedly used Gitea API merge fallback." >&2 + sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2 + exit 1 +fi +if ! grep -q 'tea network timeout' "$OUTPUT"; then + echo "Expected arbitrary tea error to be preserved in output." >&2 + sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2 + exit 1 +fi + +cat > "$MOCK_BIN/tea" <<'EOF' +#!/bin/bash +set -euo pipefail +printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG" +printf '\n' >> "$PR_MERGE_TEST_LOG" +if [[ "$*" == *"login list"* ]]; then + echo '[]' + exit 0 +fi +if [[ "$*" == *"pr merge"* ]]; then + echo 'tea merge should not run without a configured host login' >&2 + exit 99 +fi +exit 0 +EOF +chmod +x "$MOCK_BIN/tea" +unset GITEA_LOGIN +: > "$LOG_FILE" +if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then + echo "Expected missing tea login to use authenticated Gitea API fallback." >&2 + sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2 + sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2 + exit 1 +fi +if ! grep -q '/api/v1/repos/mosaicstack/stack/pulls/123/merge' "$LOG_FILE"; then + echo "Expected missing tea login path to call Gitea API merge endpoint." >&2 + sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2 + exit 1 +fi + +SENTINEL="$SANDBOX/injected-sentinel" +INJECTION="123; touch $SENTINEL #" + +cat > "$MOCK_BIN/gh" <<'EOF' +#!/bin/bash +set -euo pipefail +printf 'gh %q ' "$@" >> "$PR_MERGE_TEST_LOG" +printf '\n' >> "$PR_MERGE_TEST_LOG" +if [[ "$*" == *"pr view"* ]]; then + cat <<'JSON' +{"number":123,"title":"mock","baseRefName":"main","headRefName":"feature/mock"} +JSON + exit 0 +fi +if [[ "$*" == *"pr merge"* ]]; then + exit 0 +fi +echo "unexpected gh invocation: $*" >&2 +exit 98 +EOF +chmod +x "$MOCK_BIN/gh" + +cd "$REPO_DIR" +git remote set-url origin https://github.com/mosaicstack/stack.git +: > "$LOG_FILE" +rm -f "$SENTINEL" +if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then + echo "Expected GitHub metacharacter PR number to be rejected." >&2 + sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2 + exit 1 +fi +if [[ -e "$SENTINEL" ]]; then + echo "GitHub metacharacter PR number executed injected shell command." >&2 + exit 1 +fi +if [[ -s "$LOG_FILE" ]]; then + echo "GitHub metacharacter PR number should be rejected before gh calls." >&2 + sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2 + exit 1 +fi +if ! grep -q 'Invalid PR number' "$OUTPUT"; then + echo "Expected invalid PR number error for GitHub metacharacter input." >&2 + sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2 + exit 1 +fi + +cd "$REPO_DIR" +git remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git +export GITEA_LOGIN="git.mosaicstack.dev" +: > "$LOG_FILE" +rm -f "$SENTINEL" +if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then + echo "Expected Gitea metacharacter PR number to be rejected." >&2 + sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2 + exit 1 +fi +if [[ -e "$SENTINEL" ]]; then + echo "Gitea metacharacter PR number executed injected shell command." >&2 + exit 1 +fi +if [[ -s "$LOG_FILE" ]]; then + echo "Gitea metacharacter PR number should be rejected before tea/curl calls." >&2 + sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2 + exit 1 +fi +if ! grep -q 'Invalid PR number' "$OUTPUT"; then + echo "Expected invalid PR number error for Gitea metacharacter input." >&2 + sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2 + exit 1 +fi + +echo "pr-merge.sh Gitea fallback regression passed"