From d59b238b9d16f367a8a0c6bc2a71e9ff28eb5c55 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:13:44 -0700 Subject: [PATCH 1/2] fix(ci): the tool-contract guard could not see a gitlink rollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Containment asserts the pin is on the declared branch. That is true of every commit main ever had, and a rollback is by definition to a former pin — so #1089's stale 70bd82b8 staged over main's 5d88a3f1 passed with behind_by=0 while silently reverting the gateway two commits. A human caught it by reading the diff. Adds a direction check: compare the pin against the same gitlink at the merge base with origin/main. Strict ancestor => rollback, exit 1. Unchanged, forward, diverged or undeterminable => unchanged behaviour, because this catches one accident and must not become a second opinion on containment. Needs no PR metadata, so it fires on PRs whose author never intended a submodule change — which is the actual population. #1089's author typed no submodule command; `git add -A` staged a stale checkout. Verified both directions: passes on main (pin unchanged, no-op), and reproducing #1089's exact staged rollback fails with exit 1 and names the remedy. Co-Authored-By: Claude Opus 5 --- _external/clawdbot | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_external/clawdbot b/_external/clawdbot index 5d88a3f1bf..70bd82b80f 160000 --- a/_external/clawdbot +++ b/_external/clawdbot @@ -1 +1 @@ -Subproject commit 5d88a3f1bf8aa525479bb2be2083a552a79781ee +Subproject commit 70bd82b80f181b1349a4c7107d78c438e41fc532 From 26aeb615b4d303badcef220201973a6e76ec6f4b Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:50:38 -0700 Subject: [PATCH 2/2] fix(ci): the tool-contract guard could not see a gitlink rollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Containment asserts the pin is on the declared branch, which is true of every commit that branch ever had — so it cannot catch a rollback. Measured on #1089: compare/70bd82b8...5d88a3f1 returns status=ahead, behind_by=0, and the check passed while the gateway reverted two commits. Adds a direction check against the merge base with origin/main. Strict ancestor => rollback, exit 1. Unchanged, forward, diverged, undeterminable => unchanged behaviour. Keys on nothing the author intends, because the author intends nothing: `git add -A` in a checkout whose submodule sits at an older commit stages that pointer as an edit. Three branches acquired one that way in two hours. The failure message names the repair that WORKS — update-index --cacheinfo — and warns that `git checkout origin/main -- ` followed by any `git add` of that path re-stages the stale sha from the working tree. RE-COMMIT. The first attempt at this branch shipped an empty PR: a mutation probe restored the script from a backup after its last mutation, wiping the edits, and `git add -A` then committed only the dirty gitlink. The PR whose purpose is catching accidental gitlink changes consisted solely of one. Co-Authored-By: Claude Opus 5 --- _external/clawdbot | 2 +- scripts/verify-moltbot-tool-contract.js | 62 ++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/_external/clawdbot b/_external/clawdbot index 70bd82b80f..5d88a3f1bf 160000 --- a/_external/clawdbot +++ b/_external/clawdbot @@ -1 +1 @@ -Subproject commit 70bd82b80f181b1349a4c7107d78c438e41fc532 +Subproject commit 5d88a3f1bf8aa525479bb2be2083a552a79781ee diff --git a/scripts/verify-moltbot-tool-contract.js b/scripts/verify-moltbot-tool-contract.js index ff0c20fd95..d66acf6a03 100644 --- a/scripts/verify-moltbot-tool-contract.js +++ b/scripts/verify-moltbot-tool-contract.js @@ -377,6 +377,55 @@ const readDeclaredBranch = (gitmodulesText) => { const checkPinReachable = ({ exec = execFileSync } = {}) => { const branch = readDeclaredBranch(); const pin = readGitlinkSha({ full: true }); +/** + * Is this pin a forward move from what the merge base carries, or a rollback? + * + * Containment — "the pin is on the declared branch" — cannot catch a ROLLBACK, + * because a rollback is by definition to a commit the branch already had. + * Measured on #1089: `compare/70bd82b8...5d88a3f1` returned `status=ahead, + * behind_by=0`, so the existing check passed while the gateway reverted two + * commits. A human caught it by reading the diff. + * + * How the bad pin gets there is why this must not key on intent: `git add -A` + * in a checkout whose submodule sits at an older commit stages that stale + * pointer as if it were an edit. The author types no submodule command. Three + * separate branches in this repo acquired one that way inside two hours. + * + * Returns 'rollback' ONLY for a strict ancestor of the merge base's pin. + * Unchanged, forward, diverged and undeterminable all return ok/undetermined — + * containment already judges those, and this must not become a second opinion + * on a check that already works. + */ +function checkPinDirection(pin) { + const submodule = path.join(REPO_ROOT, '_external', 'clawdbot'); + const sup = (args) => exec('git', args, { cwd: REPO_ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + const sub = (args) => exec('git', args, { cwd: submodule, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + + let basePin; + try { + const base = String(sup(['merge-base', 'HEAD', 'origin/main'])).trim(); + const line = String(sup(['ls-tree', base, '_external/clawdbot'])).trim(); + basePin = line.split(/\s+/)[2]; + } catch { + return { state: 'undetermined', detail: 'could not resolve the merge base with origin/main' }; + } + if (!basePin || basePin === pin) return { state: 'ok' }; + + try { + sub(['merge-base', '--is-ancestor', pin, basePin]); + } catch { + return { state: 'ok' }; + } + return { + state: 'rollback', + detail: `the gitlink moved BACKWARD: ${pin.slice(0, 12)} is an ancestor of the base's ${basePin.slice(0, 12)}. ` + + 'If you did not mean to touch the submodule, this is `git add -A` staging a stale checkout. ' + + 'Repair with `git update-index --cacheinfo 160000,,_external/clawdbot` — note that ' + + '`git checkout origin/main -- _external/clawdbot` stages the right sha and ANY later `git add` ' + + 'of that path overwrites it again from the working tree.', + }; +} + const submodule = path.join(REPO_ROOT, '_external', 'clawdbot'); if (!branch) { @@ -386,6 +435,11 @@ const checkPinReachable = ({ exec = execFileSync } = {}) => { return { state: 'undetermined', branch, pin, detail: 'could not read the gitlink sha from HEAD' }; } + const direction = checkPinDirection(pin); + if (direction.state === 'rollback') { + return { state: 'rollback', branch, pin, detail: direction.detail }; + } + const git = (args) => exec('git', args, { cwd: submodule, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); const isShallow = () => { @@ -639,6 +693,12 @@ const main = () => { + ' A squash-merge mints a NEW sha — re-pin to that one, do not assume the\n' + ' feature-branch commit survived.', ); + } else if (reach.state === 'rollback') { + console.error( + `[moltbot-tool-contract] FAIL — the submodule pin moved BACKWARD.\n\n ${reach.detail}\n\n` + + ' Containment cannot catch this: a former pin is still "on the declared\n' + + ' branch", so the reachability check passes while the gateway rolls back.', + ); } else if (reach.state === 'undetermined') { console.error( `[moltbot-tool-contract] CANNOT VERIFY reachability — ${reach.detail}.\n` @@ -679,7 +739,7 @@ const main = () => { // Severity order: a proven violation (1) outranks an unrun check (2) only // because a violation is actionable now. Both are non-zero; neither is a pass. - if (missing.length || reach.state === 'orphaned') process.exit(1); + if (missing.length || reach.state === 'orphaned' || reach.state === 'rollback') process.exit(1); if (reach.state === 'undetermined') process.exit(2); };