Skip to content

Commit 6dd3c68

Browse files
fix(repair): verify pushed revision safely
1 parent 50063d4 commit 6dd3c68

2 files changed

Lines changed: 51 additions & 7 deletions

File tree

src/adapter.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2127,7 +2127,10 @@ async function maybeRunRepair(config: AdapterConfig, taskId: string, inputTask?:
21272127
} else if (live.headSha !== preparedCommit || live.baseSha !== evidenceRevision.baseSha) {
21282128
return repairStop(path, task, "repair_stale_head_during_push_recovery");
21292129
}
2130-
const liveAfter = await currentPullRevision(String(task.repository), prNumber, repairToken);
2130+
const liveAfter = await waitForExpectedPullRevision(
2131+
() => currentPullRevision(String(task.repository), prNumber, repairToken),
2132+
{headSha: preparedCommit, baseSha: evidenceRevision.baseSha},
2133+
);
21312134
if (liveAfter.headSha !== preparedCommit || liveAfter.baseSha !== evidenceRevision.baseSha) return repairStop(path, task, "repair_pushed_revision_unverified");
21322135
const followupTaskId = await createFollowupReviewTask(config, task, currentPolicy, preparedCommit, liveAfter.baseSha, String(task.repair_finding_signature || signature), preparedCommit);
21332136
task.repair_state = "repair_committed";
@@ -2260,7 +2263,10 @@ async function maybeRunRepair(config: AdapterConfig, taskId: string, inputTask?:
22602263
const push = runCommand([config.hostGitBin, "-c", "core.hooksPath=/dev/null", "push", "origin", `HEAD:refs/heads/${headRef}`], repairWorkspace, gitEnv, 180);
22612264
writeJsonAtomic(join(artifactDir, "repair-push.json"), redactedCommandResult(push));
22622265
if (push.returncode !== 0) return repairStop(path, task, "repair_push_failed", push.stderr);
2263-
const liveAfterPush = await currentPullRevision(String(task.repository), prNumber, repairToken);
2266+
const liveAfterPush = await waitForExpectedPullRevision(
2267+
() => currentPullRevision(String(task.repository), prNumber, repairToken),
2268+
{headSha: commitSha, baseSha: evidenceRevision.baseSha},
2269+
);
22642270
if (liveAfterPush.headSha !== commitSha || liveAfterPush.baseSha !== evidenceRevision.baseSha) return repairStop(path, task, "repair_pushed_revision_unverified");
22652271
const followupTaskId = await createFollowupReviewTask(config, task, refreshedPolicy, commitSha, liveAfterPush.baseSha, signature, commitSha);
22662272
task.repair_state = "repair_committed";
@@ -3658,7 +3664,7 @@ interface SubmittedReview {
36583664
staleEvidence: boolean;
36593665
}
36603666

3661-
interface PullRevision {
3667+
export interface PullRevision {
36623668
headSha: string;
36633669
baseSha: string;
36643670
}
@@ -3672,6 +3678,24 @@ function samePullRevision(left: PullRevision, right: PullRevision): boolean {
36723678
return Boolean(left.headSha && left.baseSha && left.headSha === right.headSha && left.baseSha === right.baseSha);
36733679
}
36743680

3681+
export async function waitForExpectedPullRevision(
3682+
readRevision: () => Promise<PullRevision>,
3683+
expected: PullRevision,
3684+
attempts = 6,
3685+
delayMs = 500,
3686+
): Promise<PullRevision> {
3687+
const boundedAttempts = Math.max(1, Math.min(20, Math.trunc(attempts)));
3688+
let latest: PullRevision = {headSha: "", baseSha: ""};
3689+
for (let attempt = 0; attempt < boundedAttempts; attempt += 1) {
3690+
latest = await readRevision();
3691+
if (samePullRevision(latest, expected)) return latest;
3692+
if (attempt + 1 < boundedAttempts && delayMs > 0) {
3693+
await new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs));
3694+
}
3695+
}
3696+
return latest;
3697+
}
3698+
36753699
async function currentPullRevision(repo: string, prNumber: number, token: string): Promise<PullRevision> {
36763700
const pr = (await githubRequest("GET", `https://api.github.com/repos/${repo}/pulls/${prNumber}`, token)) as JsonObject;
36773701
return {
@@ -4562,7 +4586,7 @@ export function runtimeDiagnostic(result: CommandResult): string | null {
45624586
return `Coven Code exited ${result.returncode} without a diagnostic.`;
45634587
}
45644588
const safe = redactTokenish(raw)
4565-
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[redacted email]")
4589+
.replace(/[A-Z0-9._%+-]+(?:\[[A-Z0-9_-]+\])?@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[redacted email]")
45664590
.replace(/\bon account\s+[^\n.]+/gi, "on the configured account")
45674591
.replace(/\s+/g, " ")
45684592
.trim();
@@ -4604,7 +4628,7 @@ export function redactTokenish(text: string): string {
46044628
.replace(/\b(?:gh[pousr]_|github_pat_)[A-Za-z0-9_-]{6,}/g, "[redacted github token]")
46054629
.replace(/\bsk-(?:proj-)?[A-Za-z0-9_-]{8,}/g, "[redacted OpenAI token]")
46064630
.replace(/\bBearer\s+[^\s'\"]+/gi, "Bearer [redacted]")
4607-
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[redacted email]")
4631+
.replace(/[A-Z0-9._%+-]+(?:\[[A-Z0-9_-]+\])?@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[redacted email]")
46084632
.replace(/\bon account\s+`?[^`\n.]+`?/gi, "on the configured account")
46094633
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[redacted JWT]");
46104634
}

tests/webhook-adapter.test.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
sanitizedRuntimeEnvironment,
4242
sessionBrief,
4343
trustedValidationFindings,
44+
waitForExpectedPullRevision,
4445
withEphemeralCodexCredential,
4546
type JsonObject,
4647
type JsonValue,
@@ -805,6 +806,25 @@ test("a successful repair creates one deterministic new-SHA follow-up task with
805806
assert.equal((followup.repair_history as JsonObject[])[0].finding_signature, "finding-signature");
806807
});
807808

809+
test("repair verification waits for GitHub to expose the pushed head", async () => {
810+
const expected = {headSha: "c".repeat(40), baseSha: "b".repeat(40)};
811+
let reads = 0;
812+
const observed = await waitForExpectedPullRevision(async () => {
813+
reads += 1;
814+
return reads < 3 ? {headSha: "a".repeat(40), baseSha: expected.baseSha} : expected;
815+
}, expected, 6, 0);
816+
assert.deepEqual(observed, expected);
817+
assert.equal(reads, 3);
818+
819+
reads = 0;
820+
const stale = await waitForExpectedPullRevision(async () => {
821+
reads += 1;
822+
return {headSha: "a".repeat(40), baseSha: expected.baseSha};
823+
}, expected, 2, 0);
824+
assert.equal(stale.headSha, "a".repeat(40));
825+
assert.equal(reads, 2);
826+
});
827+
808828
test("repair eligibility fails closed for forks, protected branches, limits, repeats, and kill switches", () => {
809829
const finding = {severity: "high", file: "src/app.ts", line: 12, title: "Validate input", body: "Missing validation.", recommendation: "Validate it."};
810830
const result = completeReview([finding]);
@@ -3279,7 +3299,7 @@ test("redacts credentials and passes only allowlisted ambient environment keys",
32793299
const redacted = redactTokenish(secretText);
32803300
assert.doesNotMatch(redacted, /1234567890|topsecret|password|private-data|eyJabc|reviewer@example\.com|reviewer \(/);
32813301
const artifact = redactedCommandResult({
3282-
args: ["git", "-c", "user.email=reviewer@example.com", "https://x-access-token:ghs_1234567890@github.com/OpenCoven/example.git"],
3302+
args: ["git", "-c", "user.email=covencat[bot]@users.noreply.github.com", "https://x-access-token:ghs_1234567890@github.com/OpenCoven/example.git"],
32833303
returncode: 1,
32843304
stdout: "Bearer topsecret",
32853305
stderr: "reviewer@example.com",
@@ -3291,7 +3311,7 @@ test("redacts credentials and passes only allowlisted ambient environment keys",
32913311
output_limit_bytes: 1024,
32923312
spawn_error: "failed for reviewer@example.com",
32933313
});
3294-
assert.doesNotMatch(JSON.stringify(artifact), /1234567890|topsecret|reviewer@example\.com/);
3314+
assert.doesNotMatch(JSON.stringify(artifact), /1234567890|topsecret|covencat\[bot\]@users\.noreply\.github\.com/);
32953315
assert.deepEqual(artifact.args, ["git", "-c", "user.email=[redacted email]", "https://[redacted]@github.com/OpenCoven/example.git"]);
32963316
const env = sanitizedRuntimeEnvironment({
32973317
PATH: "/bin", LANG: "C.UTF-8", SSH_AUTH_SOCK: "/tmp/agent.sock",

0 commit comments

Comments
 (0)