From 0356fcd6ec89404b630a765a0000eaa16ab09365 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Sat, 1 Aug 2026 20:09:55 +0800 Subject: [PATCH 1/4] fix(tool): handle grep file targets --- .../tool/builtin/RipgrepSearchRunner.java | 48 ++++++++++-- .../cn/lypi/tool/builtin/GrepToolTest.java | 33 +++++++++ .../tool/builtin/RipgrepSearchRunnerTest.java | 73 +++++++++++++++++++ 3 files changed, 147 insertions(+), 7 deletions(-) diff --git a/lypi-tool/src/main/java/cn/lypi/tool/builtin/RipgrepSearchRunner.java b/lypi-tool/src/main/java/cn/lypi/tool/builtin/RipgrepSearchRunner.java index 687c231c..05938947 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/builtin/RipgrepSearchRunner.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/builtin/RipgrepSearchRunner.java @@ -8,9 +8,12 @@ import cn.lypi.contracts.runtime.NetworkMode; import cn.lypi.contracts.runtime.SandboxRuntimePolicy; import cn.lypi.contracts.tool.ToolUseContext; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -37,20 +40,23 @@ final class RipgrepSearchRunner { RipgrepSearchResult search(GrepQuery query, Path searchRoot, ToolUseContext context, ProgressSink progress) { RipgrepBinary binary; + SearchTarget target; try { binary = binaryResolver.resolve(context == null ? Map.of() : context.metadata()); + target = searchTarget(searchRoot); } catch (RuntimeException exception) { return RipgrepSearchResult.error(exception.getMessage()); } List command = new ArrayList<>(); command.add(binary.command()); command.addAll(commandBuilder.build(query)); + command.addAll(target.arguments()); ExecutionRequest request = new ExecutionRequest( command, - searchRoot, + target.cwd(), Map.of(), timeout, - readOnlyPolicy(searchRoot, binary) + readOnlyPolicy(searchRoot, target.cwd(), binary) ); ExecutionResult result = executor.execute(request, progress, abortSignal(context)); if (result.timedOut()) { @@ -66,9 +72,23 @@ RipgrepSearchResult search(GrepQuery query, Path searchRoot, ToolUseContext cont return RipgrepSearchResult.error(message); } - private SandboxRuntimePolicy readOnlyPolicy(Path searchRoot, RipgrepBinary binary) { + private SearchTarget searchTarget(Path searchRoot) { + Path target = Objects.requireNonNull(searchRoot, "searchRoot must not be null") + .toAbsolutePath() + .normalize(); + if (Files.isDirectory(target)) { + return new SearchTarget(target, List.of()); + } + Path parent = target.getParent(); + if (parent == null || !Files.isDirectory(parent)) { + throw new IllegalArgumentException("搜索目标的父路径不是目录: " + target); + } + return new SearchTarget(parent, List.of("--with-filename", "--", target.toString())); + } + + private SandboxRuntimePolicy readOnlyPolicy(Path searchRoot, Path executionCwd, RipgrepBinary binary) { return new SandboxRuntimePolicy( - readOnlyPaths(searchRoot, binary), + readOnlyPaths(searchRoot, executionCwd, binary), List.of(), List.of(), List.of(), @@ -78,8 +98,8 @@ private SandboxRuntimePolicy readOnlyPolicy(Path searchRoot, RipgrepBinary binar ); } - private List readOnlyPaths(Path searchRoot, RipgrepBinary binary) { - List paths = new ArrayList<>(); + private List readOnlyPaths(Path searchRoot, Path executionCwd, RipgrepBinary binary) { + LinkedHashSet paths = new LinkedHashSet<>(); paths.add(Path.of("/usr")); paths.add(Path.of("/bin")); paths.add(Path.of("/sbin")); @@ -88,7 +108,14 @@ private List readOnlyPaths(Path searchRoot, RipgrepBinary binary) { paths.add(Path.of("/etc")); paths.add(Path.of("/nix/store")); paths.add(Path.of("/run/current-system/sw")); - paths.add(searchRoot); + paths.add(executionCwd.toAbsolutePath().normalize()); + Path lexicalTarget = searchRoot.toAbsolutePath().normalize(); + paths.add(lexicalTarget); + try { + paths.add(lexicalTarget.toRealPath()); + } catch (IOException ignored) { + // GrepTool validates existence before reaching the runner; retain the lexical mount on races. + } Path binaryParent = binaryParent(binary); if (binaryParent != null) { paths.add(binaryParent); @@ -126,4 +153,11 @@ private List lines(String stdout) { } return List.copyOf(lines); } + + private record SearchTarget(Path cwd, List arguments) { + private SearchTarget { + cwd = Objects.requireNonNull(cwd, "cwd must not be null"); + arguments = arguments == null ? List.of() : List.copyOf(arguments); + } + } } diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/GrepToolTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/GrepToolTest.java index 0d3cbff5..b4dd2625 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/builtin/GrepToolTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/GrepToolTest.java @@ -114,6 +114,39 @@ void rejectsMissingSearchPath() throws Exception { assertTrue(result.output().contains("搜索路径不存在")); } + @Test + void relativeAndAbsoluteFilePathsUseTheSameDirectoryCwd() throws Exception { + vendorBinary(); + Path nested = Files.createDirectories(tempDir.resolve("src/pkg")); + Path file = Files.writeString(nested.resolve("sample.py"), "needle\n"); + RecordingExecutor relativeExecutor = new RecordingExecutor( + new ExecutionResult(1, "", "", false, Optional.empty()) + ); + RecordingExecutor absoluteExecutor = new RecordingExecutor( + new ExecutionResult(1, "", "", false, Optional.empty()) + ); + + ToolResult relativeResult = tool(relativeExecutor).execute( + Map.of("pattern", "needle", "path", "src/pkg/sample.py"), + context(), + message -> { + } + ); + ToolResult absoluteResult = tool(absoluteExecutor).execute( + Map.of("pattern", "needle", "path", file.toString()), + context(), + message -> { + } + ); + + assertFalse(relativeResult.isError()); + assertFalse(absoluteResult.isError()); + assertEquals(nested, relativeExecutor.request.cwd()); + assertEquals(nested, absoluteExecutor.request.cwd()); + assertEquals(file.toString(), relativeExecutor.request.command().getLast()); + assertEquals(file.toString(), absoluteExecutor.request.command().getLast()); + } + @Test void doesNotSearchSymlinkDirectoryOutsideWorkspace(@TempDir Path outsideDir) throws Exception { vendorBinary(); diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/RipgrepSearchRunnerTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/RipgrepSearchRunnerTest.java index 3f65b619..b3343bbc 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/builtin/RipgrepSearchRunnerTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/RipgrepSearchRunnerTest.java @@ -51,6 +51,79 @@ void exitZeroReturnsStdoutLinesAndBuildsExecutionRequest() throws Exception { assertTrue(executor.request.sandboxPolicy().allowRead().contains(binary.getParent())); } + @Test + void fileSearchUsesParentDirectoryAndAbsoluteTargetOperand() throws Exception { + Path binary = vendorBinary(); + Path nested = Files.createDirectories(tempDir.resolve("src/pkg")); + Path file = Files.writeString(nested.resolve("sample.py"), "needle\n"); + RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); + + RipgrepSearchResult result = runner(executor).search( + GrepQuery.fromInput(Map.of("pattern", "needle")), + file, + context(), + message -> { + } + ); + + assertFalse(result.isError()); + assertEquals(nested, executor.request.cwd()); + assertEquals(binary.toAbsolutePath().normalize().toString(), executor.request.command().getFirst()); + assertTrue(executor.request.command().contains("--with-filename")); + assertEquals( + List.of("--", file.toAbsolutePath().normalize().toString()), + executor.request.command().subList( + executor.request.command().size() - 2, + executor.request.command().size() + ) + ); + assertTrue(executor.request.sandboxPolicy().allowRead().contains(file)); + assertTrue(executor.request.sandboxPolicy().allowRead().contains(nested)); + } + + @Test + void directorySearchKeepsDirectoryAsWorkingDirectoryWithoutTargetOperand() throws Exception { + vendorBinary(); + Path nested = Files.createDirectories(tempDir.resolve("src/pkg")); + RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); + + RipgrepSearchResult result = runner(executor).search( + GrepQuery.fromInput(Map.of("pattern", "needle")), + nested, + context(), + message -> { + } + ); + + assertFalse(result.isError()); + assertEquals(nested, executor.request.cwd()); + assertFalse(executor.request.command().contains(nested.toString())); + } + + @Test + void fileSymlinkMountsLexicalAndRealTargets() throws Exception { + vendorBinary(); + Path realDirectory = Files.createDirectories(tempDir.resolve("real")); + Path realFile = Files.writeString(realDirectory.resolve("sample.py"), "needle\n"); + Path linkDirectory = Files.createDirectories(tempDir.resolve("links")); + Path link = Files.createSymbolicLink(linkDirectory.resolve("sample-link.py"), realFile); + RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); + + RipgrepSearchResult result = runner(executor).search( + GrepQuery.fromInput(Map.of("pattern", "needle")), + link, + context(), + message -> { + } + ); + + assertFalse(result.isError()); + assertEquals(linkDirectory, executor.request.cwd()); + assertEquals(link.toString(), executor.request.command().getLast()); + assertTrue(executor.request.sandboxPolicy().allowRead().contains(link)); + assertTrue(executor.request.sandboxPolicy().allowRead().contains(realFile.toRealPath())); + } + @Test void exitOneIsNoMatchSuccess() throws Exception { vendorBinary(); From c49617795e28e941bc1f299b28224d64d4af77f0 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Sat, 1 Aug 2026 20:15:15 +0800 Subject: [PATCH 2/4] fix(tool): resolve absolute ripgrep executables --- .../tool/builtin/RipgrepBinaryResolver.java | 101 +++++++++++++++--- .../tool/builtin/RipgrepSearchRunner.java | 2 +- .../builtin/RipgrepBinaryResolverTest.java | 79 ++++++++++++-- .../tool/builtin/RipgrepSearchRunnerTest.java | 38 +++++++ 4 files changed, 200 insertions(+), 20 deletions(-) diff --git a/lypi-tool/src/main/java/cn/lypi/tool/builtin/RipgrepBinaryResolver.java b/lypi-tool/src/main/java/cn/lypi/tool/builtin/RipgrepBinaryResolver.java index 42f25cf2..146fde6a 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/builtin/RipgrepBinaryResolver.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/builtin/RipgrepBinaryResolver.java @@ -1,15 +1,20 @@ package cn.lypi.tool.builtin; +import java.io.File; import java.io.IOException; import java.io.InputStream; import java.net.URISyntaxException; import java.net.URL; import java.nio.file.FileSystemNotFoundException; import java.nio.file.Files; +import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.regex.Pattern; final class RipgrepBinaryResolver { static final String MODE_KEY = "lypi.tool.grep.ripgrep.mode"; @@ -18,17 +23,20 @@ final class RipgrepBinaryResolver { private final Path resourceRoot; private final Path cacheRoot; private final ClassLoader classLoader; + private final List systemSearchPath; private RipgrepBinaryResolver( RipgrepPlatform platform, Path resourceRoot, Path cacheRoot, - ClassLoader classLoader + ClassLoader classLoader, + List systemSearchPath ) { this.platform = Objects.requireNonNull(platform, "platform must not be null"); - this.resourceRoot = Objects.requireNonNull(resourceRoot, "resourceRoot must not be null"); - this.cacheRoot = Objects.requireNonNull(cacheRoot, "cacheRoot must not be null"); + this.resourceRoot = absolutePath(resourceRoot, "resourceRoot"); + this.cacheRoot = absolutePath(cacheRoot, "cacheRoot"); this.classLoader = Objects.requireNonNull(classLoader, "classLoader must not be null"); + this.systemSearchPath = normalizeSearchPath(systemSearchPath); } static RipgrepBinaryResolver defaults() { @@ -36,7 +44,8 @@ static RipgrepBinaryResolver defaults() { RipgrepPlatform.current(), Path.of("lypi-tool", "src", "main", "resources"), Path.of(".lypi", "cache", "ripgrep"), - RipgrepBinaryResolver.class.getClassLoader() + RipgrepBinaryResolver.class.getClassLoader(), + defaultSystemSearchPath() ); } @@ -45,7 +54,8 @@ static RipgrepBinaryResolver forTesting(RipgrepPlatform platform, Path resourceR platform, resourceRoot, resourceRoot.resolve(".lypi-cache"), - RipgrepBinaryResolver.class.getClassLoader() + RipgrepBinaryResolver.class.getClassLoader(), + List.of() ); } @@ -55,22 +65,33 @@ static RipgrepBinaryResolver forTesting( Path cacheRoot, ClassLoader classLoader ) { - return new RipgrepBinaryResolver(platform, resourceRoot, cacheRoot, classLoader); + return new RipgrepBinaryResolver(platform, resourceRoot, cacheRoot, classLoader, List.of()); + } + + static RipgrepBinaryResolver forTesting( + RipgrepPlatform platform, + Path resourceRoot, + Path cacheRoot, + ClassLoader classLoader, + List systemSearchPath + ) { + return new RipgrepBinaryResolver(platform, resourceRoot, cacheRoot, classLoader, systemSearchPath); } RipgrepBinary resolve(Map options) { String mode = mode(options); if ("system".equals(mode)) { - return new RipgrepBinary("rg", "system"); + return systemBinary(); } String resourcePath = platform.resourcePath(); Path binary = resourceRoot.resolve(resourcePath).normalize(); if (Files.isRegularFile(binary) && Files.isExecutable(binary)) { - return new RipgrepBinary(binary.toString(), "vendor"); + return new RipgrepBinary(requireExecutableFile(binary, "随包").toString(), "vendor"); } URL resource = classLoader.getResource(resourcePath); if (resource != null) { - return new RipgrepBinary(executableResource(resourcePath, resource).toString(), "vendor"); + Path executable = requireExecutableFile(executableResource(resourcePath, resource), "随包"); + return new RipgrepBinary(executable.toString(), "vendor"); } throw new IllegalStateException("未找到随包 ripgrep: " + platform.platformId() + ",可临时设置 " + MODE_KEY + "=system 使用系统 rg。"); @@ -80,11 +101,11 @@ private Path executableResource(String resourcePath, URL resource) { Path direct = directPath(resource); if (direct != null && Files.isRegularFile(direct)) { makeExecutable(direct); - return direct; + return requireExecutableFile(direct, "随包"); } Path cached = cacheRoot.resolve("current").resolve(platform.platformId()).resolve(platform.executableName()); if (Files.isRegularFile(cached) && Files.isExecutable(cached)) { - return cached; + return requireExecutableFile(cached, "缓存"); } try { Files.createDirectories(cached.getParent()); @@ -92,12 +113,22 @@ private Path executableResource(String resourcePath, URL resource) { Files.copy(input, cached, StandardCopyOption.REPLACE_EXISTING); } makeExecutable(cached); - return cached; + return requireExecutableFile(cached, "缓存"); } catch (IOException exception) { throw new IllegalStateException("无法缓存随包 ripgrep: " + resourcePath + " -> " + cached, exception); } } + private RipgrepBinary systemBinary() { + for (Path directory : systemSearchPath) { + Path candidate = directory.resolve(platform.executableName()).normalize(); + if (Files.isRegularFile(candidate) && Files.isExecutable(candidate)) { + return new RipgrepBinary(requireExecutableFile(candidate, "系统").toString(), "system"); + } + } + throw new IllegalStateException("未找到可执行的系统 ripgrep: " + platform.platformId()); + } + private Path directPath(URL resource) { if (!"file".equals(resource.getProtocol())) { return null; @@ -115,6 +146,52 @@ private void makeExecutable(Path binary) { } } + private Path requireExecutableFile(Path candidate, String source) { + Path absolute = candidate.toAbsolutePath().normalize(); + if (!Files.isRegularFile(absolute) || !Files.isExecutable(absolute)) { + throw new IllegalStateException(source + " ripgrep 不是可执行普通文件: " + absolute); + } + try { + return absolute.toRealPath(); + } catch (IOException exception) { + throw new IllegalStateException("无法解析" + source + " ripgrep: " + absolute, exception); + } + } + + private static Path absolutePath(Path path, String name) { + return Objects.requireNonNull(path, name + " must not be null").toAbsolutePath().normalize(); + } + + private static List normalizeSearchPath(List searchPath) { + if (searchPath == null || searchPath.isEmpty()) { + return List.of(); + } + return searchPath.stream() + .filter(Objects::nonNull) + .map(path -> path.toAbsolutePath().normalize()) + .distinct() + .toList(); + } + + private static List defaultSystemSearchPath() { + String value = System.getenv("PATH"); + if (value == null || value.isBlank()) { + return List.of(); + } + List paths = new ArrayList<>(); + for (String entry : value.split(Pattern.quote(File.pathSeparator))) { + if (entry.isBlank()) { + continue; + } + try { + paths.add(Path.of(entry)); + } catch (InvalidPathException ignored) { + // Ignore malformed PATH entries and continue searching valid directories. + } + } + return List.copyOf(paths); + } + private String mode(Map options) { Object option = options == null ? null : options.get(MODE_KEY); String value = option == null ? System.getProperty(MODE_KEY) : option.toString(); diff --git a/lypi-tool/src/main/java/cn/lypi/tool/builtin/RipgrepSearchRunner.java b/lypi-tool/src/main/java/cn/lypi/tool/builtin/RipgrepSearchRunner.java index 05938947..77460e2d 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/builtin/RipgrepSearchRunner.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/builtin/RipgrepSearchRunner.java @@ -124,7 +124,7 @@ private List readOnlyPaths(Path searchRoot, Path executionCwd, RipgrepBina } private Path binaryParent(RipgrepBinary binary) { - if (binary == null || binary.command() == null || binary.command().isBlank() || "system".equals(binary.mode())) { + if (binary == null || binary.command() == null || binary.command().isBlank()) { return null; } Path command = Path.of(binary.command()); diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/RipgrepBinaryResolverTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/RipgrepBinaryResolverTest.java index 727151ac..ec75125e 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/builtin/RipgrepBinaryResolverTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/RipgrepBinaryResolverTest.java @@ -1,6 +1,7 @@ package cn.lypi.tool.builtin; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -8,9 +9,10 @@ import java.net.URLClassLoader; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; +import java.util.Map; import java.util.jar.JarEntry; import java.util.jar.JarOutputStream; -import java.util.Map; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -32,12 +34,16 @@ void prefersVendorRipgrepByDefault() throws Exception { Path binary = vendorBinary("ripgrep/x86_64-linux/rg"); RipgrepBinaryResolver resolver = RipgrepBinaryResolver.forTesting( new RipgrepPlatform("linux", "x86_64"), - tempDir + relativeToWorkingDirectory(tempDir) ); RipgrepBinary command = resolver.resolve(Map.of()); - assertEquals(binary.toString(), command.command()); + Path resolved = Path.of(command.command()); + assertEquals(binary.toRealPath(), resolved); + assertTrue(resolved.isAbsolute()); + assertTrue(Files.isRegularFile(resolved)); + assertTrue(Files.isExecutable(resolved)); assertEquals("vendor", command.mode()); } @@ -50,11 +56,12 @@ void extractsClasspathRipgrepResourceToCacheWhenPackagedInJar() throws Exception output.closeEntry(); } Path cacheRoot = tempDir.resolve("cache"); + Path relativeCacheRoot = relativeToWorkingDirectory(cacheRoot); try (URLClassLoader classLoader = new URLClassLoader(new URL[] {jar.toUri().toURL()}, null)) { RipgrepBinaryResolver resolver = RipgrepBinaryResolver.forTesting( new RipgrepPlatform("linux", "x86_64"), tempDir.resolve("missing-resources"), - cacheRoot, + relativeCacheRoot, classLoader ); @@ -62,6 +69,7 @@ void extractsClasspathRipgrepResourceToCacheWhenPackagedInJar() throws Exception Path extracted = Path.of(command.command()); assertTrue(extracted.startsWith(cacheRoot)); + assertTrue(extracted.isAbsolute()); assertTrue(Files.isRegularFile(extracted)); assertTrue(Files.isExecutable(extracted)); assertEquals("vendor", command.mode()); @@ -96,18 +104,71 @@ void reusesExistingCachedRipgrepWithoutOverwritingRunningBinary() throws Excepti } @Test - void systemModeUsesCommandNameOnly() { + void systemModeResolvesAnAbsoluteExecutableFile() throws Exception { + Path systemDirectory = Files.createDirectories(tempDir.resolve("system-bin")); + Path executable = systemDirectory.resolve("rg"); + Files.writeString(executable, "#!/bin/sh\n"); + executable.toFile().setExecutable(true); RipgrepBinaryResolver resolver = RipgrepBinaryResolver.forTesting( new RipgrepPlatform("linux", "x86_64"), - tempDir + tempDir, + tempDir.resolve("cache"), + RipgrepBinaryResolver.class.getClassLoader(), + List.of(relativeToWorkingDirectory(systemDirectory)) ); RipgrepBinary command = resolver.resolve(Map.of("lypi.tool.grep.ripgrep.mode", "system")); - assertEquals("rg", command.command()); + Path resolved = Path.of(command.command()); + assertEquals(executable.toRealPath(), resolved); + assertTrue(resolved.isAbsolute()); + assertTrue(Files.isRegularFile(resolved)); + assertTrue(Files.isExecutable(resolved)); assertEquals("system", command.mode()); } + @Test + void systemModeRejectsDirectoriesAndNonExecutableFiles() throws Exception { + Path directoryCandidate = Files.createDirectories(tempDir.resolve("directory-bin/rg")); + Path nonExecutableDirectory = Files.createDirectories(tempDir.resolve("non-executable-bin")); + Path nonExecutable = Files.writeString(nonExecutableDirectory.resolve("rg"), "#!/bin/sh\n"); + assertTrue(Files.isDirectory(directoryCandidate)); + assertTrue(Files.isRegularFile(nonExecutable)); + assertFalse(Files.isExecutable(nonExecutable)); + RipgrepBinaryResolver resolver = RipgrepBinaryResolver.forTesting( + new RipgrepPlatform("linux", "x86_64"), + tempDir.resolve("missing-resources"), + tempDir.resolve("cache"), + RipgrepBinaryResolver.class.getClassLoader(), + List.of(directoryCandidate.getParent(), nonExecutableDirectory) + ); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> resolver.resolve(Map.of("lypi.tool.grep.ripgrep.mode", "system")) + ); + + assertTrue(exception.getMessage().contains("未找到可执行的系统 ripgrep")); + } + + @Test + void systemModeRejectsAnEmptySearchPath() { + RipgrepBinaryResolver resolver = RipgrepBinaryResolver.forTesting( + new RipgrepPlatform("linux", "x86_64"), + tempDir.resolve("missing-resources"), + tempDir.resolve("cache"), + RipgrepBinaryResolver.class.getClassLoader(), + List.of() + ); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> resolver.resolve(Map.of("lypi.tool.grep.ripgrep.mode", "system")) + ); + + assertTrue(exception.getMessage().contains("未找到可执行的系统 ripgrep")); + } + @Test void rejectsMissingVendorBinaryWithoutSystemOverride() throws Exception { try (URLClassLoader classLoader = new URLClassLoader(new URL[0], null)) { @@ -132,4 +193,8 @@ private Path vendorBinary(String relativePath) throws Exception { binary.toFile().setExecutable(true); return binary; } + + private Path relativeToWorkingDirectory(Path path) { + return Path.of("").toAbsolutePath().normalize().relativize(path.toAbsolutePath().normalize()); + } } diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/RipgrepSearchRunnerTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/RipgrepSearchRunnerTest.java index b3343bbc..5a1d8cb0 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/builtin/RipgrepSearchRunnerTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/RipgrepSearchRunnerTest.java @@ -124,6 +124,44 @@ void fileSymlinkMountsLexicalAndRealTargets() throws Exception { assertTrue(executor.request.sandboxPolicy().allowRead().contains(realFile.toRealPath())); } + @Test + void systemBinaryParentIsMountedReadOnly() throws Exception { + Path systemDirectory = Files.createDirectories(tempDir.resolve("custom-bin")); + Path executable = Files.writeString(systemDirectory.resolve("rg"), "#!/bin/sh\n"); + executable.toFile().setExecutable(true); + RipgrepBinaryResolver resolver = RipgrepBinaryResolver.forTesting( + new RipgrepPlatform("linux", "x86_64"), + tempDir.resolve("missing-resources"), + tempDir.resolve("cache"), + RipgrepSearchRunner.class.getClassLoader(), + List.of(systemDirectory) + ); + RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); + RipgrepSearchRunner runner = new RipgrepSearchRunner( + executor, + new RipgrepCommandBuilder(), + resolver, + Duration.ofSeconds(20) + ); + + RipgrepSearchResult result = runner.search( + GrepQuery.fromInput(Map.of("pattern", "needle")), + tempDir, + new ToolUseContext( + "ses_1", + "msg_1", + tempDir, + Map.of(RipgrepBinaryResolver.MODE_KEY, "system") + ), + message -> { + } + ); + + assertFalse(result.isError()); + assertEquals(executable.toRealPath().toString(), executor.request.command().getFirst()); + assertTrue(executor.request.sandboxPolicy().allowRead().contains(systemDirectory.toRealPath())); + } + @Test void exitOneIsNoMatchSuccess() throws Exception { vendorBinary(); From 4f524765cdf9b0b722fbac7506e67c1f31c2df27 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Sat, 1 Aug 2026 20:28:14 +0800 Subject: [PATCH 3/4] fix(security): parse static bash diagnostics safely --- .../lypi/security/BashCommandNormalizer.java | 236 +++++++++++++++++- .../BashSandboxEligibilityPolicy.java | 9 +- .../security/DefaultBashRiskAnalyzer.java | 17 +- .../security/BashCommandNormalizerTest.java | 85 +++++++ .../BashSandboxEligibilityPolicyTest.java | 19 ++ .../security/DefaultBashRiskAnalyzerTest.java | 42 ++++ .../PermissionDecisionPipelineTest.java | 36 +++ 7 files changed, 429 insertions(+), 15 deletions(-) create mode 100644 lypi-security/src/test/java/cn/lypi/security/BashCommandNormalizerTest.java diff --git a/lypi-security/src/main/java/cn/lypi/security/BashCommandNormalizer.java b/lypi-security/src/main/java/cn/lypi/security/BashCommandNormalizer.java index 01203b5b..d620aacf 100644 --- a/lypi-security/src/main/java/cn/lypi/security/BashCommandNormalizer.java +++ b/lypi-security/src/main/java/cn/lypi/security/BashCommandNormalizer.java @@ -75,17 +75,216 @@ String stripSafeWrappers(String command) { * 拆分复合命令段。 */ List splitCommandSegments(String normalizedCommand) { - if (normalizedCommand.isBlank()) { - return List.of(); + return scan(normalizedCommand).segments(); + } + + /** + * 扫描可静态分析的外层命令结构。 + */ + CommandScan scan(String normalizedCommand) { + String command = normalizedCommand == null ? "" : normalizedCommand; + HeredocScan heredoc = stripStrictQuotedHeredoc(command); + SegmentScan segments = splitOutsideQuotes(heredoc.analyzableCommand()); + return new CommandScan( + segments.segments(), + heredoc.analyzableCommand(), + heredoc.ambiguous() || segments.ambiguous() + ); + } + + private HeredocScan stripStrictQuotedHeredoc(String command) { + HeredocOperator operator = findHeredocOperator(command); + if (operator == null) { + return new HeredocScan(command, false); + } + if (!operator.valid()) { + return new HeredocScan(command, true); + } + int headerEnd = command.indexOf('\n', operator.end()); + if (headerEnd < 0) { + return new HeredocScan(command, true); + } + int lineStart = headerEnd + 1; + while (lineStart <= command.length()) { + int lineEnd = command.indexOf('\n', lineStart); + if (lineEnd < 0) { + lineEnd = command.length(); + } + if (command.substring(lineStart, lineEnd).equals(operator.delimiter())) { + int suffixStart = lineEnd < command.length() ? lineEnd + 1 : lineEnd; + String header = command.substring(0, operator.start()) + + " " + + command.substring(operator.end(), headerEnd); + String suffix = command.substring(suffixStart); + String analyzable = suffix.isEmpty() ? header : header + "\n" + suffix; + return new HeredocScan(analyzable, findHeredocOperator(analyzable) != null); + } + if (lineEnd == command.length()) { + break; + } + lineStart = lineEnd + 1; + } + return new HeredocScan(command, true); + } + + private HeredocOperator findHeredocOperator(String command) { + boolean singleQuoted = false; + boolean doubleQuoted = false; + boolean escaped = false; + for (int index = 0; index < command.length(); index++) { + char character = command.charAt(index); + if (singleQuoted) { + if (character == '\'') { + singleQuoted = false; + } + continue; + } + if (doubleQuoted) { + if (escaped) { + escaped = false; + } else if (character == '\\') { + escaped = true; + } else if (character == '"') { + doubleQuoted = false; + } + continue; + } + if (escaped) { + escaped = false; + continue; + } + if (character == '\\') { + escaped = true; + } else if (character == '\'') { + singleQuoted = true; + } else if (character == '"') { + doubleQuoted = true; + } else if (character == '<' && index + 1 < command.length() && command.charAt(index + 1) == '<') { + return parseHeredocOperator(command, index); + } + } + return null; + } + + private HeredocOperator parseHeredocOperator(String command, int start) { + int cursor = start + 2; + if (cursor < command.length() && (command.charAt(cursor) == '<' || command.charAt(cursor) == '-')) { + return HeredocOperator.invalid(start); + } + while (cursor < command.length() && horizontalWhitespace(command.charAt(cursor))) { + cursor++; + } + if (cursor >= command.length() || command.charAt(cursor) == '\n' || command.charAt(cursor) == '-') { + return HeredocOperator.invalid(start); + } + char quote = command.charAt(cursor); + if (quote != '\'' && quote != '"') { + return HeredocOperator.invalid(start); + } + int delimiterStart = ++cursor; + while (cursor < command.length() && command.charAt(cursor) != quote && command.charAt(cursor) != '\n') { + cursor++; + } + if (cursor >= command.length() || command.charAt(cursor) != quote) { + return HeredocOperator.invalid(start); + } + String delimiter = command.substring(delimiterStart, cursor); + if (!delimiter.matches("[A-Za-z_][A-Za-z0-9_]*")) { + return HeredocOperator.invalid(start); + } + int end = cursor + 1; + if (end < command.length() && !heredocBoundary(command.charAt(end))) { + return HeredocOperator.invalid(start); + } + return new HeredocOperator(start, end, delimiter, true); + } + + private boolean horizontalWhitespace(char character) { + return character == ' ' || character == '\t'; + } + + private boolean heredocBoundary(char character) { + return Character.isWhitespace(character) || "&|;()<>".indexOf(character) >= 0; + } + + private SegmentScan splitOutsideQuotes(String command) { + if (command.isBlank()) { + return new SegmentScan(List.of(), false); } List commands = new ArrayList<>(); - for (String part : normalizedCommand.split("\\s*(?:&&|\\|\\||;|\\n|(? 0) { + addSegment(commands, segment); + index += separatorWidth - 1; + continue; + } + segment.append(character); + } + addSegment(commands, segment); + return new SegmentScan(List.copyOf(commands), singleQuoted || doubleQuoted || escaped); + } + + private int separatorWidth(String command, int index) { + char character = command.charAt(index); + if (character == '\n' || character == ';') { + return 1; + } + if (character != '&' && character != '|') { + return 0; } - return commands; + return index + 1 < command.length() && command.charAt(index + 1) == character ? 2 : 1; + } + + private void addSegment(List commands, StringBuilder segment) { + String command = stripSafeWrappers(segment.toString().trim()); + if (!command.isBlank()) { + commands.add(command); + } + segment.setLength(0); } private int stripTimeout(List words, int index) { @@ -224,4 +423,27 @@ private List words(String command) { } return List.of(command.split("\\s+")); } + + record CommandScan( + List segments, + String analyzableCommand, + boolean ambiguous + ) { + CommandScan { + segments = segments == null ? List.of() : List.copyOf(segments); + analyzableCommand = analyzableCommand == null ? "" : analyzableCommand; + } + } + + private record HeredocScan(String analyzableCommand, boolean ambiguous) { + } + + private record HeredocOperator(int start, int end, String delimiter, boolean valid) { + private static HeredocOperator invalid(int start) { + return new HeredocOperator(start, start + 2, "", false); + } + } + + private record SegmentScan(List segments, boolean ambiguous) { + } } diff --git a/lypi-security/src/main/java/cn/lypi/security/BashSandboxEligibilityPolicy.java b/lypi-security/src/main/java/cn/lypi/security/BashSandboxEligibilityPolicy.java index 7ffb3935..967b24c1 100644 --- a/lypi-security/src/main/java/cn/lypi/security/BashSandboxEligibilityPolicy.java +++ b/lypi-security/src/main/java/cn/lypi/security/BashSandboxEligibilityPolicy.java @@ -90,14 +90,19 @@ final class BashSandboxEligibilityPolicy { } boolean allows(BashRiskAnalysis analysis) { + BashCommandNormalizer.CommandScan scan = analysis == null || analysis.normalizedCommand() == null + ? null + : normalizer.scan(analysis.normalizedCommand()); if (analysis == null || analysis.normalizedCommand() == null - || containsDynamicExpansion(analysis.normalizedCommand()) + || scan == null + || scan.ambiguous() + || containsDynamicExpansion(scan.analyzableCommand()) || !analysis.staticallyKnown() || analysis.riskLevel() == BashRiskLevel.UNKNOWN) { return false; } - List segments = normalizer.splitCommandSegments(analysis.normalizedCommand()); + List segments = scan.segments(); return !segments.isEmpty() && segments.stream().allMatch(this::allowsSegment); } diff --git a/lypi-security/src/main/java/cn/lypi/security/DefaultBashRiskAnalyzer.java b/lypi-security/src/main/java/cn/lypi/security/DefaultBashRiskAnalyzer.java index f0b7a65d..1d9a5a2a 100644 --- a/lypi-security/src/main/java/cn/lypi/security/DefaultBashRiskAnalyzer.java +++ b/lypi-security/src/main/java/cn/lypi/security/DefaultBashRiskAnalyzer.java @@ -74,11 +74,17 @@ public DefaultBashRiskAnalyzer() { @Override public BashRiskAnalysis analyze(String rawCommand) { String normalized = normalizer.normalizeRaw(rawCommand); - List parsedCommands = parseCommands(normalized); - List redirectTargets = redirectTargets(normalized); + BashCommandNormalizer.CommandScan scan = normalizer.scan(normalized); + List parsedCommands = parseCommands(scan.segments()); + List redirectTargets = redirectTargets(scan.analyzableCommand()); List reasons = new ArrayList<>(); - if (containsDynamicShell(normalized) || containsAmbiguousShellSyntax(normalized, parsedCommands)) { + if (scan.ambiguous()) { + reasons.add("包含无法静态解析的 shell 结构"); + return analysis(normalized, parsedCommands, redirectTargets, BashRiskLevel.UNKNOWN, reasons, false); + } + if (containsDynamicShell(scan.analyzableCommand()) + || containsAmbiguousShellSyntax(scan.analyzableCommand(), parsedCommands)) { reasons.add("包含动态 shell 结构"); return analysis(normalized, parsedCommands, redirectTargets, BashRiskLevel.UNKNOWN, reasons, false); } @@ -133,9 +139,9 @@ private BashRiskAnalysis analysis( ); } - private List parseCommands(String normalizedCommand) { + private List parseCommands(List segments) { List commands = new ArrayList<>(); - for (String part : normalizer.splitCommandSegments(normalizedCommand)) { + for (String part : segments) { String command = part.trim(); if (!command.isBlank()) { commands.add(displayCommand(command)); @@ -169,7 +175,6 @@ private boolean containsDynamicShell(String command) { || command.contains("`") || command.contains("<(") || command.contains(">(") - || command.matches(".*\\s<<-?\\s*\\S+.*") || command.matches(".*\\bfor\\b.*\\bdo\\b.*") || command.matches(".*\\bwhile\\b.*\\bdo\\b.*") || command.matches(".*\\bcase\\b.*\\bin\\b.*") diff --git a/lypi-security/src/test/java/cn/lypi/security/BashCommandNormalizerTest.java b/lypi-security/src/test/java/cn/lypi/security/BashCommandNormalizerTest.java new file mode 100644 index 00000000..b2dc9173 --- /dev/null +++ b/lypi-security/src/test/java/cn/lypi/security/BashCommandNormalizerTest.java @@ -0,0 +1,85 @@ +package cn.lypi.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class BashCommandNormalizerTest { + private final BashCommandNormalizer normalizer = new BashCommandNormalizer(); + + @Test + void keepsOperatorsInsideQuotedArguments() { + String command = "grep -RInE 'class Nominal|class Categorical|margin' seaborn tests | head -200"; + + assertThat(normalizer.scan(command).segments()).containsExactly( + "grep -RInE 'class Nominal|class Categorical|margin' seaborn tests", + "head -200" + ); + } + + @Test + void splitsOnlyUnquotedAndUnescapedOperators() { + String command = "printf \"a|b&&c\" && echo one\\|two; pwd\nls & wc -l || cat file"; + + assertThat(normalizer.scan(command).segments()).containsExactly( + "printf \"a|b&&c\"", + "echo one\\|two", + "pwd", + "ls", + "wc -l", + "cat file" + ); + } + + @Test + void marksUnclosedQuotesAndEscapesAsAmbiguous() { + for (String command : List.of("echo 'unterminated", "echo \"unterminated", "echo trailing\\")) { + assertThat(normalizer.scan(command).ambiguous()).as(command).isTrue(); + } + } + + @Test + void removesStrictQuotedHeredocBodiesFromAnalysis() { + String command = """ + pytest -q && python - <<'PY' + value = 'class Nominal|margin' + print('literal > output $(ignored) rm -rf target') + PY + rg done + """; + + BashCommandNormalizer.CommandScan scan = normalizer.scan(command); + + assertThat(scan.ambiguous()).isFalse(); + assertThat(scan.segments()).containsExactly("pytest -q", "python -", "rg done"); + assertThat(scan.analyzableCommand()).doesNotContain("literal > output", "$(ignored)", "rm -rf target"); + } + + @Test + void keepsShellSinkAfterStrictQuotedHeredocVisible() { + String command = """ + cat <<'EOF' | sh + echo unsafe + EOF + """; + + BashCommandNormalizer.CommandScan scan = normalizer.scan(command); + + assertThat(scan.ambiguous()).isFalse(); + assertThat(scan.segments()).containsExactly("cat", "sh"); + } + + @Test + void rejectsUnsupportedOrIncompleteHeredocs() { + for (String command : List.of( + "cat < $(ignored) rm -rf target') + PY + """))).isTrue(); + } + + @Test + void requiresReviewForHeredocShellSinksAndUnsupportedForms() { + assertThat(policy.allows(analyzer.analyze("cat <<'EOF' | sh\necho unsafe\nEOF\n"))).isFalse(); + assertThat(policy.allows(analyzer.analyze("cat < $(ignored) rm -rf target') + PY + """); + + assertThat(analysis.parsedCommands()).containsExactly("python -"); + assertThat(analysis.redirectTargets()).isEmpty(); + assertThat(analysis.riskLevel()).isEqualTo(BashRiskLevel.MEDIUM); + assertThat(analysis.staticallyKnown()).isTrue(); + } + + @Test + void analyzeKeepsHeredocShellSinksAndUnsupportedFormsUnknown() { + for (String command : java.util.List.of( + "cat <<'EOF' | sh\necho unsafe\nEOF\n", + "cat < $(ignored) rm -rf target')\nPY\n", + PermissionBehavior.ALLOW, + BashRiskLevel.MEDIUM + ); + } + + @Test + void heredocShellSinksAndUnsupportedFormsDefaultToAsk() { + PermissionDecisionPipeline pipeline = new PermissionDecisionPipeline(); + + assertBashDecision( + pipeline, + "cat <<'EOF' | sh\necho unsafe\nEOF\n", + PermissionBehavior.ASK, + BashRiskLevel.UNKNOWN + ); + assertBashDecision( + pipeline, + "cat < Date: Sat, 1 Aug 2026 20:32:31 +0800 Subject: [PATCH 4/4] fix(tool): explain unavailable ask approvals --- .../java/cn/lypi/tool/PermissionGate.java | 9 +++- .../cn/lypi/tool/ApprovalCoordinatorTest.java | 48 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/lypi-tool/src/main/java/cn/lypi/tool/PermissionGate.java b/lypi-tool/src/main/java/cn/lypi/tool/PermissionGate.java index 92a82273..f0fb4e97 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/PermissionGate.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/PermissionGate.java @@ -30,6 +30,13 @@ PermissionGateResult request( * 默认拒绝 ASK 决策,不阻塞等待用户输入。 */ static PermissionGate denying() { - return (request, tool, context, decision) -> PermissionGateResult.deny(decision == null ? null : decision.message()); + return (request, tool, context, decision) -> { + String reason = decision == null || decision.message() == null || decision.message().isBlank() + ? "权限请求未获允许。" + : decision.message(); + return PermissionGateResult.deny( + "ASK 没有可用审批通道,非交互运行时已拒绝执行;请配置 AUTO 或交互式 PermissionGate: " + reason + ); + }; } } diff --git a/lypi-tool/src/test/java/cn/lypi/tool/ApprovalCoordinatorTest.java b/lypi-tool/src/test/java/cn/lypi/tool/ApprovalCoordinatorTest.java index ed9b12e6..91fc1ac0 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/ApprovalCoordinatorTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/ApprovalCoordinatorTest.java @@ -89,6 +89,54 @@ void askModeCallsGateRegardlessOfLegacyApprovalPolicy() { assertEquals(1, gateCalls.get()); } + @Test + void denyingGateExplainsUnavailableAskApprovalChannel() { + ApprovalCoordinator coordinator = coordinator( + PermissionGate.denying(), + PermissionUpdateStore.noop(), + List.of() + ); + PermissionDecision decision = new PermissionDecision( + PermissionBehavior.ASK, + PermissionDecisionReason.BASH_RISK, + "Bash 命令无法静态确认风险,需要用户确认。", + Optional.empty(), + Map.of("approvalKind", ApprovalKind.COMMAND) + ); + + PermissionGateResult result = coordinator.resolve( + request("bash", Map.of("command", "echo $(id)")), + TestTools.echo("bash", List.of(), false, false, true), + context(runtimeState(ApprovalMode.NEVER)), + decision + ); + + assertEquals(PermissionGateResult.Status.DENY, result.status()); + assertTrue(result.message().orElseThrow().contains("ASK 没有可用审批通道")); + assertTrue(result.message().orElseThrow().contains(decision.message())); + } + + @Test + void denyingGateExplainsUnavailableAskForAdditionalPermissions() { + ApprovalCoordinator coordinator = coordinator( + PermissionGate.denying(), + PermissionUpdateStore.noop(), + List.of() + ); + + PermissionGateResult result = coordinator.resolveAdditionalPermissions( + request("request_permissions", Map.of("reason", "need write access")), + TestTools.echo("request_permissions", List.of(), false, false, false), + context(runtimeState(ApprovalMode.NEVER)), + "need write access", + AdditionalPermissionProfile.empty() + ); + + assertEquals(PermissionGateResult.Status.DENY, result.status()); + assertTrue(result.message().orElseThrow().contains("ASK 没有可用审批通道")); + assertTrue(result.message().orElseThrow().contains("need write access")); + } + @Test void legacyOnlyBypassModeAllowsWithoutCallingGate() { AtomicInteger gateCalls = new AtomicInteger();