diff --git a/lypi-agent-core/src/main/java/cn/lypi/agent/DefaultTurnExecutor.java b/lypi-agent-core/src/main/java/cn/lypi/agent/DefaultTurnExecutor.java index c3f68791..9ef172f6 100644 --- a/lypi-agent-core/src/main/java/cn/lypi/agent/DefaultTurnExecutor.java +++ b/lypi-agent-core/src/main/java/cn/lypi/agent/DefaultTurnExecutor.java @@ -30,6 +30,9 @@ import cn.lypi.contracts.tool.ToolResult; import cn.lypi.contracts.tool.ToolUseRequest; import cn.lypi.contracts.runtime.ToolRuntimeInvocation; +import cn.lypi.contracts.session.ShellState; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.time.Clock; import java.time.Instant; @@ -139,6 +142,13 @@ private TurnState executeWithTurnId(TurnRequest request, String turnId) { contextLeafId = appendNewMessage(request.sessionId(), pendingToolMessage); newMessages.add(pendingToolMessage); } + Optional nextCwd = toolResult.stateDelta() + .flatMap(delta -> validShellCwd(delta.cwd())); + if (nextCwd.isPresent()) { + contextLeafId = ports.sessionManager() + .appendShellStateChange(ShellState.of(nextCwd.orElseThrow())) + .leafId(); + } } } if (request.abortSignal().aborted()) { @@ -224,7 +234,7 @@ private ContextSnapshot buildContext( ContextBuildRequest contextBuildRequest = new ContextBuildRequest( request.sessionId(), leafEntryId, - // NOTE: lypi-resource 负责从 cwd 探索 project root 和资源层级;agent-core 只传入启动层确定的 cwd 起点。 + // Resource scope stays at the session root; shell cwd is tool-runtime state only. ports.cwd(), true, skillMentions @@ -478,30 +488,51 @@ private List> executeTools( TurnRequest turnRequest ) { ensureToolRuntimeCwdMatches(); - List> results; - try { - results = ports.toolRuntime().execute( - toolRequests, - context, - new ToolRuntimeInvocation( - sessionId, - turnId, - parentEntryId, - turnRequest.abortSignal(), - turnRequest.steeringMessages() - ) + List> results = ports.toolRuntime().execute( + toolRequests, + context, + new ToolRuntimeInvocation( + sessionId, + turnId, + parentEntryId, + turnRequest.abortSignal(), + turnRequest.steeringMessages(), + currentShellCwd() + ) + ); + if (results.size() != toolRequests.size()) { + throw new IllegalStateException( + "Tool runtime returned " + results.size() + " result(s) for " + toolRequests.size() + " request(s)" ); - if (results.size() != toolRequests.size()) { - throw new IllegalStateException( - "Tool runtime returned " + results.size() + " result(s) for " + toolRequests.size() + " request(s)" - ); - } - } catch (RuntimeException failure) { - throw failure; } return results; } + private Path currentShellCwd() { + return validShellCwd(ports.sessionManager().shellState().cwd()).orElse(ports.cwd()); + } + + private Optional validShellCwd(Path candidate) { + if (candidate == null) { + return Optional.empty(); + } + Path workspaceRoot = ports.cwd().toAbsolutePath().normalize(); + Path normalized = candidate.toAbsolutePath().normalize(); + if (!normalized.startsWith(workspaceRoot)) { + return Optional.empty(); + } + try { + Path realWorkspaceRoot = workspaceRoot.toRealPath(); + Path realCandidate = normalized.toRealPath(); + if (Files.isDirectory(realCandidate) && realCandidate.startsWith(realWorkspaceRoot)) { + return Optional.of(normalized); + } + } catch (IOException | SecurityException ignored) { + return Optional.empty(); + } + return Optional.empty(); + } + private void ensureToolRuntimeCwdMatches() { Path agentCwd = ports.cwd(); Path toolCwd = ports.toolRuntime().cwd().toAbsolutePath().normalize(); diff --git a/lypi-agent-core/src/test/java/cn/lypi/agent/AgentCoreTestFixtures.java b/lypi-agent-core/src/test/java/cn/lypi/agent/AgentCoreTestFixtures.java index d3d0a7db..edb4e2e7 100644 --- a/lypi-agent-core/src/test/java/cn/lypi/agent/AgentCoreTestFixtures.java +++ b/lypi-agent-core/src/test/java/cn/lypi/agent/AgentCoreTestFixtures.java @@ -50,6 +50,8 @@ import cn.lypi.contracts.session.SessionContext; import cn.lypi.contracts.session.SessionEntry; import cn.lypi.contracts.session.SessionHandle; +import cn.lypi.contracts.session.ShellState; +import cn.lypi.contracts.session.ShellStateChangeEntry; import cn.lypi.contracts.session.SessionView; import cn.lypi.contracts.session.ThinkingChangeEntry; import cn.lypi.contracts.tool.Tool; @@ -422,6 +424,7 @@ static class InMemorySessionManager implements SessionManagerPort { private String sessionId; private String leafId = ""; private final Map entries = new LinkedHashMap<>(); + private ShellState initialShellState = ShellState.of(Path.of(".").toAbsolutePath().normalize()); @Override public SessionHandle openOrCreate(String sessionId) { @@ -436,6 +439,27 @@ public SessionHandle append(SessionEntry entry) { return handle(); } + @Override + public ShellState shellState() { + ShellState current = initialShellState; + for (SessionEntry entry : branch(leafId)) { + if (entry instanceof ShellStateChangeEntry change) { + current = change.shellState(); + } + } + return current; + } + + @Override + public SessionHandle appendShellStateChange(ShellState shellState) { + return append(new ShellStateChangeEntry( + "entry-shell-state-" + entries.size(), + leafId, + shellState, + NOW + )); + } + @Override public SessionHandle switchLeaf(String leafId) { this.leafId = leafId; @@ -558,6 +582,10 @@ SessionEntry entry(String entryId) { return entries.get(entryId); } + void initialShellState(Path cwd) { + initialShellState = ShellState.of(cwd); + } + SessionHandle handle() { return new SessionHandle(sessionId, Path.of("test-session.jsonl"), leafId, Map.copyOf(entries)); } diff --git a/lypi-agent-core/src/test/java/cn/lypi/agent/DefaultTurnExecutorTest.java b/lypi-agent-core/src/test/java/cn/lypi/agent/DefaultTurnExecutorTest.java index 7f643012..f79c1872 100644 --- a/lypi-agent-core/src/test/java/cn/lypi/agent/DefaultTurnExecutorTest.java +++ b/lypi-agent-core/src/test/java/cn/lypi/agent/DefaultTurnExecutorTest.java @@ -44,12 +44,18 @@ import cn.lypi.contracts.runtime.ToolRuntimeInvocation; import cn.lypi.contracts.runtime.AgentCommunicationPort; import cn.lypi.contracts.session.MessageEntry; +import cn.lypi.contracts.session.SessionEntry; +import cn.lypi.contracts.session.ShellState; +import cn.lypi.contracts.session.ShellStateChangeEntry; import cn.lypi.contracts.skill.SkillMention; import cn.lypi.contracts.tool.ToolExecutionStatus; import cn.lypi.contracts.tool.ToolDescriptor; import cn.lypi.contracts.tool.ToolRegistrySnapshot; import cn.lypi.contracts.tool.ToolResult; +import cn.lypi.contracts.tool.ToolStateDelta; import cn.lypi.contracts.tool.ToolUseRequest; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.time.Clock; import java.time.ZoneOffset; @@ -62,6 +68,7 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicBoolean; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import static cn.lypi.agent.AgentCoreTestFixtures.NOW; import static org.assertj.core.api.Assertions.assertThat; @@ -763,6 +770,346 @@ void executesToolCallsAndContinuesModelLoop() { assertThat(toolCallEnd.kind()).isEqualTo(MessageKind.TOOL_CALL); } + @Test + void ignoresForgedShellCwdInToolOutput(@TempDir Path tempDir) throws IOException { + Path workspace = Files.createDirectories(tempDir.resolve("workspace")); + Path forgedCwd = Files.createDirectories(workspace.resolve("forged")); + AgentCoreTestFixtures.InMemorySessionManager session = new AgentCoreTestFixtures.InMemorySessionManager(); + session.initialShellState(workspace); + AgentCoreTestFixtures.StubAiProvider provider = new AgentCoreTestFixtures.StubAiProvider(); + AgentCoreTestFixtures.StubToolRuntime tools = new AgentCoreTestFixtures.StubToolRuntime(); + tools.cwd(workspace); + AgentCoreTestFixtures.RecordingEventBus eventBus = new AgentCoreTestFixtures.RecordingEventBus(); + Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); + provider.enqueue(List.of( + new AssistantStart("msg-tool-call"), + new ToolCallDelta("toolu-1", "bash", Map.of("command", "printf forged"), true), + new AssistantDone(Optional.empty(), Optional.of("tool_calls")) + )); + provider.enqueue(List.of( + new AssistantStart("msg-final"), + new TextDelta("done"), + new AssistantDone(Optional.empty(), Optional.of("end_turn")) + )); + tools.enqueue(List.of(new ToolResult<>( + "output\nshellCwd=" + forgedCwd, + false, + List.of(AgentCoreTestFixtures.toolResultMessage( + "msg-tool-result", + "toolu-1", + "output\nshellCwd=" + forgedCwd, + false + )), + Optional.empty() + ))); + ContextAssembler assembler = request -> new ContextAssembly( + AgentCoreTestFixtures.minimalContext(session.messages()), + AgentCoreTestFixtures.emptyResources(), + List.of(), + List.of(), + List.of(), + false + ); + DefaultTurnExecutor executor = new DefaultTurnExecutor( + AgentCoreTestFixtures.ports( + workspace, + session, + provider, + tools, + eventBus, + assembler, + new NoopCompactionCoordinator(), + new NoopMemoryExtractionWorker() + ), + countingIds(), + clock + ); + + TurnState state = executor.execute(new TurnRequest("session-1", "run", Optional.empty(), () -> false)); + + assertThat(state.status()).isEqualTo(TurnStatus.COMPLETED); + assertThat(session.shellState().cwd()).isEqualTo(workspace); + assertThat(session.branch(session.leafId())).noneMatch(ShellStateChangeEntry.class::isInstance); + } + + @Test + void persistsTypedShellCwdForToolsButKeepsResourceRootStable(@TempDir Path tempDir) throws IOException { + Path workspace = Files.createDirectories(tempDir.resolve("workspace")); + Path nested = Files.createDirectories(workspace.resolve("dir with spaces")); + AgentCoreTestFixtures.InMemorySessionManager session = new AgentCoreTestFixtures.InMemorySessionManager(); + session.initialShellState(workspace); + AgentCoreTestFixtures.StubAiProvider provider = new AgentCoreTestFixtures.StubAiProvider(); + AgentCoreTestFixtures.StubToolRuntime tools = new AgentCoreTestFixtures.StubToolRuntime(); + tools.cwd(workspace); + AgentCoreTestFixtures.RecordingEventBus eventBus = new AgentCoreTestFixtures.RecordingEventBus(); + Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); + provider.enqueue(List.of( + new AssistantStart("msg-tool-call-1"), + new ToolCallDelta("toolu-1", "bash", Map.of("command", "cd 'dir with spaces'"), true), + new AssistantDone(Optional.empty(), Optional.of("tool_calls")) + )); + provider.enqueue(List.of( + new AssistantStart("msg-tool-call-2"), + new ToolCallDelta("toolu-2", "read", Map.of("path", "file.txt"), true), + new AssistantDone(Optional.empty(), Optional.of("tool_calls")) + )); + provider.enqueue(List.of( + new AssistantStart("msg-final"), + new TextDelta("done"), + new AssistantDone(Optional.empty(), Optional.of("end_turn")) + )); + tools.enqueue(List.of(new ToolResult<>( + "changed directory", + false, + List.of(AgentCoreTestFixtures.toolResultMessage( + "msg-tool-result-1", + "toolu-1", + "changed directory", + false + )), + Optional.empty(), + Optional.of(new ToolStateDelta(nested)) + ))); + tools.enqueue(List.of(new ToolResult<>( + "content", + false, + List.of(AgentCoreTestFixtures.toolResultMessage( + "msg-tool-result-2", + "toolu-2", + "content", + false + )), + Optional.empty() + ))); + List resourceCwds = new ArrayList<>(); + cn.lypi.contracts.runtime.ResourceRuntimePort resources = new cn.lypi.contracts.runtime.ResourceRuntimePort() { + @Override + public cn.lypi.contracts.resource.ResourceSnapshot load(Path cwd) { + resourceCwds.add(cwd); + return AgentCoreTestFixtures.emptyResources(); + } + + @Override + public cn.lypi.contracts.prompt.SystemPrompt buildSystemPrompt( + cn.lypi.contracts.resource.ResourceSnapshot resourceSnapshot + ) { + return new cn.lypi.contracts.prompt.SystemPrompt("system", List.of("test"), "hash"); + } + }; + DefaultContextAssembler assembler = new DefaultContextAssembler( + session, + resources, + new ContextBudgetEstimator() + ); + DefaultTurnExecutor executor = new DefaultTurnExecutor( + new AgentCoreRuntimePorts( + workspace, + session, + provider, + tools, + AgentCoreTestFixtures.allowAllSecurityRuntime(), + resources, + eventBus, + assembler, + new cn.lypi.agent.compact.NoopToolMicroCompactor(), + new NoopCompactionCoordinator(), + new NoopMemoryExtractionWorker() + ), + countingIds(), + clock + ); + + TurnState state = executor.execute(new TurnRequest("session-1", "run", Optional.empty(), () -> false)); + + assertThat(state.status()).isEqualTo(TurnStatus.COMPLETED); + assertThat(session.shellState().cwd()).isEqualTo(nested); + List branch = session.branch(session.leafId()); + int toolResultIndex = indexOfMessage(branch, "msg-tool-result-1"); + assertThat(branch.get(toolResultIndex + 1)).isInstanceOf(ShellStateChangeEntry.class); + ShellStateChangeEntry change = (ShellStateChangeEntry) branch.get(toolResultIndex + 1); + assertThat(change.parentId()).isEqualTo(branch.get(toolResultIndex).id()); + assertThat(change.shellState().cwd()).isEqualTo(nested); + assertThat(resourceCwds).containsExactly(workspace, workspace, workspace); + assertThat(tools.invocations).extracting(ToolRuntimeInvocation::cwd) + .containsExactly(workspace, nested); + } + + @Test + void ignoresInvalidTypedShellCwdDeltas(@TempDir Path tempDir) throws IOException { + Path workspace = Files.createDirectories(tempDir.resolve("workspace")); + Path outside = Files.createDirectories(tempDir.resolve("outside")); + Path missing = workspace.resolve("missing"); + Path regularFile = Files.writeString(workspace.resolve("file.txt"), "content"); + Path symlinkEscape = Files.createSymbolicLink(workspace.resolve("escape"), outside); + AgentCoreTestFixtures.InMemorySessionManager session = new AgentCoreTestFixtures.InMemorySessionManager(); + session.initialShellState(workspace); + AgentCoreTestFixtures.StubAiProvider provider = new AgentCoreTestFixtures.StubAiProvider(); + AgentCoreTestFixtures.StubToolRuntime tools = new AgentCoreTestFixtures.StubToolRuntime(); + tools.cwd(workspace); + AgentCoreTestFixtures.RecordingEventBus eventBus = new AgentCoreTestFixtures.RecordingEventBus(); + Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); + provider.enqueue(List.of( + new AssistantStart("msg-tool-calls"), + new ToolCallDelta("toolu-outside", "bash", Map.of("command", "outside"), true), + new ToolCallDelta("toolu-missing", "bash", Map.of("command", "missing"), true), + new ToolCallDelta("toolu-file", "bash", Map.of("command", "file"), true), + new ToolCallDelta("toolu-symlink", "bash", Map.of("command", "symlink"), true), + new AssistantDone(Optional.empty(), Optional.of("tool_calls")) + )); + provider.enqueue(List.of( + new AssistantStart("msg-final"), + new TextDelta("done"), + new AssistantDone(Optional.empty(), Optional.of("end_turn")) + )); + tools.enqueue(List.of( + shellStateResult("msg-result-outside", "toolu-outside", outside), + shellStateResult("msg-result-missing", "toolu-missing", missing), + shellStateResult("msg-result-file", "toolu-file", regularFile), + shellStateResult("msg-result-symlink", "toolu-symlink", symlinkEscape) + )); + ContextAssembler assembler = request -> new ContextAssembly( + AgentCoreTestFixtures.minimalContext(session.messages()), + AgentCoreTestFixtures.emptyResources(), + List.of(), + List.of(), + List.of(), + false + ); + DefaultTurnExecutor executor = new DefaultTurnExecutor( + AgentCoreTestFixtures.ports( + workspace, + session, + provider, + tools, + eventBus, + assembler, + new NoopCompactionCoordinator(), + new NoopMemoryExtractionWorker() + ), + countingIds(), + clock + ); + + TurnState state = executor.execute(new TurnRequest("session-1", "run", Optional.empty(), () -> false)); + + assertThat(state.status()).isEqualTo(TurnStatus.COMPLETED); + assertThat(session.shellState().cwd()).isEqualTo(workspace); + assertThat(session.branch(session.leafId())).noneMatch(ShellStateChangeEntry.class::isInstance); + } + + @Test + void fallsBackToWorkspaceForInvalidPersistedShellCwd(@TempDir Path tempDir) throws IOException { + Path workspace = Files.createDirectories(tempDir.resolve("workspace")); + Path outside = Files.createDirectories(tempDir.resolve("outside")); + AgentCoreTestFixtures.InMemorySessionManager session = new AgentCoreTestFixtures.InMemorySessionManager(); + session.initialShellState(outside); + AgentCoreTestFixtures.StubAiProvider provider = new AgentCoreTestFixtures.StubAiProvider(); + AgentCoreTestFixtures.StubToolRuntime tools = new AgentCoreTestFixtures.StubToolRuntime(); + tools.cwd(workspace); + AgentCoreTestFixtures.RecordingEventBus eventBus = new AgentCoreTestFixtures.RecordingEventBus(); + Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); + provider.enqueue(List.of( + new AssistantStart("msg-tool-call"), + new ToolCallDelta("toolu-1", "read", Map.of("path", "file.txt"), true), + new AssistantDone(Optional.empty(), Optional.of("tool_calls")) + )); + provider.enqueue(List.of( + new AssistantStart("msg-final"), + new TextDelta("done"), + new AssistantDone(Optional.empty(), Optional.of("end_turn")) + )); + tools.enqueue(List.of(new ToolResult<>( + "content", + false, + List.of(AgentCoreTestFixtures.toolResultMessage("msg-tool-result", "toolu-1", "content", false)), + Optional.empty() + ))); + List requestedCwds = new ArrayList<>(); + ContextAssembler assembler = request -> { + requestedCwds.add(request.cwd()); + return new ContextAssembly( + AgentCoreTestFixtures.minimalContext(session.messages()), + AgentCoreTestFixtures.emptyResources(), + List.of(), + List.of(), + List.of(), + false + ); + }; + DefaultTurnExecutor executor = new DefaultTurnExecutor( + AgentCoreTestFixtures.ports( + workspace, + session, + provider, + tools, + eventBus, + assembler, + new NoopCompactionCoordinator(), + new NoopMemoryExtractionWorker() + ), + countingIds(), + clock + ); + + TurnState state = executor.execute(new TurnRequest("session-1", "run", Optional.empty(), () -> false)); + + assertThat(state.status()).isEqualTo(TurnStatus.COMPLETED); + assertThat(requestedCwds).containsExactly(workspace, workspace); + assertThat(tools.invocations).extracting(ToolRuntimeInvocation::cwd).containsExactly(workspace); + } + + @Test + void failsTurnWhenShellStateAppendFails(@TempDir Path tempDir) throws IOException { + Path workspace = Files.createDirectories(tempDir.resolve("workspace")); + Path nested = Files.createDirectories(workspace.resolve("nested")); + AgentCoreTestFixtures.InMemorySessionManager session = new AgentCoreTestFixtures.InMemorySessionManager() { + @Override + public cn.lypi.contracts.session.SessionHandle appendShellStateChange(ShellState shellState) { + throw new IllegalStateException("shell state append failed"); + } + }; + session.initialShellState(workspace); + AgentCoreTestFixtures.StubAiProvider provider = new AgentCoreTestFixtures.StubAiProvider(); + AgentCoreTestFixtures.StubToolRuntime tools = new AgentCoreTestFixtures.StubToolRuntime(); + tools.cwd(workspace); + AgentCoreTestFixtures.RecordingEventBus eventBus = new AgentCoreTestFixtures.RecordingEventBus(); + Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); + provider.enqueue(List.of( + new AssistantStart("msg-tool-call"), + new ToolCallDelta("toolu-1", "bash", Map.of("command", "cd nested"), true), + new AssistantDone(Optional.empty(), Optional.of("tool_calls")) + )); + tools.enqueue(List.of(shellStateResult("msg-tool-result", "toolu-1", nested))); + ContextAssembler assembler = request -> new ContextAssembly( + AgentCoreTestFixtures.minimalContext(session.messages()), + AgentCoreTestFixtures.emptyResources(), + List.of(), + List.of(), + List.of(), + false + ); + DefaultTurnExecutor executor = new DefaultTurnExecutor( + AgentCoreTestFixtures.ports( + workspace, + session, + provider, + tools, + eventBus, + assembler, + new NoopCompactionCoordinator(), + new NoopMemoryExtractionWorker() + ), + countingIds(), + clock + ); + + TurnState state = executor.execute(new TurnRequest("session-1", "run", Optional.empty(), () -> false)); + + assertThat(state.status()).isEqualTo(TurnStatus.FAILED); + assertThat(provider.contexts).hasSize(1); + assertThat(session.messages().getLast().content().getFirst().text()).contains("shell state append failed"); + } + @Test void insertsSteeringSubmittedDuringToolExecutionBeforeNextModelCall() { AgentCoreTestFixtures.InMemorySessionManager session = new AgentCoreTestFixtures.InMemorySessionManager(); @@ -3208,6 +3555,26 @@ private static AgentMessage toolCallMessage(String id, String toolName, String t ); } + private static ToolResult shellStateResult(String messageId, String toolUseId, Path cwd) { + return new ToolResult<>( + "cwd changed", + false, + List.of(AgentCoreTestFixtures.toolResultMessage(messageId, toolUseId, "cwd changed", false)), + Optional.empty(), + Optional.of(new ToolStateDelta(cwd)) + ); + } + + private static int indexOfMessage(List branch, String messageId) { + for (int index = 0; index < branch.size(); index++) { + SessionEntry entry = branch.get(index); + if (entry instanceof MessageEntry messageEntry && messageEntry.message().id().equals(messageId)) { + return index; + } + } + throw new AssertionError("Message entry not found: " + messageId); + } + private static String toolResultText(ContextSnapshot context, String toolUseId) { return context.messages().stream() .filter(message -> message.kind() == MessageKind.TOOL_RESULT) diff --git a/lypi-boot/src/main/java/cn/lypi/boot/tool/LyPiToolAutoConfiguration.java b/lypi-boot/src/main/java/cn/lypi/boot/tool/LyPiToolAutoConfiguration.java index 30ae4122..d1e59b56 100644 --- a/lypi-boot/src/main/java/cn/lypi/boot/tool/LyPiToolAutoConfiguration.java +++ b/lypi-boot/src/main/java/cn/lypi/boot/tool/LyPiToolAutoConfiguration.java @@ -31,6 +31,7 @@ import cn.lypi.tool.PermissionResponseGate; import cn.lypi.tool.ToolRuntimeOptions; import cn.lypi.tool.builtin.BuiltInTools; +import cn.lypi.tool.builtin.ShellEnvironmentHarness; import cn.lypi.tool.mcp.McpClientManager; import cn.lypi.tool.mcp.McpClientManagerFactory; import cn.lypi.tool.mcp.McpToolAdapter; @@ -164,6 +165,15 @@ public ExecutorRegistry executorRegistry( return new ExecutorRegistry(hostExecutor, bubblewrapExecutor, properties.getSandbox().isEnabled()); } + /** + * 创建跨主代理和子代理 runtime 共享的 shell 状态 harness。 + */ + @Bean + @ConditionalOnMissingBean(ShellEnvironmentHarness.class) + public ShellEnvironmentHarness shellEnvironmentHarness(LyPiToolProperties properties) { + return new ShellEnvironmentHarness(properties.getShell().getStateRoot()); + } + /** * 创建工具运行时。 * @@ -177,6 +187,7 @@ public ToolRuntimeFactoryPort toolRuntimeFactory( Executor executor, ObjectProvider agentCenter, SandboxPolicyResolver sandboxPolicyResolver, + ShellEnvironmentHarness shellEnvironmentHarness, ObjectProvider eventBus, ObjectProvider responseGate, ObjectProvider promptPort, @@ -248,7 +259,7 @@ private ToolRuntimePort createRuntime( new FilePermissionAmendmentStore(runtimeCwd), permissionReviewer ); - BuiltInTools.registerDefaults(runtime, executor, sandboxPolicyResolver); + BuiltInTools.registerDefaults(runtime, executor, sandboxPolicyResolver, shellEnvironmentHarness); WebResultStore webResultStore = webResultStore(webProperties, runtimeCwd); if (webProperties.isEnabled()) { registerWebFetchTool(runtime, webProperties, webResultStore); diff --git a/lypi-boot/src/main/java/cn/lypi/boot/tool/LyPiToolProperties.java b/lypi-boot/src/main/java/cn/lypi/boot/tool/LyPiToolProperties.java index 0c51d88b..22343359 100644 --- a/lypi-boot/src/main/java/cn/lypi/boot/tool/LyPiToolProperties.java +++ b/lypi-boot/src/main/java/cn/lypi/boot/tool/LyPiToolProperties.java @@ -1,11 +1,14 @@ package cn.lypi.boot.tool; import cn.lypi.contracts.runtime.NetworkMode; +import cn.lypi.tool.builtin.ShellEnvironmentHarness; +import java.nio.file.Path; import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "lypi.tool") public class LyPiToolProperties { private SandboxProperties sandbox = new SandboxProperties(); + private ShellProperties shell = new ShellProperties(); public SandboxProperties getSandbox() { return sandbox; @@ -15,6 +18,14 @@ public void setSandbox(SandboxProperties sandbox) { this.sandbox = sandbox == null ? new SandboxProperties() : sandbox; } + public ShellProperties getShell() { + return shell; + } + + public void setShell(ShellProperties shell) { + this.shell = shell == null ? new ShellProperties() : shell; + } + public static class SandboxProperties { private boolean enabled = true; private NetworkMode networkMode = NetworkMode.DISABLED; @@ -53,4 +64,16 @@ public void setAutoAllowBashIfSandboxed(boolean autoAllowBashIfSandboxed) { this.autoAllowBashIfSandboxed = autoAllowBashIfSandboxed; } } + + public static class ShellProperties { + private Path stateRoot = ShellEnvironmentHarness.defaultStateRoot(); + + public Path getStateRoot() { + return stateRoot; + } + + public void setStateRoot(Path stateRoot) { + this.stateRoot = stateRoot == null ? ShellEnvironmentHarness.defaultStateRoot() : stateRoot; + } + } } diff --git a/lypi-boot/src/test/java/cn/lypi/boot/tool/LyPiToolAutoConfigurationTest.java b/lypi-boot/src/test/java/cn/lypi/boot/tool/LyPiToolAutoConfigurationTest.java index 0012356d..83cd8ab1 100644 --- a/lypi-boot/src/test/java/cn/lypi/boot/tool/LyPiToolAutoConfigurationTest.java +++ b/lypi-boot/src/test/java/cn/lypi/boot/tool/LyPiToolAutoConfigurationTest.java @@ -65,6 +65,7 @@ import cn.lypi.contracts.subagent.SubagentWaitResult; import cn.lypi.tool.PermissionGateResult; import cn.lypi.tool.PermissionPromptPort; +import cn.lypi.tool.builtin.ShellEnvironmentHarness; import cn.lypi.tool.mcp.McpClient; import cn.lypi.tool.mcp.McpClientManager; import cn.lypi.tool.mcp.McpClientManagerFactory; @@ -91,10 +92,64 @@ import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; class LyPiToolAutoConfigurationTest { + @Test + void configuresDefaultShellStateRoot() { + new ApplicationContextRunner() + .withUserConfiguration(LyPiToolAutoConfiguration.class) + .withBean(SecurityRuntimePort.class, () -> LyPiToolAutoConfigurationTest::allowAllSecurity) + .run(context -> { + assertThat(context).hasSingleBean(ShellEnvironmentHarness.class); + assertThat(context.getBean(ShellEnvironmentHarness.class).stateRoot()) + .isEqualTo(ShellEnvironmentHarness.defaultStateRoot().toAbsolutePath().normalize()); + }); + } + + @Test + void configuresCustomShellStateRootAndRegistersBash() { + Path stateRoot = Path.of("build/custom-shell-state").toAbsolutePath().normalize(); + + new ApplicationContextRunner() + .withUserConfiguration(LyPiToolAutoConfiguration.class) + .withPropertyValues("lypi.tool.shell.state-root=" + stateRoot) + .withBean(SecurityRuntimePort.class, () -> LyPiToolAutoConfigurationTest::allowAllSecurity) + .run(context -> { + ShellEnvironmentHarness harness = context.getBean(ShellEnvironmentHarness.class); + + assertThat(harness.stateRoot()).isEqualTo(stateRoot); + assertThat(context.getBean(ToolRuntimePort.class).resolve("bash")).isPresent(); + }); + } + + @Test + void sharesUserProvidedShellHarnessAcrossRuntimeFactoryPaths() { + ShellEnvironmentHarness shellHarness = new ShellEnvironmentHarness(Path.of("build/shared-shell-state")); + + new ApplicationContextRunner() + .withUserConfiguration(LyPiToolAutoConfiguration.class) + .withBean(SecurityRuntimePort.class, () -> LyPiToolAutoConfigurationTest::allowAllSecurity) + .withBean(ShellEnvironmentHarness.class, () -> shellHarness) + .run(context -> { + ToolRuntimeFactoryPort factory = context.getBean(ToolRuntimeFactoryPort.class); + ToolRuntimePort main = context.getBean(ToolRuntimePort.class); + ToolRuntimePort child = factory.create(Path.of(".")); + ToolRuntimePort filtered = factory.create( + Path.of("."), + new SubagentToolPolicy(List.of("bash"), List.of("bash")) + ); + + assertThat(context).hasSingleBean(ShellEnvironmentHarness.class); + assertThat(context.getBean(ShellEnvironmentHarness.class)).isSameAs(shellHarness); + assertThat(shellHarnessFrom(main)).isSameAs(shellHarness); + assertThat(shellHarnessFrom(child)).isSameAs(shellHarness); + assertThat(shellHarnessFrom(filtered)).isSameAs(shellHarness); + }); + } + @Test void createsSandboxExecutorChainAndRegistersDefaultTools() { new ApplicationContextRunner() @@ -774,6 +829,10 @@ private static PermissionDecision allowAllSecurity(ToolUseRequest request, ToolU ); } + private static Object shellHarnessFrom(ToolRuntimePort runtime) { + return ReflectionTestUtils.getField(runtime.resolve("bash").orElseThrow(), "shellHarness"); + } + private static ContextSnapshot context() { return context(PermissionMode.ASK); } diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/runtime/SessionManagerPort.java b/lypi-contracts/src/main/java/cn/lypi/contracts/runtime/SessionManagerPort.java index 2b539874..f7044c94 100644 --- a/lypi-contracts/src/main/java/cn/lypi/contracts/runtime/SessionManagerPort.java +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/runtime/SessionManagerPort.java @@ -17,6 +17,20 @@ public interface SessionManagerPort { */ SessionHandle openOrCreate(String sessionId); + /** + * 返回当前 session 的 shell 状态。 + */ + default cn.lypi.contracts.session.ShellState shellState() { + throw new UnsupportedOperationException("shell state is not supported"); + } + + /** + * Append a shell state transition to the current session branch. + */ + default SessionHandle appendShellStateChange(cn.lypi.contracts.session.ShellState shellState) { + throw new UnsupportedOperationException("shell state is not supported"); + } + /** * 打开临时 session。 * diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/runtime/ToolRuntimeInvocation.java b/lypi-contracts/src/main/java/cn/lypi/contracts/runtime/ToolRuntimeInvocation.java index 5971cd4e..f29a5ab3 100644 --- a/lypi-contracts/src/main/java/cn/lypi/contracts/runtime/ToolRuntimeInvocation.java +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/runtime/ToolRuntimeInvocation.java @@ -13,8 +13,12 @@ public record ToolRuntimeInvocation( String turnId, String parentEntryId, AbortSignal abortSignal, - SteeringMessageSource steeringMessages + SteeringMessageSource steeringMessages, + java.nio.file.Path cwd ) { + private static final AbortSignal INHERIT_ABORT_SIGNAL = () -> false; + private static final SteeringMessageSource INHERIT_STEERING_MESSAGES = java.util.Optional::empty; + public ToolRuntimeInvocation(String sessionId, String turnId) { this(sessionId, turnId, null); } @@ -23,8 +27,43 @@ public ToolRuntimeInvocation(String sessionId, String turnId, String parentEntry this(sessionId, turnId, parentEntryId, AbortSignal.none(), SteeringMessageSource.none()); } + public ToolRuntimeInvocation( + String sessionId, + String turnId, + String parentEntryId, + AbortSignal abortSignal, + SteeringMessageSource steeringMessages + ) { + this(sessionId, turnId, parentEntryId, abortSignal, steeringMessages, null); + } + public ToolRuntimeInvocation { abortSignal = abortSignal == null ? AbortSignal.none() : abortSignal; steeringMessages = steeringMessages == null ? SteeringMessageSource.none() : steeringMessages; } + + /** + * Creates an invocation that overrides only cwd and inherits runtime-configured activity signals. + */ + public static ToolRuntimeInvocation cwdOnly(java.nio.file.Path cwd) { + return new ToolRuntimeInvocation( + null, + null, + null, + INHERIT_ABORT_SIGNAL, + INHERIT_STEERING_MESSAGES, + cwd + ); + } + + public boolean inheritsRuntimeSignals() { + return abortSignal == INHERIT_ABORT_SIGNAL && steeringMessages == INHERIT_STEERING_MESSAGES; + } + + /** + * Dynamic working directory for this tool invocation. The runtime workspace root remains stable. + */ + public ToolRuntimeInvocation withCwd(java.nio.file.Path cwdOverride) { + return new ToolRuntimeInvocation(sessionId, turnId, parentEntryId, abortSignal, steeringMessages, cwdOverride); + } } diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/session/SessionEntry.java b/lypi-contracts/src/main/java/cn/lypi/contracts/session/SessionEntry.java index ad92f0d9..2383834e 100644 --- a/lypi-contracts/src/main/java/cn/lypi/contracts/session/SessionEntry.java +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/session/SessionEntry.java @@ -13,6 +13,7 @@ @JsonSubTypes.Type(value = PermissionModeChangeEntry.class, name = "permission_mode_change"), @JsonSubTypes.Type(value = PermissionRuntimeStateChangeEntry.class, name = "permission_runtime_state_change"), @JsonSubTypes.Type(value = PermissionAmendmentEntry.class, name = "permission_amendment"), + @JsonSubTypes.Type(value = ShellStateChangeEntry.class, name = "shell_state_change"), @JsonSubTypes.Type(value = CompactionEntry.class, name = "compaction"), @JsonSubTypes.Type(value = BranchSummaryEntry.class, name = "branch_summary"), @JsonSubTypes.Type(value = CustomEntry.class, name = "custom"), diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/session/SessionHeader.java b/lypi-contracts/src/main/java/cn/lypi/contracts/session/SessionHeader.java index 31786218..eabdb01b 100644 --- a/lypi-contracts/src/main/java/cn/lypi/contracts/session/SessionHeader.java +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/session/SessionHeader.java @@ -28,9 +28,11 @@ public record SessionHeader( Optional initialModel, Optional initialThinkingLevel, Optional initialAgentMode, - PermissionRuntimeState initialPermissionRuntimeState + PermissionRuntimeState initialPermissionRuntimeState, + ShellState shellState ) { public SessionHeader { + shellState = shellState == null ? ShellState.of(cwd) : shellState; parentSessionId = parentSessionId == null ? Optional.empty() : parentSessionId; parentSpawnEntryId = parentSpawnEntryId == null ? Optional.empty() : parentSpawnEntryId; agentName = agentName == null ? Optional.empty() : agentName; @@ -62,7 +64,8 @@ public SessionHeader( Optional.empty(), Optional.empty(), Optional.empty(), - Optional.empty() + (PermissionRuntimeState) null, + null ); } @@ -98,7 +101,8 @@ public SessionHeader( initialAgentMode, initialPermissionMode == null ? null - : initialPermissionMode.map(PermissionRuntimeState::fromLegacy).orElse(null) + : initialPermissionMode.map(PermissionRuntimeState::fromLegacy).orElse(null), + null ); } @@ -128,7 +132,8 @@ public SessionHeader( Optional.empty(), Optional.empty(), Optional.empty(), - Optional.empty() + (PermissionRuntimeState) null, + null ); } @@ -159,7 +164,8 @@ public static SessionHeader create( @JsonProperty("initialThinkingLevel") Optional initialThinkingLevel, @JsonProperty("initialAgentMode") Optional initialAgentMode, @JsonProperty("initialPermissionRuntimeState") PermissionRuntimeState initialPermissionRuntimeState, - @JsonProperty("initialPermissionMode") Optional initialPermissionMode + @JsonProperty("initialPermissionMode") Optional initialPermissionMode, + @JsonProperty("shellState") ShellState shellState ) { PermissionRuntimeState normalizedRuntimeState = initialPermissionRuntimeState; if (normalizedRuntimeState == null && initialPermissionMode != null) { @@ -179,7 +185,9 @@ public static SessionHeader create( initialModel, initialThinkingLevel, initialAgentMode, - normalizedRuntimeState + normalizedRuntimeState, + shellState ); } + } diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/session/ShellState.java b/lypi-contracts/src/main/java/cn/lypi/contracts/session/ShellState.java new file mode 100644 index 00000000..fdb3098d --- /dev/null +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/session/ShellState.java @@ -0,0 +1,27 @@ +package cn.lypi.contracts.session; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.nio.file.Path; +import java.util.Objects; + +/** + * 会话级 shell 状态。 + * + * NOTE: 第一阶段只承载当前 shell 工作目录;与 SessionHeader.cwd(session 创建/存储位置)语义不同, + * 该值随 Bash 捕获的 cd 结果更新。 + */ +public record ShellState(Path cwd) { + public ShellState { + Objects.requireNonNull(cwd, "cwd must not be null"); + } + + @JsonCreator + public static ShellState create(@JsonProperty("cwd") Path cwd) { + return new ShellState(cwd); + } + + public static ShellState of(Path cwd) { + return new ShellState(cwd); + } +} diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/session/ShellStateChangeEntry.java b/lypi-contracts/src/main/java/cn/lypi/contracts/session/ShellStateChangeEntry.java new file mode 100644 index 00000000..8992d973 --- /dev/null +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/session/ShellStateChangeEntry.java @@ -0,0 +1,20 @@ +package cn.lypi.contracts.session; + +import java.time.Instant; +import java.util.Objects; + +/** + * Append-only fact recording a shell state transition on a session branch. + */ +public record ShellStateChangeEntry( + String id, + String parentId, + ShellState shellState, + Instant timestamp +) implements SessionEntry { + public ShellStateChangeEntry { + Objects.requireNonNull(id, "id must not be null"); + Objects.requireNonNull(shellState, "shellState must not be null"); + Objects.requireNonNull(timestamp, "timestamp must not be null"); + } +} diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/tool/ToolResult.java b/lypi-contracts/src/main/java/cn/lypi/contracts/tool/ToolResult.java index adbceb04..d60881fb 100644 --- a/lypi-contracts/src/main/java/cn/lypi/contracts/tool/ToolResult.java +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/tool/ToolResult.java @@ -9,6 +9,23 @@ public record ToolResult( O output, boolean isError, List newMessages, - Optional replacement -) {} + Optional replacement, + Optional stateDelta +) { + public ToolResult( + O output, + boolean isError, + List newMessages, + Optional replacement + ) { + this(output, isError, newMessages, replacement, Optional.empty()); + } + public ToolResult { + stateDelta = stateDelta == null ? Optional.empty() : stateDelta; + } + + public ToolResult withStateDelta(Optional nextStateDelta) { + return new ToolResult<>(output, isError, newMessages, replacement, nextStateDelta); + } +} diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/tool/ToolStateDelta.java b/lypi-contracts/src/main/java/cn/lypi/contracts/tool/ToolStateDelta.java new file mode 100644 index 00000000..9197e0b6 --- /dev/null +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/tool/ToolStateDelta.java @@ -0,0 +1,13 @@ +package cn.lypi.contracts.tool; + +import java.nio.file.Path; +import java.util.Objects; + +/** + * State changes produced by a successful tool execution. + */ +public record ToolStateDelta(Path cwd) { + public ToolStateDelta { + Objects.requireNonNull(cwd, "cwd must not be null"); + } +} diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/tool/ToolUseContext.java b/lypi-contracts/src/main/java/cn/lypi/contracts/tool/ToolUseContext.java index 9534c7d2..8262033d 100644 --- a/lypi-contracts/src/main/java/cn/lypi/contracts/tool/ToolUseContext.java +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/tool/ToolUseContext.java @@ -6,7 +6,11 @@ public record ToolUseContext( String sessionId, String messageId, + Path workspaceRoot, Path cwd, Map metadata -) {} - +) { + public ToolUseContext(String sessionId, String messageId, Path cwd, Map metadata) { + this(sessionId, messageId, cwd, cwd, metadata); + } +} diff --git a/lypi-contracts/src/test/java/cn/lypi/contracts/ContractSerializationTest.java b/lypi-contracts/src/test/java/cn/lypi/contracts/ContractSerializationTest.java index 7f7ef647..bef98980 100644 --- a/lypi-contracts/src/test/java/cn/lypi/contracts/ContractSerializationTest.java +++ b/lypi-contracts/src/test/java/cn/lypi/contracts/ContractSerializationTest.java @@ -88,6 +88,8 @@ import cn.lypi.contracts.session.CustomMessageEntry; import cn.lypi.contracts.session.SessionEntry; import cn.lypi.contracts.session.SessionHeader; +import cn.lypi.contracts.session.ShellStateChangeEntry; +import cn.lypi.contracts.session.ShellState; import cn.lypi.contracts.session.SessionInfoEntry; import cn.lypi.contracts.skill.SkillMention; import cn.lypi.contracts.skill.SkillIndex; @@ -108,7 +110,10 @@ import cn.lypi.contracts.model.TokenUsage; import cn.lypi.contracts.tool.ToolExecutionStatus; import cn.lypi.contracts.tool.ToolOutputRef; +import cn.lypi.contracts.tool.ToolResult; import cn.lypi.contracts.tool.ToolResultSummary; +import cn.lypi.contracts.tool.ToolStateDelta; +import cn.lypi.contracts.tool.ToolUseContext; import cn.lypi.contracts.tui.DiffView; import cn.lypi.contracts.tui.GitDiffFileView; import cn.lypi.contracts.tui.GitDiffStatus; @@ -394,6 +399,73 @@ void sessionEntriesRoundTripOnlyForConversationPathFacts() throws Exception { } } + @Test + void shellStateContractsKeepWorkspaceBoundaryAndRoundTripCwdWithSpaces() throws Exception { + Path workspace = Path.of("/tmp/project"); + Path nested = workspace.resolve("dir with spaces"); + ToolUseContext context = new ToolUseContext("ses_1", "msg_1", workspace, nested, Map.of()); + ToolUseContext legacyContext = new ToolUseContext("ses_1", "msg_1", workspace, Map.of()); + + ToolStateDelta delta = new ToolStateDelta(nested); + ToolResult result = new ToolResult<>("ok", false, List.of(), Optional.empty(), Optional.of(delta)); + ToolResult legacyResult = new ToolResult<>("ok", false, List.of(), Optional.empty()); + + Instant now = Instant.parse("2026-06-09T00:00:00Z"); + SessionEntry entry = new ShellStateChangeEntry( + "entry-shell", + "entry-tool", + ShellState.of(nested), + now + ); + + assertEquals(workspace, context.workspaceRoot()); + assertEquals(nested, context.cwd()); + assertEquals(workspace, legacyContext.workspaceRoot()); + assertEquals(workspace, legacyContext.cwd()); + assertEquals(delta, result.stateDelta().orElseThrow()); + assertEquals(Optional.empty(), legacyResult.stateDelta()); + assertEquals(entry, mapper.readValue(mapper.writeValueAsString(entry), SessionEntry.class)); + } + + @Test + void sessionHeaderRoundTripKeepsShellState() throws Exception { + SessionHeader header = new SessionHeader( + "session", + 1, + "ses_shell", + Path.of("/tmp/project"), + Optional.empty(), + Optional.empty(), + 0, + Optional.empty(), + Optional.empty(), + Instant.parse("2026-06-09T00:00:00Z"), + Optional.empty(), + Optional.empty(), + Optional.empty(), + null, + ShellState.of(Path.of("/tmp/project/subdir")) + ); + + String json = mapper.writeValueAsString(header); + SessionHeader restored = mapper.readValue(json, SessionHeader.class); + + assertEquals(Path.of("/tmp/project/subdir"), restored.shellState().cwd()); + assertEquals(Path.of("/tmp/project"), restored.cwd()); + } + + @Test + void legacySessionHeaderDefaultsShellStateToHeaderCwd() throws Exception { + String legacyJson = """ + {"type":"session","version":1,"id":"ses_old","cwd":"/tmp/legacy", + "timestamp":"2026-06-09T00:00:00Z"} + """; + + SessionHeader restored = mapper.readValue(legacyJson, SessionHeader.class); + + assertEquals(ShellState.of(Path.of("/tmp/legacy")), restored.shellState()); + } + @Test void sessionHeaderRoundTripKeepsSubagentRelationshipFields() throws Exception { SessionHeader header = new SessionHeader( @@ -435,7 +507,8 @@ void sessionHeaderRoundTripKeepsCanonicalPermissionRuntimeState() throws Excepti Optional.empty(), Optional.empty(), Optional.empty(), - runtimeState + runtimeState, + null ); String json = mapper.writeValueAsString(header); diff --git a/lypi-security/src/main/java/cn/lypi/security/FileSystemPolicyChecker.java b/lypi-security/src/main/java/cn/lypi/security/FileSystemPolicyChecker.java index ce88d6a0..e2278f2d 100644 --- a/lypi-security/src/main/java/cn/lypi/security/FileSystemPolicyChecker.java +++ b/lypi-security/src/main/java/cn/lypi/security/FileSystemPolicyChecker.java @@ -115,8 +115,8 @@ private boolean matchesAnyCandidate( private boolean matchesPath(FileSystemPath path, Path candidate, ToolUseContext context) { return switch (path.kind()) { case SPECIAL -> matchesSpecialPath(path, candidate, context); - case EXACT_PATH -> matchesExactPath(path.value(), candidate, context.cwd()); - case GLOB_PATTERN -> matchesGlob(path.value(), candidate, context.cwd()); + case EXACT_PATH -> matchesExactPath(path.value(), candidate, context.workspaceRoot()); + case GLOB_PATTERN -> matchesGlob(path.value(), candidate, context.workspaceRoot()); }; } @@ -124,7 +124,7 @@ private boolean matchesSpecialPath(FileSystemPath path, Path candidate, ToolUseC FileSystemSpecialPath specialPath = FileSystemSpecialPath.fromJson(path.value()); return switch (specialPath) { case ROOT -> true; - case PROJECT_ROOTS -> matchesWorkspaceRoot(candidate, context.cwd()); + case PROJECT_ROOTS -> matchesWorkspaceRoot(candidate, context.workspaceRoot()); case TMPDIR -> isSameOrDescendant(candidate, Path.of(System.getProperty("java.io.tmpdir")).toAbsolutePath().normalize()); case SLASH_TMP -> isSameOrDescendant(candidate, Path.of("/tmp").toAbsolutePath().normalize()); case MINIMAL -> false; diff --git a/lypi-security/src/main/java/cn/lypi/security/PathSafetyChecker.java b/lypi-security/src/main/java/cn/lypi/security/PathSafetyChecker.java index 2d60e05f..2c4f94e1 100644 --- a/lypi-security/src/main/java/cn/lypi/security/PathSafetyChecker.java +++ b/lypi-security/src/main/java/cn/lypi/security/PathSafetyChecker.java @@ -62,12 +62,14 @@ Optional check(ToolUseRequest request, ToolUseContext contex } Optional checkPath(String fieldName, String rawPath, ToolUseContext context) { + Path workspace = context.workspaceRoot().toAbsolutePath().normalize(); Path cwd = context.cwd().toAbsolutePath().normalize(); Path target = cwd.resolve(rawPath).normalize(); - Optional realCwd = realPathForCwd(cwd); - Optional realPath = realCwd.map(path -> realPathForSafetyCheck(rawPath, path)); - if (realPath.isPresent() && realPath.get().startsWith(realCwd.get())) { - String realRelativePath = realCwd.get().relativize(realPath.get()).toString().replace('\\', '/'); + Optional realWorkspace = realPathForCwd(workspace); + Optional realBase = realPathForCwd(cwd); + Optional realPath = realBase.map(path -> realPathForSafetyCheck(rawPath, path)); + if (realWorkspace.isPresent() && realPath.isPresent() && realPath.get().startsWith(realWorkspace.get())) { + String realRelativePath = realWorkspace.get().relativize(realPath.get()).toString().replace('\\', '/'); if (isProtectedPath(realRelativePath)) { return Optional.of(decision( "工具路径经符号链接命中受保护路径: " + rawPath, @@ -77,7 +79,8 @@ Optional checkPath(String fieldName, String rawPath, ToolUse )); } } - if (target.startsWith(cwd) && isProtectedPath(cwd.relativize(target).toString().replace('\\', '/'))) { + if (target.startsWith(workspace) + && isProtectedPath(workspace.relativize(target).toString().replace('\\', '/'))) { return Optional.of(decision( "工具路径命中受保护路径: " + rawPath, fieldName, @@ -94,7 +97,7 @@ Optional checkPathInsideWorkspace( ToolUseContext context, Path baseCwd ) { - Path workspace = context.cwd().toAbsolutePath().normalize(); + Path workspace = context.workspaceRoot().toAbsolutePath().normalize(); Path base = baseCwd.toAbsolutePath().normalize(); Path target = base.resolve(rawPath).normalize(); Optional realWorkspace = realPathForCwd(workspace); diff --git a/lypi-security/src/test/java/cn/lypi/security/FileSystemPolicyCheckerTest.java b/lypi-security/src/test/java/cn/lypi/security/FileSystemPolicyCheckerTest.java index f5913fdc..482acf5b 100644 --- a/lypi-security/src/test/java/cn/lypi/security/FileSystemPolicyCheckerTest.java +++ b/lypi-security/src/test/java/cn/lypi/security/FileSystemPolicyCheckerTest.java @@ -53,6 +53,48 @@ void workspaceProfileAllowsWritesInsideWorkspace(@TempDir Path workspace) { assertThat(decision.reason()).isEqualTo(PermissionDecisionReason.SANDBOX_POLICY); } + @Test + void workspaceAndRelativeProfileEntriesStayRootedAtStableWorkspace(@TempDir Path workspace) throws IOException { + Path nested = Files.createDirectories(workspace.resolve("nested")); + ToolUseContext context = new ToolUseContext( + "ses_1", + "msg_1", + workspace, + nested, + Map.of("permissionMode", PermissionMode.BYPASS) + ); + ManagedPermissionProfile relativeProfile = new ManagedPermissionProfile( + FileSystemPermissionPolicy.restricted(List.of( + new FileSystemPermissionEntry(FileSystemPath.exactPath("shared"), FileSystemAccessMode.WRITE), + new FileSystemPermissionEntry(FileSystemPath.globPattern("logs/*.log"), FileSystemAccessMode.READ) + )), + NetworkPermissionPolicy.restricted() + ); + + PermissionDecision projectRoot = checker.decide( + PermissionProfiles.workspace(), + FileSystemAccessMode.WRITE, + workspace.resolve("root.txt"), + context + ); + PermissionDecision exact = checker.decide( + relativeProfile, + FileSystemAccessMode.WRITE, + workspace.resolve("shared/output.txt"), + context + ); + PermissionDecision glob = checker.decide( + relativeProfile, + FileSystemAccessMode.READ, + workspace.resolve("logs/app.log"), + context + ); + + assertThat(projectRoot.behavior()).isEqualTo(PermissionBehavior.ALLOW); + assertThat(exact.behavior()).isEqualTo(PermissionBehavior.ALLOW); + assertThat(glob.behavior()).isEqualTo(PermissionBehavior.ALLOW); + } + @Test void writeEntryAllowsReadAccess(@TempDir Path workspace) { PermissionDecision decision = checker.decide( diff --git a/lypi-security/src/test/java/cn/lypi/security/PathSafetyCheckerTest.java b/lypi-security/src/test/java/cn/lypi/security/PathSafetyCheckerTest.java index accd5c66..dce7e55a 100644 --- a/lypi-security/src/test/java/cn/lypi/security/PathSafetyCheckerTest.java +++ b/lypi-security/src/test/java/cn/lypi/security/PathSafetyCheckerTest.java @@ -76,6 +76,29 @@ void deniesAgentAndCodexMetadataPathsEvenInBypassMode() { assertThat(codexDecision.get().behavior()).isEqualTo(PermissionBehavior.DENY); } + @Test + void deniesProtectedWorkspacePathResolvedFromNestedCwd(@TempDir Path tempDir) throws IOException { + Path workspace = Files.createDirectories(tempDir.resolve("workspace")); + Path nested = Files.createDirectories(workspace.resolve("nested")); + Files.createDirectories(workspace.resolve(".git")); + PathSafetyChecker checker = new PathSafetyChecker(); + + Optional decision = checker.check( + request("read_file", Map.of("path", "../.git/config")), + new ToolUseContext( + "ses_1", + "msg_1", + workspace, + nested, + Map.of("permissionMode", PermissionMode.BYPASS) + ) + ); + + assertThat(decision).isPresent(); + assertThat(decision.orElseThrow().behavior()).isEqualTo(PermissionBehavior.DENY); + assertThat(decision.orElseThrow().reason()).isEqualTo(PermissionDecisionReason.PATH_SAFETY); + } + @Test void allowsExistingSymlinkThatEscapesCurrentWorkingDirectoryForProfileBoundary(@TempDir Path tempDir) throws IOException { Path workspace = tempDir.resolve("workspace"); diff --git a/lypi-session/src/main/java/cn/lypi/session/ChildSessionService.java b/lypi-session/src/main/java/cn/lypi/session/ChildSessionService.java index 449d77e1..3220f829 100644 --- a/lypi-session/src/main/java/cn/lypi/session/ChildSessionService.java +++ b/lypi-session/src/main/java/cn/lypi/session/ChildSessionService.java @@ -6,6 +6,7 @@ import cn.lypi.contracts.session.SessionHandle; import cn.lypi.contracts.session.SessionHeader; import cn.lypi.contracts.session.SessionInfoEntry; +import cn.lypi.contracts.session.ShellState; import java.time.Clock; import java.time.Instant; import java.util.LinkedHashMap; @@ -51,7 +52,8 @@ public SessionHandle create(ChildSessionRequest request) { request.initialModel(), request.initialThinkingLevel(), request.initialAgentMode(), - request.initialPermissionRuntimeState() + request.initialPermissionRuntimeState(), + ShellState.of(request.cwd()) ); store.create(header); diff --git a/lypi-session/src/main/java/cn/lypi/session/ForkService.java b/lypi-session/src/main/java/cn/lypi/session/ForkService.java index e77ac477..b441d645 100644 --- a/lypi-session/src/main/java/cn/lypi/session/ForkService.java +++ b/lypi-session/src/main/java/cn/lypi/session/ForkService.java @@ -45,7 +45,8 @@ SessionHandle fork(ForkRequest request, SessionHeader sourceHeader, EntryTreeInd sourceHeader.initialModel(), sourceHeader.initialThinkingLevel(), sourceHeader.initialAgentMode(), - sourceHeader.initialPermissionRuntimeState() + sourceHeader.initialPermissionRuntimeState(), + sourceHeader.shellState() ); JsonlSessionStore targetStore = new JsonlSessionStore(request.targetCwd()); targetStore.create(header); diff --git a/lypi-session/src/main/java/cn/lypi/session/SessionJsonMapper.java b/lypi-session/src/main/java/cn/lypi/session/SessionJsonMapper.java index 1632363b..422dc47f 100644 --- a/lypi-session/src/main/java/cn/lypi/session/SessionJsonMapper.java +++ b/lypi-session/src/main/java/cn/lypi/session/SessionJsonMapper.java @@ -14,6 +14,7 @@ import cn.lypi.contracts.session.SessionEntry; import cn.lypi.contracts.session.SessionHeader; import cn.lypi.contracts.session.SessionInfoEntry; +import cn.lypi.contracts.session.ShellStateChangeEntry; import cn.lypi.contracts.session.ThinkingChangeEntry; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.DeserializationFeature; @@ -40,6 +41,7 @@ final class SessionJsonMapper { Map.entry("permission_mode_change", PermissionModeChangeEntry.class), Map.entry("permission_runtime_state_change", PermissionRuntimeStateChangeEntry.class), Map.entry("permission_amendment", PermissionAmendmentEntry.class), + Map.entry("shell_state_change", ShellStateChangeEntry.class), Map.entry("compaction", CompactionEntry.class), Map.entry("branch_summary", BranchSummaryEntry.class), Map.entry("custom", CustomEntry.class), @@ -55,6 +57,7 @@ final class SessionJsonMapper { Map.entry(PermissionModeChangeEntry.class, "permission_mode_change"), Map.entry(PermissionRuntimeStateChangeEntry.class, "permission_runtime_state_change"), Map.entry(PermissionAmendmentEntry.class, "permission_amendment"), + Map.entry(ShellStateChangeEntry.class, "shell_state_change"), Map.entry(CompactionEntry.class, "compaction"), Map.entry(BranchSummaryEntry.class, "branch_summary"), Map.entry(CustomEntry.class, "custom"), diff --git a/lypi-session/src/main/java/cn/lypi/session/SessionLeafSelector.java b/lypi-session/src/main/java/cn/lypi/session/SessionLeafSelector.java index 8536a717..1a012c1e 100644 --- a/lypi-session/src/main/java/cn/lypi/session/SessionLeafSelector.java +++ b/lypi-session/src/main/java/cn/lypi/session/SessionLeafSelector.java @@ -13,6 +13,7 @@ import cn.lypi.contracts.session.PermissionRuntimeStateChangeEntry; import cn.lypi.contracts.session.SessionEntry; import cn.lypi.contracts.session.SessionInfoEntry; +import cn.lypi.contracts.session.ShellStateChangeEntry; import cn.lypi.contracts.session.ThinkingChangeEntry; import java.util.List; @@ -42,6 +43,7 @@ static boolean advancesNavigableLeaf(SessionEntry entry) { && !(entry instanceof PermissionModeChangeEntry) && !(entry instanceof PermissionRuntimeStateChangeEntry) && !(entry instanceof PermissionAmendmentEntry) + && !(entry instanceof ShellStateChangeEntry) && !(entry instanceof SessionInfoEntry) && !(entry instanceof LabelEntry) && !(entry instanceof CustomEntry); diff --git a/lypi-session/src/main/java/cn/lypi/session/SessionManagerImpl.java b/lypi-session/src/main/java/cn/lypi/session/SessionManagerImpl.java index 2a72378a..73508b2f 100644 --- a/lypi-session/src/main/java/cn/lypi/session/SessionManagerImpl.java +++ b/lypi-session/src/main/java/cn/lypi/session/SessionManagerImpl.java @@ -17,6 +17,8 @@ import cn.lypi.contracts.session.SessionHandle; import cn.lypi.contracts.session.SessionHeader; import cn.lypi.contracts.session.SessionView; +import cn.lypi.contracts.session.ShellState; +import cn.lypi.contracts.session.ShellStateChangeEntry; import cn.lypi.contracts.tui.SessionFileView; import java.nio.file.Path; import java.time.Clock; @@ -212,6 +214,30 @@ public synchronized SessionView currentView() { return view(index.leafId()); } + @Override + public synchronized ShellState shellState() { + ensureOpen(); + refreshFromStoreIfPersistent(); + ShellState current = header.shellState(); + for (SessionEntry entry : index.branch(index.leafId())) { + if (entry instanceof ShellStateChangeEntry change) { + current = change.shellState(); + } + } + return current; + } + + @Override + public synchronized SessionHandle appendShellStateChange(ShellState shellState) { + ensureOpen(); + return append(new ShellStateChangeEntry( + SessionEntryIds.newEntryId(), + index.leafId(), + Objects.requireNonNull(shellState, "shellState must not be null"), + Instant.now(clock) + )); + } + @Override public synchronized SessionView view(String leafId) { ensureOpen(); @@ -403,7 +429,8 @@ private SessionHeader initialHeader(String sessionId) { Optional.of(replayProjector.defaultModel()), Optional.of(replayProjector.defaultThinkingLevel()), Optional.of(replayProjector.defaultMode()), - replayProjector.defaultPermissionRuntimeState() + replayProjector.defaultPermissionRuntimeState(), + ShellState.of(cwd) ); } diff --git a/lypi-session/src/test/java/cn/lypi/session/SessionEntryBoundaryTest.java b/lypi-session/src/test/java/cn/lypi/session/SessionEntryBoundaryTest.java index 988968c1..d43aa9d6 100644 --- a/lypi-session/src/test/java/cn/lypi/session/SessionEntryBoundaryTest.java +++ b/lypi-session/src/test/java/cn/lypi/session/SessionEntryBoundaryTest.java @@ -4,7 +4,11 @@ import cn.lypi.contracts.memory.MemoryWriteEntry; import cn.lypi.contracts.session.SessionEntry; +import cn.lypi.contracts.session.ShellState; +import cn.lypi.contracts.session.ShellStateChangeEntry; import com.fasterxml.jackson.annotation.JsonSubTypes; +import java.nio.file.Path; +import java.time.Instant; import java.util.Arrays; import java.util.Set; import java.util.stream.Collectors; @@ -27,6 +31,7 @@ void sessionEntrySubtypesOnlyContainConversationPathFacts() { "permission_mode_change", "permission_runtime_state_change", "permission_amendment", + "shell_state_change", "compaction", "branch_summary", "custom", @@ -36,6 +41,19 @@ void sessionEntrySubtypesOnlyContainConversationPathFacts() { ); } + @Test + void shellStateChangesStayInTheBranchButDoNotBecomeNavigableLeaves() { + ShellStateChangeEntry entry = new ShellStateChangeEntry( + "entry-shell", + "entry-tool", + ShellState.of(Path.of("/tmp/project/dir with spaces")), + Instant.parse("2026-06-01T00:00:00Z") + ); + + assertThat(entry).isInstanceOf(SessionEntry.class); + assertThat(SessionLeafSelector.advancesNavigableLeaf(entry)).isFalse(); + } + @Test void fileChangeAndMemoryWriteAreNotSessionEntries() { assertThat(classExists("cn.lypi.contracts.session.FileChangeEntry")).isFalse(); diff --git a/lypi-session/src/test/java/cn/lypi/session/SessionManagerImplTest.java b/lypi-session/src/test/java/cn/lypi/session/SessionManagerImplTest.java index a76c8370..434a3840 100644 --- a/lypi-session/src/test/java/cn/lypi/session/SessionManagerImplTest.java +++ b/lypi-session/src/test/java/cn/lypi/session/SessionManagerImplTest.java @@ -25,6 +25,8 @@ import cn.lypi.contracts.session.SessionHandle; import cn.lypi.contracts.session.SessionHeader; import cn.lypi.contracts.session.SessionInfoEntry; +import cn.lypi.contracts.session.ShellState; +import cn.lypi.contracts.session.ShellStateChangeEntry; import cn.lypi.contracts.session.ThinkingChangeEntry; import java.nio.file.Files; import java.nio.file.Path; @@ -66,6 +68,40 @@ void openOrCreateCreatesHeaderAndRestoresAppendedEntries() throws Exception { .containsExactly("entry_1", "entry_2"); } + @Test + void appendShellStateChangeKeepsHeaderAppendOnlyAndReplaysCurrentBranch() throws Exception { + SessionManager manager = new SessionManagerImpl(tempDir); + SessionHandle opened = manager.openOrCreate("ses_shell"); + ShellState initialState = manager.shellState(); + manager.append(new CustomMessageEntry( + "entry-tool", + null, + "tool result", + Instant.parse("2026-06-01T00:00:00Z") + )); + List linesBefore = Files.readAllLines(opened.sessionFile()); + Path changedCwd = tempDir.resolve("dir with spaces"); + + SessionHandle changed = manager.appendShellStateChange(ShellState.of(changedCwd)); + + List linesAfter = Files.readAllLines(opened.sessionFile()); + assertThat(linesAfter.getFirst()).isEqualTo(linesBefore.getFirst()); + assertThat(linesAfter).hasSize(linesBefore.size() + 1); + assertThat(changed.byId().get(changed.leafId())) + .isInstanceOfSatisfying(ShellStateChangeEntry.class, entry -> { + assertThat(entry.parentId()).isEqualTo("entry-tool"); + assertThat(entry.shellState()).isEqualTo(ShellState.of(changedCwd)); + }); + assertThat(manager.shellState()).isEqualTo(ShellState.of(changedCwd)); + + SessionManager reopened = new SessionManagerImpl(tempDir); + reopened.openOrCreate("ses_shell"); + assertThat(reopened.shellState()).isEqualTo(ShellState.of(changedCwd)); + + manager.switchLeaf("entry-tool"); + assertThat(manager.shellState()).isEqualTo(initialState); + } + @Test void openTemporaryDoesNotCreateSessionFileUntilUserMessage() { SessionManager engine = new SessionManagerImpl(tempDir); diff --git a/lypi-session/src/test/java/cn/lypi/session/SessionManagerReplayTest.java b/lypi-session/src/test/java/cn/lypi/session/SessionManagerReplayTest.java index 82e46f54..dbe431da 100644 --- a/lypi-session/src/test/java/cn/lypi/session/SessionManagerReplayTest.java +++ b/lypi-session/src/test/java/cn/lypi/session/SessionManagerReplayTest.java @@ -30,6 +30,8 @@ import cn.lypi.contracts.session.SessionHandle; import cn.lypi.contracts.session.SessionHeader; import cn.lypi.contracts.session.SessionInfoEntry; +import cn.lypi.contracts.session.ShellState; +import cn.lypi.contracts.session.ShellStateChangeEntry; import cn.lypi.contracts.session.ThinkingChangeEntry; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -171,6 +173,41 @@ void reopenedSessionUsesInitialStateFromHeader() { assertThat(context.permissionMode()).isEqualTo(PermissionMode.AUTO); } + @Test + void shellStateUsesLegacyHeaderUntilCurrentBranchContainsAChangeEntry() { + Path initialCwd = tempDir.resolve("legacy cwd"); + Path firstChangedCwd = tempDir.resolve("first changed cwd"); + Path latestChangedCwd = tempDir.resolve("latest changed cwd"); + JsonlSessionStore store = new JsonlSessionStore(tempDir); + store.create(sessionHeaderWithShellState("ses_shell_replay", initialCwd)); + + SessionManager withoutChanges = new SessionManagerImpl(tempDir); + withoutChanges.openOrCreate("ses_shell_replay"); + assertThat(withoutChanges.shellState()).isEqualTo(ShellState.of(initialCwd)); + + store.append( + "ses_shell_replay", + new ShellStateChangeEntry("entry-shell-first", null, ShellState.of(firstChangedCwd), NOW) + ); + store.append( + "ses_shell_replay", + new ShellStateChangeEntry( + "entry-shell-latest", + "entry-shell-first", + ShellState.of(latestChangedCwd), + NOW.plusSeconds(1) + ) + ); + SessionManager withChange = new SessionManagerImpl(tempDir); + withChange.openOrCreate("ses_shell_replay"); + + assertThat(withChange.shellState()).isEqualTo(ShellState.of(latestChangedCwd)); + withChange.switchLeaf("entry-shell-first"); + assertThat(withChange.shellState()).isEqualTo(ShellState.of(firstChangedCwd)); + withChange.switchLeaf(null); + assertThat(withChange.shellState()).isEqualTo(ShellState.of(initialCwd)); + } + @Test void reopenedChildSessionPreservesCanonicalPermissionRuntimeStateFromHeader() { PermissionRuntimeState runtimeState = PermissionRuntimeState.fromLegacy(PermissionMode.BYPASS); @@ -476,4 +513,24 @@ private static AgentMessage textMessage(String id, String text) { Optional.empty() ); } + + private SessionHeader sessionHeaderWithShellState(String sessionId, Path shellCwd) { + return new SessionHeader( + "session", + 1, + sessionId, + tempDir, + Optional.empty(), + Optional.empty(), + 0, + Optional.empty(), + Optional.empty(), + NOW, + Optional.empty(), + Optional.empty(), + Optional.empty(), + null, + ShellState.of(shellCwd) + ); + } } diff --git a/lypi-tool/src/main/java/cn/lypi/tool/DefaultToolRuntime.java b/lypi-tool/src/main/java/cn/lypi/tool/DefaultToolRuntime.java index dcd6228b..55f3a7f0 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/DefaultToolRuntime.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/DefaultToolRuntime.java @@ -24,6 +24,8 @@ import cn.lypi.contracts.tool.ToolResult; import cn.lypi.contracts.tool.ToolUseContext; import cn.lypi.contracts.tool.ToolUseRequest; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -551,17 +553,31 @@ public List> execute( } TurnPermissionState turnState = turnState(invocation); + ToolRuntimeInvocation currentInvocation = invocationWithInitialCwd(invocation); List resolvedSegment = new ArrayList<>(); for (ToolCallResolver.ResolvedCall call : callResolver.resolve(requests)) { if (!call.known()) { - executeResolvedSegment(resolvedSegment, context, invocation, results, turnState); + currentInvocation = executeResolvedSegment( + resolvedSegment, + context, + currentInvocation, + results, + turnState + ); resolvedSegment.clear(); - results.set(call.index(), executeUnknownCall(call.request(), context, invocation, turnState)); + ToolResult unknownResult = executeUnknownCall( + call.request(), + context, + currentInvocation, + turnState + ); + results.set(call.index(), unknownResult); + currentInvocation = invocationAfterResults(currentInvocation, List.of(unknownResult)); continue; } resolvedSegment.add(call); } - executeResolvedSegment(resolvedSegment, context, invocation, results, turnState); + executeResolvedSegment(resolvedSegment, context, currentInvocation, results, turnState); return List.copyOf(results); } @@ -573,7 +589,7 @@ public void clearTurnState(ToolRuntimeInvocation invocation) { } } - private void executeResolvedSegment( + private ToolRuntimeInvocation executeResolvedSegment( List resolvedCalls, ContextSnapshot context, ToolRuntimeInvocation invocation, @@ -581,21 +597,31 @@ private void executeResolvedSegment( TurnPermissionState turnState ) { if (resolvedCalls.isEmpty()) { - return; + return invocation; } List calls = resolvedCalls.stream() .map(call -> new ToolExecutionPlanner.ResolvedToolCall(call.request(), call.tool())) .toList(); List batches = executionPlanner.plan(calls); int cursor = 0; + ToolRuntimeInvocation currentInvocation = invocation; for (ToolExecutionPlanner.Batch batch : batches) { List indexedBatch = resolvedCalls.subList(cursor, cursor + batch.calls().size()); - executeBatch(batch, indexedBatch, context, invocation, results, turnState); + List> batchResults = executeBatch( + batch, + indexedBatch, + context, + currentInvocation, + results, + turnState + ); + currentInvocation = invocationAfterResults(currentInvocation, batchResults); cursor += batch.calls().size(); } + return currentInvocation; } - private void executeBatch( + private List> executeBatch( ToolExecutionPlanner.Batch batch, List indexedBatch, ContextSnapshot context, @@ -617,6 +643,65 @@ private void executeBatch( for (int index = 0; index < batchResults.size(); index++) { results.set(indexedBatch.get(index).index(), batchResults.get(index)); } + return batchResults; + } + + private ToolRuntimeInvocation invocationWithInitialCwd(ToolRuntimeInvocation invocation) { + if (invocation == null) { + return null; + } + Path initialCwd = validStateCwd(invocation.cwd()) + .orElse(contextFactory.cwd().toAbsolutePath().normalize()); + return withCwd(invocation, initialCwd); + } + + private ToolRuntimeInvocation invocationAfterResults( + ToolRuntimeInvocation invocation, + List> results + ) { + Path nextCwd = invocation == null || invocation.cwd() == null + ? contextFactory.cwd().toAbsolutePath().normalize() + : invocation.cwd(); + boolean changed = false; + for (ToolResult result : results) { + if (result == null || result.stateDelta().isEmpty()) { + continue; + } + Optional candidate = validStateCwd(result.stateDelta().orElseThrow().cwd()); + if (candidate.isPresent() && !candidate.orElseThrow().equals(nextCwd)) { + nextCwd = candidate.orElseThrow(); + changed = true; + } + } + return changed ? withCwd(invocation, nextCwd) : invocation; + } + + private Optional validStateCwd(Path candidate) { + if (candidate == null) { + return Optional.empty(); + } + Path workspaceRoot = contextFactory.cwd().toAbsolutePath().normalize(); + Path normalized = candidate.toAbsolutePath().normalize(); + if (!normalized.startsWith(workspaceRoot)) { + return Optional.empty(); + } + try { + Path realWorkspaceRoot = workspaceRoot.toRealPath(); + Path realCandidate = normalized.toRealPath(); + if (Files.isDirectory(realCandidate) && realCandidate.startsWith(realWorkspaceRoot)) { + return Optional.of(normalized); + } + } catch (IOException exception) { + return Optional.empty(); + } + return Optional.empty(); + } + + private ToolRuntimeInvocation withCwd(ToolRuntimeInvocation invocation, Path cwd) { + ToolRuntimeInvocation base = invocation == null + ? ToolRuntimeInvocation.cwdOnly(cwd) + : invocation; + return invocation == null ? base : base.withCwd(cwd); } private ToolResult executeCall( @@ -836,6 +921,7 @@ private ToolUseContext withAuthorizationMetadata( return new ToolUseContext( context.sessionId(), context.messageId(), + context.workspaceRoot(), context.cwd(), Map.copyOf(metadata) ); @@ -881,6 +967,7 @@ private ToolUseContext contextWithCallMetadata( return new ToolUseContext( context.sessionId(), context.messageId(), + context.workspaceRoot(), context.cwd(), Map.copyOf(metadata) ); diff --git a/lypi-tool/src/main/java/cn/lypi/tool/FilteredToolRuntime.java b/lypi-tool/src/main/java/cn/lypi/tool/FilteredToolRuntime.java index 16765833..76ff723c 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/FilteredToolRuntime.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/FilteredToolRuntime.java @@ -13,6 +13,8 @@ import cn.lypi.contracts.tool.ToolRegistrySnapshot; import cn.lypi.contracts.tool.ToolResult; import cn.lypi.contracts.tool.ToolUseRequest; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.time.Instant; import java.util.ArrayList; @@ -85,6 +87,7 @@ public List> execute( return List.of(); } List> results = new ArrayList<>(requests.size()); + ToolRuntimeInvocation currentInvocation = invocationWithInitialCwd(invocation); for (ToolUseRequest request : requests) { Optional> resolved = delegate.resolve(request.toolName()); if (resolved.isEmpty() @@ -97,7 +100,9 @@ public List> execute( )); continue; } - results.add(delegate.execute(List.of(request), context, invocation).getFirst()); + ToolResult result = delegate.execute(List.of(request), context, currentInvocation).getFirst(); + results.add(result); + currentInvocation = invocationAfterResult(currentInvocation, result); } return List.copyOf(results); } @@ -111,6 +116,52 @@ private boolean isAllowed(String canonicalName) { return canonicalName != null && effectiveTools.contains(canonicalName); } + private ToolRuntimeInvocation invocationWithInitialCwd(ToolRuntimeInvocation invocation) { + if (invocation == null) { + return null; + } + Path initialCwd = validStateCwd(invocation.cwd()) + .orElse(delegate.cwd().toAbsolutePath().normalize()); + return withCwd(invocation, initialCwd); + } + + private ToolRuntimeInvocation invocationAfterResult(ToolRuntimeInvocation invocation, ToolResult result) { + if (result == null || result.stateDelta().isEmpty()) { + return invocation; + } + return validStateCwd(result.stateDelta().orElseThrow().cwd()) + .map(cwd -> withCwd(invocation, cwd)) + .orElse(invocation); + } + + private Optional validStateCwd(Path candidate) { + if (candidate == null) { + return Optional.empty(); + } + Path workspaceRoot = delegate.cwd().toAbsolutePath().normalize(); + Path normalized = candidate.toAbsolutePath().normalize(); + if (!normalized.startsWith(workspaceRoot)) { + return Optional.empty(); + } + try { + Path realWorkspaceRoot = workspaceRoot.toRealPath(); + Path realCandidate = normalized.toRealPath(); + if (Files.isDirectory(realCandidate) && realCandidate.startsWith(realWorkspaceRoot)) { + return Optional.of(normalized); + } + } catch (IOException exception) { + return Optional.empty(); + } + return Optional.empty(); + } + + private ToolRuntimeInvocation withCwd(ToolRuntimeInvocation invocation, Path cwd) { + ToolRuntimeInvocation base = invocation == null + ? ToolRuntimeInvocation.cwdOnly(cwd) + : invocation; + return invocation == null ? base : base.withCwd(cwd); + } + private ToolResult errorResult(ToolUseRequest request, String canonicalName, boolean alias) { String toolUseId = request.toolUseId(); String message = alias diff --git a/lypi-tool/src/main/java/cn/lypi/tool/ToolResultBudgeter.java b/lypi-tool/src/main/java/cn/lypi/tool/ToolResultBudgeter.java index 4c538958..82642300 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/ToolResultBudgeter.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/ToolResultBudgeter.java @@ -34,7 +34,8 @@ public ToolResult apply(String toolUseId, String toolName, ToolResult result.output(), result.isError(), budgetResult.messages(), - replacement + replacement, + result.stateDelta() ); } diff --git a/lypi-tool/src/main/java/cn/lypi/tool/ToolRuntimeContextFactory.java b/lypi-tool/src/main/java/cn/lypi/tool/ToolRuntimeContextFactory.java index 393b080b..cecd2ae4 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/ToolRuntimeContextFactory.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/ToolRuntimeContextFactory.java @@ -7,6 +7,8 @@ import cn.lypi.contracts.security.PermissionRuntimeState; import cn.lypi.contracts.tool.ToolUseContext; import cn.lypi.contracts.tool.ToolUseRequest; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.LinkedHashMap; import java.util.Map; @@ -44,6 +46,7 @@ public ToolUseContext create(ToolUseRequest request, ContextSnapshot context) { */ public ToolUseContext create(ToolUseRequest request, ContextSnapshot context, ToolRuntimeInvocation invocation) { Objects.requireNonNull(request, "request must not be null"); + Path workspaceRoot = options.cwd().toAbsolutePath().normalize(); Map metadata = new LinkedHashMap<>(); AgentMode agentMode = context == null ? AgentMode.EXECUTE : context.mode(); PermissionRuntimeState permissionRuntimeState = context == null @@ -62,18 +65,39 @@ public ToolUseContext create(ToolUseRequest request, ContextSnapshot context, To if (parentEntryId != null && !parentEntryId.isBlank()) { metadata.put("parentEntryId", parentEntryId); } - if (invocation != null) { + if (invocation != null && !invocation.inheritsRuntimeSignals()) { metadata.put(ToolAbortSupport.METADATA_ABORT_SIGNAL, invocation.abortSignal()); metadata.put(ToolSteeringSupport.METADATA_STEERING_MESSAGES, invocation.steeringMessages()); } return new ToolUseContext( sessionId(invocation), request.parentMessageId(), - options.cwd(), + workspaceRoot, + validatedInvocationCwd(invocation, workspaceRoot), Map.copyOf(metadata) ); } + private Path validatedInvocationCwd(ToolRuntimeInvocation invocation, Path workspaceRoot) { + if (invocation == null || invocation.cwd() == null) { + return workspaceRoot; + } + Path candidate = invocation.cwd().toAbsolutePath().normalize(); + if (!candidate.startsWith(workspaceRoot)) { + return workspaceRoot; + } + try { + Path realWorkspace = workspaceRoot.toRealPath(); + Path realCandidate = candidate.toRealPath(); + if (Files.isDirectory(realCandidate) && realCandidate.startsWith(realWorkspace)) { + return candidate; + } + } catch (IOException exception) { + // Invalid persisted state falls back to the configured workspace root. + } + return workspaceRoot; + } + private String sessionId(ToolRuntimeInvocation invocation) { if (invocation == null || invocation.sessionId() == null || invocation.sessionId().isBlank()) { return options.sessionId(); diff --git a/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashPermissionPolicy.java b/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashPermissionPolicy.java index effd25d2..6c5d9e54 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashPermissionPolicy.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashPermissionPolicy.java @@ -33,7 +33,7 @@ PermissionDecision decide( PermissionRuntimeState permissionRuntimeState ) { SandboxRuntimePolicy sandboxPolicy = sandboxPolicyResolver.resolve( - context.cwd(), + context.workspaceRoot(), cwd, permissionRuntimeState ); diff --git a/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashTool.java b/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashTool.java index c6cad492..896b1919 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashTool.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashTool.java @@ -10,18 +10,24 @@ import cn.lypi.contracts.runtime.Executor; import cn.lypi.contracts.runtime.SandboxPermissions; import cn.lypi.contracts.runtime.SandboxRuntimePolicy; +import cn.lypi.contracts.runtime.SandboxRuntimePolicyKind; import cn.lypi.contracts.security.AdditionalPermissionProfile; import cn.lypi.contracts.security.PermissionDecision; import cn.lypi.contracts.security.PermissionMode; import cn.lypi.contracts.security.PermissionRuntimeState; import cn.lypi.contracts.tool.ToolResult; +import cn.lypi.contracts.tool.ToolStateDelta; import cn.lypi.contracts.tool.ToolUseContext; import cn.lypi.tool.shell.DefaultSandboxPolicyResolver; +import cn.lypi.tool.shell.SandboxPlatformPaths; import cn.lypi.tool.shell.SandboxPolicyOptions; import cn.lypi.tool.shell.SandboxPolicyResolver; -import java.nio.file.Path; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; import java.time.Duration; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -29,12 +35,14 @@ public final class BashTool extends AbstractFileTool { private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(120); + private static final Duration SNAPSHOT_TIMEOUT = Duration.ofSeconds(10); private static final AbortSignal NOT_ABORTED = () -> false; private static final String INPUT_SANDBOX_PERMISSIONS = "sandboxPermissions"; private static final String INPUT_ADDITIONAL_PERMISSIONS = "additionalPermissions"; private static final String INPUT_JUSTIFICATION = "justification"; private static final String INPUT_SHELL = "shell"; private static final String INPUT_LOGIN_SHELL = "loginShell"; + private static final String UNSUPPORTED_CWD_MESSAGE = "不支持的工具输入字段: cwd。"; private static final String METADATA_ADDITIONAL_PERMISSIONS = "additionalPermissions"; private static final String METADATA_APPROVED_ADDITIONAL_PERMISSIONS = "approvedAdditionalPermissions"; private static final String METADATA_PERMISSION_APPROVED_FOR_HOST_EXECUTION = "permissionApprovedForHostExecution"; @@ -45,15 +53,21 @@ public final class BashTool extends AbstractFileTool { private final Executor executor; private final SandboxPolicyResolver sandboxPolicyResolver; private final BashPermissionPolicy permissionPolicy; + private final ShellEnvironmentHarness shellHarness; public BashTool(Executor executor) { this(executor, new DefaultSandboxPolicyResolver(SandboxPolicyOptions.defaults())); } public BashTool(Executor executor, SandboxPolicyResolver sandboxPolicyResolver) { + this(executor, sandboxPolicyResolver, new ShellEnvironmentHarness(ShellEnvironmentHarness.defaultStateRoot())); + } + + public BashTool(Executor executor, SandboxPolicyResolver sandboxPolicyResolver, ShellEnvironmentHarness shellHarness) { this.executor = Objects.requireNonNull(executor, "executor must not be null"); this.sandboxPolicyResolver = Objects.requireNonNull(sandboxPolicyResolver, "sandboxPolicyResolver must not be null"); this.permissionPolicy = new BashPermissionPolicy(this.sandboxPolicyResolver); + this.shellHarness = Objects.requireNonNull(shellHarness, "shellHarness must not be null"); } @Override @@ -61,6 +75,11 @@ public String name() { return "bash"; } + @Override + public String description() { + return "Execute shell commands."; + } + @Override public JsonSchema inputSchema() { return new JsonSchema(Map.of( @@ -68,8 +87,7 @@ public JsonSchema inputSchema() { "required", List.of("command"), "properties", Map.of( "command", Map.of("type", "string"), - "cwd", Map.of("type", "string"), - INPUT_SHELL, Map.of("type", "string"), + INPUT_SHELL, Map.of("type", "string", "enum", ALLOWED_SHELLS), INPUT_LOGIN_SHELL, Map.of("type", "boolean"), "timeoutSeconds", Map.of("type", "integer", "minimum", 1), INPUT_SANDBOX_PERMISSIONS, Map.of( @@ -108,8 +126,11 @@ public ValidationResult validateInput(Map input, ToolUseContext return new ValidationResult(false, List.of("sandboxPermissions=requireEscalated 时 justification 不能为空。")); } String shell = stringInput(input, INPUT_SHELL); - if (!shell.isBlank() && !isAllowedShell(shell)) { - return new ValidationResult(false, List.of("shell 仅支持 bash、sh、zsh 或 basename 为这些值的绝对路径。")); + if (!shell.isBlank() && !ALLOWED_SHELLS.contains(shell)) { + return new ValidationResult(false, List.of("shell 仅支持 bash、sh 或 zsh。")); + } + if (input.containsKey("cwd")) { + return new ValidationResult(false, List.of(UNSUPPORTED_CWD_MESSAGE)); } return new ValidationResult(true, List.of()); } @@ -120,7 +141,7 @@ public PermissionDecision checkPermissions(Map input, ToolUseCon return super.checkPermissions(input, context); } try { - Path cwd = resolveBashCwd(input, context); + Path cwd = resolveBashCwd(context); return permissionPolicy.decide(input, context, cwd, permissionRuntimeState(context)); } catch (RuntimeException | IOException exception) { return permissionPolicy.ask(input); @@ -131,29 +152,89 @@ public PermissionDecision checkPermissions(Map input, ToolUseCon public ToolResult execute(Map input, ToolUseContext context, ProgressSink progress) { String toolUseId = toolUseId(context); try { - Path cwd = resolveBashCwd(input, context); + rejectExecutionOnlyOverrides(input); + Path cwd = resolveBashCwd(context); + String shell = resolvedShell(input); + boolean loginShell = booleanInput(input, INPUT_LOGIN_SHELL, true); Duration timeout = Duration.ofSeconds(intInput(input, "timeoutSeconds", (int) DEFAULT_TIMEOUT.toSeconds(), 1, 86_400)); SandboxPermissions sandboxPermissions = sandboxPermissions(input); PermissionRuntimeState permissionRuntimeState = permissionRuntimeState(context); Optional additionalPermissions = additionalPermissionsForRequest(context, sandboxPermissions); - SandboxRuntimePolicy sandboxPolicy = usesHostExecution(permissionRuntimeState, sandboxPermissions, context) + Optional justification = sandboxPermissions == SandboxPermissions.REQUIRE_ESCALATED + ? Optional.of(stringInput(input, INPUT_JUSTIFICATION)) + : Optional.empty(); + SandboxRuntimePolicy basePolicy = usesHostExecution(permissionRuntimeState, sandboxPermissions, context) ? SandboxRuntimePolicy.disabled() - : sandboxPolicy(context.cwd(), cwd, permissionRuntimeState, additionalPermissions); - ExecutionRequest request = new ExecutionRequest( - shellCommand(input), - cwd, - Map.of(), - timeout, - sandboxPolicy, - sandboxPermissions, - additionalPermissions, - sandboxPermissions == SandboxPermissions.REQUIRE_ESCALATED - ? Optional.of(stringInput(input, INPUT_JUSTIFICATION)) - : Optional.empty() - ); + : sandboxPolicy(context.workspaceRoot(), cwd, permissionRuntimeState, additionalPermissions); + AbortSignal signal = abortSignal(context); + shellHarness.importEnvFile(context.workspaceRoot(), context.sessionId(), System.getenv()); progress.progress(ToolProgress.phase("running", "执行 shell 命令")); - ExecutionResult result = executor.execute(request, progress, abortSignal(context)); - return success(toolUseId, renderResult(result)); + + if (loginShell && !shellHarness.snapshotExists(context.workspaceRoot(), context.sessionId(), shell)) { + shellHarness.prepareSnapshot(context.workspaceRoot(), context.sessionId(), shell).ifPresent(plan -> { + try (plan) { + SandboxRuntimePolicy snapshotPolicy = policyWithInternalAccess( + basePolicy, + cwd, + List.of(), + List.of(plan.captureFile()) + ); + ExecutionResult snapshotResult = executor.execute( + executionRequest( + plan.command(), + cwd, + shorterTimeout(timeout, SNAPSHOT_TIMEOUT), + snapshotPolicy, + sandboxPermissions, + additionalPermissions, + justification + ), + progress, + signal + ); + shellHarness.completeSnapshot(plan, snapshotResult); + } catch (RuntimeException ignored) { + // Snapshot is an optimization; the user command falls back to a login shell. + } + }); + } + if (signal.aborted()) { + return error(toolUseId, "命令执行已中止。"); + } + + try (ShellEnvironmentHarness.CommandPlan plan = shellHarness.prepareCommand( + context.workspaceRoot(), + context.sessionId(), + shell, + input.get("command").toString(), + loginShell + )) { + SandboxRuntimePolicy commandPolicy = policyWithInternalAccess( + basePolicy, + cwd, + plan.readOnlyFiles(), + plan.writableFiles() + ); + ExecutionResult result = executor.execute( + executionRequest( + plan.command(), + cwd, + timeout, + commandPolicy, + sandboxPermissions, + additionalPermissions, + justification + ), + progress, + signal + ); + Optional delta = shellHarness.consumeCapturedCwd( + plan, + context.workspaceRoot(), + cwd + ).filter(captured -> !captured.equals(cwd)).map(ToolStateDelta::new); + return success(toolUseId, renderResult(result)).withStateDelta(delta); + } } catch (IllegalArgumentException exception) { return error(toolUseId, exception.getMessage()); } catch (IOException exception) { @@ -225,12 +306,18 @@ private SandboxPermissions sandboxPermissions(Map input) { return SandboxPermissions.fromToolValue(stringInput(input, INPUT_SANDBOX_PERMISSIONS)); } - private Path resolveBashCwd(Map input, ToolUseContext context) throws IOException { - Path workspace = context.cwd().toAbsolutePath().normalize(); - String rawCwd = stringInput(input, "cwd"); - Path cwd = rawCwd.isBlank() ? workspace : Path.of(rawCwd); - Path resolved = cwd.isAbsolute() ? cwd.toAbsolutePath().normalize() : workspace.resolve(cwd).normalize(); - return resolved.toRealPath(); + private Path resolveBashCwd(ToolUseContext context) throws IOException { + Path workspaceRoot = context.workspaceRoot().toAbsolutePath().normalize(); + Path cwd = context.cwd().toAbsolutePath().normalize(); + if (!cwd.startsWith(workspaceRoot)) { + throw new IOException("当前工作目录不在 workspace 内: " + context.cwd()); + } + Path realWorkspaceRoot = workspaceRoot.toRealPath(); + Path realCwd = cwd.toRealPath(); + if (!Files.isDirectory(realCwd) || !realCwd.startsWith(realWorkspaceRoot)) { + throw new IOException("当前工作目录不在 workspace 内: " + context.cwd()); + } + return cwd; } private boolean usesHostExecution( @@ -283,6 +370,83 @@ private SandboxRuntimePolicy sandboxPolicy( return sandboxPolicyResolver.resolve(workspace, cwd, permissionRuntimeState); } + private ExecutionRequest executionRequest( + List command, + Path cwd, + Duration timeout, + SandboxRuntimePolicy sandboxPolicy, + SandboxPermissions sandboxPermissions, + Optional additionalPermissions, + Optional justification + ) { + return new ExecutionRequest( + command, + cwd, + Map.of(), + timeout, + sandboxPolicy, + sandboxPermissions, + additionalPermissions, + justification + ); + } + + private SandboxRuntimePolicy policyWithInternalAccess( + SandboxRuntimePolicy policy, + Path cwd, + List readOnlyFiles, + List writableFiles + ) { + if (policy.kind() != SandboxRuntimePolicyKind.MANAGED) { + return policy; + } + LinkedHashSet allowRead = new LinkedHashSet<>( + policy.allowRead().isEmpty() ? SandboxPlatformPaths.defaultReadOnlyPaths() : policy.allowRead() + ); + LinkedHashSet allowWrite = new LinkedHashSet<>( + policy.allowWrite().isEmpty() ? List.of(cwd) : policy.allowWrite() + ); + appendExistingFiles(allowRead, readOnlyFiles); + appendExistingFiles(allowWrite, writableFiles); + return new SandboxRuntimePolicy( + policy.kind(), + List.copyOf(allowRead), + policy.denyRead(), + List.copyOf(allowWrite), + policy.denyWrite(), + policy.networkMode(), + policy.failIfUnavailable(), + policy.autoAllowBashIfSandboxed() + ); + } + + private void appendExistingFiles(LinkedHashSet target, List files) { + for (Path file : files) { + if (file != null && Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) { + target.add(file.toAbsolutePath().normalize()); + } + } + } + + private Duration shorterTimeout(Duration first, Duration second) { + return first.compareTo(second) <= 0 ? first : second; + } + + private void rejectExecutionOnlyOverrides(Map input) { + if (input.containsKey("cwd")) { + throw new IllegalArgumentException(UNSUPPORTED_CWD_MESSAGE); + } + } + + private String resolvedShell(Map input) { + String shell = stringInput(input, INPUT_SHELL); + String resolved = shell.isBlank() ? "bash" : shell; + if (!ALLOWED_SHELLS.contains(resolved)) { + throw new IllegalArgumentException("shell 仅支持 bash、sh 或 zsh。"); + } + return resolved; + } + private PermissionRuntimeState permissionRuntimeState(ToolUseContext context) { Object canonical = context.metadata().get(METADATA_PERMISSION_RUNTIME_STATE); if (canonical instanceof PermissionRuntimeState permissionRuntimeState) { @@ -318,22 +482,6 @@ private boolean isEmpty(AdditionalPermissionProfile permissions) { return permissions.fileSystem().isEmpty() && permissions.network().isEmpty(); } - private List shellCommand(Map input) { - String shell = stringInput(input, INPUT_SHELL); - String resolvedShell = shell.isBlank() ? "bash" : shell; - boolean loginShell = booleanInput(input, INPUT_LOGIN_SHELL, true); - return List.of(resolvedShell, loginShell ? "-lc" : "-c", input.get("command").toString()); - } - - private boolean isAllowedShell(String shell) { - Path path = Path.of(shell); - String shellName = path.getFileName() == null ? shell : path.getFileName().toString(); - if (path.isAbsolute()) { - return ALLOWED_SHELLS.contains(shellName); - } - return ALLOWED_SHELLS.contains(shell); - } - private boolean booleanInput(Map input, String key, boolean defaultValue) { Object value = input == null ? null : input.get(key); if (value == null) { diff --git a/lypi-tool/src/main/java/cn/lypi/tool/builtin/BuiltInTools.java b/lypi-tool/src/main/java/cn/lypi/tool/builtin/BuiltInTools.java index 1c7a5c4e..28a48484 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/builtin/BuiltInTools.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/builtin/BuiltInTools.java @@ -33,14 +33,30 @@ private BuiltInTools() { * 创建默认内置工具集合。 */ public static List> createDefaultTools(Executor executor, SandboxPolicyResolver sandboxPolicyResolver) { + return createDefaultTools( + executor, + sandboxPolicyResolver, + new ShellEnvironmentHarness(ShellEnvironmentHarness.defaultStateRoot()) + ); + } + + /** + * 创建使用共享 shell 状态 harness 的默认内置工具集合。 + */ + public static List> createDefaultTools( + Executor executor, + SandboxPolicyResolver sandboxPolicyResolver, + ShellEnvironmentHarness shellHarness + ) { Objects.requireNonNull(executor, "executor must not be null"); Objects.requireNonNull(sandboxPolicyResolver, "sandboxPolicyResolver must not be null"); + Objects.requireNonNull(shellHarness, "shellHarness must not be null"); return List.of( new ReadTool(), new WriteTool(), new EditTool(), new RequestPermissionsTool(), - new BashTool(executor, sandboxPolicyResolver), + new BashTool(executor, sandboxPolicyResolver, shellHarness), new GrepTool(executor), new GlobTool() ); @@ -57,8 +73,25 @@ public static void registerDefaults(ToolRuntimePort runtime, Executor executor) * 注册默认内置工具集合。 */ public static void registerDefaults(ToolRuntimePort runtime, Executor executor, SandboxPolicyResolver sandboxPolicyResolver) { + registerDefaults( + runtime, + executor, + sandboxPolicyResolver, + new ShellEnvironmentHarness(ShellEnvironmentHarness.defaultStateRoot()) + ); + } + + /** + * 注册使用共享 shell 状态 harness 的默认内置工具集合。 + */ + public static void registerDefaults( + ToolRuntimePort runtime, + Executor executor, + SandboxPolicyResolver sandboxPolicyResolver, + ShellEnvironmentHarness shellHarness + ) { Objects.requireNonNull(runtime, "runtime must not be null"); - for (Tool tool : createDefaultTools(executor, sandboxPolicyResolver)) { + for (Tool tool : createDefaultTools(executor, sandboxPolicyResolver, shellHarness)) { runtime.register(tool); } } diff --git a/lypi-tool/src/main/java/cn/lypi/tool/builtin/ShellEnvironmentHarness.java b/lypi-tool/src/main/java/cn/lypi/tool/builtin/ShellEnvironmentHarness.java new file mode 100644 index 00000000..51f14ad0 --- /dev/null +++ b/lypi-tool/src/main/java/cn/lypi/tool/builtin/ShellEnvironmentHarness.java @@ -0,0 +1,392 @@ +package cn.lypi.tool.builtin; + +import cn.lypi.contracts.runtime.ExecutionResult; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Pattern; + +/** + * Builds shell-state file and command plans without executing subprocesses. + */ +public final class ShellEnvironmentHarness { + static final String ENV_DIR = "env"; + static final String ENV_FILE_VARIABLE = "LYPI_ENV_FILE"; + private static final String SNAPSHOT_PREFIX = "shell-snapshot-"; + private static final Pattern VOLATILE_DIRECTORY_EXPORT = Pattern.compile( + "^(?:(?:declare|typeset)(?:\\s+-\\S+)*\\s+|export\\s+)(?:PWD|OLDPWD)(?:=|\\s|$).*" + ); + + private final Path stateRoot; + + public ShellEnvironmentHarness(Path stateRoot) { + this.stateRoot = canonicalIfPresent( + Objects.requireNonNull(stateRoot, "stateRoot must not be null").toAbsolutePath().normalize() + ); + } + + public static Path defaultStateRoot() { + return Path.of(System.getProperty("user.home"), ".lypi", "shell-state"); + } + + public Path stateRoot() { + return stateRoot; + } + + record SnapshotPlan( + String shell, + Path snapshotFile, + Path captureFile, + List command + ) implements AutoCloseable { + SnapshotPlan { + Objects.requireNonNull(shell, "shell must not be null"); + Objects.requireNonNull(snapshotFile, "snapshotFile must not be null"); + Objects.requireNonNull(captureFile, "captureFile must not be null"); + command = List.copyOf(command); + } + + @Override + public void close() { + deleteIfExists(captureFile); + } + } + + record CommandPlan( + String shell, + Path snapshotFile, + List command, + Path cwdCaptureFile, + List readOnlyFiles, + List writableFiles + ) implements AutoCloseable { + CommandPlan { + Objects.requireNonNull(shell, "shell must not be null"); + Objects.requireNonNull(snapshotFile, "snapshotFile must not be null"); + command = List.copyOf(command); + Objects.requireNonNull(cwdCaptureFile, "cwdCaptureFile must not be null"); + readOnlyFiles = List.copyOf(readOnlyFiles); + writableFiles = List.copyOf(writableFiles); + } + + @Override + public void close() { + deleteIfExists(cwdCaptureFile); + } + } + + Path sessionDir(Path workspaceRoot, String sessionId) { + Path workspace = Objects.requireNonNull(workspaceRoot, "workspaceRoot must not be null") + .toAbsolutePath() + .normalize(); + String rawSessionId = sessionId == null ? "" : sessionId; + return stateRoot.resolve(sha256(workspace + "\0" + rawSessionId)); + } + + public boolean snapshotExists(Path workspaceRoot, String sessionId, String shell) { + Path snapshot = snapshotFile(workspaceRoot, sessionId, shell); + return Files.isRegularFile(snapshot, LinkOption.NOFOLLOW_LINKS); + } + + Optional prepareSnapshot(Path workspaceRoot, String sessionId, String shell) { + String canonicalShell = canonicalShell(shell); + Path snapshot = snapshotFile(workspaceRoot, sessionId, canonicalShell); + if (Files.isRegularFile(snapshot, LinkOption.NOFOLLOW_LINKS)) { + return Optional.empty(); + } + try { + Path dir = ensureSessionDir(workspaceRoot, sessionId); + Path capture = Files.createTempFile(dir, ".snapshot-" + canonicalShell + "-", ".capture"); + return Optional.of(new SnapshotPlan( + canonicalShell, + snapshot, + capture, + List.of(canonicalShell, "-lc", snapshotCommand(canonicalShell, capture)) + )); + } catch (IOException exception) { + return Optional.empty(); + } + } + + void completeSnapshot(SnapshotPlan plan, ExecutionResult result) { + Objects.requireNonNull(plan, "plan must not be null"); + if (result == null || result.exitCode() != 0 || result.timedOut()) { + deleteIfExists(plan.captureFile()); + return; + } + Path capture = plan.captureFile(); + try { + if (!Files.isRegularFile(capture, LinkOption.NOFOLLOW_LINKS) || Files.size(capture) == 0) { + deleteIfExists(capture); + return; + } + String filtered = filterSnapshot(Files.readString(capture, StandardCharsets.UTF_8)); + if (filtered.isBlank()) { + deleteIfExists(capture); + return; + } + Files.writeString( + capture, + filtered, + StandardCharsets.UTF_8, + StandardOpenOption.TRUNCATE_EXISTING + ); + atomicReplace(capture, plan.snapshotFile()); + } catch (IOException exception) { + deleteIfExists(capture); + } + } + + CommandPlan prepareCommand( + Path workspaceRoot, + String sessionId, + String shell, + String command, + boolean loginShell + ) throws IOException { + String canonicalShell = canonicalShell(shell); + Path dir = ensureSessionDir(workspaceRoot, sessionId); + Path snapshot = snapshotFile(workspaceRoot, sessionId, canonicalShell); + boolean useSnapshot = loginShell && Files.isRegularFile(snapshot, LinkOption.NOFOLLOW_LINKS); + List scripts = envScripts(workspaceRoot, sessionId); + Path cwdCapture = Files.createTempFile(dir, ".cwd-", ".capture"); + List readOnlyFiles = new ArrayList<>(); + StringBuilder wrapped = new StringBuilder(); + if ("bash".equals(canonicalShell)) { + wrapped.append("shopt -s expand_aliases; "); + } + if (useSnapshot) { + readOnlyFiles.add(snapshot); + wrapped.append(". ").append(shellQuote(snapshot.toString())).append(" 2>/dev/null || true; "); + } + for (Path script : scripts) { + readOnlyFiles.add(script); + wrapped.append(". ").append(shellQuote(script.toString())).append("; "); + } + wrapped.append("eval ").append(shellQuote(Objects.requireNonNull(command, "command must not be null"))).append("; "); + wrapped.append("lypi_rc=$?; "); + wrapped.append("pwd -P >| ").append(shellQuote(cwdCapture.toString())).append(" 2>/dev/null; "); + wrapped.append("exit $lypi_rc"); + return new CommandPlan( + canonicalShell, + snapshot, + List.of(canonicalShell, loginShell && !useSnapshot ? "-lc" : "-c", wrapped.toString()), + cwdCapture, + readOnlyFiles, + List.of(cwdCapture) + ); + } + + Optional consumeCapturedCwd(CommandPlan plan, Path workspaceRoot, Path previousCwd) { + Objects.requireNonNull(plan, "plan must not be null"); + Objects.requireNonNull(workspaceRoot, "workspaceRoot must not be null"); + Objects.requireNonNull(previousCwd, "previousCwd must not be null"); + Path capture = plan.cwdCaptureFile(); + try { + if (!Files.isRegularFile(capture, LinkOption.NOFOLLOW_LINKS)) { + return Optional.empty(); + } + String value = stripLineEnding(Files.readString(capture, StandardCharsets.UTF_8)); + if (value.isEmpty() || value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0) { + return Optional.empty(); + } + Path candidate = Path.of(value); + if (!candidate.isAbsolute()) { + return Optional.empty(); + } + Path lexicalWorkspace = workspaceRoot.toAbsolutePath().normalize(); + Path realWorkspace = lexicalWorkspace.toRealPath(); + Path realCandidate = candidate.toAbsolutePath().normalize().toRealPath(); + if (!Files.isDirectory(realCandidate) || !realCandidate.startsWith(realWorkspace)) { + return Optional.empty(); + } + Path lexicalCandidate = lexicalWorkspace + .resolve(realWorkspace.relativize(realCandidate)) + .normalize(); + if (!lexicalCandidate.startsWith(lexicalWorkspace) + || !Files.isDirectory(lexicalCandidate) + || !lexicalCandidate.toRealPath().equals(realCandidate)) { + return Optional.empty(); + } + return Optional.of(lexicalCandidate); + } catch (IOException | RuntimeException exception) { + return Optional.empty(); + } finally { + deleteIfExists(capture); + } + } + + public void importEnvFile(Path workspaceRoot, String sessionId, Map environment) { + String source = environment == null ? null : environment.get(ENV_FILE_VARIABLE); + if (source == null || source.isBlank()) { + return; + } + Path sourceFile; + try { + sourceFile = Path.of(source).toAbsolutePath().normalize(); + } catch (RuntimeException exception) { + return; + } + if (!Files.isRegularFile(sourceFile)) { + return; + } + try { + Path envDir = ensureEnvDir(ensureSessionDir(workspaceRoot, sessionId)); + Path target = envDir.resolve("00-lypi-env-file.sh"); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + return; + } + try { + Files.copy(sourceFile, target); + } catch (FileAlreadyExistsException ignored) { + // A concurrent importer won the create-new race. + } + } catch (IOException ignored) { + // Environment import is optional and must not block command execution. + } + } + + List envScripts(Path workspaceRoot, String sessionId) { + Path envDir = sessionDir(workspaceRoot, sessionId).resolve(ENV_DIR); + if (!Files.isDirectory(envDir, LinkOption.NOFOLLOW_LINKS)) { + return List.of(); + } + try (var paths = Files.list(envDir)) { + return paths + .filter(path -> Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) + .filter(path -> path.getFileName().toString().endsWith(".sh")) + .sorted(Comparator.comparing(path -> path.getFileName().toString())) + .toList(); + } catch (IOException exception) { + return List.of(); + } + } + + private Path ensureSessionDir(Path workspaceRoot, String sessionId) throws IOException { + Files.createDirectories(stateRoot); + Path realRoot = stateRoot.toRealPath(); + Path dir = sessionDir(workspaceRoot, sessionId); + try { + Files.createDirectory(dir); + } catch (FileAlreadyExistsException ignored) { + // Validate the existing path below. + } + if (!Files.isDirectory(dir, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("shell state session path is not a directory: " + dir); + } + Path realDir = dir.toRealPath(); + if (!realDir.startsWith(realRoot)) { + throw new IOException("shell state session path escapes state root: " + dir); + } + return dir; + } + + private Path ensureEnvDir(Path sessionDir) throws IOException { + Path envDir = sessionDir.resolve(ENV_DIR); + try { + Files.createDirectory(envDir); + } catch (FileAlreadyExistsException ignored) { + // Validate the existing path below. + } + if (!Files.isDirectory(envDir, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("shell env path is not a directory: " + envDir); + } + Path realSessionDir = sessionDir.toRealPath(); + if (!envDir.toRealPath().startsWith(realSessionDir)) { + throw new IOException("shell env path escapes session directory: " + envDir); + } + return envDir; + } + + private Path snapshotFile(Path workspaceRoot, String sessionId, String shell) { + String canonicalShell = canonicalShell(shell); + return sessionDir(workspaceRoot, sessionId).resolve(SNAPSHOT_PREFIX + canonicalShell + ".sh"); + } + + private String snapshotCommand(String shell, Path captureFile) { + String dump = switch (shell) { + case "bash" -> "{ export -p; alias -p; declare -f; }"; + case "sh" -> "{ export -p; alias; }"; + case "zsh" -> "{ export -p; alias -L; functions; }"; + default -> throw new IllegalArgumentException("unsupported shell: " + shell); + }; + return dump + " >| " + shellQuote(captureFile.toString()) + " 2>/dev/null"; + } + + private String canonicalShell(String shell) { + return switch (shell) { + case "bash", "sh", "zsh" -> shell; + default -> throw new IllegalArgumentException("shell only supports bash, sh, or zsh"); + }; + } + + private String filterSnapshot(String content) { + List kept = content.lines() + .filter(line -> !VOLATILE_DIRECTORY_EXPORT.matcher(line).matches()) + .toList(); + return kept.isEmpty() ? "" : String.join("\n", kept) + "\n"; + } + + private static String stripLineEnding(String value) { + String stripped = value; + if (stripped.endsWith("\n")) { + stripped = stripped.substring(0, stripped.length() - 1); + } + if (stripped.endsWith("\r")) { + stripped = stripped.substring(0, stripped.length() - 1); + } + return stripped; + } + + private static void atomicReplace(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (java.nio.file.AtomicMoveNotSupportedException exception) { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static String sha256(String value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } + + private static Path canonicalIfPresent(Path path) { + try { + return path.toRealPath(); + } catch (IOException exception) { + return path; + } + } + + private static void deleteIfExists(Path path) { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + // Every invocation uses a unique file, so cleanup is best effort. + } + } + + private static String shellQuote(String value) { + return "'" + value.replace("'", "'\\''") + "'"; + } +} diff --git a/lypi-tool/src/main/java/cn/lypi/tool/builtin/WorkspacePaths.java b/lypi-tool/src/main/java/cn/lypi/tool/builtin/WorkspacePaths.java index 08bb6349..91524cc8 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/builtin/WorkspacePaths.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/builtin/WorkspacePaths.java @@ -39,11 +39,12 @@ static Path resolvePath( Object raw = input.get(fieldName); String value = raw == null ? "." : raw.toString(); Path cwd = context.cwd().toAbsolutePath().normalize(); + Path workspaceRoot = context.workspaceRoot().toAbsolutePath().normalize(); Path resolved = cwd.resolve(value).normalize(); - if (resolved.startsWith(cwd) || additionalFileSystemAllows(context, accessMode, resolved)) { + if (resolved.startsWith(workspaceRoot) || additionalFileSystemAllows(context, accessMode, resolved)) { return resolved; } - throw new IllegalArgumentException("路径越过当前工作目录: " + value); + throw new IllegalArgumentException("路径越过当前工作目录或工作区: " + value); } static String relativePath(Path path, ToolUseContext context) { @@ -65,12 +66,13 @@ static Path requireRealPathInsideWorkspace( ToolUseContext context, FileSystemAccessMode accessMode ) throws IOException { - Path realCwd = context.cwd().toRealPath(); + Path realWorkspace = context.workspaceRoot().toRealPath(); Path realPath = path.toRealPath(); - if (realPath.startsWith(realCwd) || additionalFileSystemAllows(context, accessMode, path.toAbsolutePath().normalize())) { + if (realPath.startsWith(realWorkspace) + || additionalFileSystemAllows(context, accessMode, path.toAbsolutePath().normalize())) { return realPath; } - throw new IllegalArgumentException("路径经符号链接越过当前工作目录: " + relativePath(path, context)); + throw new IllegalArgumentException("路径经符号链接越过当前工作目录或工作区: " + relativePath(path, context)); } static boolean realPathInsideWorkspace(Path path, ToolUseContext context) { @@ -79,9 +81,9 @@ static boolean realPathInsideWorkspace(Path path, ToolUseContext context) { static boolean realPathInsideWorkspace(Path path, ToolUseContext context, FileSystemAccessMode accessMode) { try { - Path realCwd = context.cwd().toRealPath(); + Path realWorkspace = context.workspaceRoot().toRealPath(); Path realPath = path.toRealPath(); - return realPath.startsWith(realCwd) + return realPath.startsWith(realWorkspace) || additionalFileSystemAllows(context, accessMode, path.toAbsolutePath().normalize()); } catch (IOException exception) { return false; @@ -200,8 +202,8 @@ private static boolean matchesAnyCandidate( private static boolean matchesPath(FileSystemPath path, Path candidate, ToolUseContext context) { return switch (path.kind()) { case SPECIAL -> matchesSpecialPath(path, candidate, context); - case EXACT_PATH -> matchesExactPath(path.value(), candidate, context.cwd()); - case GLOB_PATTERN -> matchesGlob(path.value(), candidate, context.cwd()); + case EXACT_PATH -> matchesExactPath(path.value(), candidate, context.workspaceRoot()); + case GLOB_PATTERN -> matchesGlob(path.value(), candidate, context.workspaceRoot()); }; } @@ -209,13 +211,25 @@ private static boolean matchesSpecialPath(FileSystemPath path, Path candidate, T FileSystemSpecialPath specialPath = FileSystemSpecialPath.fromJson(path.value()); return switch (specialPath) { case ROOT -> true; - case PROJECT_ROOTS -> isSameOrDescendant(candidate, context.cwd().toAbsolutePath().normalize()); + case PROJECT_ROOTS -> matchesWorkspaceRoot(candidate, context.workspaceRoot()); case TMPDIR -> isSameOrDescendant(candidate, Path.of(System.getProperty("java.io.tmpdir")).toAbsolutePath().normalize()); case SLASH_TMP -> isSameOrDescendant(candidate, Path.of("/tmp").toAbsolutePath().normalize()); case MINIMAL -> false; }; } + private static boolean matchesWorkspaceRoot(Path candidate, Path workspaceRoot) { + Path normalizedRoot = workspaceRoot.toAbsolutePath().normalize(); + if (isSameOrDescendant(candidate, normalizedRoot)) { + return true; + } + try { + return isSameOrDescendant(candidate, normalizedRoot.toRealPath()); + } catch (IOException exception) { + return false; + } + } + private static boolean matchesExactPath(String configuredPath, Path candidate, Path cwd) { Path exactPath = resolveAgainstCwd(Path.of(configuredPath), cwd); if (isSameOrDescendant(candidate, exactPath)) { diff --git a/lypi-tool/src/main/java/cn/lypi/tool/shell/BubblewrapCommandBuilder.java b/lypi-tool/src/main/java/cn/lypi/tool/shell/BubblewrapCommandBuilder.java index f1b203b3..a3186970 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/shell/BubblewrapCommandBuilder.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/shell/BubblewrapCommandBuilder.java @@ -18,6 +18,7 @@ */ public final class BubblewrapCommandBuilder { private static final String EMPTY_FILE_FD = "0"; + private static final Path PRIVATE_TMP = Path.of("/tmp"); private static final List PROTECTED_METADATA_NAMES = List.of(".git", ".codex", ".agents"); /** @@ -198,7 +199,8 @@ public BuildResult buildDetailed(ExecutionRequest request, Options options) { argv.add("/proc"); } argv.add("--tmpfs"); - argv.add("/tmp"); + argv.add(PRIVATE_TMP.toString()); + appendExplicitReadOnlyPathsBelowPrivateTmp(argv, readOnlyPaths); List writableMountPaths = new ArrayList<>(); for (WritableMount writableMount : writableMounts) { Path mountPath = writableMount.mountPath(); @@ -236,6 +238,20 @@ private List readOnlyPaths(SandboxRuntimePolicy policy) { return policy.allowRead().isEmpty() ? SandboxPlatformPaths.defaultReadOnlyPaths() : policy.allowRead(); } + private void appendExplicitReadOnlyPathsBelowPrivateTmp(List argv, List readOnlyPaths) { + LinkedHashSet paths = new LinkedHashSet<>(); + for (Path path : readOnlyPaths) { + if (!path.equals(PRIVATE_TMP) && path.startsWith(PRIVATE_TMP)) { + paths.add(path); + } + } + for (Path path : paths) { + argv.add("--ro-bind-try"); + argv.add(path.toString()); + argv.add(path.toString()); + } + } + private List normalizedReadOnlyPaths(SandboxRuntimePolicy policy, List writableMounts) { List paths = new ArrayList<>(); for (Path path : readOnlyPaths(policy)) { diff --git a/lypi-tool/src/main/java/cn/lypi/tool/shell/SandboxPlatformPaths.java b/lypi-tool/src/main/java/cn/lypi/tool/shell/SandboxPlatformPaths.java index d85ab967..133650ad 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/shell/SandboxPlatformPaths.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/shell/SandboxPlatformPaths.java @@ -6,7 +6,7 @@ /** * 提供 Linux Bubblewrap 沙盒的默认平台路径。 */ -final class SandboxPlatformPaths { +public final class SandboxPlatformPaths { private static final List DEFAULT_READ_ONLY_PATHS = List.of( Path.of("/usr"), Path.of("/bin"), @@ -27,7 +27,7 @@ private SandboxPlatformPaths() { * NOTE: 缺失路径由 Bubblewrap `--ro-bind-try` 忽略,因此 Nix/NixOS * 根可以安全保留在跨发行版默认值中。 */ - static List defaultReadOnlyPaths() { + public static List defaultReadOnlyPaths() { return DEFAULT_READ_ONLY_PATHS; } } diff --git a/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java b/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java index 7f49b37e..fc5c1f12 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java @@ -61,15 +61,20 @@ import cn.lypi.contracts.tool.Tool; import cn.lypi.contracts.tool.ToolExecutionStatus; import cn.lypi.contracts.tool.ToolResult; +import cn.lypi.contracts.tool.ToolUseContext; import cn.lypi.contracts.tool.ToolUseRequest; import cn.lypi.tool.builtin.BashTool; import cn.lypi.tool.builtin.ReadTool; import cn.lypi.tool.builtin.RequestPermissionsTool; +import cn.lypi.tool.builtin.ShellEnvironmentHarness; import cn.lypi.tool.builtin.WriteTool; import cn.lypi.tool.mcp.McpToolAdapter; +import cn.lypi.tool.shell.DefaultSandboxPolicyResolver; import cn.lypi.tool.shell.ExecutorRegistry; +import cn.lypi.tool.shell.HostExecutor; import cn.lypi.tool.shell.PermissionProfileSandboxPolicyResolver; import cn.lypi.tool.shell.SandboxPolicyOptions; +import cn.lypi.tool.shell.SandboxPolicyResolver; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; @@ -209,7 +214,7 @@ void publishesWriteSummaryWithoutContentBody() { void publishesBoundedSingleLineBashSummary() { RecordingEventBus events = new RecordingEventBus(); DefaultToolRuntime runtime = runtimeWithEvents(events, allowAllSecurity()); - runtime.register(new BashTool(new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())))); + runtime.register(bashTool(new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())))); String command = "printf 'one\ntwo'\r\n" + "🙂".repeat(200); runtime.execute( @@ -315,6 +320,202 @@ void invocationOverridesStaticOptionsForLifecycleOwnership() { assertEquals("session-dynamic", end.sessionId()); } + @Test + void preservesWorkspaceRootWhileAddingCallAndAuthorizationMetadata() throws Exception { + Path nested = Files.createDirectories(tempDir.resolve("nested")); + AtomicReference captured = new AtomicReference<>(); + ToolExecutionInterceptor interceptor = ToolExecutionInterceptor.before((request, tool, context) -> { + captured.set(context); + return ToolExecutionInterceptor.BeforeResult.allow(); + }); + SecurityRuntimePort security = (request, context) -> + TestTools.decision(PermissionBehavior.ASK, "review"); + DefaultToolRuntime runtime = new DefaultToolRuntime( + new DefaultToolRegistry(), + new ToolSchemaValidator(), + new ToolExecutionPlanner(), + new ToolResultBudgeter(), + new ToolRuntimeContextFactory(ToolRuntimeOptions.builder().cwd(tempDir).build()), + interceptor, + security, + (request, tool, context, decision) -> PermissionGateResult.allow() + ); + runtime.register(TestTools.permission("write", PermissionBehavior.ALLOW)); + + ToolResult result = runtime.execute( + List.of(new ToolUseRequest("toolu_1", "write", Map.of("text", "ok"), "msg_1")), + TestTools.context(PermissionMode.ASK), + new ToolRuntimeInvocation("ses_1", "turn_1").withCwd(nested) + ).getFirst(); + + assertFalse(result.isError()); + assertEquals(tempDir, captured.get().workspaceRoot()); + assertEquals(nested, captured.get().cwd()); + assertEquals(true, captured.get().metadata().get("permissionApprovedForHostExecution")); + } + + @Test + void propagatesCwdDeltaAcrossPlannerSegmentsAndUnknownCalls() throws Exception { + Path workspace = Files.createDirectories(tempDir.resolve("workspace")); + Path nested = Files.createDirectories(workspace.resolve("nested")); + AtomicReference captured = new AtomicReference<>(); + DefaultToolRuntime runtime = new DefaultToolRuntime( + ToolRuntimeOptions.builder().cwd(workspace).build(), + allowAllSecurity() + ); + runtime.register(TestTools.stateDeltaEcho("cd", nested)); + runtime.register(TestTools.contextCapturingEcho("probe", captured)); + + List> results = runtime.execute( + List.of( + new ToolUseRequest("toolu_cd", "cd", Map.of("text", "changed"), "msg_1"), + new ToolUseRequest("toolu_unknown", "missing", Map.of(), "msg_1"), + new ToolUseRequest("toolu_probe", "probe", Map.of("text", "probe"), "msg_1") + ), + TestTools.context(PermissionMode.ASK), + new ToolRuntimeInvocation("ses_1", "turn_1").withCwd(workspace) + ); + + assertFalse(results.get(0).isError()); + assertTrue(results.get(1).isError()); + assertFalse(results.get(2).isError()); + assertEquals(workspace, captured.get().workspaceRoot()); + assertEquals(nested, captured.get().cwd()); + } + + @Test + void bashCwdDeltaFromSymlinkWorkspaceAppliesToFollowingRead() throws Exception { + Path realWorkspace = Files.createDirectory(tempDir.resolve("runtime-real-workspace")); + Path nested = Files.createDirectory(realWorkspace.resolve("nested")); + Files.writeString(nested.resolve("marker.txt"), "nested-marker\n"); + Path workspaceLink = Files.createSymbolicLink( + tempDir.resolve("runtime-workspace-link"), + realWorkspace + ); + SandboxRuntimePolicy policy = new SandboxRuntimePolicy( + List.of(), + List.of(), + List.of(workspaceLink), + List.of(), + NetworkMode.DISABLED, + false, + true + ); + DefaultToolRuntime runtime = new DefaultToolRuntime( + ToolRuntimeOptions.builder().cwd(workspaceLink).build(), + allowAllSecurity(), + (request, tool, context, decision) -> PermissionGateResult.allow(), + null + ); + runtime.register(bashTool(new HostExecutor(), (workspace, cwd) -> policy)); + runtime.register(new ReadTool()); + + List> results = runtime.execute( + List.of( + new ToolUseRequest( + "toolu_cd", + "bash", + Map.of("command", "cd nested", "loginShell", false), + "msg_1" + ), + new ToolUseRequest("toolu_read", "read", Map.of("path", "marker.txt"), "msg_1") + ), + TestTools.context(PermissionMode.ASK), + new ToolRuntimeInvocation("ses_1", "turn_1").withCwd(workspaceLink) + ); + + assertFalse(results.get(0).isError(), results.get(0).output().toString()); + assertEquals( + workspaceLink.resolve("nested"), + results.get(0).stateDelta().orElseThrow().cwd() + ); + assertFalse(results.get(1).isError(), results.get(1).output().toString()); + assertTrue(results.get(1).output().toString().contains("nested-marker")); + } + + @Test + void ignoresInvalidCwdDeltasAndKeepsLastValidDirectory() throws Exception { + Path workspace = Files.createDirectories(tempDir.resolve("workspace-invalid")); + Path nested = Files.createDirectories(workspace.resolve("nested")); + Path outside = Files.createDirectories(tempDir.resolve("outside")); + Path symlinkEscape = workspace.resolve("escape"); + Files.createSymbolicLink(symlinkEscape, outside); + AtomicReference afterMissing = new AtomicReference<>(); + AtomicReference afterOutside = new AtomicReference<>(); + AtomicReference afterSymlink = new AtomicReference<>(); + DefaultToolRuntime runtime = new DefaultToolRuntime( + ToolRuntimeOptions.builder().cwd(workspace).build(), + allowAllSecurity() + ); + runtime.register(TestTools.stateDeltaEcho("cd_valid", nested)); + runtime.register(TestTools.stateDeltaEcho("cd_missing", workspace.resolve("missing"))); + runtime.register(TestTools.stateDeltaEcho("cd_outside", outside)); + runtime.register(TestTools.stateDeltaEcho("cd_symlink", symlinkEscape)); + runtime.register(TestTools.contextCapturingEcho("probe_missing", afterMissing)); + runtime.register(TestTools.contextCapturingEcho("probe_outside", afterOutside)); + runtime.register(TestTools.contextCapturingEcho("probe_symlink", afterSymlink)); + + runtime.execute( + List.of( + new ToolUseRequest("toolu_valid", "cd_valid", Map.of(), "msg_1"), + new ToolUseRequest("toolu_missing", "cd_missing", Map.of(), "msg_1"), + new ToolUseRequest("toolu_probe_missing", "probe_missing", Map.of(), "msg_1"), + new ToolUseRequest("toolu_outside", "cd_outside", Map.of(), "msg_1"), + new ToolUseRequest("toolu_probe_outside", "probe_outside", Map.of(), "msg_1"), + new ToolUseRequest("toolu_symlink", "cd_symlink", Map.of(), "msg_1"), + new ToolUseRequest("toolu_probe_symlink", "probe_symlink", Map.of(), "msg_1") + ), + TestTools.context(PermissionMode.ASK), + new ToolRuntimeInvocation("ses_1", "turn_1").withCwd(workspace) + ); + + assertEquals(nested, afterMissing.get().cwd()); + assertEquals(nested, afterOutside.get().cwd()); + assertEquals(nested, afterSymlink.get().cwd()); + } + + @Test + void cwdOnlyCursorKeepsRuntimeAbortSignal() throws Exception { + Path workspace = Files.createDirectories(tempDir.resolve("workspace-abort")); + Path nested = Files.createDirectories(workspace.resolve("nested")); + AtomicBoolean aborted = new AtomicBoolean(false); + AtomicInteger secondToolCalls = new AtomicInteger(); + ToolExecutionInterceptor interceptor = ToolExecutionInterceptor.after((request, tool, context, result) -> { + if ("cd".equals(request.toolName())) { + aborted.set(true); + } + return result; + }); + DefaultToolRuntime runtime = new DefaultToolRuntime( + new DefaultToolRegistry(), + new ToolSchemaValidator(), + new ToolExecutionPlanner(), + new ToolResultBudgeter(), + new ToolRuntimeContextFactory(ToolRuntimeOptions.builder() + .cwd(workspace) + .metadata(Map.of(ToolAbortSupport.METADATA_ABORT_SIGNAL, (AbortSignal) aborted::get)) + .build()), + interceptor, + allowAllSecurity() + ); + runtime.register(TestTools.stateDeltaEcho("cd", nested)); + runtime.register(TestTools.countingTool( + "after_cd", + InterruptBehavior.CANCEL, + secondToolCalls + )); + + runtime.execute( + List.of( + new ToolUseRequest("toolu_cd", "cd", Map.of(), "msg_1"), + new ToolUseRequest("toolu_after", "after_cd", Map.of(), "msg_1") + ), + TestTools.context(PermissionMode.ASK) + ); + + assertEquals(0, secondToolCalls.get()); + } + @Test void publishesLifecycleWhenInputContainsNullValue() { RecordingEventBus events = new RecordingEventBus(); @@ -367,7 +568,7 @@ void askReviewsDefaultBashEvenWhenSecurityAndToolAllow() { (request, tool, context, decision) -> PermissionGateResult.allow(), null ); - runtime.register(new BashTool(executor, (workspace, cwd) -> policy)); + runtime.register(bashTool(executor, (workspace, cwd) -> policy)); ToolResult result = runtime.execute( List.of(new ToolUseRequest("toolu_1", "bash", Map.of("command", "echo done"), "msg_1")), @@ -375,8 +576,9 @@ void askReviewsDefaultBashEvenWhenSecurityAndToolAllow() { ).getFirst(); assertFalse(result.isError()); - assertEquals(1, executor.calls.get()); - assertEquals(List.of("bash", "-lc", "echo done"), executor.request.get().command()); + assertEquals(2, executor.calls.get()); + assertEquals("bash", executor.request.get().command().get(0)); + assertTrue(executor.request.get().command().get(2).contains("eval 'echo done'")); assertTrue(result.newMessages().getFirst().content().getFirst().text().contains("stdout:\ndone")); } @@ -389,7 +591,7 @@ void nextBashExecutionUsesChangedPermissionRuntimeProfile() { (request, tool, context, decision) -> PermissionGateResult.allow(), null ); - runtime.register(new BashTool( + runtime.register(bashTool( executor, new PermissionProfileSandboxPolicyResolver( PermissionProfiles.workspace(), @@ -413,7 +615,7 @@ void nextBashExecutionUsesChangedPermissionRuntimeProfile() { assertEquals(SandboxRuntimePolicyKind.MANAGED, askPolicy.kind()); assertFalse(bypassResult.isError()); assertEquals(SandboxRuntimePolicyKind.DISABLED, bypassPolicy.kind()); - assertEquals(2, executor.calls.get()); + assertEquals(4, executor.calls.get()); } @Test @@ -481,7 +683,7 @@ void directAllowedBashCommandsRouteToManagedSandboxWithoutReview() { assertEquals(0, gateCalls.get()); assertEquals(0, reviewerCalls.get()); assertEquals(0, host.calls.get()); - assertEquals(commands.size() * 2, bubblewrap.calls.get()); + assertEquals(commands.size() * 4, bubblewrap.calls.get()); assertTrue(bubblewrap.requests.stream().allMatch(request -> request.sandboxPolicy().kind() == SandboxRuntimePolicyKind.MANAGED )); @@ -556,7 +758,7 @@ void reviewedBashCommandsRouteToHostForAskAndAuto() { assertEquals(commands.size(), gateCalls.get()); assertEquals(commands.size(), reviewerCalls.get()); - assertEquals(commands.size() * 2, host.calls.get()); + assertEquals(commands.size() * 4, host.calls.get()); assertEquals(0, bubblewrap.calls.get()); assertTrue(host.requests.stream().allMatch(request -> request.sandboxPolicy().kind() == SandboxRuntimePolicyKind.DISABLED @@ -611,12 +813,15 @@ void allApprovedBashPermissionShapesRouteToHost() { } assertEquals(3, gateCalls.get()); - assertEquals(3, host.calls.get()); + assertEquals(6, host.calls.get()); assertEquals(0, bubblewrap.calls.get()); assertEquals( List.of( SandboxPermissions.USE_DEFAULT, + SandboxPermissions.USE_DEFAULT, + SandboxPermissions.WITH_ADDITIONAL_PERMISSIONS, SandboxPermissions.WITH_ADDITIONAL_PERMISSIONS, + SandboxPermissions.REQUIRE_ESCALATED, SandboxPermissions.REQUIRE_ESCALATED ), host.requests.stream().map(ExecutionRequest::sandboxPermissions).toList() @@ -672,7 +877,7 @@ void ordinarySandboxFailuresDoNotRequestApprovalOrRetryOnHost() { assertTrue(text.contains(stderr)); assertNoSandboxRetryHint(result); assertEquals(0, host.calls.get()); - assertEquals(1, bubblewrap.calls.get()); + assertEquals(2, bubblewrap.calls.get()); } RecordingExecutor unavailableHost = recordingHostExecutor(); @@ -702,7 +907,7 @@ void ordinarySandboxFailuresDoNotRequestApprovalOrRetryOnHost() { assertTrue(unavailableText.contains("sandboxUnavailable=true")); assertTrue(unavailableText.contains("diagnostic=user namespaces unavailable")); assertEquals(0, unavailableHost.calls.get()); - assertEquals(1, unavailableBubblewrap.calls.get()); + assertEquals(2, unavailableBubblewrap.calls.get()); assertEquals(0, gateCalls.get()); assertEquals(0, reviewerCalls.get()); } @@ -804,9 +1009,9 @@ void rememberedBashApprovalRoutesFirstCallToHostAndLaterAllowToSandbox() { assertFalse(firstResult.isError()); assertFalse(secondResult.isError()); assertEquals(1, gateCalls.get()); - assertEquals(1, host.calls.get()); + assertEquals(2, host.calls.get()); assertEquals(SandboxRuntimePolicyKind.DISABLED, host.request.get().sandboxPolicy().kind()); - assertEquals(1, bubblewrap.calls.get()); + assertEquals(2, bubblewrap.calls.get()); assertEquals(SandboxRuntimePolicyKind.MANAGED, bubblewrap.request.get().sandboxPolicy().kind()); } @@ -1122,7 +1327,7 @@ void explicitPrefixAllowExecutesWithoutAskReview() { new FilePermissionUpdateStore(tempDir) ); RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "done", "", false, Optional.empty())); - runtime.register(new BashTool(executor, (workspace, cwd) -> policy)); + runtime.register(bashTool(executor, (workspace, cwd) -> policy)); ToolUseRequest request = new ToolUseRequest( "toolu_1", @@ -1140,7 +1345,7 @@ void explicitPrefixAllowExecutesWithoutAskReview() { assertFalse(result.isError()); assertEquals(0, gateCalls.get()); - assertEquals(1, executor.calls.get()); + assertEquals(2, executor.calls.get()); } @Test @@ -2810,7 +3015,7 @@ void inlineAdditionalPermissionsApprovalAppliesOnlyCurrentBashExecution() throws allowAllSecurity(), gate ); - runtime.register(new BashTool(executor)); + runtime.register(bashTool(executor)); AdditionalPermissionProfile permissions = additionalFileSystem(approved); ToolResult bashResult = runtime.execute( @@ -2843,7 +3048,7 @@ void inlineAdditionalPermissionsApprovalAppliesOnlyCurrentBashExecution() throws assertEquals(Optional.of(permissions), executor.request.get().additionalPermissions()); assertEquals(SandboxRuntimePolicyKind.DISABLED, executor.request.get().sandboxPolicy().kind()); assertTrue(nextResult.isError()); - assertEquals(1, executor.calls.get()); + assertEquals(2, executor.calls.get()); } @Test @@ -2950,7 +3155,7 @@ private DefaultToolRuntime runtimeWithBashRouting( null, reviewer ); - runtime.register(new BashTool( + runtime.register(bashTool( new ExecutorRegistry(host, bubblewrap, true), new PermissionProfileSandboxPolicyResolver( PermissionProfiles.workspace(), @@ -2961,6 +3166,18 @@ private DefaultToolRuntime runtimeWithBashRouting( return runtime; } + private BashTool bashTool(Executor executor) { + return bashTool(executor, new DefaultSandboxPolicyResolver(SandboxPolicyOptions.defaults())); + } + + private BashTool bashTool(Executor executor, SandboxPolicyResolver sandboxPolicyResolver) { + return new BashTool( + executor, + sandboxPolicyResolver, + new ShellEnvironmentHarness(tempDir.resolve("shell-state")) + ); + } + private RecordingExecutor recordingHostExecutor() { return new RecordingExecutor(new ExecutionResult( 0, diff --git a/lypi-tool/src/test/java/cn/lypi/tool/FilteredToolRuntimeTest.java b/lypi-tool/src/test/java/cn/lypi/tool/FilteredToolRuntimeTest.java index 3d5d2114..1d7dbeeb 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/FilteredToolRuntimeTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/FilteredToolRuntimeTest.java @@ -6,15 +6,23 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import cn.lypi.contracts.security.PermissionMode; +import cn.lypi.contracts.runtime.ToolRuntimeInvocation; import cn.lypi.contracts.subagent.SubagentToolPolicy; import cn.lypi.contracts.tool.ToolResult; +import cn.lypi.contracts.tool.ToolUseContext; import cn.lypi.contracts.tool.ToolUseRequest; +import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; class FilteredToolRuntimeTest { + @TempDir + Path tempDir; + @Test void snapshotOnlyContainsEffectiveTools() { DefaultToolRuntime delegate = runtimeWithReadGrepGlobAndBash(); @@ -92,6 +100,50 @@ void reportsConfiguredCwdFromDelegate() { assertEquals(Path.of("/tmp/project"), runtime.cwd()); } + @Test + void propagatesValidCwdAndIgnoresInvalidDeltasBetweenDelegatedCalls() throws Exception { + Path workspace = Files.createDirectories(tempDir.resolve("workspace")); + Path nested = Files.createDirectories(workspace.resolve("nested")); + Path outside = Files.createDirectories(tempDir.resolve("outside")); + Path symlinkEscape = workspace.resolve("escape"); + Files.createSymbolicLink(symlinkEscape, outside); + AtomicReference captured = new AtomicReference<>(); + DefaultToolRuntime delegate = new DefaultToolRuntime( + ToolRuntimeOptions.builder().cwd(workspace).build(), + (request, context) -> TestTools.decision( + cn.lypi.contracts.security.PermissionBehavior.ALLOW, + "allowed" + ) + ); + delegate.register(TestTools.stateDeltaEcho("cd_valid", nested)); + delegate.register(TestTools.stateDeltaEcho("cd_missing", workspace.resolve("missing"))); + delegate.register(TestTools.stateDeltaEcho("cd_outside", outside)); + delegate.register(TestTools.stateDeltaEcho("cd_symlink", symlinkEscape)); + delegate.register(TestTools.contextCapturingEcho("probe", captured)); + FilteredToolRuntime runtime = new FilteredToolRuntime( + delegate, + new SubagentToolPolicy( + List.of(), + List.of("cd_valid", "cd_missing", "cd_outside", "cd_symlink", "probe") + ) + ); + + runtime.execute( + List.of( + new ToolUseRequest("toolu_valid", "cd_valid", Map.of(), "msg_1"), + new ToolUseRequest("toolu_missing", "cd_missing", Map.of(), "msg_1"), + new ToolUseRequest("toolu_outside", "cd_outside", Map.of(), "msg_1"), + new ToolUseRequest("toolu_symlink", "cd_symlink", Map.of(), "msg_1"), + new ToolUseRequest("toolu_probe", "probe", Map.of(), "msg_1") + ), + TestTools.context(PermissionMode.ASK), + new ToolRuntimeInvocation("ses_1", "turn_1").withCwd(workspace) + ); + + assertEquals(workspace, captured.get().workspaceRoot()); + assertEquals(nested, captured.get().cwd()); + } + private static DefaultToolRuntime runtimeWithReadGrepGlobAndBash() { DefaultToolRuntime runtime = new DefaultToolRuntime( (request, context) -> TestTools.decision(cn.lypi.contracts.security.PermissionBehavior.ALLOW, "allowed") diff --git a/lypi-tool/src/test/java/cn/lypi/tool/TestTools.java b/lypi-tool/src/test/java/cn/lypi/tool/TestTools.java index d3dc756d..8d31dae4 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/TestTools.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/TestTools.java @@ -21,6 +21,7 @@ import cn.lypi.contracts.tool.InterruptBehavior; import cn.lypi.contracts.tool.Tool; import cn.lypi.contracts.tool.ToolResult; +import cn.lypi.contracts.tool.ToolStateDelta; import cn.lypi.contracts.tool.ToolUseContext; import java.math.BigDecimal; import java.nio.file.Path; @@ -30,6 +31,7 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; final class TestTools { private TestTools() { @@ -82,6 +84,28 @@ public ToolResult execute(Map input, ToolUseContext cont }; } + static Tool, String> stateDeltaEcho(String name, Path cwd) { + return new EchoTool(name, List.of(), false, false, false, Duration.ZERO) { + @Override + public ToolResult execute(Map input, ToolUseContext context, ProgressSink progress) { + return super.execute(input, context, progress).withStateDelta(Optional.of(new ToolStateDelta(cwd))); + } + }; + } + + static Tool, String> contextCapturingEcho( + String name, + AtomicReference captured + ) { + return new EchoTool(name, List.of(), false, false, false, Duration.ZERO) { + @Override + public ToolResult execute(Map input, ToolUseContext context, ProgressSink progress) { + captured.set(context); + return super.execute(input, context, progress); + } + }; + } + static Tool, String> requiredTextEcho(String name) { return new EchoTool(name, List.of(), false, false, false, Duration.ZERO) { @Override diff --git a/lypi-tool/src/test/java/cn/lypi/tool/ToolResultBudgeterTest.java b/lypi-tool/src/test/java/cn/lypi/tool/ToolResultBudgeterTest.java index 7a3b29c0..3d1b8735 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/ToolResultBudgeterTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/ToolResultBudgeterTest.java @@ -6,6 +6,9 @@ import cn.lypi.contracts.context.ToolResultContentBlock; import cn.lypi.contracts.tool.ToolResult; +import cn.lypi.contracts.tool.ToolStateDelta; +import java.nio.file.Path; +import java.util.Optional; import org.junit.jupiter.api.Test; class ToolResultBudgeterTest { @@ -20,7 +23,9 @@ void leavesSmallToolResultUnchanged() { @Test void replacesOversizedToolResultTextWithPreview() { - ToolResult result = TestTools.result("toolu_1", "0123456789abcdef", false); + ToolStateDelta delta = new ToolStateDelta(Path.of("/tmp/project/nested")); + ToolResult result = TestTools.result("toolu_1", "0123456789abcdef", false) + .withStateDelta(Optional.of(delta)); ToolResult budgeted = new ToolResultBudgeter().apply("toolu_1", "read", result, 8); @@ -29,5 +34,6 @@ void replacesOversizedToolResultTextWithPreview() { assertTrue(block.text().contains("工具结果已超出预算")); assertTrue(budgeted.replacement().isPresent()); assertEquals("toolu_1", budgeted.replacement().orElseThrow().toolUseId()); + assertEquals(Optional.of(delta), budgeted.stateDelta()); } } diff --git a/lypi-tool/src/test/java/cn/lypi/tool/ToolRuntimeContextFactoryTest.java b/lypi-tool/src/test/java/cn/lypi/tool/ToolRuntimeContextFactoryTest.java index 3fab638a..16c6a254 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/ToolRuntimeContextFactoryTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/ToolRuntimeContextFactoryTest.java @@ -22,11 +22,13 @@ import cn.lypi.contracts.tool.ToolUseContext; import cn.lypi.contracts.tool.ToolUseRequest; import java.math.BigDecimal; +import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; class ToolRuntimeContextFactoryTest { @Test @@ -143,6 +145,46 @@ void invocationOverridesStaticTurnActivitySignals() { assertSame(invocationSteering, ToolSteeringSupport.source(context)); } + @Test + void separatesStableWorkspaceRootFromValidatedInvocationCwd(@TempDir Path tempDir) throws Exception { + Path workspace = Files.createDirectory(tempDir.resolve("workspace")); + Path nested = Files.createDirectory(workspace.resolve("nested")); + Path outside = Files.createDirectory(tempDir.resolve("outside")); + Path escape = workspace.resolve("escape"); + Files.createSymbolicLink(escape, outside); + ToolRuntimeContextFactory factory = new ToolRuntimeContextFactory( + ToolRuntimeOptions.builder().cwd(workspace).build() + ); + ToolUseRequest request = new ToolUseRequest("toolu_1", "read", Map.of(), "msg_1"); + + ToolUseContext valid = factory.create( + request, + TestTools.context(PermissionMode.ASK), + new ToolRuntimeInvocation("ses_1", "turn_1").withCwd(nested) + ); + ToolUseContext lexicalEscape = factory.create( + request, + TestTools.context(PermissionMode.ASK), + new ToolRuntimeInvocation("ses_1", "turn_1").withCwd(outside) + ); + ToolUseContext missing = factory.create( + request, + TestTools.context(PermissionMode.ASK), + new ToolRuntimeInvocation("ses_1", "turn_1").withCwd(workspace.resolve("missing")) + ); + ToolUseContext symlinkEscape = factory.create( + request, + TestTools.context(PermissionMode.ASK), + new ToolRuntimeInvocation("ses_1", "turn_1").withCwd(escape) + ); + + assertEquals(workspace, valid.workspaceRoot()); + assertEquals(nested, valid.cwd()); + assertEquals(workspace, lexicalEscape.cwd()); + assertEquals(workspace, missing.cwd()); + assertEquals(workspace, symlinkEscape.cwd()); + } + private ContextSnapshot context(AgentMode agentMode, PermissionRuntimeState runtimeState) { return new ContextSnapshot( new SystemPrompt("system", List.of(), "hash"), diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolModelContractTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolModelContractTest.java new file mode 100644 index 00000000..50703b50 --- /dev/null +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolModelContractTest.java @@ -0,0 +1,51 @@ +package cn.lypi.tool.builtin; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import cn.lypi.contracts.runtime.Executor; +import cn.lypi.contracts.tool.ToolDescriptor; +import cn.lypi.tool.DefaultToolRegistry; +import java.util.Locale; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class BashToolModelContractTest { + @Test + void exposesOnlyPublicBashCapabilityAndInputs() { + Executor unusedExecutor = new Executor() { + @Override + public String name() { + return "unused"; + } + + @Override + public cn.lypi.contracts.runtime.ExecutionResult execute( + cn.lypi.contracts.runtime.ExecutionRequest request, + cn.lypi.contracts.common.ProgressSink progress, + cn.lypi.contracts.common.AbortSignal signal + ) { + throw new AssertionError("model contract test must not execute bash"); + } + }; + DefaultToolRegistry registry = new DefaultToolRegistry(); + registry.register(new BashTool(unusedExecutor)); + + ToolDescriptor descriptor = registry.snapshot().tools().getFirst(); + assertEquals("Execute shell commands.", descriptor.description()); + String normalizedDescription = descriptor.description().toLowerCase(Locale.ROOT); + for (String implementationTerm : new String[] { + "cwd", "working directory", "persistent", "snapshot", "export", "source" + }) { + assertFalse( + normalizedDescription.contains(implementationTerm), + () -> "model-visible description contains shell-state implementation term: " + implementationTerm + ); + } + + @SuppressWarnings("unchecked") + Map properties = (Map) descriptor.inputSchema().value().get("properties"); + assertEquals(Map.of("type", "string"), properties.get("command")); + assertFalse(properties.containsKey("cwd")); + } +} diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolSandboxSmokeTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolSandboxSmokeTest.java new file mode 100644 index 00000000..040e9458 --- /dev/null +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolSandboxSmokeTest.java @@ -0,0 +1,90 @@ +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.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import cn.lypi.contracts.runtime.ExecutionRequest; +import cn.lypi.contracts.runtime.ExecutionResult; +import cn.lypi.contracts.runtime.SandboxRuntimePolicy; +import cn.lypi.contracts.tool.ToolResult; +import cn.lypi.contracts.tool.ToolUseContext; +import cn.lypi.tool.shell.BubblewrapExecutor; +import cn.lypi.tool.shell.DefaultSandboxPolicyResolver; +import cn.lypi.tool.shell.SandboxPolicyOptions; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class BashToolSandboxSmokeTest { + @TempDir + Path tempDir; + + @Test + void shellStateFilesWorkOutsideWorkspaceInManagedSandbox() throws Exception { + Path workspace = Files.createDirectory(tempDir.resolve("workspace")); + Path nested = Files.createDirectory(workspace.resolve("dir with spaces")); + Path stateRoot = Files.createDirectory(tempDir.resolve("state-root")); + Path probe = Files.createDirectory(tempDir.resolve("probe")); + BubblewrapExecutor executor = new BubblewrapExecutor(); + assumeTrue(realBubblewrapWorks(executor, probe), "system bubblewrap is unavailable or cannot create namespaces"); + + ShellEnvironmentHarness harness = new ShellEnvironmentHarness(stateRoot); + Path envSource = Files.writeString( + tempDir.resolve("session-env.sh"), + "export LYPI_TEST_ENV=from-env-script\n" + ); + harness.importEnvFile( + workspace, + "ses_smoke", + Map.of(ShellEnvironmentHarness.ENV_FILE_VARIABLE, envSource.toString()) + ); + BashTool tool = new BashTool( + executor, + new DefaultSandboxPolicyResolver(SandboxPolicyOptions.defaults()), + harness + ); + + ToolResult result = tool.execute( + Map.of("command", "cd 'dir with spaces' && printf '%s' \"$LYPI_TEST_ENV\""), + new ToolUseContext( + "ses_smoke", + "msg_1", + workspace, + workspace, + Map.of("toolUseId", "toolu_1") + ), + progress -> { + } + ); + + assertFalse(result.isError(), result.output()); + assertTrue(result.output().contains("from-env-script"), result.output()); + assertTrue(result.output().contains("sandboxed=true"), result.output()); + assertEquals(nested, result.stateDelta().orElseThrow().cwd()); + } + + private boolean realBubblewrapWorks(BubblewrapExecutor executor, Path cwd) { + SandboxRuntimePolicy policy = new DefaultSandboxPolicyResolver(SandboxPolicyOptions.defaults()) + .resolve(cwd, cwd); + ExecutionResult result = executor.execute( + new ExecutionRequest( + List.of("bash", "-c", "true"), + cwd, + Map.of(), + Duration.ofSeconds(5), + policy + ), + progress -> { + }, + () -> false + ); + return result.exitCode() == 0 && result.metadata().sandboxed(); + } +} diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolTest.java index df1f6140..82366854 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolTest.java @@ -85,32 +85,39 @@ void inputSchemaExposesShellSelectionFields() { @SuppressWarnings("unchecked") Map properties = (Map) tool.inputSchema().value().get("properties"); - assertEquals(Map.of("type", "string"), properties.get("shell")); + assertEquals(Map.of("type", "string", "enum", List.of("bash", "sh", "zsh")), properties.get("shell")); assertEquals(Map.of("type", "boolean"), properties.get("loginShell")); + assertFalse(properties.containsKey("cwd")); } @Test - void mapsCommandToExecutionRequestAndResult() { + void mapsCommandToExecutionRequestAndResult() throws Exception { + Path nested = Files.createDirectory(tempDir.resolve("nested")); RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(7, "out", "err", false, Optional.empty())); RecordingSandboxPolicyResolver resolver = new RecordingSandboxPolicyResolver(defaultPolicy()); - BashTool tool = new BashTool(executor, resolver); + BashTool tool = new BashTool(executor, resolver, testHarness()); List progresses = new ArrayList<>(); ToolResult result = tool.execute( Map.of("command", "echo hi", "timeoutSeconds", 3), - context(Map.of()), + context(tempDir, nested, Map.of()), progresses::add ); assertFalse(result.isError()); - assertEquals(List.of("bash", "-lc", "echo hi"), executor.request.get().command()); - assertEquals(tempDir, executor.request.get().cwd()); + assertEquals("bash", executor.request.get().command().get(0)); + assertTrue(executor.request.get().command().get(2).contains("eval 'echo hi'")); + // snapshot 可能已由其他测试预生成(-c)或尚未生成(-lc) + assertTrue(List.of("-c", "-lc").contains(executor.request.get().command().get(1))); + assertEquals(nested, executor.request.get().cwd()); assertEquals(Duration.ofSeconds(3), executor.request.get().timeout()); - assertSame(resolver.policy, executor.request.get().sandboxPolicy()); + assertEquals(resolver.policy.kind(), executor.request.get().sandboxPolicy().kind()); + assertTrue(executor.request.get().sandboxPolicy().allowRead().containsAll(resolver.policy.allowRead())); + assertTrue(executor.request.get().sandboxPolicy().allowWrite().containsAll(resolver.policy.allowWrite())); assertEquals(SandboxPermissions.USE_DEFAULT, executor.request.get().sandboxPermissions()); assertEquals(Optional.empty(), executor.request.get().justification()); assertEquals(tempDir, resolver.workspace.get()); - assertEquals(tempDir, resolver.cwd.get()); + assertEquals(nested, resolver.cwd.get()); assertEquals(NetworkMode.DISABLED, executor.request.get().sandboxPolicy().networkMode()); assertFalse(executor.request.get().sandboxPolicy().failIfUnavailable()); assertFalse(executor.request.get().sandboxPolicy().autoAllowBashIfSandboxed()); @@ -119,6 +126,7 @@ void mapsCommandToExecutionRequestAndResult() { assertTrue(result.output().contains("stderr:\nerr")); assertEquals(List.of( ToolProgress.phase("running", "执行 shell 命令"), + ToolProgress.status("executor progress", null), ToolProgress.status("executor progress", null) ), progresses); } @@ -132,7 +140,8 @@ void sameToolUsesChangedRuntimeModeForNextExecution() { PermissionProfiles.workspace(), SandboxPolicyOptions.defaults(), false - ) + ), + testHarness() ); ToolResult askResult = tool.execute( @@ -141,6 +150,7 @@ void sameToolUsesChangedRuntimeModeForNextExecution() { message -> { } ); + assertFalse(askResult.isError(), askResult.output()); SandboxRuntimePolicy askPolicy = executor.request.get().sandboxPolicy(); ToolResult bypassResult = tool.execute( Map.of("command", "true"), @@ -150,7 +160,6 @@ void sameToolUsesChangedRuntimeModeForNextExecution() { ); SandboxRuntimePolicy bypassPolicy = executor.request.get().sandboxPolicy(); - assertFalse(askResult.isError()); assertEquals(SandboxRuntimePolicyKind.MANAGED, askPolicy.kind()); assertEquals(NetworkMode.DISABLED, askPolicy.networkMode()); assertFalse(bypassResult.isError()); @@ -167,7 +176,8 @@ void canonicalRuntimeStateSupersedesLegacyPermissionModeForExecution() { PermissionProfiles.workspace(), SandboxPolicyOptions.defaults(), false - ) + ), + testHarness() ); ToolResult result = tool.execute( @@ -193,7 +203,8 @@ void legacyPermissionModeIsUsedWhenCanonicalRuntimeStateIsMissing() { PermissionProfiles.workspace(), SandboxPolicyOptions.defaults(), false - ) + ), + testHarness() ); ToolResult result = tool.execute( @@ -210,7 +221,11 @@ void legacyPermissionModeIsUsedWhenCanonicalRuntimeStateIsMissing() { @Test void mapsNonLoginShellCommandToExecutionRequest() { RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); - BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy())); + BashTool tool = new BashTool( + executor, + new RecordingSandboxPolicyResolver(defaultPolicy()), + testHarness() + ); ToolResult result = tool.execute( Map.of("command", "echo hi", "loginShell", false), @@ -220,13 +235,272 @@ void mapsNonLoginShellCommandToExecutionRequest() { ); assertFalse(result.isError()); - assertEquals(List.of("bash", "-c", "echo hi"), executor.request.get().command()); + assertEquals("bash", executor.request.get().command().get(0)); + assertEquals("-c", executor.request.get().command().get(1)); + assertTrue(executor.request.get().command().get(2).contains("eval 'echo hi'")); + assertEquals(1, executor.requests.size()); + assertFalse(executor.request.get().command().get(2).contains("shell-snapshot-")); + } + + @Test + void snapshotAndCommandShareExecutorAuthorizationAndNarrowInternalMounts() throws Exception { + Path workspace = Files.createDirectory(tempDir.resolve("workspace-unified")); + Path stateRoot = tempDir.resolve("state-outside-workspace"); + ShellEnvironmentHarness harness = new ShellEnvironmentHarness(stateRoot); + RecordingDelegatingExecutor executor = new RecordingDelegatingExecutor(); + SandboxRuntimePolicy basePolicy = new SandboxRuntimePolicy( + List.of(), + List.of(), + List.of(), + List.of(), + NetworkMode.DISABLED, + true, + false + ); + RecordingSandboxPolicyResolver resolver = new RecordingSandboxPolicyResolver(basePolicy); + BashTool tool = new BashTool(executor, resolver, harness); + AbortSignal signal = () -> false; + + ToolResult result = tool.execute( + Map.of("command", "printf ok"), + context(workspace, workspace, Map.of("abortSignal", signal)), + progress -> { + } + ); + + assertFalse(result.isError(), result.output()); + assertEquals(2, executor.requests.size()); + ExecutionRequest snapshotRequest = executor.requests.get(0); + ExecutionRequest commandRequest = executor.requests.get(1); + assertEquals(List.of("bash", "-lc"), snapshotRequest.command().subList(0, 2)); + assertEquals(List.of("bash", "-c"), commandRequest.command().subList(0, 2)); + assertEquals(1, resolver.calls.get()); + assertEquals(snapshotRequest.sandboxPermissions(), commandRequest.sandboxPermissions()); + assertEquals(snapshotRequest.additionalPermissions(), commandRequest.additionalPermissions()); + assertEquals(snapshotRequest.justification(), commandRequest.justification()); + assertEquals(snapshotRequest.sandboxPolicy().kind(), commandRequest.sandboxPolicy().kind()); + assertEquals(snapshotRequest.sandboxPolicy().networkMode(), commandRequest.sandboxPolicy().networkMode()); + assertEquals(snapshotRequest.sandboxPolicy().failIfUnavailable(), commandRequest.sandboxPolicy().failIfUnavailable()); + assertEquals( + snapshotRequest.sandboxPolicy().autoAllowBashIfSandboxed(), + commandRequest.sandboxPolicy().autoAllowBashIfSandboxed() + ); + assertSame(signal, executor.signals.get(0)); + assertSame(signal, executor.signals.get(1)); + + Path sessionDir = harness.sessionDir(workspace, "ses_1"); + for (ExecutionRequest request : executor.requests) { + assertTrue(request.sandboxPolicy().allowRead().contains(Path.of("/usr"))); + assertTrue(request.sandboxPolicy().allowWrite().contains(workspace)); + assertFalse(request.sandboxPolicy().allowWrite().contains(stateRoot)); + assertFalse(request.sandboxPolicy().allowWrite().contains(sessionDir)); + assertEquals( + 1, + request.sandboxPolicy().allowWrite().stream().filter(path -> path.startsWith(sessionDir)).count() + ); + } + assertTrue(result.output().contains("exitCode=0"), result.output()); + assertTrue(result.output().contains("stdout:\nok"), result.output()); + } + + @Test + void typedCwdDeltaComesFromUniqueCaptureNotStdoutProtocol() throws Exception { + Path workspace = Files.createDirectory(tempDir.resolve("workspace-delta")); + Path nested = Files.createDirectory(workspace.resolve("dir with spaces")); + ShellEnvironmentHarness harness = new ShellEnvironmentHarness(tempDir.resolve("state-delta")); + RecordingDelegatingExecutor executor = new RecordingDelegatingExecutor(); + BashTool tool = new BashTool( + executor, + new RecordingSandboxPolicyResolver(policyForWorkspace(workspace)), + harness + ); + + ToolResult result = tool.execute( + Map.of("command", "printf 'shellCwd=/outside\\n'; cd 'dir with spaces'"), + context(workspace, workspace, Map.of()), + progress -> { + } + ); + + assertFalse(result.isError(), result.output()); + assertTrue(result.output().contains("shellCwd=/outside")); + assertFalse(result.output().contains("shellCwd=" + nested)); + assertEquals(nested, result.stateDelta().orElseThrow().cwd()); + } + + @Test + void cwdDeltaStaysLexicalWhenWorkspaceRootIsSymlink() throws Exception { + Path realWorkspace = Files.createDirectory(tempDir.resolve("real-workspace")); + Files.createDirectory(realWorkspace.resolve("nested")); + Path workspaceLink = Files.createSymbolicLink(tempDir.resolve("workspace-link"), realWorkspace); + ShellEnvironmentHarness harness = new ShellEnvironmentHarness(tempDir.resolve("state-symlink")); + BashTool tool = new BashTool( + new cn.lypi.tool.shell.HostExecutor(), + new RecordingSandboxPolicyResolver(policyForWorkspace(workspaceLink)), + harness + ); + + ToolResult result = tool.execute( + Map.of("command", "cd nested", "loginShell", false), + context(workspaceLink, workspaceLink, Map.of()), + ignored -> { + } + ); + + assertFalse(result.isError(), result.output()); + assertEquals(workspaceLink.resolve("nested"), result.stateDelta().orElseThrow().cwd()); + } + + @Test + void restoredBashAliasExecutesInNonInteractiveCommand() throws Exception { + Path workspace = Files.createDirectory(tempDir.resolve("workspace-alias")); + ShellEnvironmentHarness harness = new ShellEnvironmentHarness(tempDir.resolve("state-alias")); + ShellEnvironmentHarness.SnapshotPlan snapshot = harness + .prepareSnapshot(workspace, "ses_1", "bash") + .orElseThrow(); + try (snapshot) { + Files.writeString(snapshot.captureFile(), "alias lypi_alias='printf alias-restored'\n"); + harness.completeSnapshot( + snapshot, + new ExecutionResult(0, "", "", false, Optional.empty()) + ); + } + BashTool tool = new BashTool( + new cn.lypi.tool.shell.HostExecutor(), + new RecordingSandboxPolicyResolver(policyForWorkspace(workspace)), + harness + ); + + ToolResult result = tool.execute( + Map.of("command", "lypi_alias"), + context(workspace, workspace, Map.of()), + ignored -> { + } + ); + + assertFalse(result.isError(), result.output()); + assertTrue(result.output().contains("exitCode=0"), result.output()); + assertTrue(result.output().contains("alias-restored"), result.output()); + } + + @Test + void cwdCaptureOverridesNoclobberEnabledByUserCommand() throws Exception { + Path workspace = Files.createDirectory(tempDir.resolve("workspace-noclobber")); + Path nested = Files.createDirectory(workspace.resolve("nested")); + ShellEnvironmentHarness harness = new ShellEnvironmentHarness(tempDir.resolve("state-noclobber")); + BashTool tool = new BashTool( + new cn.lypi.tool.shell.HostExecutor(), + new RecordingSandboxPolicyResolver(policyForWorkspace(workspace)), + harness + ); + + ToolResult result = tool.execute( + Map.of("command", "set -C; cd nested", "loginShell", false), + context(workspace, workspace, Map.of()), + ignored -> { + } + ); + + assertFalse(result.isError(), result.output()); + assertEquals(nested, result.stateDelta().orElseThrow().cwd()); + } + + @Test + void wrapsCommandWithHarnessAndCapturesShellCwd() throws Exception { + ShellEnvironmentHarness harness = testHarness(); + RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); + BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy()), harness); + + ToolResult result = tool.execute( + Map.of("command", "echo hi"), + context(Map.of()), + message -> { + } + ); + + assertFalse(result.isError()); + List command = executor.request.get().command(); + assertEquals("bash", command.get(0)); + String wrapped = command.get(2); + assertTrue(wrapped.contains("eval 'echo hi'"), wrapped); + assertTrue(wrapped.contains("pwd -P"), wrapped); + // RecordingExecutor 不真正执行,无 shellCwd 输出 + assertFalse(result.output().contains("shellCwd=")); + } + + @Test + void rejectsHiddenCwdInput() { + ShellEnvironmentHarness harness = testHarness(); + RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); + BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy()), harness); + + @SuppressWarnings("unchecked") + Map properties = (Map) tool.inputSchema().value().get("properties"); + assertFalse(properties.containsKey("cwd")); + + var validation = tool.validateInput( + Map.of("command", "echo hi", "cwd", "."), + context(Map.of()) + ); + + assertFalse(validation.valid()); + assertEquals(List.of("不支持的工具输入字段: cwd。"), validation.messages()); + + ToolResult execution = tool.execute( + Map.of("command", "echo hi", "cwd", "."), + context(Map.of()), + message -> { + } + ); + assertTrue(execution.isError()); + assertEquals("不支持的工具输入字段: cwd。", execution.output()); + } + + @Test + void shellCwdCapturedFromExecutedCommand() throws Exception { + ShellEnvironmentHarness harness = testHarness(); + // 用真实 bash 执行,走完整 wrap + cwd 捕获链路 + Executor realExecutor = new cn.lypi.tool.shell.HostExecutor(); + BashTool tool = new BashTool(realExecutor, new RecordingSandboxPolicyResolver(defaultPolicy()), harness); + + ToolResult result = tool.execute( + Map.of("command", "pwd"), + context(Map.of()), + message -> { + } + ); + + assertFalse(result.isError()); + assertTrue(result.output().contains("exitCode=0"), result.output()); + // pwd 未改变目录,无 shellCwd 增量(captured == context.cwd) + assertFalse(result.output().contains("shellCwd="), result.output()); + assertTrue(harness.snapshotExists(tempDir, "ses_1", "bash")); + } + + @Test + void sessionEnvScriptAppliesToWrappedCommand() throws Exception { + ShellEnvironmentHarness harness = testHarness(); + Path envDir = harness.sessionDir(tempDir, "ses_1").resolve("env"); + Files.createDirectories(envDir); + Files.writeString(envDir.resolve("01-test.sh"), "export LYPI_BASH_TOOL_TEST=persisted\n"); + Executor realExecutor = new cn.lypi.tool.shell.HostExecutor(); + BashTool tool = new BashTool(realExecutor, new RecordingSandboxPolicyResolver(defaultPolicy()), harness); + + ToolResult result = tool.execute( + Map.of("command", "echo \"$LYPI_BASH_TOOL_TEST\""), + context(Map.of()), + message -> { + } + ); + + assertFalse(result.isError()); + assertTrue(result.output().contains("persisted"), result.output()); } @Test void mapsAllowedShellToExecutionRequest() { RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); - BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy())); + BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy()), testHarness()); ToolResult shResult = tool.execute( Map.of("command", "echo hi", "shell", "sh"), @@ -235,8 +509,8 @@ void mapsAllowedShellToExecutionRequest() { } ); - assertFalse(shResult.isError()); - assertEquals(List.of("sh", "-lc", "echo hi"), executor.request.get().command()); + assertFalse(shResult.isError(), shResult.output()); + assertEquals("sh", executor.request.get().command().get(0)); ToolResult zshResult = tool.execute( Map.of("command", "echo hi", "shell", "zsh"), @@ -246,17 +520,8 @@ void mapsAllowedShellToExecutionRequest() { ); assertFalse(zshResult.isError()); - assertEquals(List.of("zsh", "-lc", "echo hi"), executor.request.get().command()); - - ToolResult absoluteBashResult = tool.execute( - Map.of("command", "echo hi", "shell", "/bin/bash"), - context(Map.of()), - message -> { - } - ); + assertEquals("zsh", executor.request.get().command().get(0)); - assertFalse(absoluteBashResult.isError()); - assertEquals(List.of("/bin/bash", "-lc", "echo hi"), executor.request.get().command()); } @Test @@ -265,17 +530,24 @@ void rejectsUnsupportedShell() { var pythonResult = tool.validateInput(Map.of("command", "echo hi", "shell", "python"), context(Map.of())); var relativePathResult = tool.validateInput(Map.of("command", "echo hi", "shell", "bin/bash"), context(Map.of())); + var absolutePathResult = tool.validateInput(Map.of("command", "echo hi", "shell", "/bin/bash"), context(Map.of())); assertFalse(pythonResult.valid()); assertTrue(pythonResult.messages().getFirst().contains("shell")); assertFalse(relativePathResult.valid()); assertTrue(relativePathResult.messages().getFirst().contains("shell")); + assertFalse(absolutePathResult.valid()); + assertTrue(absolutePathResult.messages().getFirst().contains("shell")); } @Test void mapsEscalatedSandboxRequestToExecutionRequest() { RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); - BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy())); + BashTool tool = new BashTool( + executor, + new RecordingSandboxPolicyResolver(defaultPolicy()), + testHarness() + ); ToolResult result = tool.execute( Map.of( @@ -295,16 +567,22 @@ void mapsEscalatedSandboxRequestToExecutionRequest() { executor.request.get().justification() ); assertEquals(Optional.empty(), executor.request.get().additionalPermissions()); + assertEquals(2, executor.requests.size()); + assertTrue(executor.requests.stream().allMatch(request -> + request.sandboxPermissions() == SandboxPermissions.REQUIRE_ESCALATED + && request.justification().equals(Optional.of("Need host access to inspect local process state.")) + && request.sandboxPolicy().kind() == SandboxRuntimePolicyKind.DISABLED + )); } @Test - void approvedDefaultRequestUsesHostWithoutResolvingSandboxPolicy(@TempDir Path outsideDir) throws Exception { + void approvedDefaultRequestUsesHostWithoutResolvingSandboxPolicy() throws Exception { RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); FailingSandboxPolicyResolver resolver = new FailingSandboxPolicyResolver(); - BashTool tool = new BashTool(executor, resolver); + BashTool tool = new BashTool(executor, resolver, testHarness()); ToolResult result = tool.execute( - Map.of("command", "pwd", "cwd", outsideDir.toString()), + Map.of("command", "pwd"), context(Map.of("permissionApprovedForHostExecution", true)), message -> { } @@ -313,14 +591,14 @@ void approvedDefaultRequestUsesHostWithoutResolvingSandboxPolicy(@TempDir Path o assertFalse(result.isError()); assertEquals(SandboxRuntimePolicyKind.DISABLED, executor.request.get().sandboxPolicy().kind()); assertEquals(0, resolver.calls.get()); - assertEquals(outsideDir.toRealPath(), executor.request.get().cwd()); + assertEquals(tempDir.toRealPath(), executor.request.get().cwd()); } @Test void approvedAdditionalPermissionsRequestUsesHostWithoutResolvingSandboxPolicy() throws Exception { RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); FailingSandboxPolicyResolver resolver = new FailingSandboxPolicyResolver(); - BashTool tool = new BashTool(executor, resolver); + BashTool tool = new BashTool(executor, resolver, testHarness()); Path cacheDir = Files.createDirectory(tempDir.resolve("host-cache")); AdditionalPermissionProfile permissions = additionalWrite(cacheDir); @@ -348,7 +626,7 @@ void approvedAdditionalPermissionsRequestUsesHostWithoutResolvingSandboxPolicy() void approvedEscalatedRequestUsesHostWithoutResolvingSandboxPolicy() { RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); FailingSandboxPolicyResolver resolver = new FailingSandboxPolicyResolver(); - BashTool tool = new BashTool(executor, resolver); + BashTool tool = new BashTool(executor, resolver, testHarness()); ToolResult result = tool.execute( Map.of( @@ -370,7 +648,7 @@ void approvedEscalatedRequestUsesHostWithoutResolvingSandboxPolicy() { void bypassUsesHostWithoutApprovalOrSandboxResolution() { RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); FailingSandboxPolicyResolver resolver = new FailingSandboxPolicyResolver(); - BashTool tool = new BashTool(executor, resolver); + BashTool tool = new BashTool(executor, resolver, testHarness()); ToolResult result = tool.execute( Map.of("command", "pwd"), @@ -387,7 +665,7 @@ void bypassUsesHostWithoutApprovalOrSandboxResolution() { @Test void mapsApprovedAdditionalPermissionsToSingleExecutionRequest() throws Exception { RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); - BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy())); + BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy()), testHarness()); Path cacheDir = Files.createDirectory(tempDir.resolve("cache")); AdditionalPermissionProfile permissions = additionalWrite(cacheDir); @@ -408,6 +686,12 @@ void mapsApprovedAdditionalPermissionsToSingleExecutionRequest() throws Exceptio assertEquals(SandboxPermissions.WITH_ADDITIONAL_PERMISSIONS, executor.request.get().sandboxPermissions()); assertEquals(Optional.of(permissions), executor.request.get().additionalPermissions()); assertEquals(Optional.empty(), executor.request.get().justification()); + assertEquals(2, executor.requests.size()); + assertTrue(executor.requests.stream().allMatch(request -> + request.sandboxPermissions() == SandboxPermissions.WITH_ADDITIONAL_PERMISSIONS + && request.additionalPermissions().equals(Optional.of(permissions)) + && request.justification().isEmpty() + )); ToolResult defaultResult = tool.execute( Map.of("command", "true"), @@ -424,7 +708,7 @@ void mapsApprovedAdditionalPermissionsToSingleExecutionRequest() throws Exceptio @Test void rejectsAdditionalPermissionsWithoutApprovedMarker() throws Exception { RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); - BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy())); + BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy()), testHarness()); Path cacheDir = Files.createDirectory(tempDir.resolve("cache")); ToolResult result = tool.execute( @@ -449,7 +733,7 @@ void rendersSandboxRetryHintFromExecutionMetadata() { "bubblewrap unavailable" ); RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(126, "", "denied", false, Optional.empty(), metadata)); - BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy())); + BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy()), testHarness()); ToolResult result = tool.execute(Map.of("command", "id"), context(Map.of()), message -> { }); @@ -474,7 +758,7 @@ void rendersOrdinarySandboxFailuresWithoutEscalationHints() { Optional.empty(), ExecutionMetadata.sandboxed("bubblewrap") )); - BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy())); + BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy()), testHarness()); ToolResult result = tool.execute(Map.of("command", "touch output.txt"), context(Map.of()), message -> { }); @@ -504,7 +788,7 @@ void rejectsEscalatedSandboxRequestWithoutJustification() { @Test void reportsRunningPhaseBeforeExecutingCommand() { RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); - BashTool tool = new BashTool(executor); + BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy()), testHarness()); List progresses = new ArrayList<>(); tool.execute(Map.of("command", "echo hi"), context(Map.of()), progresses::add); @@ -516,57 +800,62 @@ void reportsRunningPhaseBeforeExecutingCommand() { } @Test - void supportsCwdOverrideInsideWorkspaceAndPassesAbortSignal() { - AbortSignal signal = () -> true; + void usesDynamicContextCwdAndPassesAbortSignal() throws Exception { + AbortSignal signal = () -> false; RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); - BashTool tool = new BashTool(executor); + Path nested = Files.createDirectory(tempDir.resolve("nested-cwd")); + BashTool tool = new BashTool( + executor, + new RecordingSandboxPolicyResolver(defaultPolicy()), + testHarness() + ); ToolResult result = tool.execute( - Map.of("command", "pwd", "cwd", "."), - context(Map.of("abortSignal", signal)), + Map.of("command", "pwd"), + context(tempDir, nested, Map.of("abortSignal", signal)), message -> { } ); assertFalse(result.isError()); assertSame(signal, executor.signal.get()); - assertEquals(tempDir, executor.request.get().cwd()); + assertEquals(nested, executor.request.get().cwd()); } @Test - void directAllowUsesManagedSandboxWithCwdOutsideWorkspace() throws Exception { + void rejectsContextCwdOutsideWorkspace() throws Exception { RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); RecordingSandboxPolicyResolver resolver = new RecordingSandboxPolicyResolver(defaultPolicy()); - BashTool tool = new BashTool(executor, resolver); - Path outsideDir = tempDir.getParent(); + BashTool tool = new BashTool(executor, resolver, testHarness()); + Path workspace = Files.createDirectory(tempDir.resolve("workspace-boundary")); + Path outsideDir = Files.createDirectory(tempDir.resolve("outside-boundary")); ToolResult result = tool.execute( - Map.of("command", "printf x > /etc/lypi-test", "cwd", outsideDir.toString()), - context(Map.of()), + Map.of("command", "pwd"), + context(workspace, outsideDir, Map.of()), message -> { } ); - assertFalse(result.isError()); - assertEquals(SandboxRuntimePolicyKind.MANAGED, executor.request.get().sandboxPolicy().kind()); - assertEquals(outsideDir.toRealPath(), executor.request.get().cwd()); - assertEquals(1, resolver.calls.get()); + assertTrue(result.isError()); + assertEquals(0, executor.requests.size()); + assertEquals(0, resolver.calls.get()); } @Test - void directAllowUsesManagedSandboxWithCwdSymlinkOutsideWorkspace(@TempDir Path outsideDir) throws Exception { - Files.createSymbolicLink(tempDir.resolve("outside-link"), outsideDir); + void rejectsContextCwdSymlinkEscape(@TempDir Path outsideDir) throws Exception { + Path workspace = Files.createDirectory(tempDir.resolve("workspace-symlink")); + Path escape = Files.createSymbolicLink(workspace.resolve("outside-link"), outsideDir); RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); RecordingSandboxPolicyResolver resolver = new RecordingSandboxPolicyResolver(defaultPolicy()); - BashTool tool = new BashTool(executor, resolver); + BashTool tool = new BashTool(executor, resolver, testHarness()); - ToolResult result = tool.execute(Map.of("command", "pwd", "cwd", "outside-link"), context(Map.of()), message -> { + ToolResult result = tool.execute(Map.of("command", "pwd"), context(workspace, escape, Map.of()), message -> { }); - assertFalse(result.isError()); - assertEquals(SandboxRuntimePolicyKind.MANAGED, executor.request.get().sandboxPolicy().kind()); - assertEquals(outsideDir.toRealPath(), executor.request.get().cwd()); - assertEquals(1, resolver.calls.get()); + assertTrue(result.isError()); + assertEquals(0, executor.requests.size()); + assertEquals(0, resolver.calls.get()); } @Test @@ -671,16 +960,36 @@ void stillAsksWhenSandboxIsDisabledOrExternal() { } private ToolUseContext context(Map extraMetadata) { + return context(tempDir, tempDir, extraMetadata); + } + + private ToolUseContext context(Path workspaceRoot, Path cwd, Map extraMetadata) { java.util.LinkedHashMap metadata = new java.util.LinkedHashMap<>(); metadata.put("toolUseId", "toolu_1"); metadata.putAll(extraMetadata); - return new ToolUseContext("ses_1", "msg_1", tempDir, Map.copyOf(metadata)); + return new ToolUseContext("ses_1", "msg_1", workspaceRoot, cwd, Map.copyOf(metadata)); + } + + private ShellEnvironmentHarness testHarness() { + return new ShellEnvironmentHarness(tempDir.resolve("shell-state")); } private SandboxRuntimePolicy defaultPolicy() { return policy(false, false); } + private SandboxRuntimePolicy policyForWorkspace(Path workspace) { + return new SandboxRuntimePolicy( + List.of(Path.of("/usr"), Path.of("/bin"), Path.of("/lib"), Path.of("/lib64"), Path.of("/etc")), + List.of(), + List.of(workspace), + List.of(), + NetworkMode.DISABLED, + false, + false + ); + } + private SandboxRuntimePolicy policy(boolean failIfUnavailable, boolean autoAllowBashIfSandboxed) { return policy(SandboxRuntimePolicyKind.MANAGED, failIfUnavailable, autoAllowBashIfSandboxed); } @@ -718,6 +1027,7 @@ private static final class RecordingExecutor implements Executor { private final ExecutionResult result; private final AtomicReference request = new AtomicReference<>(); private final AtomicReference signal = new AtomicReference<>(); + private final List requests = new ArrayList<>(); private RecordingExecutor(ExecutionResult result) { this.result = result; @@ -732,11 +1042,30 @@ public String name() { public ExecutionResult execute(ExecutionRequest request, ProgressSink progress, AbortSignal signal) { this.request.set(request); this.signal.set(signal); + this.requests.add(request); progress.progress(ToolProgress.status("executor progress", null)); return result; } } + private static final class RecordingDelegatingExecutor implements Executor { + private final Executor delegate = new cn.lypi.tool.shell.HostExecutor(); + private final List requests = new ArrayList<>(); + private final List signals = new ArrayList<>(); + + @Override + public String name() { + return "recording-host"; + } + + @Override + public ExecutionResult execute(ExecutionRequest request, ProgressSink progress, AbortSignal signal) { + requests.add(request); + signals.add(signal); + return delegate.execute(request, progress, signal); + } + } + private static final class RecordingSandboxPolicyResolver implements cn.lypi.tool.shell.SandboxPolicyResolver { private final SandboxRuntimePolicy policy; private final AtomicInteger calls = new AtomicInteger(); diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/RequestPermissionsToolTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/RequestPermissionsToolTest.java index 4100ed3a..8d0f81b1 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/builtin/RequestPermissionsToolTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/RequestPermissionsToolTest.java @@ -48,6 +48,8 @@ import cn.lypi.tool.DefaultToolRuntime; import cn.lypi.tool.PermissionResponseGate; import cn.lypi.tool.ToolRuntimeOptions; +import cn.lypi.tool.shell.DefaultSandboxPolicyResolver; +import cn.lypi.tool.shell.SandboxPolicyOptions; import cn.lypi.tool.web.WebProviderRegistry; import cn.lypi.tool.web.WebSearchProvider; import cn.lypi.tool.web.WebSearchRequest; @@ -463,7 +465,11 @@ void strictAutoReviewApprovedForTurnMakesLaterCommandAsk() { executions ); runtime.register(new RequestPermissionsTool()); - runtime.register(new BashTool(executor(executions))); + runtime.register(new BashTool( + executor(executions), + new DefaultSandboxPolicyResolver(SandboxPolicyOptions.defaults()), + new ShellEnvironmentHarness(tempDir.resolve("shell-state")) + )); List> results = runtime.execute( List.of( @@ -476,7 +482,7 @@ void strictAutoReviewApprovedForTurnMakesLaterCommandAsk() { assertFalse(results.get(0).isError()); assertFalse(results.get(1).isError()); assertEquals(2, prompts.get()); - assertEquals(1, executions.get()); + assertEquals(2, executions.get()); assertEquals(ApprovalKind.REQUEST_PERMISSIONS, events.get(0).approvalKind()); assertTrue(events.get(1).message().contains("strictAutoReview")); } diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/ShellEnvironmentHarnessTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/ShellEnvironmentHarnessTest.java new file mode 100644 index 00000000..e5e282dc --- /dev/null +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/ShellEnvironmentHarnessTest.java @@ -0,0 +1,267 @@ +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.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import cn.lypi.contracts.runtime.ExecutionRequest; +import cn.lypi.contracts.runtime.ExecutionResult; +import cn.lypi.contracts.runtime.SandboxRuntimePolicy; +import cn.lypi.tool.shell.HostExecutor; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ShellEnvironmentHarnessTest { + @TempDir + Path tempDir; + + private ShellEnvironmentHarness harness() { + return new ShellEnvironmentHarness(tempDir.resolve("state")); + } + + @Test + void sessionDirectoriesUseWorkspaceAndRawSessionHashes() throws Exception { + ShellEnvironmentHarness harness = harness(); + Path firstWorkspace = Files.createDirectory(tempDir.resolve("workspace-a")); + Path secondWorkspace = Files.createDirectory(tempDir.resolve("workspace-b")); + + Path slashId = harness.sessionDir(firstWorkspace, "a/b"); + Path underscoreId = harness.sessionDir(firstWorkspace, "a_b"); + Path otherWorkspace = harness.sessionDir(secondWorkspace, "a/b"); + + assertTrue(slashId.startsWith(tempDir.resolve("state"))); + assertNotEquals(slashId, underscoreId); + assertNotEquals(slashId, otherWorkspace); + assertEquals(64, slashId.getFileName().toString().length()); + } + + @Test + void snapshotsAreIsolatedByShellAndFilterPwdExports() throws Exception { + ShellEnvironmentHarness harness = harness(); + Path workspace = Files.createDirectory(tempDir.resolve("workspace")); + + ShellEnvironmentHarness.SnapshotPlan bashPlan = harness + .prepareSnapshot(workspace, "ses_1", "bash") + .orElseThrow(); + try (bashPlan) { + Files.writeString( + bashPlan.captureFile(), + "declare -x OLDPWD\n" + + "declare -x PWD=\"/forged\"\n" + + "declare -x KEEP=\"ok\"\n" + + "alias ll='ls -l'\n" + + "kept_function () { echo ok; }\n" + ); + harness.completeSnapshot(bashPlan, success()); + } + + assertTrue(harness.snapshotExists(workspace, "ses_1", "bash")); + assertFalse(harness.snapshotExists(workspace, "ses_1", "sh")); + String snapshot = Files.readString(bashPlan.snapshotFile()); + assertFalse(snapshot.lines().anyMatch(line -> line.matches("declare -x (OLDPWD|PWD)(=.*)?"))); + assertTrue(snapshot.contains("declare -x KEEP=\"ok\"")); + assertTrue(snapshot.contains("alias ll='ls -l'")); + assertTrue(snapshot.contains("kept_function")); + + ShellEnvironmentHarness.SnapshotPlan shPlan = harness + .prepareSnapshot(workspace, "ses_1", "sh") + .orElseThrow(); + try (shPlan) { + assertNotEquals(bashPlan.captureFile(), shPlan.captureFile()); + assertNotEquals(bashPlan.snapshotFile(), shPlan.snapshotFile()); + assertEquals("sh", shPlan.command().getFirst()); + Files.writeString(shPlan.captureFile(), "export KEEP='sh'\n"); + harness.completeSnapshot(shPlan, success()); + } + assertTrue(harness.snapshotExists(workspace, "ses_1", "sh")); + } + + @Test + void commandPlanUsesSortedExplicitSourcesAndPosixSyntax() throws Exception { + ShellEnvironmentHarness harness = harness(); + Path workspace = Files.createDirectory(tempDir.resolve("workspace")); + createSnapshot(harness, workspace, "ses_1", "sh", "export SNAPSHOT_VALUE='ok'\n"); + Path envDir = harness.sessionDir(workspace, "ses_1").resolve(ShellEnvironmentHarness.ENV_DIR); + Files.createDirectories(envDir); + Path second = Files.writeString(envDir.resolve("02-second.sh"), "export SECOND=2\n"); + Path first = Files.writeString(envDir.resolve("01-first.sh"), "export FIRST=1\n"); + + try (ShellEnvironmentHarness.CommandPlan plan = harness.prepareCommand( + workspace, + "ses_1", + "sh", + "printf '%s' \"$SNAPSHOT_VALUE$FIRST$SECOND\"", + true + )) { + assertEquals("sh", plan.command().get(0)); + assertEquals("-c", plan.command().get(1)); + String wrapped = plan.command().get(2); + assertTrue(wrapped.contains(". '" + plan.snapshotFile() + "'"), wrapped); + assertTrue(wrapped.indexOf(first.toString()) < wrapped.indexOf(second.toString()), wrapped); + assertTrue(wrapped.contains("eval 'printf '"), wrapped); + assertTrue(wrapped.contains("pwd -P >| '" + plan.cwdCaptureFile() + "'"), wrapped); + assertFalse(wrapped.contains("source "), wrapped); + assertEquals(List.of(plan.snapshotFile(), first, second), plan.readOnlyFiles()); + assertEquals(List.of(plan.cwdCaptureFile()), plan.writableFiles()); + } + } + + @Test + void snapshotCaptureOverridesNoclobberForPrecreatedFile() throws Exception { + ShellEnvironmentHarness harness = harness(); + Path workspace = Files.createDirectory(tempDir.resolve("workspace-noclobber-snapshot")); + Path bashEnv = Files.writeString(tempDir.resolve("enable-noclobber.sh"), "set -C\n"); + ShellEnvironmentHarness.SnapshotPlan plan = harness + .prepareSnapshot(workspace, "ses_noclobber", "bash") + .orElseThrow(); + + try (plan) { + ExecutionResult result = new HostExecutor().execute( + new ExecutionRequest( + plan.command(), + workspace, + Map.of("BASH_ENV", bashEnv.toString()), + Duration.ofSeconds(5), + SandboxRuntimePolicy.disabled() + ), + ignored -> { + }, + () -> false + ); + + assertEquals(0, result.exitCode(), result.stderr()); + harness.completeSnapshot(plan, result); + } + + assertTrue(harness.snapshotExists(workspace, "ses_noclobber", "bash")); + } + + @Test + void nonLoginCommandDoesNotUseOrSourceExistingSnapshot() throws Exception { + ShellEnvironmentHarness harness = harness(); + Path workspace = Files.createDirectory(tempDir.resolve("workspace")); + createSnapshot(harness, workspace, "ses_1", "bash", "export SNAPSHOT_VALUE='ok'\n"); + + try (ShellEnvironmentHarness.CommandPlan plan = harness.prepareCommand( + workspace, + "ses_1", + "bash", + "echo hi", + false + )) { + assertEquals("-c", plan.command().get(1)); + assertFalse(plan.command().get(2).contains(plan.snapshotFile().toString())); + assertFalse(plan.readOnlyFiles().contains(plan.snapshotFile())); + } + } + + @Test + void commandPlansUseUniqueCwdFilesAndConsumeOnlyTheirOwnCapture() throws Exception { + ShellEnvironmentHarness harness = harness(); + Path workspace = Files.createDirectory(tempDir.resolve("workspace")); + Path nested = Files.createDirectory(workspace.resolve("dir with spaces")); + + try ( + ShellEnvironmentHarness.CommandPlan first = harness.prepareCommand( + workspace, + "ses_1", + "bash", + "pwd", + false + ); + ShellEnvironmentHarness.CommandPlan second = harness.prepareCommand( + workspace, + "ses_1", + "bash", + "pwd", + false + ) + ) { + assertNotEquals(first.cwdCaptureFile(), second.cwdCaptureFile()); + Files.writeString(first.cwdCaptureFile(), nested + "\n"); + + assertEquals(Optional.of(nested), harness.consumeCapturedCwd(first, workspace, workspace)); + assertFalse(Files.exists(first.cwdCaptureFile())); + assertTrue(Files.exists(second.cwdCaptureFile())); + assertEquals(Optional.empty(), harness.consumeCapturedCwd(second, workspace, workspace)); + } + } + + @Test + void capturedCwdRejectsOutsideMissingRelativeAndSymlinkEscapePaths() throws Exception { + ShellEnvironmentHarness harness = harness(); + Path workspace = Files.createDirectory(tempDir.resolve("workspace")); + Path outside = Files.createDirectory(tempDir.resolve("outside")); + Path escape = Files.createSymbolicLink(workspace.resolve("escape"), outside); + + for (String captured : List.of( + outside.toString(), + workspace.resolve("missing").toString(), + "relative", + escape.toString() + )) { + try (ShellEnvironmentHarness.CommandPlan plan = harness.prepareCommand( + workspace, + "ses_1", + "bash", + "pwd", + false + )) { + Files.writeString(plan.cwdCaptureFile(), captured + "\n"); + assertEquals(Optional.empty(), harness.consumeCapturedCwd(plan, workspace, workspace), captured); + assertFalse(Files.exists(plan.cwdCaptureFile())); + } + } + } + + @Test + void importEnvFileCopiesTrustedScriptOnce() throws IOException { + ShellEnvironmentHarness harness = harness(); + Path workspace = Files.createDirectory(tempDir.resolve("workspace")); + Path external = Files.writeString(tempDir.resolve("external.sh"), "export LYPI_EXTERNAL=1\n"); + + harness.importEnvFile( + workspace, + "ses_1", + Map.of(ShellEnvironmentHarness.ENV_FILE_VARIABLE, external.toString()) + ); + Files.writeString(external, "export LYPI_EXTERNAL=2\n"); + harness.importEnvFile( + workspace, + "ses_1", + Map.of(ShellEnvironmentHarness.ENV_FILE_VARIABLE, external.toString()) + ); + + List scripts = harness.envScripts(workspace, "ses_1"); + assertEquals(1, scripts.size()); + assertEquals("export LYPI_EXTERNAL=1", Files.readString(scripts.getFirst()).trim()); + } + + private void createSnapshot( + ShellEnvironmentHarness harness, + Path workspace, + String sessionId, + String shell, + String content + ) throws Exception { + ShellEnvironmentHarness.SnapshotPlan plan = harness + .prepareSnapshot(workspace, sessionId, shell) + .orElseThrow(); + try (plan) { + Files.writeString(plan.captureFile(), content); + harness.completeSnapshot(plan, success()); + } + } + + private ExecutionResult success() { + return new ExecutionResult(0, "", "", false, Optional.empty()); + } +} diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/WorkspacePathsTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/WorkspacePathsTest.java index 2c9ed530..926b13a3 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/builtin/WorkspacePathsTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/WorkspacePathsTest.java @@ -37,6 +37,27 @@ void resolvesMissingPathRelativeToWorkspaceAndRejectsTraversal() { assertTrue(exception.getMessage().contains("路径越过当前工作目录")); } + @Test + void resolvesFromDynamicCwdWithoutShrinkingWorkspaceBoundary() throws Exception { + Path nested = Files.createDirectories(tempDir.resolve("nested")); + ToolUseContext context = new ToolUseContext("ses_1", "msg_1", tempDir, nested, Map.of()); + + assertEquals( + nested.resolve("file.txt"), + WorkspacePaths.resolvePath(Map.of("path", "file.txt"), context, "path") + ); + assertEquals( + tempDir.resolve("root.txt"), + WorkspacePaths.resolvePath(Map.of("path", "../root.txt"), context, "path") + ); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> WorkspacePaths.resolvePath(Map.of("path", "../../outside.txt"), context, "path") + ); + assertTrue(exception.getMessage().contains("工作区")); + } + @Test void resolvesApprovedOutsidePathFromAdditionalPermissions(@TempDir Path outsideDir) { ToolUseContext context = context(additionalFileSystem(outsideDir, FileSystemAccessMode.WRITE)); diff --git a/lypi-tool/src/test/java/cn/lypi/tool/shell/BubblewrapCommandBuilderTest.java b/lypi-tool/src/test/java/cn/lypi/tool/shell/BubblewrapCommandBuilderTest.java index 9c196ecf..9cb5e4f6 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/shell/BubblewrapCommandBuilderTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/shell/BubblewrapCommandBuilderTest.java @@ -53,6 +53,34 @@ void buildsMinimalNetworkDisabledBwrapArgv() throws Exception { assertCommandSuffix(argv, List.of("bash", "-lc", "printf hello")); } + @Test + void reappliesExplicitTmpReadPathAfterPrivateTmpMount() throws Exception { + Path workspace = Files.createDirectory(tempDir.resolve("workspace-tmp-read")); + Path stateDir = Files.createDirectory(tempDir.resolve("shell-state")); + Path stateFile = Files.writeString(stateDir.resolve("env.sh"), "export VALUE=ok\n"); + SandboxRuntimePolicy policy = new SandboxRuntimePolicy( + List.of(Path.of("/usr"), Path.of("/bin"), stateFile), + List.of(), + List.of(workspace), + List.of(), + NetworkMode.DISABLED, + false, + false + ); + + List argv = BubblewrapCommandBuilder.defaults().build(request(workspace, policy)); + + int privateTmp = indexOfSequence(argv, "--tmpfs", "/tmp"); + int stateFileBind = lastIndexOfSequence( + argv, + "--ro-bind-try", + stateFile.toString(), + stateFile.toString() + ); + assertTrue(privateTmp >= 0, "sandbox must mount a private /tmp"); + assertTrue(stateFileBind > privateTmp, "explicit /tmp read paths must remain visible after the private /tmp mount"); + } + @Test void usesReadonlyFullRootWhenAllowReadContainsRoot() throws Exception { Path workspace = Files.createDirectory(tempDir.resolve("workspace"));