Skip to content

Commit 83d7ab0

Browse files
fix(changelog): reject bot/app handles as Thanks attribution and require explicit human credit (#81357)
Summary: - The PR expands forbidden changelog `Thanks` attribution rules for bot/app handles, shares the Node predicate ... ngelog gate, requires explicit human credit for bot/app-authored changelog entries, and adds focused tests. - Reproducibility: yes. Current main source shows bot/app changelog authors can skip human attribution and bot/app `Thanks` handles are not all rejected; I did not execute tests because this review was read-only. Automerge notes: - PR branch already contained follow-up commit before automerge: fix: simplify bot changelog credit guard - PR branch already contained follow-up commit before automerge: fix: share changelog credit attribution rule - PR branch already contained follow-up commit before automerge: fix: tighten changelog attribution scanning - PR branch already contained follow-up commit before automerge: test: cover legacy changelog credit exclusions - PR branch already contained follow-up commit before automerge: fix: express changelog credit exclusions as union sets - PR branch already contained follow-up commit before automerge: fix: avoid substring changelog credit exclusions Validation: - ClawSweeper review passed for head 1e6d0f5. - Required merge gates passed before the squash merge. Prepared head SHA: 1e6d0f5 Review: #81357 (comment) Co-authored-by: Mason Huang <masonxhuang@tencent.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
1 parent 1f45b37 commit 83d7ab0

4 files changed

Lines changed: 262 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ Docs: https://docs.openclaw.ai
244244
- Codex app-server: keep the short post-tool completion watchdog armed across dynamic tool completion bookkeeping so embedded Codex runs fail fast and release their session lane when Codex goes quiet after a tool result. (#81697) Thanks @mbelinky.
245245
- Models: restore authenticated CLI runtime providers in the `/models` picker while keeping legacy runtime aliases hidden from setup/default model choices. Closes #81212. (#81239) Thanks @anagnorisis2peripeteia.
246246
- Agents/cron: honor a cron payload's explicit `timeoutSeconds` for the LLM idle watchdog even when it numerically equals `agents.defaults.timeoutSeconds`, preserving explicit per-run timeout intent and preventing stalled streaming replies from being cut to the implicit 120s cap. (#79426) Thanks @legolaz8451.
247+
- Changelog gates: reject bot/app handles as `Thanks` attribution and require explicit human credit for bot/app-authored changelog entries. (#81357) Thanks @hxy91819.
247248

248249
### Changes
249250

@@ -8650,7 +8651,7 @@ Docs: https://docs.openclaw.ai
86508651
- Gateway/Commands: keep webchat command authorization on the internal `webchat` context instead of inferring another provider from channel allowlists, fixing dropped `/new`/`/status` commands in Control UI when channel allowlists are configured. (#7189) Thanks @karlisbergmanis-lv.
86518652
- Control UI: prevent stored XSS via assistant name/avatar by removing inline script injection, serving bootstrap config as JSON, and enforcing `script-src 'self'`. Thanks @Adam55A-code.
86528653
- Agents/Security: sanitize workspace paths before embedding into LLM prompts (strip Unicode control/format chars) to prevent instruction injection via malicious directory names. Thanks @aether-ai-agent.
8653-
- Agents/Sandbox: clarify system prompt path guidance so sandbox `bash/exec` uses container paths (for example `/workspace`) while file tools keep host-bridge mapping, avoiding first-attempt path misses from host-only absolute paths in sandbox command execution. (#17693) Thanks @app/juniordevbot.
8654+
- Agents/Sandbox: clarify system prompt path guidance so sandbox `bash/exec` uses container paths (for example `/workspace`) while file tools keep host-bridge mapping, avoiding first-attempt path misses from host-only absolute paths in sandbox command execution. (#17693)
86548655
- Agents/Context: apply configured model `contextWindow` overrides after provider discovery so `lookupContextTokens()` honors operator config values (including discovery-failure paths). (#17404) Thanks @michaelbship and @vignesh07.
86558656
- Agents/Context: derive `lookupContextTokens()` from auth-available model metadata and keep the smallest discovered context window for duplicate model ids, preventing cross-provider cache collisions from overestimating session context limits. (#17586) Thanks @githabideri and @vignesh07.
86568657
- Agents/OpenAI: force `store=true` for direct OpenAI Responses/Codex runs to preserve multi-turn server-side conversation state, while leaving proxy/non-OpenAI endpoints unchanged. (#16803) Thanks @mark9232 and @vignesh07.

scripts/check-changelog-attributions.mjs

Lines changed: 85 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,99 @@ import fs from "node:fs";
44
import path from "node:path";
55
import { fileURLToPath } from "node:url";
66

7-
export const FORBIDDEN_CHANGELOG_THANKS_HANDLES = ["codex", "openclaw", "steipete"];
7+
export const FORBIDDEN_CHANGELOG_THANKS_HANDLES = [
8+
"codex",
9+
"openclaw",
10+
"steipete",
11+
"clawsweeper",
12+
"openclaw-clawsweeper",
13+
"clawsweeper[bot]",
14+
"openclaw-clawsweeper[bot]",
15+
];
16+
export const FORBIDDEN_CHANGELOG_THANKS_HANDLE_PREFIXES = ["app/"];
17+
export const FORBIDDEN_CHANGELOG_THANKS_HANDLE_SUFFIXES = ["[bot]"];
18+
export const CHANGELOG_THANKS_REQUIRE_HUMAN_CREDIT_HANDLES = [
19+
"clawsweeper",
20+
"openclaw-clawsweeper",
21+
"clawsweeper[bot]",
22+
"openclaw-clawsweeper[bot]",
23+
];
24+
export const CHANGELOG_THANKS_REQUIRE_HUMAN_CREDIT_HANDLE_PREFIXES = ["app/"];
25+
export const CHANGELOG_THANKS_REQUIRE_HUMAN_CREDIT_HANDLE_SUFFIXES = ["[bot]"];
826

9-
const HANDLE_PATTERN = FORBIDDEN_CHANGELOG_THANKS_HANDLES.join("|");
10-
const FORBIDDEN_THANKS_PATTERN = new RegExp(
11-
`\\bThanks\\b[^\\n]*@(${HANDLE_PATTERN})(?=\\b|[^A-Za-z0-9-])`,
12-
"iu",
13-
);
27+
const THANKS_PATTERN = /\bThanks\b/iu;
28+
const THANKED_HANDLE_PATTERN = /@([-_/A-Za-z0-9]+(?:\[bot\])?)/giu;
29+
30+
export function isForbiddenChangelogThanksHandle(handle, options = {}) {
31+
const { strictBotHandle = false } = options;
32+
const normalized = handle.toLowerCase();
33+
if (normalized === "" || normalized === "null") {
34+
// Empty/null input is not a GitHub handle, but the shell query path may pass it through.
35+
return true;
36+
}
37+
if (
38+
FORBIDDEN_CHANGELOG_THANKS_HANDLES.includes(normalized) ||
39+
FORBIDDEN_CHANGELOG_THANKS_HANDLE_PREFIXES.some((prefix) => normalized.startsWith(prefix)) ||
40+
FORBIDDEN_CHANGELOG_THANKS_HANDLE_SUFFIXES.some((suffix) => normalized.endsWith(suffix))
41+
) {
42+
return true;
43+
}
44+
if (strictBotHandle) {
45+
// PR-author checks should not reject a real human whose login merely contains a bot keyword.
46+
return false;
47+
}
48+
return false;
49+
}
50+
51+
export function requiresExplicitHumanChangelogThanks(handle) {
52+
const normalized = handle.toLowerCase();
53+
if (normalized === "" || normalized === "null") {
54+
return false;
55+
}
56+
return (
57+
CHANGELOG_THANKS_REQUIRE_HUMAN_CREDIT_HANDLES.includes(normalized) ||
58+
CHANGELOG_THANKS_REQUIRE_HUMAN_CREDIT_HANDLE_PREFIXES.some((prefix) =>
59+
normalized.startsWith(prefix),
60+
) ||
61+
CHANGELOG_THANKS_REQUIRE_HUMAN_CREDIT_HANDLE_SUFFIXES.some((suffix) =>
62+
normalized.endsWith(suffix),
63+
)
64+
);
65+
}
1466

1567
export function findForbiddenChangelogThanks(content) {
1668
return content
1769
.split(/\r?\n/u)
1870
.map((text, index) => {
19-
const match = text.match(FORBIDDEN_THANKS_PATTERN);
20-
return match ? { line: index + 1, handle: match[1].toLowerCase(), text } : null;
71+
if (!THANKS_PATTERN.test(text)) {
72+
return null;
73+
}
74+
// A single changelog line may thank multiple handles; scan all of them.
75+
for (const match of text.matchAll(THANKED_HANDLE_PATTERN)) {
76+
if (isForbiddenChangelogThanksHandle(match[1])) {
77+
return { line: index + 1, handle: match[1].toLowerCase(), text };
78+
}
79+
}
80+
return null;
2181
})
2282
.filter(Boolean);
2383
}
2484

2585
export async function main(argv = process.argv.slice(2)) {
86+
if (argv[0] === "--is-forbidden-handle") {
87+
process.exitCode = isForbiddenChangelogThanksHandle(argv[1] ?? "", {
88+
strictBotHandle: true,
89+
})
90+
? 0
91+
: 1;
92+
return;
93+
}
94+
95+
if (argv[0] === "--requires-explicit-human-thanks") {
96+
process.exitCode = requiresExplicitHumanChangelogThanks(argv[1] ?? "") ? 0 : 1;
97+
return;
98+
}
99+
26100
const changelogPath = argv[0] ?? "CHANGELOG.md";
27101
const absolutePath = path.resolve(process.cwd(), changelogPath);
28102
const content = fs.readFileSync(absolutePath, "utf8");
@@ -37,7 +111,9 @@ export async function main(argv = process.argv.slice(2)) {
37111
console.error(`- ${relativePath}:${violation.line} uses Thanks @${violation.handle}`);
38112
}
39113
console.error(
40-
`Use a credited external GitHub username instead of ${FORBIDDEN_CHANGELOG_THANKS_HANDLES.map((handle) => `@${handle}`).join(", ")}.`,
114+
`Use a credited external GitHub username instead of ${FORBIDDEN_CHANGELOG_THANKS_HANDLES.map(
115+
(handle) => `@${handle}`,
116+
).join(", ")}.`,
41117
);
42118
process.exitCode = 1;
43119
}

scripts/pr-lib/changelog.sh

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
changelog_helper_root() {
2+
cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd
3+
}
4+
5+
changelog_attribution_script() {
6+
printf '%s\n' "$(changelog_helper_root)/scripts/check-changelog-attributions.mjs"
7+
}
8+
19
normalize_pr_changelog_entries() {
210
local pr="$1"
311
local changelog_path="CHANGELOG.md"
@@ -156,23 +164,23 @@ EOF_NODE
156164
}
157165

158166
validate_changelog_attribution_policy() {
159-
node scripts/check-changelog-attributions.mjs CHANGELOG.md
167+
node "$(changelog_attribution_script)" CHANGELOG.md
160168
}
161169

162170
changelog_thanks_required_for_contributor() {
163171
local contrib="${1:-}"
164-
local normalized
165-
normalized=$(printf '%s' "$contrib" | tr '[:upper:]' '[:lower:]')
166-
167-
case "$normalized" in
168-
""|"null"|"app/"*|"codex"|"openclaw"|"clawsweeper"|"openclaw-clawsweeper"|"clawsweeper[bot]"|"openclaw-clawsweeper[bot]"|"steipete")
169-
return 1
170-
;;
171-
esac
172+
[ -n "$contrib" ] || return 1
173+
node "$(changelog_attribution_script)" --is-forbidden-handle "$contrib" && return 1
172174

173175
return 0
174176
}
175177

178+
changelog_explicit_human_thanks_required_for_contributor() {
179+
local contrib="${1:-}"
180+
[ -n "$contrib" ] || return 1
181+
node "$(changelog_attribution_script)" --requires-explicit-human-thanks "$contrib"
182+
}
183+
176184
validate_changelog_entry_for_pr() {
177185
local pr="$1"
178186
local contrib="$2"
@@ -339,7 +347,20 @@ END {
339347
return 0
340348
fi
341349

342-
echo "changelog validated: found PR #$pr (no eligible human contributor handle, skipping thanks check)"
350+
if ! changelog_explicit_human_thanks_required_for_contributor "$contrib"; then
351+
echo "changelog validated: found PR #$pr (no eligible human contributor handle, skipping thanks check)"
352+
return 0
353+
fi
354+
355+
local with_pr_and_any_thanks
356+
with_pr_and_any_thanks=$(printf '%s\n' "$added_lines" | rg -in "$pr_pattern" | rg -i '\bthanks[[:space:]]+@' || true)
357+
if [ -z "$with_pr_and_any_thanks" ]; then
358+
echo "CHANGELOG.md update for bot/app/non-creditable author $contrib must include an explicit human Thanks @handle on the PR #$pr entry line."
359+
echo "Choose the credited original contributor, or stop for maintainer input if authorship is unclear."
360+
exit 1
361+
fi
362+
363+
echo "changelog validated: found PR #$pr + explicit thanks for bot/app/non-creditable author $contrib"
343364
}
344365

345366
validate_changelog_merge_hygiene() {

test/scripts/check-changelog-attributions.test.ts

Lines changed: 144 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,61 @@
1-
import { readFileSync } from "node:fs";
1+
import { execFileSync } from "node:child_process";
2+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3+
import os from "node:os";
4+
import path from "node:path";
25
import { describe, expect, it } from "vitest";
3-
import { findForbiddenChangelogThanks } from "../../scripts/check-changelog-attributions.mjs";
6+
import {
7+
findForbiddenChangelogThanks,
8+
isForbiddenChangelogThanksHandle,
9+
requiresExplicitHumanChangelogThanks,
10+
} from "../../scripts/check-changelog-attributions.mjs";
11+
12+
const changelogScriptPath = path.join(process.cwd(), "scripts", "pr-lib", "changelog.sh");
13+
14+
function run(cwd: string, command: string, args: string[], env?: NodeJS.ProcessEnv): string {
15+
return execFileSync(command, args, {
16+
cwd,
17+
encoding: "utf8",
18+
env: env ? { ...process.env, ...env } : process.env,
19+
}).trim();
20+
}
21+
22+
function createRepoWithPrChangelogDiff(entry: string): string {
23+
const repo = mkdtempSync(path.join(os.tmpdir(), "openclaw-changelog-credit-"));
24+
run(repo, "git", ["init", "-q", "--initial-branch=main"]);
25+
run(repo, "git", ["config", "user.email", "test@example.com"]);
26+
run(repo, "git", ["config", "user.name", "Test User"]);
27+
writeFileSync(repo + "/CHANGELOG.md", "# Changelog\n\n## Unreleased\n\n### Fixes\n\n", "utf8");
28+
run(repo, "git", ["add", "CHANGELOG.md"]);
29+
run(repo, "git", ["commit", "-qm", "seed"]);
30+
const baseSha = run(repo, "git", ["rev-parse", "HEAD"]);
31+
// validate_changelog_entry_for_pr reads origin/main...HEAD, so the test
32+
// fixture needs a real base ref plus a feature-branch changelog diff.
33+
run(repo, "git", ["update-ref", "refs/remotes/origin/main", baseSha]);
34+
run(repo, "git", ["checkout", "-qb", "feature"]);
35+
writeFileSync(
36+
repo + "/CHANGELOG.md",
37+
`# Changelog\n\n## Unreleased\n\n### Fixes\n\n${entry}\n`,
38+
"utf8",
39+
);
40+
run(repo, "git", ["add", "CHANGELOG.md"]);
41+
run(repo, "git", ["commit", "-qm", "add changelog entry"]);
42+
return repo;
43+
}
44+
45+
function validateChangelogEntry(repo: string, contrib: string): string {
46+
return run(
47+
repo,
48+
"bash",
49+
[
50+
"-c",
51+
'source "$OPENCLAW_PR_CHANGELOG_SH"; validate_changelog_entry_for_pr 123 "$OPENCLAW_TEST_CONTRIB"',
52+
],
53+
{
54+
OPENCLAW_PR_CHANGELOG_SH: changelogScriptPath,
55+
OPENCLAW_TEST_CONTRIB: contrib,
56+
},
57+
);
58+
}
459

560
describe("check-changelog-attributions", () => {
661
it("flags forbidden bot, org, and maintainer thanks attributions", () => {
@@ -9,13 +64,19 @@ describe("check-changelog-attributions", () => {
964
"- Org-owned fix. Thanks @openclaw.",
1065
"- Maintainer-owned fix. Thanks @steipete.",
1166
"- Mixed credit. Thanks @contributor and @OpenClaw.",
67+
"- Bot repair. Thanks @clawsweeper[bot].",
68+
"- Dependency bump. Thanks @dependabot[bot].",
69+
"- App repair. Thanks @app/clawsweeper.",
1270
].join("\n");
1371

1472
expect(findForbiddenChangelogThanks(content)).toEqual([
1573
{ line: 1, handle: "codex", text: "- Internal cleanup. Thanks @codex." },
1674
{ line: 2, handle: "openclaw", text: "- Org-owned fix. Thanks @openclaw." },
1775
{ line: 3, handle: "steipete", text: "- Maintainer-owned fix. Thanks @steipete." },
1876
{ line: 4, handle: "openclaw", text: "- Mixed credit. Thanks @contributor and @OpenClaw." },
77+
{ line: 5, handle: "clawsweeper[bot]", text: "- Bot repair. Thanks @clawsweeper[bot]." },
78+
{ line: 6, handle: "dependabot[bot]", text: "- Dependency bump. Thanks @dependabot[bot]." },
79+
{ line: 7, handle: "app/clawsweeper", text: "- App repair. Thanks @app/clawsweeper." },
1980
]);
2081
});
2182

@@ -27,6 +88,82 @@ describe("check-changelog-attributions", () => {
2788
).toStrictEqual([]);
2889
});
2990

91+
it("checks every thanked handle on a changelog line", () => {
92+
expect(
93+
findForbiddenChangelogThanks("- Mixed credit (#123). Thanks @openclaw and @alice."),
94+
).toEqual([
95+
{
96+
line: 1,
97+
handle: "openclaw",
98+
text: "- Mixed credit (#123). Thanks @openclaw and @alice.",
99+
},
100+
]);
101+
});
102+
103+
it("uses one attribution predicate for scanner and shell checks", () => {
104+
expect(isForbiddenChangelogThanksHandle("")).toBe(true);
105+
expect(isForbiddenChangelogThanksHandle("null")).toBe(true);
106+
expect(isForbiddenChangelogThanksHandle("app/any-bot")).toBe(true);
107+
expect(isForbiddenChangelogThanksHandle("codex")).toBe(true);
108+
expect(isForbiddenChangelogThanksHandle("openclaw")).toBe(true);
109+
expect(isForbiddenChangelogThanksHandle("steipete")).toBe(true);
110+
expect(isForbiddenChangelogThanksHandle("app/clawsweeper")).toBe(true);
111+
expect(isForbiddenChangelogThanksHandle("clawsweeper")).toBe(true);
112+
expect(isForbiddenChangelogThanksHandle("clawsweeper[bot]")).toBe(true);
113+
expect(isForbiddenChangelogThanksHandle("openclaw-clawsweeper")).toBe(true);
114+
expect(isForbiddenChangelogThanksHandle("openclaw-clawsweeper[bot]")).toBe(true);
115+
expect(isForbiddenChangelogThanksHandle("dependabot[bot]")).toBe(true);
116+
expect(isForbiddenChangelogThanksHandle("dependabot[bot]", { strictBotHandle: true })).toBe(
117+
true,
118+
);
119+
expect(isForbiddenChangelogThanksHandle("alice")).toBe(false);
120+
expect(isForbiddenChangelogThanksHandle("human-clawsweeper-fan")).toBe(false);
121+
expect(
122+
isForbiddenChangelogThanksHandle("human-clawsweeper-fan", { strictBotHandle: true }),
123+
).toBe(false);
124+
125+
expect(requiresExplicitHumanChangelogThanks("clawsweeper")).toBe(true);
126+
expect(requiresExplicitHumanChangelogThanks("clawsweeper[bot]")).toBe(true);
127+
expect(requiresExplicitHumanChangelogThanks("dependabot[bot]")).toBe(true);
128+
expect(requiresExplicitHumanChangelogThanks("app/clawsweeper")).toBe(true);
129+
expect(requiresExplicitHumanChangelogThanks("human-clawsweeper-fan")).toBe(false);
130+
expect(requiresExplicitHumanChangelogThanks("steipete")).toBe(false);
131+
expect(requiresExplicitHumanChangelogThanks("")).toBe(false);
132+
});
133+
134+
it("requires explicit human thanks for bot PR changelog entries", () => {
135+
const repo = createRepoWithPrChangelogDiff("- Bot repair (#123).");
136+
try {
137+
let output = "";
138+
try {
139+
validateChangelogEntry(repo, "dependabot[bot]");
140+
} catch (error) {
141+
output = String((error as { stdout?: unknown }).stdout ?? error);
142+
}
143+
expect(output).toContain("must include an explicit human Thanks @handle");
144+
} finally {
145+
rmSync(repo, { recursive: true, force: true });
146+
}
147+
});
148+
149+
it("accepts explicit human thanks for bot PR changelog entries", () => {
150+
const repo = createRepoWithPrChangelogDiff("- Bot repair (#123). Thanks @alice.");
151+
try {
152+
expect(validateChangelogEntry(repo, "dependabot[bot]")).toContain("explicit thanks");
153+
} finally {
154+
rmSync(repo, { recursive: true, force: true });
155+
}
156+
});
157+
158+
it("keeps non-bot forbidden contributors on the no-thanks fallback", () => {
159+
const repo = createRepoWithPrChangelogDiff("- Maintainer repair (#123).");
160+
try {
161+
expect(validateChangelogEntry(repo, "steipete")).toContain("skipping thanks check");
162+
} finally {
163+
rmSync(repo, { recursive: true, force: true });
164+
}
165+
});
166+
30167
it("keeps PR changelog gates on the same attribution policy", () => {
31168
const commonLib = readFileSync("scripts/pr-lib/common.sh", "utf8");
32169
const changelogLib = readFileSync("scripts/pr-lib/changelog.sh", "utf8");
@@ -36,10 +173,12 @@ describe("check-changelog-attributions", () => {
36173

37174
expect(commonLib).toContain("pr_contributor_allows_human_trailers");
38175
expect(commonLib).toContain("resolve_contributor_coauthor_email");
39-
expect(changelogLib).toContain("node scripts/check-changelog-attributions.mjs CHANGELOG.md");
176+
expect(changelogLib).toContain("changelog_attribution_script");
177+
expect(changelogLib).toContain("--is-forbidden-handle");
178+
expect(changelogLib).toContain("--requires-explicit-human-thanks");
40179
expect(changelogLib).toContain("changelog_thanks_required_for_contributor");
41-
expect(changelogLib).toContain('"app/"*');
42-
expect(changelogLib).toContain('"clawsweeper"');
180+
expect(changelogLib).toContain("changelog_explicit_human_thanks_required_for_contributor");
181+
expect(changelogLib).toContain("Choose the credited original contributor");
43182
expect(gates).toContain("validate_changelog_attribution_policy");
44183
expect(prepareCore).toContain("resolve_contributor_coauthor_email");
45184
expect(mergeLib).toContain("pr_contributor_allows_human_trailers");

0 commit comments

Comments
 (0)