Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e7aefc2
fix(teardown): recognize work that landed under rewritten commits
IanQiu979 Aug 17, 2026
80f59dc
no-mistakes(document): docs: correct teardown's default-branch fallba…
IanQiu979 Aug 17, 2026
ce5e7fe
no-mistakes(review): dedupe patch matching, fix landed-but-dirty refu…
IanQiu979 Aug 18, 2026
f58d763
no-mistakes(document): docs: fix teardown header's gh-error fallback …
IanQiu979 Aug 18, 2026
a9803ba
fix(teardown): recognize work that landed under rewritten commits
IanQiu979 Aug 17, 2026
86c924f
no-mistakes(document): docs: correct teardown's default-branch fallba…
IanQiu979 Aug 17, 2026
3a3c251
no-mistakes(review): dedupe patch matching, fix landed-but-dirty refu…
IanQiu979 Aug 18, 2026
4e39a18
no-mistakes(document): docs: fix teardown header's gh-error fallback …
IanQiu979 Aug 18, 2026
488f8db
no-mistakes(review): batch and bound patch scan, refuse reverted patches
IanQiu979 Aug 22, 2026
a79c805
Merge commit 'refs/no-mistakes/recover/01M0M4K3ZZTWWCK2ZAJ77KGDBM' in…
IanQiu979 Aug 22, 2026
be0f04d
no-mistakes(review): Harden teardown patch landing proof
IanQiu979 Aug 22, 2026
d9a211e
no-mistakes(review): Detect jointly reverted patch sequences
IanQiu979 Aug 22, 2026
0ee0e32
no-mistakes(review): Prove landed patches in current tree
IanQiu979 Aug 22, 2026
b72f234
no-mistakes(review): Verify landed changes per diff hunk
IanQiu979 Aug 22, 2026
9ea6915
no-mistakes(review): Verify landed changes line by line
IanQiu979 Aug 22, 2026
38d3571
no-mistakes(review): Require context-aware aggregate landing proof
IanQiu979 Aug 22, 2026
ce2ca81
no-mistakes(review): Require affirmative aggregate patch representation
IanQiu979 Aug 22, 2026
247878e
no-mistakes(review): Allow provable post-landing file supersets
IanQiu979 Aug 22, 2026
5b2c03d
no-mistakes(review): Make landed superset proof newline-aware
IanQiu979 Aug 22, 2026
f0bd840
no-mistakes(review): Remove unrelated GitLab merge feature
IanQiu979 Aug 22, 2026
07921d1
no-mistakes(review): Preserve patch locations and allow proven net-ze…
IanQiu979 Aug 22, 2026
8e2df30
no-mistakes(review): Validate current tree for net-zero sequences
IanQiu979 Aug 22, 2026
2c8dc6c
fix(teardown): clear shellcheck findings in the patch-landing proof
IanQiu979 Aug 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ Firstmate's skills live in two separate places with different audiences:
- [docs/cmux-backend.md](docs/cmux-backend.md) - current setup, socket security, and limits for the experimental cmux backend.
- [docs/codex-app-backend.md](docs/codex-app-backend.md) - the current blocked Codex App backend boundary and rollout contract.
- [docs/verification/runtime-backends.md](docs/verification/runtime-backends.md) - active maintainer verification for runtime backend guarantees.
- [docs/gitlab-merge-watch.md](docs/gitlab-merge-watch.md) - maintainer verification for watching and merging GitLab merge requests on arbitrary instances.
- [docs/gitlab-merge-watch.md](docs/gitlab-merge-watch.md) - maintainer verification for GitLab merge watching on arbitrary instances.
- [docs/turnend-guard.md](docs/turnend-guard.md) - the primary session's current "no turn ends blind" backstop, scope, loop safety, and compatibility limits.
- [docs/verification/supervision.md](docs/verification/supervision.md) - active maintainer verification for session-start, guard, continuity, and wedge integrations.
- [docs/supervision-protocols/](docs/supervision-protocols/) - rendered primary-harness watcher protocols for Claude, Codex, OpenCode, Pi and `pi-signed`, Grok, Cursor, and unknown harness fallback.
Expand Down
2 changes: 0 additions & 2 deletions bin/fm-pr-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,6 @@ fi
# bin/fm-teardown.sh reads the head from the forge at teardown rather than from
# metadata and falls back to its provider-agnostic content check, and
# bin/fm-review-diff.sh resolves the head from the remote when none is recorded.
# bin/fm-pr-merge.sh reads a GitLab head live at merge time for the same reason,
# and treats a recorded value that disagrees as stale rather than authoritative.
WT=$(grep '^worktree=' "$META" | tail -1 | cut -d= -f2- || true)
PR_HEAD=
if [ "$PROVIDER" = github ] && [ -n "$WT" ] && [ -d "$WT" ] && command -v gh >/dev/null 2>&1; then
Expand Down
4 changes: 2 additions & 2 deletions bin/fm-pr-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,8 @@ fm_pr_gitlab_path_valid() {
#
# FM_PR_OWNER and FM_PR_REPO are additionally set for github because
# bin/fm-pr-merge.sh addresses GitHub by owner/repository. A gitlab URL leaves
# them empty, and that path addresses the project by FM_PR_HOST and FM_PR_PATH
# instead, so a merge request on any instance resolves without a hardcoded host.
# them empty; teaching the merge path about GitLab is a separate change, and
# until then it refuses a GitLab URL rather than merging anything.
fm_pr_url_parse() {
local raw=${1-} pattern host path
local LC_ALL=C
Expand Down
215 changes: 18 additions & 197 deletions bin/fm-pr-merge.sh
Original file line number Diff line number Diff line change
@@ -1,33 +1,13 @@
#!/usr/bin/env bash
# Merge a task's PR or MR after recording pr= and any available pr_head= through
# Merge a task's PR after recording pr= and any available pr_head= through
# bin/fm-pr-check.sh, so teardown can verify landed work after squash merges.
# The full canonical URL is parsed by bin/fm-pr-lib.sh. A GitHub pull request is
# addressed through gh-axi by the derived owner and repository; a GitLab merge
# request is addressed through glab by the project URL rebuilt from the parsed
# host and path, so any instance works and no host is hardcoded.
# The full canonical GitHub PR URL is parsed by bin/fm-pr-lib.sh and the derived
# owner/repository and PR number are passed to gh-axi as separate arguments.
#
# Merge method on GitHub defaults to --squash when the caller passes none of
# --squash, --merge, --rebase, or --method after the optional -- separator.
# GitLab adds no method flag at all: its merge method is the project's own
# setting, which the merge API applies, and imposing squash there would override
# that convention rather than mirror the GitHub default.
#
# A GitLab merge is refused unless every pre-merge condition holds, each read
# live at merge time rather than taken from recorded metadata: the merge request
# is open, detailed_merge_status is mergeable, has_conflicts is false,
# blocking_discussions_resolved is true, and the head pipeline succeeded at the
# exact current head commit. Every failing condition is reported, not just the
# first. The verified head is then passed to glab as --sha, so a push that lands
# between that read and the merge fails the merge instead of landing commits
# nothing verified. A recorded pr_head that disagrees with the live head is
# reported rather than trusted, because a rebase moves the head and leaves the
# recorded value stale. Reading that state needs glab and jq, and either one
# absent stops the merge before any state is recorded.
#
# Extra args must not include --repo or -R in any form, including a bundled
# short-option cluster such as -yR, because the repository comes only from the
# URL, nor --sha on GitLab because the head comes only from the live read.
# Usage: fm-pr-merge.sh <task-id> <pr-url> [-- <extra forge merge args>]
# Merge method defaults to --squash when the caller passes none of --squash,
# --merge, --rebase, or --method after the optional -- separator. Extra args
# must not include --repo or -R because the repository comes only from the URL.
# Usage: fm-pr-merge.sh <task-id> <pr-url> [-- <extra gh-axi pr merge args>]
set -eu

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
Expand All @@ -44,18 +24,18 @@ if [ "$#" -lt 2 ]; then
fi
ID=$1
RAW_URL=$2
if ! fm_pr_task_id_valid "$ID" || ! fm_pr_url_parse "$RAW_URL"; then
# bin/fm-pr-lib.sh parses GitLab merge request URLs so the watcher can follow
# them, but this path still addresses only GitHub by owner/repository. The
# provider check holds that refusal exactly as it was until merge parity lands.
if ! fm_pr_task_id_valid "$ID" || ! fm_pr_url_parse "$RAW_URL" \
|| [ "$FM_PR_PROVIDER" != github ]; then
echo "error: invalid PR merge request" >&2
exit 2
fi
URL=$FM_PR_URL
PROVIDER=$FM_PR_PROVIDER
PR_OWNER=$FM_PR_OWNER
PR_REPO=$FM_PR_REPO
PR_NUMBER=$FM_PR_NUMBER
# glab resolves the instance from the project URL passed to -R, so the host is
# rebuilt from the parsed identity rather than read from any ambient default.
PROJECT_URL="https://$FM_PR_HOST/$FM_PR_PATH"
shift 2
[ "${1:-}" = "--" ] && shift

Expand All @@ -73,35 +53,15 @@ reject_repo_overrides() {
local arg
for arg in "$@"; do
case "$arg" in
--repo|--repo=*)
echo "error: extra merge arguments must not override the repository" >&2
return 1
;;
--*) ;;
# A single-dash argument is a short-option cluster, which both CLIs expand
# one character at a time, so -yR carries --repo exactly as a bare -R does.
-*R*)
--repo|--repo=*|-R|-R?*)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Bundled repository override bypass

When a caller supplies an extra argument such as -dR wrong/repo, the narrowed filter accepts it and forwards it after the URL-derived --repo, allowing the merge command to target a repository other than the canonical PR URL names.

How this was verified: The removed regression case identifies -dR as a repository override, while the new pattern no longer matches that cluster and the accepted arguments are forwarded verbatim.

echo "error: extra merge arguments must not override the repository" >&2
return 1
;;
esac
done
}

reject_head_overrides() {
local arg
for arg in "$@"; do
case "$arg" in
--sha|--sha=*)
echo "error: extra merge arguments must not override the head commit" >&2
return 1
;;
esac
done
}

reject_repo_overrides "$@" || exit 1
[ "$PROVIDER" != gitlab ] || reject_head_overrides "$@" || exit 1

# Task-derived paths are constructed only after the canonical ID validation.
META="$STATE/$ID.meta"
Expand All @@ -110,154 +70,15 @@ if [ ! -f "$META" ] || [ -L "$META" ]; then
exit 1
fi

# Reading the merge request state needs both tools. Report them together and
# before anything is recorded, so a missing tool is a named prerequisite rather
# than a merge that is armed and then refused for an unexplained reason.
GITLAB_MISSING=
if [ "$PROVIDER" = gitlab ]; then
command -v glab >/dev/null 2>&1 || GITLAB_MISSING="glab"
if ! command -v jq >/dev/null 2>&1; then
GITLAB_MISSING="${GITLAB_MISSING:+$GITLAB_MISSING and }jq"
fi
if [ -n "$GITLAB_MISSING" ]; then
echo "error: merging a GitLab merge request requires $GITLAB_MISSING on PATH" >&2
exit 1
fi
fi

# The recorded head is read before bin/fm-pr-check.sh rewrites the metadata,
# because that script re-records pr= and drops a pr_head= it cannot resolve.
RECORDED_HEAD=
if [ "$PROVIDER" = gitlab ]; then
RECORDED_HEAD=$(grep '^pr_head=' "$META" | tail -1 | cut -d= -f2- || true)
fi

"$SCRIPT_DIR/fm-pr-check.sh" "$ID" "$URL"
grep -qxF "pr=$URL" "$META" || {
echo "error: PR metadata recording failed" >&2
exit 1
}

# Pre-merge conditions for a GitLab merge request, read from one live view of
# the merge request. Sets FM_PR_MERGE_HEAD to the verified head on success and
# returns non-zero after reporting every condition that failed.
FM_PR_MERGE_HEAD=
gitlab_verify_mergeable() {
local json fields line
local total=0 named=0 refusals=''
local state='' detail='' conflicts='' discussions=''
local live_head='' pipeline_sha='' pipeline_status=''

# GITLAB_HOST is set to the same host the project URL already carries, so the
# instance is taken from the parsed URL by both signals and never from the
# operator's configured default.
if ! json=$(GITLAB_HOST="$FM_PR_HOST" glab mr view "$PR_NUMBER" -R "$PROJECT_URL" -F json 2>/dev/null) \
|| [ -z "$json" ]; then
echo "error: could not read the GitLab merge request state before merging" >&2
return 1
fi
# One named field per line. The names keep a trailing empty value readable
# after command substitution strips blank lines, and an absent or null field
# becomes an empty string or the literal "null", neither of which satisfies any
# check below, so an unreadable field refuses the merge instead of passing it.
if ! fields=$(printf '%s' "$json" | jq -r '
if type == "object" then
"state=" + ((.state // "") | tostring),
"detail=" + ((.detailed_merge_status // "") | tostring),
"conflicts=" + (.has_conflicts | tostring),
"discussions=" + (.blocking_discussions_resolved | tostring),
"head=" + ((.sha // "") | tostring),
"pipeline_sha=" + ((.head_pipeline.sha // "") | tostring),
"pipeline_status=" + ((.head_pipeline.status // "") | tostring)
else
error("merge request payload is not an object")
end' 2>/dev/null); then
echo "error: could not read the GitLab merge request state before merging" >&2
return 1
fi
while IFS= read -r line; do
total=$((total + 1))
case "$line" in
state=*) state=${line#state=} ;;
detail=*) detail=${line#detail=} ;;
conflicts=*) conflicts=${line#conflicts=} ;;
discussions=*) discussions=${line#discussions=} ;;
head=*) live_head=${line#head=} ;;
pipeline_sha=*) pipeline_sha=${line#pipeline_sha=} ;;
pipeline_status=*) pipeline_status=${line#pipeline_status=} ;;
*) continue ;;
esac
named=$((named + 1))
done <<FIELDS
$fields
FIELDS
# Every field named exactly once and no unnamed line: a value carrying a
# newline would split into a line no name matches, so it is refused here
# rather than silently truncated into a value a check could accept.
if [ "$named" -ne 7 ] || [ "$total" -ne 7 ]; then
echo "error: could not read the GitLab merge request state before merging" >&2
return 1
fi

if ! fm_pr_head_valid "$live_head"; then
echo "error: could not read the GitLab merge request head commit before merging" >&2
return 1
fi
# A rebase moves the head and leaves the recorded value behind, so the
# disagreement is reported and the live head is what gets verified and merged.
if [ -n "$RECORDED_HEAD" ] && [ "$RECORDED_HEAD" != "$live_head" ]; then
printf 'notice: recorded head %s disagrees with the live head %s; verifying the live head\n' \
"$RECORDED_HEAD" "$live_head" >&2
fi

[ "$state" = opened ] \
|| refusals="$refusals - state is \"${state:-unreadable}\", not open
"
[ "$detail" = mergeable ] \
|| refusals="$refusals - detailed_merge_status is \"${detail:-unreadable}\", not mergeable
"
[ "$conflicts" = false ] \
|| refusals="$refusals - has_conflicts is \"${conflicts:-unreadable}\", not false
"
[ "$discussions" = true ] \
|| refusals="$refusals - blocking_discussions_resolved is \"${discussions:-unreadable}\", not true
"
[ "$pipeline_status" = success ] \
|| refusals="$refusals - the head pipeline status is \"${pipeline_status:-none}\", not success
"
[ "$pipeline_sha" = "$live_head" ] \
|| refusals="$refusals - the head pipeline ran at \"${pipeline_sha:-none}\", not at the current head $live_head
"

if [ -n "$refusals" ]; then
printf 'error: refusing to merge %s\n' "$URL" >&2
printf '%s' "$refusals" >&2
return 1
fi
printf 'verified: %s is open and mergeable, with a successful pipeline at head %s\n' \
"$URL" "$live_head" >&2
FM_PR_MERGE_HEAD=$live_head
}
merge_args=()
if ! caller_has_merge_method "$@"; then
merge_args=(--squash)
fi

case "$PROVIDER" in
github)
merge_args=()
if ! caller_has_merge_method "$@"; then
merge_args=(--squash)
fi
gh-axi pr merge "$PR_NUMBER" --repo "$PR_OWNER/$PR_REPO" "${merge_args[@]+"${merge_args[@]}"}" "$@"
;;
gitlab)
gitlab_verify_mergeable || exit 1
# --sha binds the merge to the head this run verified, so a push that lands
# in between is refused by GitLab instead of merged unverified. --yes only
# skips the interactive confirmation, which no supervised run can answer;
# the conditions above are what authorize the merge.
GITLAB_HOST="$FM_PR_HOST" glab mr merge "$PR_NUMBER" -R "$PROJECT_URL" \
--sha "$FM_PR_MERGE_HEAD" --yes "$@"
;;
*)
echo "error: invalid PR merge request" >&2
exit 2
;;
esac
gh-axi pr merge "$PR_NUMBER" --repo "$PR_OWNER/$PR_REPO" "${merge_args[@]+"${merge_args[@]}"}" "$@"
Loading
Loading