From 526a3614888062d312cef3ad4656c8b403a44e5c Mon Sep 17 00:00:00 2001 From: matevip Date: Sun, 31 May 2026 07:56:51 +0800 Subject: [PATCH] feat(wiki): restricted Skill pipeline step executor --- .../wiki/pipeline/WikiSkillStepExecutor.java | 73 +++++++++++++++++ .../pipeline/WikiSkillStepExecutorTest.java | 81 +++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 mateclaw-server/src/main/java/vip/mate/wiki/pipeline/WikiSkillStepExecutor.java create mode 100644 mateclaw-server/src/test/java/vip/mate/wiki/pipeline/WikiSkillStepExecutorTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/pipeline/WikiSkillStepExecutor.java b/mateclaw-server/src/main/java/vip/mate/wiki/pipeline/WikiSkillStepExecutor.java new file mode 100644 index 00000000..b3445e59 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/pipeline/WikiSkillStepExecutor.java @@ -0,0 +1,73 @@ +package vip.mate.wiki.pipeline; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.service.SkillService; + +import java.util.Set; + +/** + * Pipeline step executor that resolves a registered skill and contributes its + * declarative content to the chain. + * + *

Scope (MVP, security-restricted): the skill must be installed, + * enabled and not have a failed/blocked security scan. This executor injects + * the skill's instructions/content as the step output; it deliberately does + * not execute skill scripts — arbitrary script execution from a + * system-triggered pipeline requires a real sandbox and a separate security + * review (the same constraint that keeps a Python executor out of the MVP). + * + * @author MateClaw Team + */ +@Slf4j +@Component +public class WikiSkillStepExecutor implements WikiStepExecutor { + + private static final Set BLOCKED_SCAN_STATUSES = Set.of("FAILED", "BLOCKED", "REJECTED"); + + private final SkillService skillService; + + public WikiSkillStepExecutor(SkillService skillService) { + this.skillService = skillService; + } + + @Override + public String type() { + return "skill"; + } + + @Override + public String execute(WikiStepContext context) { + String skillName = context.stepConfig() == null ? null + : (String) (context.stepConfig().get("skill") instanceof String s ? s : null); + if (skillName == null || skillName.isBlank()) { + throw new IllegalArgumentException("skill step '" + context.stepId() + "' has no skill name"); + } + SkillEntity skill = skillService.findByName(skillName); + if (skill == null) { + throw new IllegalArgumentException("Skill not found: " + skillName); + } + if (Boolean.FALSE.equals(skill.getEnabled())) { + throw new IllegalStateException("Skill is disabled: " + skillName); + } + String scan = skill.getSecurityScanStatus(); + if (scan != null && BLOCKED_SCAN_STATUSES.contains(scan.trim().toUpperCase())) { + throw new IllegalStateException("Skill failed its security scan and cannot run in a pipeline: " + + skillName + " (" + scan + ")"); + } + // Reject any attempt to run the skill's script from a pipeline — not in MVP. + Object runScript = context.stepConfig().get("run_script"); + if (Boolean.TRUE.equals(runScript) || "true".equalsIgnoreCase(String.valueOf(runScript))) { + throw new IllegalStateException("Script execution is not permitted for pipeline skill steps " + + "(requires a sandbox + approval); skill: " + skillName); + } + String content = skill.getSkillContent(); + if (content == null || content.isBlank()) { + content = skill.getDescription(); + } + log.info("[WikiPipeline] skill step '{}' contributed content from skill '{}'", + context.stepId(), skillName); + return content == null ? "" : content; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/pipeline/WikiSkillStepExecutorTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/pipeline/WikiSkillStepExecutorTest.java new file mode 100644 index 00000000..2ea48913 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/pipeline/WikiSkillStepExecutorTest.java @@ -0,0 +1,81 @@ +package vip.mate.wiki.pipeline; + +import org.junit.jupiter.api.Test; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.service.SkillService; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link WikiSkillStepExecutor}: returns the skill's content + * when allowed, and refuses disabled / scan-failed skills and script execution. + */ +class WikiSkillStepExecutorTest { + + private SkillEntity skill(String name, Boolean enabled, String scan, String content) { + SkillEntity s = new SkillEntity(); + s.setName(name); + s.setEnabled(enabled); + s.setSecurityScanStatus(scan); + s.setSkillContent(content); + return s; + } + + private WikiSkillStepExecutor executor(SkillEntity skill) { + SkillService service = mock(SkillService.class); + when(service.findByName("wiki-link-enrich")).thenReturn(skill); + return new WikiSkillStepExecutor(service); + } + + private WikiStepContext ctx(Map config) { + return new WikiStepContext(1L, 42L, "s1", config, "prior"); + } + + @Test + void returnsSkillContent_whenAllowed() throws Exception { + WikiSkillStepExecutor e = executor(skill("wiki-link-enrich", true, "PASSED", "do the thing")); + assertEquals("do the thing", e.execute(ctx(Map.of("skill", "wiki-link-enrich")))); + } + + @Test + void missingSkillName_throws() { + WikiSkillStepExecutor e = executor(skill("wiki-link-enrich", true, "PASSED", "x")); + assertThrows(IllegalArgumentException.class, () -> e.execute(ctx(Map.of()))); + } + + @Test + void unknownSkill_throws() { + WikiSkillStepExecutor e = executor(skill("wiki-link-enrich", true, "PASSED", "x")); + assertThrows(IllegalArgumentException.class, + () -> e.execute(ctx(Map.of("skill", "does-not-exist")))); + } + + @Test + void disabledSkill_throws() { + WikiSkillStepExecutor e = executor(skill("wiki-link-enrich", false, "PASSED", "x")); + assertThrows(IllegalStateException.class, + () -> e.execute(ctx(Map.of("skill", "wiki-link-enrich")))); + } + + @Test + void scanFailedSkill_throws() { + WikiSkillStepExecutor e = executor(skill("wiki-link-enrich", true, "FAILED", "x")); + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> e.execute(ctx(Map.of("skill", "wiki-link-enrich")))); + assertTrue(ex.getMessage().contains("security scan")); + } + + @Test + void scriptExecutionRequest_isRefused() { + WikiSkillStepExecutor e = executor(skill("wiki-link-enrich", true, "PASSED", "x")); + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> e.execute(ctx(Map.of("skill", "wiki-link-enrich", "run_script", true)))); + assertTrue(ex.getMessage().contains("Script execution is not permitted")); + } +}