From ec47d19bf8a0d6a7b97605a05617637635f948c9 Mon Sep 17 00:00:00 2001 From: matevip Date: Sun, 31 May 2026 07:53:59 +0800 Subject: [PATCH] feat(wiki): gate wiki write/mutate tools by pageType permission --- .../java/vip/mate/wiki/tool/WikiTool.java | 105 ++++++++++++ .../wiki/tool/WikiToolPermissionTest.java | 155 ++++++++++++++++++ 2 files changed, 260 insertions(+) create mode 100644 mateclaw-server/src/test/java/vip/mate/wiki/tool/WikiToolPermissionTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java index fece36d4..2e28a671 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java @@ -450,6 +450,14 @@ public class WikiTool { if (kbRes.hasError()) return kbRes.errorJson(); kbId = kbRes.kbId(); + // wiki_create_page does not take an explicit pageType, so creation is + // governed by the agent's wildcard ('*') write rule for this KB. + String createErr = checkWrite(agentId, kbId, null, + WikiPageTypePermissionService.WriteOp.CREATE); + if (createErr != null) { + return createErr; + } + String slug = title.toLowerCase() .replaceAll("[^a-z0-9\\u4e00-\\u9fff]+", "-") .replaceAll("^-|-$", ""); @@ -501,6 +509,12 @@ public class WikiTool { kbId = kbRes.kbId(); if (compileService == null) return error("Compile service not available"); + String compileErr = checkWrite(agentId, kbId, null, + WikiPageTypePermissionService.WriteOp.CREATE); + if (compileErr != null) { + return compileErr; + } + try { WikiCompileService.CompileResult res = compileService.compilePage(kbId, topic, slug, maxEvidenceChunks); // RFC-051 follow-up: distinguish "no source material" from a hard error @@ -613,6 +627,19 @@ public class WikiTool { KbResolution kbRes = resolveKb(agentId, kbName, kbId); if (kbRes.hasError()) return kbRes.errorJson(); kbId = kbRes.kbId(); + // Archiving toggles visibility — gate it as an update, and hide pages + // whose type the agent cannot read. + WikiPageEntity target = pageService.getBySlug(kbId, slug); + if (target != null) { + if (!canRead(pageTypeAccess(agentId, kbId), target)) { + return error("Page not found: " + slug); + } + String writeErr = checkWrite(agentId, kbId, target.getPageType(), + WikiPageTypePermissionService.WriteOp.UPDATE); + if (writeErr != null) { + return writeErr; + } + } boolean changed; try { changed = pageService.setArchived(kbId, slug, archive); @@ -648,6 +675,15 @@ public class WikiTool { if (page == null) { return error("Page not found: " + slug); } + // Unreadable page types must not even be discoverable as delete targets. + if (!canRead(pageTypeAccess(agentId, kbId), page)) { + return error("Page not found: " + slug); + } + String writeErr = checkWrite(agentId, kbId, page.getPageType(), + WikiPageTypePermissionService.WriteOp.DELETE); + if (writeErr != null) { + return writeErr; + } if ("manual".equals(page.getLastUpdatedBy())) { return error("Cannot delete manually curated page: " + page.getTitle() + ". Please manage via admin UI."); @@ -763,6 +799,14 @@ public class WikiTool { WikiPageEntity page = pageService.getBySlug(kbId, slug); if (page == null) return error("Page not found: " + slug); + if (!canRead(pageTypeAccess(agentId, kbId), page)) { + return error("Page not found: " + slug); + } + String enrichErr = checkWrite(agentId, kbId, page.getPageType(), + WikiPageTypePermissionService.WriteOp.UPDATE); + if (enrichErr != null) { + return enrichErr; + } Long rawId = 0L; try { @@ -829,6 +873,13 @@ public class WikiTool { return error("Transformations not available"); } + // A transformation persists a synthesis run/page — gate as a create. + String txErr = checkWrite(agentId, kbId, null, + WikiPageTypePermissionService.WriteOp.CREATE); + if (txErr != null) { + return txErr; + } + WikiKnowledgeBaseEntity kb = kbService.getById(kbId); Long wsId = (kb == null || kb.getWorkspaceId() == null) ? 1L : kb.getWorkspaceId(); @@ -879,6 +930,15 @@ public class WikiTool { WikiPageEntity page = pageService.getBySlug(kbId, slug); if (page == null) return error("Page not found: " + slug); + if (!canRead(pageTypeAccess(agentId, kbId), page)) { + return error("Page not found: " + slug); + } + // Reads the source page and persists a derived run — gate as a create. + String txErr = checkWrite(agentId, kbId, null, + WikiPageTypePermissionService.WriteOp.CREATE); + if (txErr != null) { + return txErr; + } WikiKnowledgeBaseEntity kb = kbService.getById(kbId); Long wsId = (kb == null || kb.getWorkspaceId() == null) ? 1L : kb.getWorkspaceId(); @@ -928,6 +988,13 @@ public class WikiTool { return error("Transformations not available"); } + // Aggregation upserts a synthesis page — gate as a create. + String aggErr = checkWrite(agentId, kbId, null, + WikiPageTypePermissionService.WriteOp.CREATE); + if (aggErr != null) { + return aggErr; + } + WikiKnowledgeBaseEntity kb = kbService.getById(kbId); Long wsId = (kb == null || kb.getWorkspaceId() == null) ? 1L : kb.getWorkspaceId(); @@ -1002,6 +1069,44 @@ public class WikiTool { return access == null || access.canRead(pageType); } + /** + * Gate a write/mutate operation by pageType permission. Returns an error + * JSON string to short-circuit the tool when the write is not permitted, or + * {@code null} when it may proceed. Null-safe: when the permission service + * is absent, every write is allowed (pre-permission behaviour). + * + *

{@code APPROVAL_REQUIRED} currently fails closed with an explanatory + * message rather than opening a pending approval — the deferred + * approve-then-execute flow needs a conversation context the wiki tools do + * not yet receive. The permission row still records the intent so the + * deferred flow can be wired later without a schema change. + */ + private String checkWrite(Long agentId, Long kbId, String pageType, + WikiPageTypePermissionService.WriteOp op) { + if (pageTypePermissionService == null) { + return null; + } + WikiPageTypePermissionService.WriteDecision decision = + pageTypePermissionService.resolveWrite(agentId, kbId, pageType, op); + String typeLabel = (pageType == null || pageType.isBlank()) ? "(default)" : pageType; + return switch (decision) { + case ALLOW -> null; + case DENY -> { + log.info("[WikiTool] write denied by pageType permission: agent={} kb={} type={} op={}", + agentId, kbId, pageType, op); + yield error("Not permitted: this agent may not " + op.name().toLowerCase() + + " '" + typeLabel + "' pages in this knowledge base."); + } + case APPROVAL_REQUIRED -> { + log.info("[WikiTool] write requires approval (blocked): agent={} kb={} type={} op={}", + agentId, kbId, pageType, op); + yield error("Approval required: " + op.name().toLowerCase() + " of '" + typeLabel + + "' pages in this knowledge base needs administrator approval. " + + "The operation was NOT performed."); + } + }; + } + /** * Single helper every wiki tool uses. Caller passes the agent id and at * most one of {@code kbId} / {@code kbName}; the helper decides which diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/tool/WikiToolPermissionTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/tool/WikiToolPermissionTest.java new file mode 100644 index 00000000..b9831ffd --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/tool/WikiToolPermissionTest.java @@ -0,0 +1,155 @@ +package vip.mate.wiki.tool; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import vip.mate.wiki.model.WikiAgentPageTypePermissionEntity; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.repository.WikiAgentPageTypePermissionMapper; +import vip.mate.wiki.service.HybridRetriever; +import vip.mate.wiki.service.WikiKnowledgeBaseService; +import vip.mate.wiki.service.WikiPageService; +import vip.mate.wiki.service.WikiPageTypePermissionService; +import vip.mate.wiki.service.WikiRawMaterialService; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Verifies the pageType permission gate wired into {@link WikiTool} read and + * write tools, using a real {@link WikiPageTypePermissionService} backed by a + * mocked mapper so permission rows are controlled directly. + */ +class WikiToolPermissionTest { + + private static final long AGENT = 11L; + private static final long KB = 7L; + + private record Harness(WikiTool tool, WikiPageService pageService, + WikiAgentPageTypePermissionMapper permMapper) {} + + private Harness harness(List rows) { + WikiPageService pageService = mock(WikiPageService.class); + WikiKnowledgeBaseService kbService = mock(WikiKnowledgeBaseService.class); + WikiRawMaterialService rawService = mock(WikiRawMaterialService.class); + HybridRetriever retriever = mock(HybridRetriever.class); + ObjectMapper om = new ObjectMapper(); + + WikiKnowledgeBaseEntity kb = new WikiKnowledgeBaseEntity(); + kb.setId(KB); + when(kbService.findVisibleById(AGENT, KB)).thenReturn(kb); + when(kbService.getById(KB)).thenReturn(kb); + + WikiAgentPageTypePermissionMapper permMapper = mock(WikiAgentPageTypePermissionMapper.class); + when(permMapper.selectList(any())).thenReturn(rows); + WikiPageTypePermissionService permService = + new WikiPageTypePermissionService(permMapper, kbService, om); + + WikiTool tool = new WikiTool(pageService, kbService, rawService, retriever, om); + ReflectionTestUtils.setField(tool, "pageTypePermissionService", permService); + return new Harness(tool, pageService, permMapper); + } + + private WikiAgentPageTypePermissionEntity row(String type, int read, int create, int update, + int delete, String writePolicy) { + WikiAgentPageTypePermissionEntity e = new WikiAgentPageTypePermissionEntity(); + e.setAgentId(AGENT); + e.setKbId(KB); + e.setPageType(type); + e.setCanRead(read); + e.setCanCreate(create); + e.setCanUpdate(update); + e.setCanDelete(delete); + e.setWritePolicy(writePolicy); + return e; + } + + private WikiPageEntity page(String slug, String type) { + WikiPageEntity p = new WikiPageEntity(); + p.setId(100L); + p.setKbId(KB); + p.setSlug(slug); + p.setTitle("T " + slug); + p.setContent("body"); + p.setPageType(type); + p.setLastUpdatedBy("ai"); + return p; + } + + @Test + void readPage_unreadableType_reportsNotFound() { + Harness h = harness(List.of(row("*", 0, 0, 0, 0, "deny"))); + when(h.pageService().getBySlug(KB, "secret")).thenReturn(page("secret", "analysis")); + + String out = h.tool().wiki_read_page(AGENT, "secret", null, null, null, KB); + + assertTrue(out.contains("Page not found"), out); + } + + @Test + void readPage_readableType_returnsContent() { + Harness h = harness(List.of(row("*", 1, 0, 0, 0, "deny"))); + when(h.pageService().getBySlug(KB, "ok")).thenReturn(page("ok", "concept")); + + String out = h.tool().wiki_read_page(AGENT, "ok", null, null, null, KB); + + assertTrue(out.contains("\"content\""), out); + assertFalse(out.contains("Page not found"), out); + } + + @Test + void deletePage_denied_doesNotDelete() { + // can read, but delete flag off → DENY + Harness h = harness(List.of(row("concept", 1, 0, 0, 0, "deny"))); + when(h.pageService().getBySlug(KB, "p")).thenReturn(page("p", "concept")); + + String out = h.tool().wiki_delete_page(AGENT, "p", null, KB); + + assertTrue(out.contains("Not permitted"), out); + verify(h.pageService(), never()).delete(anyLong(), any()); + } + + @Test + void deletePage_approvalRequired_blocksAndDoesNotDelete() { + Harness h = harness(List.of(row("concept", 1, 0, 0, 1, "approval_required"))); + when(h.pageService().getBySlug(KB, "p")).thenReturn(page("p", "concept")); + + String out = h.tool().wiki_delete_page(AGENT, "p", null, KB); + + assertTrue(out.contains("Approval required"), out); + verify(h.pageService(), never()).delete(anyLong(), any()); + } + + @Test + void deletePage_allowed_deletes() { + Harness h = harness(List.of(row("concept", 1, 1, 1, 1, "allow"))); + when(h.pageService().getBySlug(KB, "p")).thenReturn(page("p", "concept")); + + String out = h.tool().wiki_delete_page(AGENT, "p", null, KB); + + assertTrue(out.contains("\"ok\":true"), out); + verify(h.pageService(), times(1)).delete(eq(KB), eq("p")); + } + + @Test + void createPage_deniedByWildcard_doesNotCreate() { + // a row exists for 'episode' only → KB is gated, wildcard create not granted + Harness h = harness(List.of(row("episode", 1, 1, 1, 1, "allow"))); + + String out = h.tool().wiki_create_page(AGENT, "New Page", "content here", null, KB); + + assertTrue(out.contains("Not permitted"), out); + verify(h.pageService(), never()).createPage(anyLong(), any(), any(), any(), any(), any()); + } +}