feat(agent-core): add WorkspaceFileTools with path traversal guard

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
everywhere.z
2026-04-19 19:41:48 +08:00
parent 7740bab36d
commit 3b4c283ae4
2 changed files with 164 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
package com.yomahub.liteflow.agent.tool;
import com.yomahub.liteflow.property.agent.AgentConfig;
import io.agentscope.core.tool.Tool;
import io.agentscope.core.tool.ToolParam;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.ArrayList;
import java.util.List;
public class WorkspaceFileTools {
private final Path workspace;
private final long maxBytes;
private final int maxList;
public WorkspaceFileTools(Path workspace, AgentConfig cfg) {
this.workspace = workspace.toAbsolutePath().normalize();
this.maxBytes = cfg.getWorkspace().getMaxFileBytes();
this.maxList = cfg.getWorkspace().getMaxListSize();
}
@Tool(name = "read_file", description = "Read a text file in the current workspace")
public String readFile(
@ToolParam(name = "path", description = "Relative path") String path) {
Path p = resolveSafe(path);
try {
long size = Files.size(p);
if (size > maxBytes) {
byte[] buf = new byte[(int) maxBytes];
try (var in = Files.newInputStream(p)) {
int read = in.read(buf);
return new String(buf, 0, Math.max(0, read), StandardCharsets.UTF_8);
}
}
return Files.readString(p, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new RuntimeException("read_file failed: " + e.getMessage(), e);
}
}
@Tool(name = "write_file", description = "Write text to a file in the current workspace (overwrite)")
public String writeFile(
@ToolParam(name = "path", description = "Relative path") String path,
@ToolParam(name = "content", description = "File content") String content) {
Path p = resolveSafe(path);
try {
Files.createDirectories(p.getParent());
Files.writeString(p, content, StandardCharsets.UTF_8);
return "ok";
} catch (IOException e) {
throw new RuntimeException("write_file failed: " + e.getMessage(), e);
}
}
@Tool(name = "list_files", description = "List files in a workspace directory")
public List<String> listFiles(
@ToolParam(name = "path", required = false, description = "Relative path; defaults to current dir") String path) {
Path dir = resolveSafe(path == null || path.isEmpty() ? "." : path);
List<String> out = new ArrayList<>();
try (var ds = Files.newDirectoryStream(dir)) {
for (Path p : ds) {
out.add(workspace.relativize(p).toString());
if (out.size() >= maxList) break;
}
} catch (IOException e) {
throw new RuntimeException("list_files failed: " + e.getMessage(), e);
}
return out;
}
@Tool(name = "delete_file", description = "Delete a file in the current workspace")
public String deleteFile(
@ToolParam(name = "path", description = "Relative path") String path) {
Path p = resolveSafe(path);
try {
Files.deleteIfExists(p);
return "ok";
} catch (IOException e) {
throw new RuntimeException("delete_file failed: " + e.getMessage(), e);
}
}
private Path resolveSafe(String rel) {
if (rel == null) throw new SecurityException("path is null");
if (rel.startsWith("/")) throw new SecurityException("absolute path denied: " + rel);
Path abs = workspace.resolve(rel).toAbsolutePath().normalize();
if (!abs.startsWith(workspace)) {
throw new SecurityException("path escapes workspace: " + rel);
}
return abs;
}
}

View File

@@ -0,0 +1,69 @@
package com.yomahub.liteflow.agent.tool;
import com.yomahub.liteflow.property.agent.AgentConfig;
import com.yomahub.liteflow.property.agent.WorkspaceConfig;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
class WorkspaceFileToolsTest {
@TempDir Path tmp;
private WorkspaceFileTools newTool(Path ws, long maxBytes, int maxList) {
AgentConfig cfg = new AgentConfig();
WorkspaceConfig w = new WorkspaceConfig();
w.setMaxFileBytes(maxBytes);
w.setMaxListSize(maxList);
cfg.setWorkspace(w);
return new WorkspaceFileTools(ws, cfg);
}
@Test
void write_then_read_round_trip() {
WorkspaceFileTools t = newTool(tmp, 1024, 10);
t.writeFile("a.txt", "hello");
assertEquals("hello", t.readFile("a.txt"));
}
@Test
void path_traversal_rejected() {
WorkspaceFileTools t = newTool(tmp, 1024, 10);
assertThrows(SecurityException.class, () -> t.readFile("../escape"));
assertThrows(SecurityException.class, () -> t.writeFile("../../evil", "x"));
assertThrows(SecurityException.class, () -> t.deleteFile("../../evil"));
}
@Test
void absolute_path_rejected() {
WorkspaceFileTools t = newTool(tmp, 1024, 10);
assertThrows(SecurityException.class, () -> t.readFile("/etc/passwd"));
}
@Test
void read_truncates_oversize_file() throws IOException {
WorkspaceFileTools t = newTool(tmp, 4, 10);
Files.writeString(tmp.resolve("big.txt"), "1234567890");
String out = t.readFile("big.txt");
assertTrue(out.length() <= 4, "exceeds maxFileBytes, must truncate");
}
@Test
void list_limits_entries() throws IOException {
WorkspaceFileTools t = newTool(tmp, 1024, 3);
for (int i = 0; i < 5; i++) Files.writeString(tmp.resolve("f" + i + ".txt"), "x");
java.util.List<String> list = t.listFiles(".");
assertTrue(list.size() <= 3, "exceeds maxListSize, must truncate");
}
@Test
void delete_nonexistent_is_ok() {
WorkspaceFileTools t = newTool(tmp, 1024, 10);
assertDoesNotThrow(() -> t.deleteFile("nope.txt"));
}
}