fix: reject non-text skill proposal files

This commit is contained in:
Shakker
2026-05-30 13:22:04 +01:00
committed by Shakker
parent f7729028ae
commit bc6d570659
4 changed files with 47 additions and 6 deletions
+3 -3
View File
@@ -541,7 +541,7 @@ export function registerSkillsCli(program: Command) {
.option("--proposal <path>", "Path to PROPOSAL.md draft content")
.option(
"--proposal-dir <path>",
"Path to proposal directory with PROPOSAL.md and support files",
"Path to proposal directory with PROPOSAL.md and UTF-8 text support files",
)
.option("--goal <text>", "Research or improvement goal")
.option("--evidence <text>", "Evidence or notes for the proposal")
@@ -592,7 +592,7 @@ export function registerSkillsCli(program: Command) {
.option("--proposal <path>", "Path to PROPOSAL.md draft content")
.option(
"--proposal-dir <path>",
"Path to proposal directory with PROPOSAL.md and support files",
"Path to proposal directory with PROPOSAL.md and UTF-8 text support files",
)
.option("--goal <text>", "Research or improvement goal")
.option("--evidence <text>", "Evidence or notes for the proposal")
@@ -646,7 +646,7 @@ export function registerSkillsCli(program: Command) {
.option("--proposal <path>", "Path to revised PROPOSAL.md draft content")
.option(
"--proposal-dir <path>",
"Path to revised proposal directory with PROPOSAL.md and support files",
"Path to revised proposal directory with PROPOSAL.md and UTF-8 text support files",
)
.option("--description <description>", "Replacement proposal description")
.option("--goal <text>", "Replacement research or improvement goal")
+22
View File
@@ -12,6 +12,7 @@ import {
proposeCreateSkill,
proposeUpdateSkill,
quarantineSkillProposal,
readSkillProposalDraftDirectory,
rejectSkillProposal,
resolvePendingSkillProposal,
reviseSkillProposal,
@@ -618,6 +619,27 @@ describe("skill workshop proposals", () => {
await expect(fs.access(path.join(stateDir, "skill-workshop"))).rejects.toThrow();
});
it("rejects non-text and executable proposal directory support files", async () => {
const draftDir = path.join(await makeWorkspace(), "draft");
await fs.mkdir(path.join(draftDir, "assets"), { recursive: true });
await fs.writeFile(path.join(draftDir, "PROPOSAL.md"), "# Binary Asset\n", "utf8");
await fs.writeFile(path.join(draftDir, "assets", "icon.png"), Buffer.from([0x89, 0x50]));
await expect(readSkillProposalDraftDirectory(draftDir)).rejects.toThrow(
"Proposal files must be UTF-8 text",
);
await fs.rm(path.join(draftDir, "assets", "icon.png"));
await fs.mkdir(path.join(draftDir, "scripts"), { recursive: true });
const scriptPath = path.join(draftDir, "scripts", "run.sh");
await fs.writeFile(scriptPath, "#!/bin/sh\necho ok\n", "utf8");
await fs.chmod(scriptPath, 0o755);
await expect(readSkillProposalDraftDirectory(draftDir)).rejects.toThrow(
"Proposal support files must not be executable",
);
});
it("rejects rendered proposals that exceed the persisted draft size limit", async () => {
const workspaceDir = await makeWorkspace();
+19 -3
View File
@@ -1,3 +1,4 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { readLocalFileSafely, root, walkDirectory } from "../../infra/fs-safe.js";
@@ -92,7 +93,7 @@ export async function readSkillProposalDraftFile(filePath: string): Promise<stri
filePath,
maxBytes: MAX_PROPOSAL_DRAFT_BYTES,
});
return read.buffer.toString("utf8");
return decodeProposalTextFile(read.buffer, filePath);
}
export async function readSkillProposalDraftDirectory(dirPath: string): Promise<{
@@ -129,19 +130,34 @@ export async function readSkillProposalDraftDirectory(dirPath: string): Promise<
throw new Error(`Proposal support file must be a regular file: ${relativePath}`);
}
const supportPath = normalizeSkillProposalSupportPath(relativePath);
const stats = await fs.stat(entry.path);
if ((stats.mode & 0o111) !== 0) {
throw new Error(`Proposal support files must not be executable: ${relativePath}`);
}
const read = await draftRoot.read(relativePath, {
hardlinks: "reject",
maxBytes: MAX_PROPOSAL_SUPPORT_FILE_BYTES,
symlinks: "reject",
});
supportFiles.push({ path: supportPath, content: read.buffer.toString("utf8") });
supportFiles.push({
path: supportPath,
content: decodeProposalTextFile(read.buffer, relativePath),
});
}
return {
content: proposal.buffer.toString("utf8"),
content: decodeProposalTextFile(proposal.buffer, "PROPOSAL.md"),
supportFiles,
};
}
function decodeProposalTextFile(buffer: Buffer, label: string): string {
const content = buffer.toString("utf8");
if (!Buffer.from(content, "utf8").equals(buffer) || content.includes("\0")) {
throw new Error(`Proposal files must be UTF-8 text: ${label}`);
}
return content;
}
export async function inspectSkillProposal(
proposalId: string,
options: SkillProposalScopeOptions = {},
+3
View File
@@ -169,6 +169,9 @@ export function prepareSkillProposalSupportFiles(
if (sizeBytes > MAX_PROPOSAL_SUPPORT_FILE_BYTES) {
throw new Error(`Support file is too large: ${filePath}`);
}
if (file.content.includes("\0")) {
throw new Error(`Support files must be UTF-8 text: ${filePath}`);
}
totalBytes += sizeBytes;
if (totalBytes > MAX_PROPOSAL_SUPPORT_FILES_TOTAL_BYTES) {
throw new Error("Skill proposal support files exceed the total size limit.");