Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,939 @@
#!/usr/bin/env node
// GitHub dependency-change guard: detects dependency files, manages override
// comments/labels, and can autoscrub lockfile-only PR changes.
import { appendFile, readFile } from "node:fs/promises";
import {
GITHUB_API_REQUEST_TIMEOUT_MS,
GITHUB_ERROR_BODY_MAX_BYTES,
GITHUB_RESPONSE_BODY_MAX_BYTES,
createGitHubApi,
createGuardApproverChecks,
createIssueMutationHelpers,
guardCommentHeadSha,
guardTrustedActorCandidates,
isCommentNewerThan,
readBoundedGitHubErrorText,
readBoundedGitHubJson,
} from "./guard-shared.mjs";
/** Marker used to identify dependency guard comments. */
export const dependencyChangeMarker = "<!-- openclaw:dependency-guard -->";
export const dependencyGraphGuardMarker = "<!-- openclaw:dependency-graph-guard -->";
export const dependencyChangedLabel = "dependencies-changed";
export const allowDependenciesCommand = "/allow-dependencies-change";
export {
GITHUB_API_REQUEST_TIMEOUT_MS,
GITHUB_ERROR_BODY_MAX_BYTES,
GITHUB_RESPONSE_BODY_MAX_BYTES,
readBoundedGitHubErrorText,
readBoundedGitHubJson,
};
const maxListedFiles = 25;
const autoscrubCommitMessage = "chore: remove dependency lockfile change";
const securityTeamSlug = process.env.OPENCLAW_SECURITY_TEAM_SLUG ?? "openclaw-secops";
const dependencyManifestFields = [
"dependencies",
"devDependencies",
"optionalDependencies",
"peerDependencies",
"peerDependenciesMeta",
"bundleDependencies",
"bundledDependencies",
"dependenciesMeta",
"overrides",
"resolutions",
"packageManager",
"workspaces",
"pnpm",
"name",
"version",
"engines",
"os",
"cpu",
"libc",
];
export function isDependencyFile(filename) {
return (
filename.endsWith("package-lock.json") ||
filename.endsWith("npm-shrinkwrap.json") ||
filename.endsWith("pnpm-lock.yaml") ||
filename === "pnpm-workspace.yaml" ||
filename.startsWith("patches/")
);
}
export function isDependencyManifest(filename) {
return filename.endsWith("package.json");
}
export function isPackageLockfile(filename) {
return (
filename.endsWith("pnpm-lock.yaml") ||
filename.endsWith("package-lock.json") ||
filename.endsWith("npm-shrinkwrap.json")
);
}
export function dependencyFieldChanges(baseManifest, headManifest) {
const changes = [];
for (const field of dependencyManifestFields) {
if (stableJson(baseManifest?.[field] ?? null) !== stableJson(headManifest?.[field] ?? null)) {
changes.push(field);
}
}
return changes;
}
export function shouldAutoscrubDependencyLockfiles({
dependencyFiles = [],
lockfileChanges,
dependencyManifestChanges = [],
}) {
return (
lockfileChanges.length > 0 &&
dependencyManifestChanges.length === 0 &&
dependencyFiles.every(isPackageLockfile)
);
}
export function canAutoscrubPullRequest({ owner, repo, pullRequest }) {
return autoscrubTargetRepository({ owner, repo, pullRequest }) !== null;
}
function autoscrubTargetRepository({ owner, repo, pullRequest }) {
const baseRepository = `${owner}/${repo}`;
const headRepository = pullRequest.head?.repo;
const headRepositoryName = headRepository?.full_name;
if (
typeof pullRequest.head?.ref === "string" &&
pullRequest.head.ref.length > 0 &&
typeof pullRequest.head?.sha === "string" &&
pullRequest.head.sha.length > 0
) {
if (headRepositoryName === baseRepository) {
return { owner, repo };
}
if (pullRequest.maintainer_can_modify === true && typeof headRepositoryName === "string") {
const [headOwner, headRepo] = headRepositoryName.split("/");
if (headOwner && headRepo) {
return { owner: headOwner, repo: headRepo };
}
}
}
return null;
}
function stableJson(value) {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return JSON.stringify(value);
}
const sorted = {};
for (const key of Object.keys(value).toSorted((left, right) => left.localeCompare(right))) {
sorted[key] = value[key];
}
return JSON.stringify(sorted);
}
export function sanitizeDisplayValue(value) {
return String(value)
.replace(/[\p{Cc}]/gu, "?")
.slice(0, 240);
}
export function markdownCode(value) {
return `\`${sanitizeDisplayValue(value).replaceAll("`", "\\`")}\``;
}
function shellQuote(value) {
return `'${sanitizeDisplayValue(value).replaceAll("'", "'\\''")}'`;
}
function* dependencyOverrideCandidates({ comments, expectedSha, newerThan }) {
if (!expectedSha) {
return;
}
const commandPattern = /^\/allow-dependencies-change(?:\s+(.+))?$/gimu;
for (const comment of comments.toReversed()) {
const body = comment.body ?? "";
for (const match of body.matchAll(commandPattern)) {
const reason = match[1]?.trim();
const login = comment.user?.login;
if (!login || !isCommentNewerThan(comment, newerThan)) {
continue;
}
yield {
login,
reason: reason ? sanitizeDisplayValue(reason) : null,
sha: expectedSha,
url: comment.html_url,
};
}
}
}
export function findDependencyOverrideCommand({
comments,
expectedSha,
isSecurityMember,
newerThan,
}) {
for (const candidate of dependencyOverrideCandidates({ comments, expectedSha, newerThan })) {
if (isSecurityMember(candidate.login)) {
return candidate;
}
}
return null;
}
export async function findDependencyOverrideCommandAsync(input) {
for (const candidate of dependencyOverrideCandidates(input)) {
if (await input.isSecurityMember(candidate.login)) {
return candidate;
}
}
return null;
}
export function dependencyGuardCommentHeadSha(comment) {
return guardCommentHeadSha(comment);
}
export function dependencyOverrideExpectedSha(existingGuardComment, currentHeadSha) {
if (
!currentHeadSha ||
existingGuardComment?.body?.includes("### Dependency graph changes are blocked") !== true
) {
return null;
}
return dependencyGuardCommentHeadSha(existingGuardComment) === currentHeadSha
? currentHeadSha
: null;
}
export function isDependencyGuardAuthorizedForHead(comment, currentHeadSha) {
return (
Boolean(currentHeadSha) &&
comment?.body?.includes("### Dependency graph change authorized") === true &&
dependencyGuardCommentHeadSha(comment) === currentHeadSha
);
}
export function isDependencyGuardTrustedForHead(comment, currentHeadSha) {
return (
Boolean(currentHeadSha) &&
comment?.body?.includes("### Dependency graph changes noted") === true &&
dependencyGuardCommentHeadSha(comment) === currentHeadSha
);
}
export function securityApproverSet(value) {
return new Set(
String(value ?? "")
.split(/[\s,]+/u)
.map((login) => login.trim().toLowerCase())
.filter(Boolean),
);
}
export function dependencyGuardCommentAuthors(value) {
return new Set(
String(value ?? "github-actions[bot]")
.split(/[\s,]+/u)
.map((login) => login.trim().toLowerCase())
.filter(Boolean),
);
}
export function isDependencyGuardMarkerComment(comment, marker, trustedAuthors) {
const login = comment.user?.login?.toLowerCase();
return Boolean(login && trustedAuthors.has(login) && comment.body?.includes(marker));
}
export function renderDependencyAwarenessComment(dependencyFiles) {
const listedFiles = dependencyFiles.slice(0, maxListedFiles);
const omittedCount = dependencyFiles.length - listedFiles.length;
const fileLines = listedFiles.map((filename) => `- ${markdownCode(filename)}`);
if (omittedCount > 0) {
fileLines.push(`- ${omittedCount} additional dependency-related files not shown`);
}
return [
dependencyChangeMarker,
"",
"### Dependency Guard",
"",
"This PR changes dependency-related files. Maintainers should confirm these changes are intentional.",
"",
"Changed files:",
...fileLines,
"",
"Maintainer follow-up:",
"- Review whether the dependency changes are intentional.",
"- Inspect resolved package deltas when lockfile, shrinkwrap, or workspace dependency policy changes are present.",
"- Treat `package-lock.json` and `npm-shrinkwrap.json` diffs as security-review surfaces.",
"- Run `pnpm deps:changes:report -- --base-ref origin/main --markdown /tmp/dependency-changes.md --json /tmp/dependency-changes.json` locally for detailed release-style evidence.",
].join("\n");
}
export function renderAuthorizedDependencyComment(override) {
const lines = [
dependencyGraphGuardMarker,
"",
"### Dependency graph change authorized",
"",
"This PR includes dependency graph changes. A repository admin or member of `@openclaw/openclaw-secops` authorized this exact head SHA with `/allow-dependencies-change`.",
"",
`- Approved SHA: ${markdownCode(override.sha)}`,
`- Approved by: @${sanitizeDisplayValue(override.login)}`,
];
if (override.reason) {
lines.push(`- Reason: ${markdownCode(override.reason)}`);
}
lines.push("", "A later push changes the PR head SHA and requires a fresh security approval.");
return lines.join("\n");
}
export function renderTrustedDependencyComment({ actor, headSha }) {
return [
dependencyGraphGuardMarker,
"",
"### Dependency graph changes noted",
"",
"This PR includes dependency graph changes. The dependency guard is informational because the PR author is a repository admin or a member of `@openclaw/openclaw-secops`.",
"",
`- Current SHA: ${markdownCode(headSha ?? "<head-sha>")}`,
`- Trusted actor: @${sanitizeDisplayValue(actor.login)}`,
`- Trusted role: ${markdownCode(actor.reason)}`,
"",
"Security review is still recommended before merge when the dependency graph change is intentional.",
].join("\n");
}
export function renderAutoscrubbedDependencyComment({ baseBranch, lockfileChanges, commitSha }) {
const safeBranch = sanitizeDisplayValue(baseBranch ?? "main");
const fileLines = lockfileChanges.map((path) => `- ${markdownCode(path)}`);
return `${dependencyGraphGuardMarker}
### Dependency lockfile changes were removed
OpenClaw does not accept package lockfile changes through PRs. This PR did not change dependency graph fields in package manifests, so the workflow restored the lockfile residue from the target branch automatically.
Restored lockfiles:
${fileLines.join("\n")}
- Target branch: ${markdownCode(safeBranch)}
- Cleanup commit: ${markdownCode(commitSha)}
- Workflow action: restored each listed lockfile from the target branch and pushed the cleanup commit to this PR head.
- Verification result: this PR no longer carries those package lockfile diffs after the cleanup commit.
No action is needed unless this PR intentionally requires a dependency update. If it does, mention that in the PR and a maintainer will handle the dependency update internally.`;
}
export function isAutoscrubbedDependencyComment(comment) {
return comment?.body?.includes("### Dependency lockfile changes were removed") === true;
}
export function renderClearedDependencyGuardComment({ headSha }) {
return [
dependencyGraphGuardMarker,
"",
"### Dependency graph guard cleared",
"",
"This PR no longer has blocked dependency graph changes. A future dependency graph change requires a fresh `/allow-dependencies-change` comment after the guard blocks that new head SHA.",
"",
`- Current SHA: ${markdownCode(headSha ?? "<head-sha>")}`,
].join("\n");
}
export function renderBlockedDependencyComment({
baseBranch,
headSha,
lockfileChanges,
dependencyManifestChanges,
autoscrubStatus,
}) {
const safeBranch = sanitizeDisplayValue(baseBranch ?? "main");
const baseRef = shellQuote(`origin/${safeBranch}`);
const reasons = [];
for (const path of lockfileChanges) {
reasons.push(`- ${markdownCode(path)} changed.`);
}
for (const change of dependencyManifestChanges) {
reasons.push(renderManifestChangeLine(change));
}
const autoscrubLines = renderAutoscrubStatusLines(autoscrubStatus);
const removalSteps =
lockfileChanges.length > 0
? [
"",
"To remove lockfile changes, restore them from the target branch:",
"",
"```bash",
"git fetch origin",
`git checkout ${baseRef} -- ${lockfileChanges.map(shellQuote).join(" ")}`,
`git commit -m ${shellQuote(autoscrubCommitMessage)}`,
"git push",
"```",
]
: [];
return [
dependencyGraphGuardMarker,
"",
"### Dependency graph changes are blocked",
"",
"OpenClaw does not accept dependency graph changes through PRs unless a repository admin or security explicitly authorizes the current head SHA. Dependency updates are generated internally by maintainers so external PRs cannot change the resolved graph.",
"",
"Detected dependency graph changes:",
...reasons,
...autoscrubLines,
...removalSteps,
"",
"If this PR intentionally needs a dependency graph change, ask a repository admin or member of `@openclaw/openclaw-secops` to comment:",
"",
"```text",
allowDependenciesCommand,
"```",
"",
`The action will approve the current head SHA (${markdownCode(headSha ?? "<head-sha>")}) when it reruns. A later push requires a fresh approval.`,
].join("\n");
}
function renderAutoscrubStatusLines(status) {
if (!status) {
return [];
}
if (status.kind === "not-attempted") {
return [
"",
"Auto-scrub was not attempted because this workflow can only push deterministic cleanup commits to PR branches that maintainers can modify. Please remove the lockfile changes manually.",
];
}
if (status.kind === "blocked-by-dependency-manifest-fields") {
return [
"",
"Auto-scrub was not attempted because this PR changes package manifest dependency graph fields:",
...status.changes.map(renderManifestChangeLine),
"",
"Dependency graph changes must be reviewed by security or handled by maintainers internally. Please remove lockfile changes manually if they are not needed.",
];
}
if (status.kind === "blocked-by-other-dependency-files") {
return [
"",
"Auto-scrub was not attempted because this PR also changes dependency-related files that are not package lockfiles:",
...status.files.map((path) => `- ${markdownCode(path)}`),
"",
"Please remove lockfile changes manually if they are not needed.",
];
}
if (status.kind === "failed") {
return [
"",
`Auto-scrub was attempted, but GitHub rejected the cleanup commit: ${markdownCode(status.reason)}. Please remove the lockfile changes manually.`,
];
}
return [];
}
export function dependencyGuardTrustedActorCandidates({ pullRequest, event, currentHeadSha }) {
return guardTrustedActorCandidates({ pullRequest, event, currentHeadSha });
}
export async function findTrustedDependencyGuardActor({ candidates, isDependencyApprover }) {
for (const candidate of candidates) {
const role = await isDependencyApprover(candidate.login);
if (role) {
return {
login: candidate.login,
reason: `${candidate.source}; ${role}`,
};
}
}
return null;
}
function renderManifestChangeLine(change) {
return `- ${markdownCode(change.path)} changed ${change.fields.map(markdownCode).join(", ")}.`;
}
export function githubApi(token, options = {}) {
const api = createGitHubApi(token, { ...options, userAgent: "openclaw-dependency-guard" });
return {
...api,
graphql: async (query, variables) => {
const result = await api.request("/graphql", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ query, variables }),
});
if (Array.isArray(result.errors) && result.errors.length > 0) {
const error = new Error(
result.errors.map((entry) => entry.message ?? "GraphQL error").join("; "),
);
error.errors = result.errors;
throw error;
}
return result.data;
},
};
}
function decodeContentFile(payload) {
if (!payload || payload.type !== "file" || typeof payload.content !== "string") {
return null;
}
return Buffer.from(payload.content, payload.encoding ?? "base64").toString("utf8");
}
async function readJsonFileAtRef(api, { owner, repo, path, ref }) {
if (!ref) {
return null;
}
const encodedPath = path.split("/").map(encodeURIComponent).join("/");
const payload = await api
.request(`/repos/${owner}/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(ref)}`)
.catch((error) => {
if (error?.status === 404) {
return null;
}
throw error;
});
const text = decodeContentFile(payload);
return text ? JSON.parse(text) : null;
}
async function readContentFileMetadataAtRef(api, { owner, repo, path, ref }) {
if (!ref) {
return null;
}
const encodedPath = path.split("/").map(encodeURIComponent).join("/");
return api
.request(`/repos/${owner}/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(ref)}`)
.catch((error) => {
if (error?.status === 404) {
return null;
}
throw error;
});
}
async function readBase64FileAtRef(api, { owner, repo, path, ref }) {
const file = await readContentFileMetadataAtRef(api, { owner, repo, path, ref });
if (!file) {
return null;
}
if (file.encoding === "base64" && typeof file.content === "string" && file.content.length > 0) {
return file.content.replace(/\s+/gu, "");
}
if (typeof file.sha === "string" && file.sha.length > 0) {
const blob = await api.request(`/repos/${owner}/${repo}/git/blobs/${file.sha}`);
if (blob.encoding === "base64" && typeof blob.content === "string" && blob.content.length > 0) {
return blob.content.replace(/\s+/gu, "");
}
}
throw new Error(`Unable to read base64 file contents for ${path}`);
}
async function collectDependencyManifestChanges(api, { owner, repo, pullRequest, files }) {
const manifestPaths = files
.map((file) => file.filename)
.filter((filename) => typeof filename === "string" && isDependencyManifest(filename))
.toSorted((left, right) => left.localeCompare(right));
const changes = [];
for (const path of manifestPaths) {
const [baseManifest, headManifest] = await Promise.all([
readJsonFileAtRef(api, {
owner,
repo,
path,
ref: pullRequest.base?.sha,
}),
readJsonFileAtRef(api, {
owner,
repo,
path,
ref: pullRequest.head?.sha,
}),
]);
const fields = dependencyFieldChanges(baseManifest, headManifest);
if (fields.length > 0) {
changes.push({ path, fields });
}
}
return changes;
}
export async function createAutoscrubCommit(
{ baseApi, writeApi },
{ owner, repo, pullRequest, lockfileChanges, targetRepository },
) {
const headSha = pullRequest.head.sha;
const headRef = pullRequest.head.ref;
const writeOwner = targetRepository.owner;
const writeRepo = targetRepository.repo;
const additions = [];
const deletions = [];
for (const path of lockfileChanges) {
const contents = await readBase64FileAtRef(baseApi, {
owner,
repo,
path,
ref: pullRequest.base?.sha,
});
if (contents) {
additions.push({ path, contents });
} else {
deletions.push({ path });
}
}
const data = await writeApi.graphql(
`mutation CreateAutoscrubCommit($input: CreateCommitOnBranchInput!) {
createCommitOnBranch(input: $input) {
commit {
oid
}
}
}`,
{
input: {
branch: {
repositoryNameWithOwner: `${writeOwner}/${writeRepo}`,
branchName: headRef,
},
expectedHeadOid: headSha,
fileChanges: { additions, deletions },
message: { headline: autoscrubCommitMessage },
},
},
);
return { sha: data.createCommitOnBranch.commit.oid };
}
async function writeSummary(markdown) {
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
if (!summaryPath) {
console.log(markdown);
return;
}
await appendFile(summaryPath, `${markdown}\n`);
}
async function setOutput(name, value) {
const outputPath = process.env.GITHUB_OUTPUT;
if (!outputPath) {
return;
}
await appendFile(outputPath, `${name}=${value}\n`);
}
async function main() {
const token = process.env.GITHUB_TOKEN;
const eventPath = process.env.GITHUB_EVENT_PATH;
const repository = process.env.GITHUB_REPOSITORY;
if (!token || !eventPath || !repository) {
throw new Error("GITHUB_TOKEN, GITHUB_EVENT_PATH, and GITHUB_REPOSITORY are required.");
}
const [owner, repo] = repository.split("/");
const event = JSON.parse(await readFile(eventPath, "utf8"));
const eventPullRequest = event.pull_request;
if (!eventPullRequest) {
console.log("No pull_request payload found; skipping.");
return;
}
const api = githubApi(token);
const autoscrubToken = process.env.OPENCLAW_DEPENDENCY_GUARD_AUTOSCRUB_TOKEN;
const autoscrubApi = autoscrubToken ? githubApi(autoscrubToken) : null;
const explicitSecurityApprovers = securityApproverSet(process.env.OPENCLAW_SECURITY_APPROVERS);
const trustedCommentAuthors = dependencyGuardCommentAuthors(
process.env.OPENCLAW_DEPENDENCY_GUARD_COMMENT_BOTS,
);
const issuePath = `/repos/${owner}/${repo}/issues/${eventPullRequest.number}`;
const pullPath = `/repos/${owner}/${repo}/pulls/${eventPullRequest.number}`;
const pullRequest = await api.request(pullPath);
const mode = process.env.OPENCLAW_DEPENDENCY_GUARD_MODE ?? "enforce";
const files = await api.paginate(`${pullPath}/files`);
const dependencyFiles = files
.map((file) => file.filename)
.filter((filename) => typeof filename === "string" && isDependencyFile(filename))
.toSorted((left, right) => left.localeCompare(right));
const lockfileChanges = dependencyFiles.filter(isPackageLockfile);
const dependencyManifestChanges = await collectDependencyManifestChanges(api, {
owner,
repo,
pullRequest,
files,
});
const hasDependencyGraphChange =
lockfileChanges.length > 0 || dependencyManifestChanges.length > 0;
const dependencyGraphFiles = [
...dependencyFiles,
...dependencyManifestChanges.map((change) => change.path),
].toSorted((left, right) => left.localeCompare(right));
const [comments, labels] = await Promise.all([
api.paginate(`${issuePath}/comments`),
api.paginate(`${issuePath}/labels`),
]);
const findDependencyGuardComment = (marker) =>
comments.find((comment) =>
isDependencyGuardMarkerComment(comment, marker, trustedCommentAuthors),
);
let dependencyComment = findDependencyGuardComment(dependencyChangeMarker);
const existingGuardComment = findDependencyGuardComment(dependencyGraphGuardMarker);
const labelNames = new Set(labels.map((label) => label.name));
const { removeLabelIfPresent, addLabelIfMissing, deleteCommentIfPresent, upsertComment } =
createIssueMutationHelpers({ api, issuePath, owner, repo, labelNames });
if (dependencyGraphFiles.length === 0) {
await removeLabelIfPresent(dependencyChangedLabel);
await deleteCommentIfPresent(dependencyComment);
if (existingGuardComment && !isAutoscrubbedDependencyComment(existingGuardComment)) {
await upsertComment(
existingGuardComment,
renderClearedDependencyGuardComment({ headSha: pullRequest.head?.sha }),
);
}
await writeSummary("## Dependency Guard\n\nNo dependency-related file changes detected.");
console.log("No dependency-related file changes detected.");
return;
}
await addLabelIfMissing(dependencyChangedLabel);
dependencyComment = await upsertComment(
dependencyComment,
renderDependencyAwarenessComment(dependencyGraphFiles),
);
await writeSummary(
[
"## Dependency Guard",
"",
`Detected ${dependencyGraphFiles.length} dependency-related file change(s).`,
"",
...dependencyGraphFiles.map((filename) => `- ${markdownCode(filename)}`),
].join("\n"),
);
console.log(`Detected ${dependencyGraphFiles.length} dependency-related file change(s).`);
if (!hasDependencyGraphChange) {
if (existingGuardComment && !isAutoscrubbedDependencyComment(existingGuardComment)) {
await upsertComment(
existingGuardComment,
renderClearedDependencyGuardComment({ headSha: pullRequest.head?.sha }),
);
}
return;
}
const { isSecurityMember, isRepositoryAdmin } = createGuardApproverChecks({
api,
owner,
repo,
securityTeamSlug,
explicitSecurityApprovers,
});
const isDependencyApprover = async (login) => {
if (await isSecurityMember(login)) {
return securityTeamSlug;
}
if (await isRepositoryAdmin(login)) {
return "repository admin";
}
return null;
};
const currentHeadSha = pullRequest.head?.sha;
if (isDependencyGuardTrustedForHead(existingGuardComment, currentHeadSha)) {
if (mode === "detect") {
await setOutput("autoscrub", "false");
}
await writeSummary(
[
"## Dependency Guard",
"",
`Dependency graph change remains informational for a trusted actor at ${markdownCode(currentHeadSha)}.`,
].join("\n"),
);
console.log("Dependency graph change remains informational for this head SHA.");
return;
}
const trustedActor = await findTrustedDependencyGuardActor({
candidates: dependencyGuardTrustedActorCandidates({ pullRequest, event, currentHeadSha }),
isDependencyApprover,
});
if (trustedActor) {
if (mode === "detect") {
await setOutput("autoscrub", "false");
}
await upsertComment(
existingGuardComment,
renderTrustedDependencyComment({ actor: trustedActor, headSha: currentHeadSha }),
);
await writeSummary(
[
"## Dependency Guard",
"",
`Dependency graph change noted for trusted actor @${sanitizeDisplayValue(trustedActor.login)} and allowed to continue.`,
].join("\n"),
);
console.log("Dependency graph change noted for trusted actor; guard is informational.");
return;
}
const autoscrubCandidate = shouldAutoscrubDependencyLockfiles({
dependencyFiles,
lockfileChanges,
dependencyManifestChanges,
});
const autoscrubTarget = autoscrubCandidate
? autoscrubTargetRepository({ owner, repo, pullRequest })
: null;
if (mode === "detect" && autoscrubTarget) {
await setOutput("autoscrub", "true");
await setOutput("autoscrub-owner", autoscrubTarget.owner);
await setOutput("autoscrub-repository", autoscrubTarget.repo);
await writeSummary(
[
"## Dependency Guard",
"",
`Detected ${lockfileChanges.length} autoscrubbable package lockfile change(s).`,
"",
...lockfileChanges.map((filename) => `- ${markdownCode(filename)}`),
].join("\n"),
);
console.log("Detected autoscrubbable package lockfile changes.");
return;
}
if (mode === "detect") {
await setOutput("autoscrub", "false");
await writeSummary(
"## Dependency Guard\n\nDependency graph enforcement deferred to the final guard job.",
);
console.log("Dependency graph enforcement deferred to the final guard job.");
return;
}
let autoscrubStatus = null;
if (mode === "autoscrub") {
if (autoscrubTarget) {
try {
if (!autoscrubApi) {
throw new Error("autoscrub app token was unavailable");
}
const commit = await createAutoscrubCommit(
{ baseApi: api, writeApi: autoscrubApi },
{
owner,
repo,
pullRequest,
lockfileChanges,
targetRepository: autoscrubTarget,
},
);
await removeLabelIfPresent(dependencyChangedLabel);
await deleteCommentIfPresent(dependencyComment);
await upsertComment(
existingGuardComment,
renderAutoscrubbedDependencyComment({
baseBranch: pullRequest.base?.ref ?? "main",
lockfileChanges,
commitSha: commit.sha,
}),
);
await writeSummary(
[
"## Dependency Guard",
"",
`Removed ${lockfileChanges.length} package lockfile change(s) in ${markdownCode(commit.sha)}.`,
"",
...lockfileChanges.map((filename) => `- ${markdownCode(filename)}`),
].join("\n"),
);
console.log("Removed package lockfile changes with an autoscrub commit.");
return;
} catch (error) {
autoscrubStatus = {
kind: "failed",
reason: error instanceof Error ? error.message : String(error),
};
console.warn(`Autoscrub failed: ${autoscrubStatus.reason}`);
}
} else {
autoscrubStatus = { kind: "not-attempted" };
}
} else if (autoscrubCandidate && !autoscrubTarget) {
autoscrubStatus = { kind: "not-attempted" };
} else if (lockfileChanges.length > 0 && dependencyManifestChanges.length > 0) {
autoscrubStatus = {
kind: "blocked-by-dependency-manifest-fields",
changes: dependencyManifestChanges,
};
} else if (lockfileChanges.length > 0) {
const nonLockfileDependencyFiles = dependencyFiles.filter((path) => !isPackageLockfile(path));
if (nonLockfileDependencyFiles.length > 0) {
autoscrubStatus = {
kind: "blocked-by-other-dependency-files",
files: nonLockfileDependencyFiles,
};
}
}
if (isDependencyGuardAuthorizedForHead(existingGuardComment, currentHeadSha)) {
await writeSummary(
[
"## Dependency Guard",
"",
`Dependency graph change remains authorized for ${markdownCode(currentHeadSha)}.`,
].join("\n"),
);
console.log("Dependency graph change remains authorized for this head SHA.");
return;
}
const override = await findDependencyOverrideCommandAsync({
comments,
expectedSha: dependencyOverrideExpectedSha(existingGuardComment, currentHeadSha),
isSecurityMember: async (login) => Boolean(await isDependencyApprover(login)),
newerThan: existingGuardComment?.updated_at ?? existingGuardComment?.created_at,
});
if (override) {
await upsertComment(existingGuardComment, renderAuthorizedDependencyComment(override));
await writeSummary(
[
"## Dependency Guard",
"",
`Dependency graph change authorized by @${sanitizeDisplayValue(override.login)} for ${markdownCode(override.sha)}.`,
].join("\n"),
);
console.log("Dependency graph change authorized by trusted override.");
return;
}
await upsertComment(
existingGuardComment,
renderBlockedDependencyComment({
baseBranch: pullRequest.base?.ref ?? "main",
headSha: pullRequest.head?.sha,
lockfileChanges,
dependencyManifestChanges,
autoscrubStatus,
}),
);
await writeSummary(
"## Dependency Guard\n\nDependency graph changes are blocked without a current admin or secops override.",
);
throw new Error(
"Dependency graph changes require removal or a current admin or secops override.",
);
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch(
/** @param {unknown} error */ (error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
},
);
}

View File

@@ -0,0 +1,300 @@
import { readBoundedResponseText } from "../lib/bounded-response.mjs";
export const GITHUB_ERROR_BODY_MAX_BYTES = 64 * 1024;
export const GITHUB_RESPONSE_BODY_MAX_BYTES = 4 * 1024 * 1024;
export const GITHUB_API_REQUEST_TIMEOUT_MS = 30_000;
export function guardTrustedActorCandidates({ pullRequest, event, currentHeadSha }) {
const eventHeadSha = event?.pull_request?.head?.sha;
const eventAfterSha = event?.after;
const eventMatchesCurrentHead =
Boolean(currentHeadSha) &&
(eventHeadSha === currentHeadSha || eventAfterSha === currentHeadSha);
if (!eventMatchesCurrentHead) {
return [];
}
const candidates = [];
const seen = new Set();
for (const [source, login] of [["pull request author", pullRequest?.user?.login]]) {
if (typeof login !== "string" || login.length === 0) {
continue;
}
const normalizedLogin = login.toLowerCase();
if (seen.has(normalizedLogin)) {
continue;
}
seen.add(normalizedLogin);
candidates.push({ login, source });
}
return candidates;
}
export function isCommentNewerThan(comment, newerThan) {
if (!newerThan) {
return false;
}
const commentTime = Date.parse(comment.created_at ?? "");
const barrierTime = Date.parse(newerThan);
return Number.isFinite(commentTime) && Number.isFinite(barrierTime) && commentTime > barrierTime;
}
export function guardCommentHeadSha(comment) {
const body = comment?.body ?? "";
const patterns = [
/Approved SHA:\s+`([a-f0-9]{40})`/iu,
/current head SHA\s+\(`([a-f0-9]{40})`\)/iu,
/Current SHA:\s+`([a-f0-9]{40})`/iu,
];
for (const pattern of patterns) {
const match = body.match(pattern);
if (match?.[1]) {
return match[1];
}
}
return null;
}
export function createIssueMutationHelpers({
api,
issuePath,
owner,
repo,
labelNames,
warn = console.warn,
}) {
const ignoreUnavailableWritePermission = (action) => (error) => {
if (error?.status === 403) {
warn(`Skipping ${action}; token does not have write permission.`);
return;
}
if (error?.status === 404 || error?.status === 422) {
warn(`${action} is unavailable.`);
return;
}
throw error;
};
const removeLabelIfPresent = async (label) => {
if (!labelNames.has(label)) {
return;
}
await api
.request(`${issuePath}/labels/${encodeURIComponent(label)}`, {
method: "DELETE",
})
.catch(ignoreUnavailableWritePermission(`label "${label}" removal`));
labelNames.delete(label);
};
const addLabelIfMissing = async (label) => {
if (labelNames.has(label)) {
return;
}
await api
.request(`${issuePath}/labels`, {
method: "POST",
body: JSON.stringify({ labels: [label] }),
})
.catch(ignoreUnavailableWritePermission(`label "${label}" update`));
labelNames.add(label);
};
const deleteCommentIfPresent = async (comment) => {
if (!comment) {
return;
}
await api
.request(`/repos/${owner}/${repo}/issues/comments/${comment.id}`, {
method: "DELETE",
})
.catch(ignoreUnavailableWritePermission("comment deletion"));
};
const upsertComment = async (comment, body) => {
if (comment) {
return await api
.request(`/repos/${owner}/${repo}/issues/comments/${comment.id}`, {
method: "PATCH",
body: JSON.stringify({ body }),
})
.catch(ignoreUnavailableWritePermission("comment update"));
}
return await api
.request(`${issuePath}/comments`, {
method: "POST",
body: JSON.stringify({ body }),
})
.catch(ignoreUnavailableWritePermission("comment creation"));
};
return { removeLabelIfPresent, addLabelIfMissing, deleteCommentIfPresent, upsertComment };
}
export function createGuardApproverChecks({
api,
owner,
repo,
securityTeamSlug,
explicitSecurityApprovers,
warn = console.warn,
}) {
const membershipCache = new Map();
const permissionCache = new Map();
const isSecurityMember = async (login) => {
const normalizedLogin = login.toLowerCase();
if (explicitSecurityApprovers.has(normalizedLogin)) {
return true;
}
if (membershipCache.has(normalizedLogin)) {
return membershipCache.get(normalizedLogin);
}
try {
const membership = await api.request(
`/orgs/${owner}/teams/${securityTeamSlug}/memberships/${encodeURIComponent(login)}`,
);
const allowed = membership?.state === "active";
membershipCache.set(normalizedLogin, allowed);
return allowed;
} catch (error) {
if (error?.status !== 404) {
warn(`Could not verify ${login} against ${securityTeamSlug}: ${error.message}`);
}
membershipCache.set(normalizedLogin, false);
return false;
}
};
const isRepositoryAdmin = async (login) => {
const normalizedLogin = login.toLowerCase();
if (permissionCache.has(normalizedLogin)) {
return permissionCache.get(normalizedLogin);
}
try {
const result = await api.request(
`/repos/${owner}/${repo}/collaborators/${encodeURIComponent(login)}/permission`,
);
const allowed = result?.permission === "admin";
permissionCache.set(normalizedLogin, allowed);
return allowed;
} catch (error) {
if (error?.status !== 404) {
warn(`Could not verify repository permission for ${login}: ${error.message}`);
}
permissionCache.set(normalizedLogin, false);
return false;
}
};
return { isSecurityMember, isRepositoryAdmin };
}
function githubErrorBodyTooLarge(maxBytes) {
return new Error(`GitHub error response body exceeded ${maxBytes} bytes`);
}
function githubResponseBodyTooLarge(maxBytes) {
return new Error(`GitHub response body exceeded ${maxBytes} bytes`);
}
export async function readBoundedGitHubErrorText(
response,
maxBytes = GITHUB_ERROR_BODY_MAX_BYTES,
options = {},
) {
return await readBoundedResponseText(response, "GitHub error", maxBytes, {
createTooLargeError: () => githubErrorBodyTooLarge(maxBytes),
...options,
});
}
export async function readBoundedGitHubJson(
response,
maxBytes = GITHUB_RESPONSE_BODY_MAX_BYTES,
options = {},
) {
const text = await readBoundedResponseText(response, "GitHub", maxBytes, {
createTooLargeError: () => githubResponseBodyTooLarge(maxBytes),
...options,
});
return JSON.parse(text);
}
function timeoutError(path, method, timeoutMs) {
return new Error(`GitHub API ${method} ${path} exceeded timeout ${timeoutMs}ms`);
}
function combineAbortSignals(signals) {
const activeSignals = signals.filter(Boolean);
if (activeSignals.length === 0) {
return undefined;
}
if (activeSignals.length === 1) {
return activeSignals[0];
}
return AbortSignal.any(activeSignals);
}
export function createGitHubApi(token, options = {}) {
const fetchImpl = options.fetchImpl ?? fetch;
const timeoutMs = options.timeoutMs ?? GITHUB_API_REQUEST_TIMEOUT_MS;
const responseMaxBodyBytes = options.responseMaxBodyBytes ?? GITHUB_RESPONSE_BODY_MAX_BYTES;
const baseHeaders = {
accept: "application/vnd.github+json",
authorization: `Bearer ${token}`,
"user-agent": options.userAgent,
"x-github-api-version": "2022-11-28",
};
const request = async (path, requestOptions = {}) => {
const method = requestOptions.method ?? "GET";
const timeoutController = new AbortController();
let timeout;
const timeoutPromise = new Promise((_, reject) => {
timeout = setTimeout(() => {
timeoutController.abort();
reject(timeoutError(path, method, timeoutMs));
}, timeoutMs);
timeout.unref?.();
});
const operationPromise = (async () => {
const response = await fetchImpl(`https://api.github.com${path}`, {
...requestOptions,
signal: combineAbortSignals([requestOptions.signal, timeoutController.signal]),
headers: { ...baseHeaders, ...requestOptions.headers },
});
if (response.status === 204) {
return null;
}
if (!response.ok) {
let errorText;
try {
errorText = await readBoundedGitHubErrorText(response, GITHUB_ERROR_BODY_MAX_BYTES, {
signal: timeoutController.signal,
timeoutPromise,
});
} catch (bodyError) {
errorText = bodyError instanceof Error ? bodyError.message : String(bodyError);
}
const error = new Error(`${response.status} ${response.statusText}: ${errorText}`);
error.status = response.status;
throw error;
}
return await readBoundedGitHubJson(response, responseMaxBodyBytes, {
signal: timeoutController.signal,
timeoutPromise,
});
})();
operationPromise.catch(() => {});
try {
return await Promise.race([operationPromise, timeoutPromise]);
} finally {
clearTimeout(timeout);
}
};
return {
request,
paginate: async (path) => {
const items = [];
for (let page = 1; ; page += 1) {
const separator = path.includes("?") ? "&" : "?";
const pageItems = await request(`${path}${separator}per_page=100&page=${page}`);
items.push(...pageItems);
if (pageItems.length < 100) {
return items;
}
}
},
};
}

View File

@@ -0,0 +1,67 @@
#!/usr/bin/env node
// Checks external PR body context and evidence.
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import {
evaluatePullRequestContext,
isMaintainerTeamMember,
} from "./real-behavior-proof-policy.mjs";
function escapeCommandValue(value) {
return String(value)
.replace(/%/g, "%25")
.replace(/\r/g, "%0D")
.replace(/\n/g, "%0A")
.replace(/:/g, "%3A");
}
function isMainModule() {
return Boolean(process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href);
}
async function main(env = process.env) {
const eventPath = env.GITHUB_EVENT_PATH;
if (!eventPath) {
console.error("::error title=PR context check failed::GITHUB_EVENT_PATH is not set.");
process.exit(1);
}
const event = JSON.parse(readFileSync(eventPath, "utf8"));
const pullRequest = event.pull_request;
if (!pullRequest) {
console.log("No pull_request payload found; skipping PR context check.");
process.exit(0);
}
const appToken = env.GH_APP_TOKEN;
const org = event.repository?.owner?.login;
const authorLogin = pullRequest.user?.login;
if (appToken && org && authorLogin) {
try {
if (await isMaintainerTeamMember({ token: appToken, org, login: authorLogin })) {
console.log(
`PR author @${authorLogin} is an active member of the ${org}/maintainer team; skipping PR context check.`,
);
process.exit(0);
}
} catch (error) {
console.warn(
`::warning title=Maintainer membership check failed::${escapeCommandValue(error?.message ?? String(error))}`,
);
}
}
const evaluation = evaluatePullRequestContext({ pullRequest });
if (evaluation.passed) {
console.log(evaluation.reason);
process.exit(0);
}
const message = `${evaluation.reason} Add a concise problem statement and the most useful validation evidence to the PR body. Focused tests, CI results, screenshots, recordings, terminal output, live observations, redacted logs, and artifact links all count.`;
console.error(`::error title=PR context required::${escapeCommandValue(message)}`);
process.exit(1);
}
if (isMainModule()) {
await main();
}

View File

@@ -0,0 +1,464 @@
// Shared PR context and evidence policy for GitHub checks and label decisions.
import { readBoundedResponseText } from "../lib/bounded-response.mjs";
import { escapeRegExp } from "../lib/regexp.mjs";
/** ClawSweeper-owned labels that OpenClaw preserves but does not mutate. */
export const PROOF_OVERRIDE_LABEL = "proof: override";
export const PROOF_SUFFICIENT_LABEL = "proof: sufficient";
export const NEEDS_PR_CONTEXT_LABEL = "triage: needs-pr-context";
export const MAINTAINER_TEAM_SLUG = "maintainer";
export const DEFAULT_GITHUB_API_TIMEOUT_MS = 30_000;
export const GITHUB_API_RESPONSE_BODY_MAX_BYTES = 1024 * 1024;
export const CLAWSWEEPER_PROOF_VERDICT_STATUS = "clawsweeper_exact_head_pass";
const CLAWSWEEPER_BOT_LOGINS = new Set(["clawsweeper[bot]", "openclaw-clawsweeper[bot]"]);
const privilegedAuthorAssociations = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
// Existing open PRs still use the previous structured section. Remove these
// fallbacks once those PRs no longer need body revalidation.
const legacyProofFields = {
evidence: {
names: [
"Evidence after fix",
"After-fix evidence",
"Evidence link or embedded proof",
"Evidence",
],
},
problem: {
names: ["Behavior or issue addressed", "Issue addressed", "Behavior addressed"],
},
};
const legacyProofFieldNames = [
...legacyProofFields.problem.names,
"Real environment tested",
"Environment tested",
"Real setup tested",
"Exact steps or command run after this patch",
"Exact steps or command run after the patch",
"Exact steps or command run after fix",
"Steps run after the patch",
"Command run after the patch",
...legacyProofFields.evidence.names,
"Observed result after fix",
"Observed result after the fix",
"Observed result",
"What was not tested",
"Not tested",
"Before evidence",
"Before evidence optional",
];
const missingValueRegex =
/^(?:n\/?a|none|not applicable|tbd|todo|unknown|unsure|none provided|no evidence|not tested|untested|did not test|didn't test|could not test|couldn't test|-|(?:-{3,}|\*{3,}|_{3,})|\[[^\]]*\])\.?$/i;
function createTimeoutError(label, timeoutMs) {
const error = new Error(`${label} timed out after ${timeoutMs}ms`);
error.code = "ETIMEDOUT";
return error;
}
function createTooLargeGitHubApiBodyError(label, maxBytes) {
const error = new Error(`${label} response body exceeded ${maxBytes} bytes`);
error.code = "ETOOBIG";
return error;
}
export async function withGitHubApiTimeout(label, timeoutMs, run) {
const boundedTimeoutMs = Math.max(1, timeoutMs);
const controller = new AbortController();
const timeoutError = createTimeoutError(label, boundedTimeoutMs);
let timeout;
const timeoutPromise = new Promise((_, reject) => {
timeout = setTimeout(() => {
controller.abort(timeoutError);
reject(timeoutError);
}, boundedTimeoutMs);
timeout.unref?.();
});
try {
return await Promise.race([run(controller.signal), timeoutPromise]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
export async function readBoundedGitHubApiJson(
response,
label,
maxBytes = GITHUB_API_RESPONSE_BODY_MAX_BYTES,
options = {},
) {
const text = await readBoundedResponseText(response, label, maxBytes, {
...options,
createTooLargeError: () => createTooLargeGitHubApiBodyError(label, maxBytes),
});
return JSON.parse(text);
}
async function cancelGitHubApiResponseBody(response) {
await response.body?.cancel?.().catch(() => undefined);
}
function normalizeLineEndings(text = "") {
return text.replace(/\r\n?/g, "\n");
}
function maskHtmlComments(text) {
let commentOpen = false;
let fenceMarker = "";
return text
.split("\n")
.map((line) => {
if (fenceMarker) {
fenceMarker = nextFenceMarker(line, fenceMarker);
return line;
}
let maskedLine = line;
if (commentOpen) {
const end = maskedLine.indexOf("-->");
if (end < 0) {
return maskedLine.replace(/[^\n]/g, " ");
}
maskedLine = `${maskedLine.slice(0, end + 3).replace(/[^\n]/g, " ")}${maskedLine.slice(end + 3)}`;
commentOpen = false;
}
if (nextFenceMarker(maskedLine, "")) {
fenceMarker = nextFenceMarker(maskedLine, "");
return maskedLine;
}
let offset = 0;
while (offset < maskedLine.length) {
const start = maskedLine.indexOf("<!--", offset);
if (start < 0) {
break;
}
const end = maskedLine.indexOf("-->", start + 4);
if (end < 0) {
maskedLine = `${maskedLine.slice(0, start)}${maskedLine
.slice(start)
.replace(/[^\n]/g, " ")}`;
commentOpen = true;
break;
}
maskedLine = `${maskedLine.slice(0, start)}${maskedLine
.slice(start, end + 3)
.replace(/[^\n]/g, " ")}${maskedLine.slice(end + 3)}`;
offset = end + 3;
}
return maskedLine;
})
.join("\n");
}
function stripHtmlComments(text) {
return maskHtmlComments(text);
}
function isAutomationUser(user = {}, fallbackLogin = "") {
const login = user?.login ?? fallbackLogin;
return user?.type === "Bot" || /\[bot\]$/i.test(login) || login.startsWith("app/");
}
export function isExternalPullRequest(pullRequest) {
if (!pullRequest) {
return false;
}
if (isAutomationUser(pullRequest.user)) {
return false;
}
const authorAssociation = String(
pullRequest.author_association ?? pullRequest.authorAssociation ?? "",
).toUpperCase();
return !privilegedAuthorAssociations.has(authorAssociation);
}
export async function isMaintainerTeamMember({
token,
org,
login,
teamSlug = MAINTAINER_TEAM_SLUG,
fetch = globalThis.fetch,
timeoutMs = DEFAULT_GITHUB_API_TIMEOUT_MS,
} = {}) {
if (!token || !org || !login) {
return false;
}
const url = `https://api.github.com/orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(teamSlug)}/memberships/${encodeURIComponent(login)}`;
const response = await withGitHubApiTimeout(
`maintainer membership lookup for ${login}`,
timeoutMs,
(signal) =>
fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
signal,
}),
);
try {
if (response.status === 404) {
return false;
}
if (!response.ok) {
throw new Error(`Team membership lookup failed: ${response.status}`);
}
const body = await withGitHubApiTimeout(
`maintainer membership response for ${login}`,
timeoutMs,
(signal) =>
readBoundedGitHubApiJson(
response,
`maintainer membership response for ${login}`,
undefined,
{
signal,
},
),
);
return body?.state === "active";
} finally {
await cancelGitHubApiResponseBody(response);
}
}
function nextFenceMarker(line, fenceMarker) {
const fence = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
const marker = fence?.[1] ?? "";
const suffix = fence?.[2] ?? "";
if (!fenceMarker && marker) {
return marker;
}
if (
fenceMarker &&
marker[0] === fenceMarker[0] &&
marker.length >= fenceMarker.length &&
suffix.trim() === ""
) {
return "";
}
return fenceMarker;
}
function markdownHeadingLevel(line) {
return line.match(/^(#{1,6})\s+\S/)?.[1].length ?? 0;
}
function extractMarkdownSections(headingRegex, body = "") {
// Normalize CRLF → LF so regexes and section slicing see GitHub web-editor PR
// bodies the same way as locally-authored Markdown.
const normalizedBody = normalizeLineEndings(body);
const headingBody = maskHtmlComments(normalizedBody);
const sections = [];
const matcher = new RegExp(headingRegex.source, headingRegex.flags.replaceAll("g", ""));
let fenceMarker = "";
let sectionHeadingLevel = 0;
let sectionStart = -1;
let lineStart = 0;
for (const line of headingBody.split("\n")) {
const match = !fenceMarker ? line.match(matcher) : null;
const headingLevel = !fenceMarker ? markdownHeadingLevel(line) : 0;
if (sectionStart >= 0 && headingLevel > 0 && headingLevel <= sectionHeadingLevel) {
sections.push(normalizedBody.slice(sectionStart, lineStart === 0 ? 0 : lineStart - 1).trim());
sectionStart = -1;
sectionHeadingLevel = 0;
}
if (match) {
sectionStart = lineStart + (match.index ?? 0) + match[0].length;
sectionHeadingLevel = headingLevel;
}
fenceMarker = nextFenceMarker(line, fenceMarker);
lineStart += line.length + 1;
}
if (sectionStart >= 0) {
sections.push(normalizedBody.slice(sectionStart).trim());
}
return sections;
}
export function hasAuthoredPullRequestSection(heading, body = "") {
const headingPattern = new RegExp(`^#{2,6}\\s+${escapeRegExp(heading)}\\b[^\\n]*$`, "im");
return !isMissingValue(extractMarkdownSections(headingPattern, body).at(-1) ?? "");
}
function extractLegacyProofSections(body = "") {
return extractMarkdownSections(/^#{2,6}\s+real behavior proof\b[^\n]*$/im, body);
}
function fieldLineRegex(name) {
return new RegExp(
`^\\s*(?:[-*]\\s*)?(?:\\*\\*)?${escapeRegExp(name)}(?:\\s*\\([^)]*\\))?(?:\\*\\*)?\\s*:\\s*(.*)$`,
"i",
);
}
function legacyProofFieldLineValue(line) {
const matchingName = legacyProofFieldNames.find((name) => fieldLineRegex(name).test(line));
const match = matchingName ? line.match(fieldLineRegex(matchingName)) : null;
return match?.[1] ?? null;
}
function isAnyLegacyProofFieldLine(line) {
return legacyProofFieldLineValue(line) !== null;
}
function extractFieldValue(section, field) {
const lines = maskHtmlComments(normalizeLineEndings(section)).split("\n");
let fenceMarker = "";
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
const matchingName = !fenceMarker
? field.names.find((name) => fieldLineRegex(name).test(line))
: null;
if (!matchingName) {
const fenceLine = !fenceMarker ? (legacyProofFieldLineValue(line) ?? line) : line;
fenceMarker = nextFenceMarker(fenceLine, fenceMarker);
continue;
}
const match = line.match(fieldLineRegex(matchingName));
const valueLines = [match?.[1] ?? ""];
fenceMarker = nextFenceMarker(valueLines[0], "");
for (let next = index + 1; next < lines.length; next += 1) {
const lineLocal = lines[next];
if (
!fenceMarker &&
(markdownHeadingLevel(lineLocal) > 0 || isAnyLegacyProofFieldLine(lineLocal))
) {
break;
}
valueLines.push(lineLocal);
fenceMarker = nextFenceMarker(lineLocal, fenceMarker);
}
return valueLines.join("\n").trim();
}
return "";
}
function stripMarkdownFenceMarkers(value) {
return stripHtmlComments(normalizeLineEndings(value))
.split("\n")
.filter((line) => !/^ {0,3}(?:`{3,}|~{3,})(?:.*)?$/.test(line))
.join("\n")
.trim();
}
function isMissingValue(value) {
const trimmed = stripMarkdownFenceMarkers(value).replace(/^\s*[-*]\s+/, "");
if (!trimmed) {
return true;
}
return missingValueRegex.test(trimmed);
}
function result(status, reason, details = {}) {
return {
status,
reason,
applies: ["passed", "missing", "insufficient"].includes(status),
passed: ["passed", "skipped", CLAWSWEEPER_PROOF_VERDICT_STATUS].includes(status),
...details,
};
}
function extractMarkerField(marker, name) {
const match = marker.match(new RegExp(`\\b${escapeRegExp(name)}=([^\\s>]+)`, "i"));
return match?.[1] ?? "";
}
function isTrustedClawSweeperComment(comment) {
const appSlug = String(
comment?.performed_via_github_app?.slug ?? comment?.performedViaGithubApp?.slug ?? "",
).toLowerCase();
if (appSlug === "clawsweeper") {
return true;
}
// GitHub can omit performed_via_github_app on issue comments while still
// returning a reserved ClawSweeper App bot identity.
const login = String(comment?.user?.login ?? "").toLowerCase();
const userType = String(comment?.user?.type ?? "");
return CLAWSWEEPER_BOT_LOGINS.has(login) && userType === "Bot";
}
export function hasClawSweeperExactHeadProof({ pullRequest, comments = [] } = {}) {
const pullNumber = String(pullRequest?.number ?? "");
const headSha = String(pullRequest?.head?.sha ?? pullRequest?.head_sha ?? "").toLowerCase();
if (!pullNumber || !/^[0-9a-f]{40}$/i.test(headSha)) {
return false;
}
for (const comment of comments) {
if (!isTrustedClawSweeperComment(comment)) {
continue;
}
const body = String(comment?.body ?? "");
const markers = body.match(/<!--\s*clawsweeper-verdict:pass\b[\s\S]*?-->/gi) ?? [];
for (const marker of markers) {
const item = extractMarkerField(marker, "item");
const sha = extractMarkerField(marker, "sha").toLowerCase();
if (item === pullNumber && sha === headSha) {
return true;
}
}
}
return false;
}
export function evaluateClawSweeperExactHeadProof({ pullRequest, comments = [] } = {}) {
if (hasClawSweeperExactHeadProof({ pullRequest, comments })) {
return result(
CLAWSWEEPER_PROOF_VERDICT_STATUS,
"ClawSweeper accepted the PR evidence for the exact PR head.",
);
}
return result("insufficient", "No exact-head ClawSweeper proof verdict was found.");
}
export function evaluatePullRequestContext({ pullRequest } = {}) {
if (!isExternalPullRequest(pullRequest)) {
return result("skipped", "Maintainer, collaborator, or bot PRs do not require this gate.");
}
const body = pullRequest?.body ?? "";
const latestLegacyProof = extractLegacyProofSections(body).at(-1) ?? "";
const hasAuthoredProblem = hasAuthoredPullRequestSection("What Problem This Solves", body);
const hasLegacyProblem = !isMissingValue(
extractFieldValue(latestLegacyProof, legacyProofFields.problem),
);
const hasAuthoredEvidence = hasAuthoredPullRequestSection("Evidence", body);
const hasLegacyEvidence = !isMissingValue(
extractFieldValue(latestLegacyProof, legacyProofFields.evidence),
);
const missingSections = [];
if (!hasAuthoredProblem && !hasLegacyProblem) {
missingSections.push("What Problem This Solves");
}
if (!hasAuthoredEvidence && !hasLegacyEvidence) {
missingSections.push("Evidence");
}
if (missingSections.length > 0) {
return result(
"missing",
`External PRs must include authored ${missingSections.join(" and ")} sections.`,
{ missingSections },
);
}
return result("passed", "External PR includes problem context and evidence.");
}
export function labelsForPullRequestContext(evaluation) {
if (evaluation.status === "missing" || evaluation.status === "insufficient") {
return [NEEDS_PR_CONTEXT_LABEL];
}
return [];
}

View File

@@ -0,0 +1,221 @@
#!/usr/bin/env bash
set -euo pipefail
REMOTE_URL="${OPENCLAW_REF_REMOTE:-https://github.com/openclaw/openclaw.git}"
REF=""
EXPECTED_SHA=""
FALLBACK_OK=0
GITHUB_OUTPUT_FILE="${GITHUB_OUTPUT:-}"
usage() {
cat >&2 <<'EOF'
Usage: resolve-openclaw-ref.sh --ref <ref> [--expected-sha <sha>] [--fallback-ok] [--github-output <file>]
Fast-resolves OpenClaw branch and tag refs with git ls-remote. Full commit SHAs
are returned as fallback refs so callers can decide whether to run deeper
reachability validation.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--ref)
REF="${2:-}"
shift 2
;;
--expected-sha)
EXPECTED_SHA="${2:-}"
shift 2
;;
--fallback-ok)
FALLBACK_OK=1
shift
;;
--github-output)
GITHUB_OUTPUT_FILE="${2:-}"
shift 2
;;
--help|-h)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage
exit 2
;;
esac
done
trim() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "$value"
}
write_output() {
local key="$1"
local value="$2"
if [[ -n "$GITHUB_OUTPUT_FILE" ]]; then
printf '%s=%s\n' "$key" "$value" >> "$GITHUB_OUTPUT_FILE"
else
printf '%s=%s\n' "$key" "$value"
fi
}
lower_sha() {
printf '%s' "$1" | tr '[:upper:]' '[:lower:]'
}
resolve_unique_remote_ref() {
local refspec
for refspec in "$@"; do
[[ -n "$refspec" ]] || continue
local raw=""
local stderr_file=""
local status=0
stderr_file="$(mktemp)"
set +e
raw="$(git ls-remote "$REMOTE_URL" "$refspec" 2>"$stderr_file")"
status="$?"
set -e
if [[ "$status" -ne 0 ]]; then
local stderr=""
stderr="$(cat "$stderr_file")"
rm -f "$stderr_file"
if [[ "$refspec" == *"^{}" && "$stderr" == *"fatal: no tag message?"* ]]; then
continue
fi
[[ -z "$stderr" ]] || printf '%s\n' "$stderr" >&2
return 3
fi
rm -f "$stderr_file"
local match=""
local match_count=0
local line=""
while IFS= read -r line; do
[[ -n "$line" ]] || continue
match_count=$((match_count + 1))
if [[ "$match_count" -eq 1 ]]; then
match="$line"
fi
done < <(printf '%s\n' "$raw" | awk 'NF {print $1}' | awk '!seen[$0]++')
if [[ "$match_count" -eq 0 ]]; then
continue
fi
if [[ "$match_count" -ne 1 ]]; then
return 2
fi
printf '%s\n' "$match"
return 0
done
return 1
}
read_remote_matches() {
local output_name="$1"
shift
local output=""
local status=0
eval "$output_name=()"
set +e
output="$(resolve_unique_remote_ref "$@")"
status="$?"
set -e
case "$status" in
0)
eval "$output_name=(\"\$output\")"
;;
1)
;;
2)
echo "Ref resolved to multiple remote matches." >&2
exit 1
;;
*)
exit 1
;;
esac
}
REF="$(trim "$REF")"
EXPECTED_SHA="$(trim "$EXPECTED_SHA")"
if [[ -z "$REF" ]] || [[ "$REF" == -* ]]; then
echo "Expected a branch, tag, or full commit SHA; got: ${REF}" >&2
exit 1
fi
if [[ -n "$EXPECTED_SHA" ]] && [[ ! "$EXPECTED_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Expected --expected-sha to be a full commit SHA; got: ${EXPECTED_SHA}" >&2
exit 1
fi
if [[ "$REF" =~ ^[0-9a-fA-F]{40}$ ]]; then
if [[ -n "$EXPECTED_SHA" ]] && [[ "$(lower_sha "$REF")" != "$(lower_sha "$EXPECTED_SHA")" ]]; then
echo "Ref SHA ${REF} does not match expected SHA ${EXPECTED_SHA}." >&2
exit 1
fi
write_output sha "$(lower_sha "$REF")"
write_output ref_kind sha
write_output fast false
write_output fallback true
exit 0
fi
declare -a matches=()
if [[ "$REF" == refs/heads/* ]]; then
read_remote_matches matches "$REF"
elif [[ "$REF" == refs/tags/* ]]; then
read_remote_matches matches "${REF}^{}" "$REF"
elif [[ "$REF" == refs/* ]]; then
read_remote_matches matches "$REF"
else
read_remote_matches branch_matches "refs/heads/${REF}"
read_remote_matches tag_matches "refs/tags/${REF}^{}" "refs/tags/${REF}"
match_count=$(( ${#branch_matches[@]} + ${#tag_matches[@]} ))
if [[ "$match_count" -eq 1 ]]; then
if [[ "${#branch_matches[@]}" -eq 1 ]]; then
matches=("${branch_matches[0]}")
ref_kind=branch
else
matches=("${tag_matches[0]}")
ref_kind=tag
fi
elif [[ "$match_count" -gt 1 ]]; then
echo "Ref resolved ambiguously as both branch and tag: ${REF}" >&2
exit 1
fi
fi
if [[ "${#matches[@]}" -eq 1 ]]; then
resolved="$(lower_sha "${matches[0]}")"
if [[ -n "$EXPECTED_SHA" ]] && [[ "$resolved" != "$(lower_sha "$EXPECTED_SHA")" ]]; then
echo "Ref ${REF} resolved to ${resolved}, expected ${EXPECTED_SHA}." >&2
exit 1
fi
if [[ -z "${ref_kind:-}" ]]; then
if [[ "$REF" == refs/tags/* ]]; then
ref_kind=tag
elif [[ "$REF" == refs/heads/* ]]; then
ref_kind=branch
else
ref_kind=ref
fi
fi
write_output sha "$resolved"
write_output ref_kind "$ref_kind"
write_output fast true
write_output fallback false
exit 0
fi
if [[ "$FALLBACK_OK" -eq 1 ]]; then
write_output sha "$EXPECTED_SHA"
write_output ref_kind unknown
write_output fast false
write_output fallback true
exit 0
fi
echo "Failed to resolve OpenClaw ref: ${REF}" >&2
exit 1

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env bash
set -euo pipefail
tsx_version="${OPENCLAW_RELEASE_TSX_VERSION:-${TSX_VERSION:-4.21.0}}"
script_path="${OPENCLAW_RELEASE_CHECKS_SCRIPT:-workflow/scripts/openclaw-cross-os-release-checks.ts}"
if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then
if command -v cygpath >/dev/null 2>&1; then
for node_dir in /c/hostedtoolcache/windows/node/*/x64 /c/actions-runner/_work/_tool/node/*/x64; do
if [[ -x "${node_dir}/node.exe" ]]; then
export PATH="${node_dir}:${PATH}"
break
fi
done
fi
fi
node_cmd="node"
npm_cmd="npm"
npm_cli_js=""
if command -v cygpath >/dev/null 2>&1; then
if command -v node.exe >/dev/null 2>&1; then
node_cmd="node.exe"
node_path="$(command -v node.exe)"
node_dir="$(dirname "$(cygpath -u "${node_path}")")"
if [[ -f "${node_dir}/node_modules/npm/bin/npm-cli.js" ]]; then
npm_cli_js="${node_dir}/node_modules/npm/bin/npm-cli.js"
fi
fi
if command -v npm.cmd >/dev/null 2>&1; then
npm_cmd="npm.cmd"
elif command -v npm.exe >/dev/null 2>&1; then
npm_cmd="npm.exe"
fi
fi
temp_root="${OPENCLAW_RELEASE_TSX_TOOL_ROOT:-${RUNNER_TEMP:-${TMPDIR:-/tmp}}}"
if command -v cygpath >/dev/null 2>&1; then
temp_root="$(cygpath -u "${temp_root}")"
fi
tool_dir="${OPENCLAW_RELEASE_TSX_TOOL_DIR:-${temp_root}/openclaw-release-tsx-${tsx_version}}"
loader_path="${tool_dir}/node_modules/tsx/dist/loader.mjs"
npm_tool_dir="${tool_dir}"
npm_cli_arg="${npm_cli_js}"
loader_arg="${loader_path}"
if command -v cygpath >/dev/null 2>&1; then
npm_tool_dir="$(cygpath -w "${tool_dir}")"
if [[ -n "${npm_cli_js}" ]]; then
npm_cli_arg="$(cygpath -w "${npm_cli_js}")"
fi
loader_arg="$(cygpath -w "${loader_path}")"
fi
command -v "${node_cmd}" >/dev/null 2>&1 || {
echo "node is required to run cross-OS release checks." >&2
exit 127
}
command -v "${npm_cmd}" >/dev/null 2>&1 || {
echo "npm is required to install the cross-OS release-check loader." >&2
exit 127
}
if [[ ! -f "${loader_path}" ]]; then
mkdir -p "${tool_dir}"
if [[ -n "${npm_cli_js}" ]]; then
if ! "${node_cmd}" "${npm_cli_arg}" install --prefix "${npm_tool_dir}" --no-save --no-package-lock "tsx@${tsx_version}" >/dev/null; then
echo "failed to install cross-OS release-check loader with ${node_cmd} ${npm_cli_arg}." >&2
exit 127
fi
elif ! "${npm_cmd}" install --prefix "${npm_tool_dir}" --no-save --no-package-lock "tsx@${tsx_version}" >/dev/null; then
echo "failed to install cross-OS release-check loader with ${npm_cmd}." >&2
exit 127
fi
fi
if [[ ! -f "${loader_path}" ]]; then
echo "tsx loader missing after install: ${loader_path}" >&2
find "${tool_dir}" -maxdepth 5 -type f 2>/dev/null | sort | sed 's/^/ /' >&2 || true
exit 127
fi
loader_url="$(
"${node_cmd}" -e '
const { resolve } = require("node:path");
const { pathToFileURL } = require("node:url");
process.stdout.write(pathToFileURL(resolve(process.argv[1])).href);
' "${loader_arg}"
)"
exec "${node_cmd}" --import "${loader_url}" "${script_path}" "$@"

View File

@@ -0,0 +1,528 @@
#!/usr/bin/env node
// GitHub security-sensitive file guard: detects sensitive boundary files,
// manages sticky comments/labels, and requires SHA-bound secops/admin approval.
import { appendFile, readFile } from "node:fs/promises";
import {
GITHUB_API_REQUEST_TIMEOUT_MS,
GITHUB_ERROR_BODY_MAX_BYTES,
GITHUB_RESPONSE_BODY_MAX_BYTES,
createGitHubApi,
createGuardApproverChecks,
createIssueMutationHelpers,
guardCommentHeadSha,
guardTrustedActorCandidates,
isCommentNewerThan,
readBoundedGitHubErrorText,
readBoundedGitHubJson,
} from "./guard-shared.mjs";
/** Marker used to identify security-sensitive guard comments. */
export const securitySensitiveGuardMarker = "<!-- openclaw:security-sensitive-guard -->";
export const securitySensitiveChangedLabel = "security-sensitive-changed";
export const allowSecuritySensitiveCommand = "/allow-security-sensitive-change";
export {
GITHUB_API_REQUEST_TIMEOUT_MS,
GITHUB_ERROR_BODY_MAX_BYTES,
GITHUB_RESPONSE_BODY_MAX_BYTES,
readBoundedGitHubErrorText,
readBoundedGitHubJson,
};
const securityTeamSlug = process.env.OPENCLAW_SECURITY_TEAM_SLUG ?? "openclaw-secops";
const maxListedFiles = 25;
const securitySensitiveFiles = [
{
path: ".gitignore",
reason:
"Controls ignored secret and local files, including common `.env` files, before they can be accidentally committed.",
},
];
export function securitySensitiveFileDefinitions() {
return securitySensitiveFiles.map((entry) => ({ ...entry }));
}
export function securitySensitiveFileDefinition(filename) {
return securitySensitiveFiles.find((entry) => entry.path === filename) ?? null;
}
export function isSecuritySensitiveFile(filename) {
return securitySensitiveFileDefinition(filename) !== null;
}
export function sanitizeDisplayValue(value) {
return String(value)
.replace(/[\p{Cc}]/gu, "?")
.slice(0, 240);
}
export function markdownCode(value) {
return `\`${sanitizeDisplayValue(value).replaceAll("`", "\\`")}\``;
}
function* securitySensitiveOverrideCandidates({ comments, expectedSha, newerThan }) {
if (!expectedSha) {
return;
}
const commandPattern = /^\/allow-security-sensitive-change(?:\s+(.+))?$/gimu;
for (const comment of comments.toReversed()) {
const body = comment.body ?? "";
for (const match of body.matchAll(commandPattern)) {
const reason = match[1]?.trim();
const login = comment.user?.login;
if (!login || !isCommentNewerThan(comment, newerThan)) {
continue;
}
yield {
login,
reason: reason ? sanitizeDisplayValue(reason) : null,
sha: expectedSha,
url: comment.html_url,
};
}
}
}
export function findSecuritySensitiveOverrideCommand({
comments,
expectedSha,
isSecurityMember,
newerThan,
}) {
for (const candidate of securitySensitiveOverrideCandidates({
comments,
expectedSha,
newerThan,
})) {
if (isSecurityMember(candidate.login)) {
return candidate;
}
}
return null;
}
export async function findSecuritySensitiveOverrideCommandAsync(input) {
for (const candidate of securitySensitiveOverrideCandidates(input)) {
if (await input.isSecurityMember(candidate.login)) {
return candidate;
}
}
return null;
}
export function securitySensitiveGuardCommentHeadSha(comment) {
return guardCommentHeadSha(comment);
}
export function securitySensitiveOverrideExpectedSha(existingGuardComment, currentHeadSha) {
if (
!currentHeadSha ||
existingGuardComment?.body?.includes("### Security-sensitive changes are blocked") !== true
) {
return null;
}
return securitySensitiveGuardCommentHeadSha(existingGuardComment) === currentHeadSha
? currentHeadSha
: null;
}
export function isSecuritySensitiveGuardAuthorizedForHead(comment, currentHeadSha) {
return (
Boolean(currentHeadSha) &&
comment?.body?.includes("### Security-sensitive change authorized") === true &&
securitySensitiveGuardCommentHeadSha(comment) === currentHeadSha
);
}
export function isSecuritySensitiveGuardTrustedForHead(comment, currentHeadSha) {
return (
Boolean(currentHeadSha) &&
comment?.body?.includes("### Security-sensitive changes noted") === true &&
securitySensitiveGuardCommentHeadSha(comment) === currentHeadSha
);
}
export function securityApproverSet(value) {
return new Set(
String(value ?? "")
.split(/[\s,]+/u)
.map((login) => login.trim().toLowerCase())
.filter(Boolean),
);
}
export function securitySensitiveGuardCommentAuthors(value) {
return new Set(
String(value ?? "github-actions[bot]")
.split(/[\s,]+/u)
.map((login) => login.trim().toLowerCase())
.filter(Boolean),
);
}
export function isSecuritySensitiveGuardMarkerComment(comment, trustedAuthors) {
const login = comment.user?.login?.toLowerCase();
return Boolean(
login && trustedAuthors.has(login) && comment.body?.includes(securitySensitiveGuardMarker),
);
}
function sortedSecuritySensitiveChanges(filenames) {
const byPath = new Map();
for (const filename of filenames) {
if (typeof filename !== "string") {
continue;
}
const definition = securitySensitiveFileDefinition(filename);
if (definition) {
byPath.set(definition.path, definition);
}
}
return [...byPath.values()].toSorted((left, right) => left.path.localeCompare(right.path));
}
export function collectSecuritySensitiveChanges(files) {
const filenames = [];
for (const file of files) {
if (typeof file === "string") {
filenames.push(file);
continue;
}
if (file && typeof file === "object") {
filenames.push(file.filename, file.previous_filename);
}
}
return sortedSecuritySensitiveChanges(filenames);
}
function renderChangedFileLines(changes) {
const listedFiles = changes.slice(0, maxListedFiles);
const omittedCount = changes.length - listedFiles.length;
const lines = listedFiles.map(
(change) => `- ${markdownCode(change.path)}: ${sanitizeDisplayValue(change.reason)}`,
);
if (omittedCount > 0) {
lines.push(`- ${omittedCount} additional security-sensitive files not shown`);
}
return lines;
}
export function renderSecuritySensitiveAwarenessComment(changes) {
return [
securitySensitiveGuardMarker,
"",
"### Security-sensitive file changes detected",
"",
"This PR changes files that define security boundaries. Maintainers should confirm these changes are intentional.",
"",
"Changed files:",
...renderChangedFileLines(changes),
"",
"Maintainer follow-up:",
"- Review whether each security-sensitive file change is intentional.",
"- Confirm the change does not weaken secret, credential, or local-state protection.",
"- If this PR intentionally needs the change, a repository admin or member of `@openclaw/openclaw-secops` must approve the exact head SHA.",
].join("\n");
}
export function renderAuthorizedSecuritySensitiveComment(override) {
const lines = [
securitySensitiveGuardMarker,
"",
"### Security-sensitive change authorized",
"",
"This PR includes security-sensitive file changes. A repository admin or member of `@openclaw/openclaw-secops` authorized this exact head SHA with `/allow-security-sensitive-change`.",
"",
`- Approved SHA: ${markdownCode(override.sha)}`,
`- Approved by: @${sanitizeDisplayValue(override.login)}`,
];
if (override.reason) {
lines.push(`- Reason: ${markdownCode(override.reason)}`);
}
lines.push("", "A later push changes the PR head SHA and requires a fresh security approval.");
return lines.join("\n");
}
export function renderTrustedSecuritySensitiveComment({ actor, headSha, changes }) {
return [
securitySensitiveGuardMarker,
"",
"### Security-sensitive changes noted",
"",
"This PR includes security-sensitive file changes. The guard is informational because the PR author is a repository admin or a member of `@openclaw/openclaw-secops`.",
"",
`- Current SHA: ${markdownCode(headSha ?? "<head-sha>")}`,
`- Trusted actor: @${sanitizeDisplayValue(actor.login)}`,
`- Trusted role: ${markdownCode(actor.reason)}`,
"",
"Changed files:",
...renderChangedFileLines(changes),
"",
"Security review is still recommended before merge when the change is intentional.",
].join("\n");
}
export function renderClearedSecuritySensitiveGuardComment({ headSha }) {
return [
securitySensitiveGuardMarker,
"",
"### Security-sensitive guard cleared",
"",
"This PR no longer has blocked security-sensitive file changes. A future security-sensitive change requires a fresh `/allow-security-sensitive-change` comment after the guard blocks that new head SHA.",
"",
`- Current SHA: ${markdownCode(headSha ?? "<head-sha>")}`,
].join("\n");
}
export function renderBlockedSecuritySensitiveComment({ headSha, changes }) {
return [
securitySensitiveGuardMarker,
"",
"### Security-sensitive changes are blocked",
"",
"OpenClaw does not accept security-sensitive file changes through PRs unless a repository admin or security explicitly authorizes the current head SHA.",
"",
"Detected security-sensitive changes:",
...renderChangedFileLines(changes),
"",
"If this PR intentionally needs these changes, ask a repository admin or member of `@openclaw/openclaw-secops` to comment:",
"",
"```text",
allowSecuritySensitiveCommand,
"```",
"",
`The action will approve the current head SHA (${markdownCode(headSha ?? "<head-sha>")}) when it reruns. A later push requires a fresh approval.`,
].join("\n");
}
export function securitySensitiveGuardTrustedActorCandidates({
pullRequest,
event,
currentHeadSha,
}) {
return guardTrustedActorCandidates({ pullRequest, event, currentHeadSha });
}
export async function findTrustedSecuritySensitiveGuardActor({
candidates,
isSecuritySensitiveApprover,
}) {
for (const candidate of candidates) {
const role = await isSecuritySensitiveApprover(candidate.login);
if (role) {
return {
login: candidate.login,
reason: `${candidate.source}; ${role}`,
};
}
}
return null;
}
export function githubApi(token, options = {}) {
return createGitHubApi(token, {
...options,
userAgent: "openclaw-security-sensitive-guard",
});
}
async function writeSummary(markdown) {
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
if (!summaryPath) {
console.log(markdown);
return;
}
await appendFile(summaryPath, `${markdown}\n`);
}
async function main() {
const token = process.env.GITHUB_TOKEN;
const eventPath = process.env.GITHUB_EVENT_PATH;
const repository = process.env.GITHUB_REPOSITORY;
if (!token || !eventPath || !repository) {
throw new Error("GITHUB_TOKEN, GITHUB_EVENT_PATH, and GITHUB_REPOSITORY are required.");
}
const [owner, repo] = repository.split("/");
const event = JSON.parse(await readFile(eventPath, "utf8"));
const eventPullRequest = event.pull_request;
if (!eventPullRequest) {
console.log("No pull_request payload found; skipping.");
return;
}
const api = githubApi(token);
const explicitSecurityApprovers = securityApproverSet(process.env.OPENCLAW_SECURITY_APPROVERS);
const trustedCommentAuthors = securitySensitiveGuardCommentAuthors(
process.env.OPENCLAW_SECURITY_SENSITIVE_GUARD_COMMENT_BOTS,
);
const issuePath = `/repos/${owner}/${repo}/issues/${eventPullRequest.number}`;
const pullPath = `/repos/${owner}/${repo}/pulls/${eventPullRequest.number}`;
const pullRequest = await api.request(pullPath);
const mode = process.env.OPENCLAW_SECURITY_SENSITIVE_GUARD_MODE ?? "enforce";
const files = await api.paginate(`${pullPath}/files`);
const securitySensitiveChanges = collectSecuritySensitiveChanges(files);
const [comments, labels] = await Promise.all([
api.paginate(`${issuePath}/comments`),
api.paginate(`${issuePath}/labels`),
]);
const existingGuardComment = comments.find((comment) =>
isSecuritySensitiveGuardMarkerComment(comment, trustedCommentAuthors),
);
const labelNames = new Set(labels.map((label) => label.name));
const { removeLabelIfPresent, addLabelIfMissing, upsertComment } = createIssueMutationHelpers({
api,
issuePath,
owner,
repo,
labelNames,
});
if (securitySensitiveChanges.length === 0) {
await removeLabelIfPresent(securitySensitiveChangedLabel);
if (existingGuardComment) {
await upsertComment(
existingGuardComment,
renderClearedSecuritySensitiveGuardComment({ headSha: pullRequest.head?.sha }),
);
}
await writeSummary(
"## Security Sensitive Guard\n\nNo security-sensitive file changes detected.",
);
console.log("No security-sensitive file changes detected.");
return;
}
await addLabelIfMissing(securitySensitiveChangedLabel);
await writeSummary(
[
"## Security Sensitive Guard",
"",
`Detected ${securitySensitiveChanges.length} security-sensitive file change(s).`,
"",
...securitySensitiveChanges.map((change) => `- ${markdownCode(change.path)}`),
].join("\n"),
);
console.log(`Detected ${securitySensitiveChanges.length} security-sensitive file change(s).`);
const { isSecurityMember, isRepositoryAdmin } = createGuardApproverChecks({
api,
owner,
repo,
securityTeamSlug,
explicitSecurityApprovers,
});
const isSecuritySensitiveApprover = async (login) => {
if (await isSecurityMember(login)) {
return securityTeamSlug;
}
if (await isRepositoryAdmin(login)) {
return "repository admin";
}
return null;
};
const currentHeadSha = pullRequest.head?.sha;
if (isSecuritySensitiveGuardTrustedForHead(existingGuardComment, currentHeadSha)) {
await writeSummary(
[
"## Security Sensitive Guard",
"",
`Security-sensitive changes remain informational for a trusted actor at ${markdownCode(currentHeadSha)}.`,
].join("\n"),
);
console.log("Security-sensitive changes remain informational for this head SHA.");
return;
}
const trustedActor = await findTrustedSecuritySensitiveGuardActor({
candidates: securitySensitiveGuardTrustedActorCandidates({
pullRequest,
event,
currentHeadSha,
}),
isSecuritySensitiveApprover,
});
if (trustedActor) {
await upsertComment(
existingGuardComment,
renderTrustedSecuritySensitiveComment({
actor: trustedActor,
changes: securitySensitiveChanges,
headSha: currentHeadSha,
}),
);
await writeSummary(
[
"## Security Sensitive Guard",
"",
`Security-sensitive changes noted for trusted actor @${sanitizeDisplayValue(trustedActor.login)} and allowed to continue.`,
].join("\n"),
);
console.log("Security-sensitive changes noted for trusted actor; guard is informational.");
return;
}
if (isSecuritySensitiveGuardAuthorizedForHead(existingGuardComment, currentHeadSha)) {
await writeSummary(
[
"## Security Sensitive Guard",
"",
`Security-sensitive changes remain authorized for ${markdownCode(currentHeadSha)}.`,
].join("\n"),
);
console.log("Security-sensitive changes remain authorized for this head SHA.");
return;
}
const override = await findSecuritySensitiveOverrideCommandAsync({
comments,
expectedSha: securitySensitiveOverrideExpectedSha(existingGuardComment, currentHeadSha),
isSecurityMember: async (login) => Boolean(await isSecuritySensitiveApprover(login)),
newerThan: existingGuardComment?.updated_at ?? existingGuardComment?.created_at,
});
if (override) {
await upsertComment(existingGuardComment, renderAuthorizedSecuritySensitiveComment(override));
await writeSummary(
[
"## Security Sensitive Guard",
"",
`Security-sensitive changes authorized by @${sanitizeDisplayValue(override.login)} for ${markdownCode(override.sha)}.`,
].join("\n"),
);
console.log("Security-sensitive changes authorized by trusted override.");
return;
}
if (mode === "detect") {
await upsertComment(
existingGuardComment,
renderSecuritySensitiveAwarenessComment(securitySensitiveChanges),
);
await writeSummary(
"## Security Sensitive Guard\n\nSecurity-sensitive enforcement deferred to the final guard job.",
);
console.log("Security-sensitive enforcement deferred to the final guard job.");
return;
}
await upsertComment(
existingGuardComment,
renderBlockedSecuritySensitiveComment({
changes: securitySensitiveChanges,
headSha: pullRequest.head?.sha,
}),
);
await writeSummary(
"## Security Sensitive Guard\n\nSecurity-sensitive changes are blocked without a current admin or secops override.",
);
throw new Error(
"Security-sensitive changes require removal or a current admin or secops override.",
);
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch(
/** @param {unknown} error */ (error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
},
);
}