feat(wiki): restricted Skill pipeline step executor

This commit is contained in:
matevip 2026-05-31 07:56:51 +08:00
parent 15a8b2d73c
commit 526a361488
2 changed files with 154 additions and 0 deletions

View File

@ -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.
*
* <p><b>Scope (MVP, security-restricted)</b>: 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
* <b>not</b> 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<String> 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;
}
}

View File

@ -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<String, Object> 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"));
}
}