fix: harden skill proposal boundaries

This commit is contained in:
Shakker
2026-05-30 13:02:00 +01:00
committed by Shakker
parent 897a7efe15
commit 11d6ce15e8
6 changed files with 130 additions and 6 deletions

View File

@@ -1,6 +1,9 @@
import { Value } from "typebox/value";
import { describe, expect, it } from "vitest";
import { ToolsEffectiveResultSchema } from "./agents-models-skills.js";
import {
SkillsProposalInspectResultSchema,
ToolsEffectiveResultSchema,
} from "./agents-models-skills.js";
function toolsEffectiveResult() {
return {
@@ -58,3 +61,51 @@ describe("ToolsEffectiveResultSchema", () => {
expect(Value.Check(ToolsEffectiveResultSchema, result)).toBe(false);
});
});
describe("SkillsProposalInspectResultSchema", () => {
it("accepts update proposal support file target metadata", () => {
const result = {
record: {
id: "proposal-1",
kind: "update",
status: "pending",
title: "weather-helper",
description: "Improve weather checks",
schema: "openclaw.skill-workshop.proposal.v1",
createdAt: "2026-05-30T00:00:00.000Z",
updatedAt: "2026-05-30T00:00:00.000Z",
createdBy: "skill-research",
proposedVersion: "v1",
draftFile: "PROPOSAL.md",
target: {
skillName: "weather-helper",
skillDir: "/tmp/workspace/skills/weather-helper",
skillFile: "/tmp/workspace/skills/weather-helper/SKILL.md",
skillKey: "weather-helper",
currentContentHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
},
draftHash: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
scan: {
state: "clean",
scannedAt: "2026-05-30T00:00:00.000Z",
critical: 0,
warn: 0,
info: 0,
findings: [],
},
supportFiles: [
{
path: "references/weather.md",
sizeBytes: 42,
hash: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
targetExisted: true,
targetContentHash: "123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0",
},
],
},
content: "# Weather Helper\n",
};
expect(Value.Check(SkillsProposalInspectResultSchema, result)).toBe(true);
});
});

View File

@@ -514,6 +514,8 @@ const SkillProposalSupportFileSchema = Type.Object(
path: NonEmptyString,
sizeBytes: Type.Integer({ minimum: 0, maximum: 262_144 }),
hash: Sha256String,
targetExisted: Type.Optional(Type.Boolean()),
targetContentHash: Type.Optional(Sha256String),
},
{ additionalProperties: false },
);

View File

@@ -442,11 +442,15 @@ export function createOpenClawTools(
sessionAgentId,
config: resolvedConfig,
}),
createSkillResearchTool({
workspaceDir,
config: resolvedConfig,
agentId: sessionAgentId,
}),
...(options?.sandboxed
? []
: [
createSkillResearchTool({
workspaceDir,
config: resolvedConfig,
agentId: sessionAgentId,
}),
]),
...(includeUpdatePlanTool ? [createUpdatePlanTool()] : []),
createSessionsListTool({
agentSessionKey: options?.agentSessionKey,

View File

@@ -32,6 +32,18 @@ describe("skill_research tool", () => {
expect(tools.some((tool) => tool.name === "skill_research")).toBe(true);
});
it("is not exposed from sandboxed OpenClaw tool sets", async () => {
const workspaceDir = await tempDirs.make("openclaw-skill-research-tool-");
const tools = createOpenClawTools({
workspaceDir,
config: {},
disablePluginTools: true,
sandboxed: true,
});
expect(tools.some((tool) => tool.name === "skill_research")).toBe(false);
});
it("creates pending skill proposals without applying them", async () => {
const workspaceDir = await tempDirs.make("openclaw-skill-research-tool-");
const tool = createSkillResearchTool({ workspaceDir, config: {}, agentId: "main" });

View File

@@ -415,6 +415,44 @@ describe("skill workshop proposals", () => {
);
});
it("keeps update proposal support baselines when revising", async () => {
const workspaceDir = await makeWorkspace();
const skillDir = path.join(workspaceDir, "skills", "support-revise-stale");
await writeSkill({
dir: skillDir,
name: "support-revise-stale",
description: "Detect stale support files during revision",
body: "# Support Revise Stale\n\nOld checklist.\n",
});
await fs.mkdir(path.join(skillDir, "references"), { recursive: true });
await fs.writeFile(path.join(skillDir, "references", "qa.md"), "Old support file.\n", "utf8");
const proposal = await proposeUpdateSkill({
workspaceDir,
skillName: "support-revise-stale",
content: "# Support Revise Stale\n\nNew checklist.\n",
supportFiles: [
{
path: "references/qa.md",
content: "New support file.\n",
},
],
});
await fs.writeFile(path.join(skillDir, "references", "qa.md"), "Changed elsewhere.\n", "utf8");
await expect(
reviseSkillProposal({
workspaceDir,
proposalId: proposal.record.id,
content: "# Support Revise Stale\n\nRevised checklist.\n",
}),
).rejects.toThrow("Target support file changed after proposal creation");
expect((await inspectSkillProposal(proposal.record.id))?.record.status).toBe("stale");
await expect(fs.readFile(path.join(skillDir, "references", "qa.md"), "utf8")).resolves.toBe(
"Changed elsewhere.\n",
);
});
it("rejects and quarantines proposals without touching active skills", async () => {
const workspaceDir = await makeWorkspace();
const rejected = await proposeCreateSkill({

View File

@@ -339,6 +339,7 @@ export async function reviseSkillProposal(
await markProposalStale(record, "Target skill changed after proposal creation.");
throw new Error("Target skill changed after proposal creation; proposal marked stale.");
}
await assertSupportTargetsUnchanged(record);
}
const supportFiles =
@@ -670,6 +671,22 @@ async function assertSupportTargetUnchanged(params: {
}
}
async function assertSupportTargetsUnchanged(record: SkillProposalRecord): Promise<void> {
if (record.kind !== "update" || !record.supportFiles) {
return;
}
for (const file of record.supportFiles) {
if (file.targetExisted === undefined) {
continue;
}
const currentContent = await readWorkspaceSupportFile({
skillDir: record.target.skillDir,
relativePath: file.path,
});
await assertSupportTargetUnchanged({ record, file, currentContent });
}
}
async function readRequiredProposal(
proposalId: string,
workspaceDir?: string,