diff --git a/openspec/changes/align-subagent-loading-and-parent-context/proposal.md b/openspec/changes/align-subagent-loading-and-parent-context/proposal.md index 4fb394b74..b5e7881cf 100644 --- a/openspec/changes/align-subagent-loading-and-parent-context/proposal.md +++ b/openspec/changes/align-subagent-loading-and-parent-context/proposal.md @@ -43,6 +43,12 @@ creates avoidable drift between main-session and subagent behavior. - Align explicit `spawn_agent` delegation and declarative `metadata.subagent` routing so both paths use the same live-loaded registry and inherited parent context contract. +- Define inherited shell cwd snapshot for subagent executions so the child's + `ToolExecutionContext.InheritedCwd` captures the parent's resolved working + directory at spawn time, and pin down approval-gate behavior for subagent + invocations under both inherited and null cwd (folder-scoped grants match + under the parent's cwd; global grants match regardless of cwd, including + null). ## Capabilities @@ -61,6 +67,9 @@ creates avoidable drift between main-session and subagent behavior. subagent system prompts using the same file precedence as the parent session. - `skill-execution-routing`: align `metadata.subagent` routing with the same reloadable registry and parent-context inheritance behavior as `spawn_agent`. +- `tool-approval-gates`: pin down how the approval gate evaluates subagent + shell invocations under inherited and null cwd so persisted folder-scoped + and global grants match consistently with the parent session. ## Impact diff --git a/openspec/changes/align-subagent-loading-and-parent-context/specs/session-cwd/spec.md b/openspec/changes/align-subagent-loading-and-parent-context/specs/session-cwd/spec.md index 5b160bed7..5bc89c647 100644 --- a/openspec/changes/align-subagent-loading-and-parent-context/specs/session-cwd/spec.md +++ b/openspec/changes/align-subagent-loading-and-parent-context/specs/session-cwd/spec.md @@ -23,3 +23,60 @@ the child, and subagent execution SHALL NOT mutate the parent session's - **WHEN** a spawned subagent completes - **THEN** the parent session still has the same `ProjectDirectory` - **AND** no child-side action implicitly rewrites the parent working context + +### Requirement: Resolved shell cwd flows to spawned subagents as read-only snapshot + +When a session spawns a subagent, the runtime SHALL capture the parent's +*resolved* shell working directory at spawn time — equivalent to the value +`ToolExecutionContext.ResolveShellCwd(null)` returns on the parent's tool +execution context — and populate the child's +`ToolExecutionContext.InheritedCwd` with that value before any tool +authorization runs inside the child. `ToolExecutionContext.Cwd` remains the +per-call resolved output written by the approval gate when a concrete tool +invocation is evaluated. The inherited cwd SHALL be read-only from the child: +subagent execution SHALL NOT mutate the parent session's +`WorkingContext.ProjectDirectory` or otherwise rewrite the parent's own cwd +inputs. When the parent has no resolvable cwd at spawn time (no explicit +working directory, no `ProjectDirectory`, no `SessionDirectory`), the child's +`InheritedCwd` SHALL be `null`; the approval gate SHALL continue to evaluate +persisted global grants in that case as defined by the +`tool-approval-gates` capability. + +#### Scenario: Subagent inherits parent's resolved working directory + +- **GIVEN** a session whose parent `ToolExecutionContext.ResolveShellCwd(null)` resolves to + `/home/user/repos/foo` +- **WHEN** the session spawns a subagent +- **THEN** the subagent's `ToolExecutionContext.InheritedCwd` is + `/home/user/repos/foo` before its first tool invocation +- **AND** a `shell_execute` call inside the subagent with no + `WorkingDirectory` argument resolves cwd to `/home/user/repos/foo` for + approval purposes + +#### Scenario: Subagent shell approval shows the inherited cwd in the header + +- **GIVEN** the parent's resolved cwd at spawn time is `/home/user/repos/foo` +- **WHEN** the subagent invokes an approval-gated shell command with no + explicit `WorkingDirectory` argument +- **THEN** the approval prompt header reads + `Approve in /home/user/repos/foo?` +- **AND** the prompt header does NOT read + `Approve in (no working directory)?` + +#### Scenario: Subagent with no inheritable cwd surfaces null cwd faithfully + +- **GIVEN** the parent's `ToolExecutionContext.ResolveShellCwd(null)` returns + `null` (no explicit cwd, no `ProjectDirectory`, no `SessionDirectory`) +- **WHEN** the session spawns a subagent +- **THEN** the subagent's `ToolExecutionContext.InheritedCwd` is `null` +- **AND** the approval gate SHALL still evaluate persisted global grants + per the `tool-approval-gates` capability + +#### Scenario: Subagent does not mutate parent's working context + +- **GIVEN** a session with `WorkingContext.ProjectDirectory` = + `/home/user/repos/foo` and resolved cwd = `/home/user/repos/foo` +- **WHEN** a spawned subagent runs to completion (succeeds, fails, or times + out) +- **THEN** the parent session's `WorkingContext.ProjectDirectory` is unchanged +- **AND** no subagent action implicitly rewrites the parent working context diff --git a/openspec/changes/align-subagent-loading-and-parent-context/specs/tool-approval-gates/spec.md b/openspec/changes/align-subagent-loading-and-parent-context/specs/tool-approval-gates/spec.md new file mode 100644 index 000000000..05557a7ee --- /dev/null +++ b/openspec/changes/align-subagent-loading-and-parent-context/specs/tool-approval-gates/spec.md @@ -0,0 +1,72 @@ +## ADDED Requirements + +### Requirement: Subagent approval evaluation uses the inherited parent cwd + +The approval gate SHALL treat a subagent's `shell_execute` invocation as +having the cwd inherited from the parent session at spawn time, captured per +the `session-cwd` capability's "Resolved shell cwd flows to spawned subagents +as read-only snapshot" requirement. Persisted folder-scoped grants whose +directory contains the inherited cwd SHALL therefore auto-approve the +subagent invocation under the same rules as the parent session. Persisted +global grants (`directory: null`) SHALL continue to auto-approve regardless +of cwd, including when the inherited cwd is `null`. The matcher SHALL NOT +introduce a new short-circuit that bypasses persisted grants when the +inherited cwd is `null`; the existing +`ApprovalPatternMatching.MatchesShellApproval` semantics apply. + +#### Scenario: Folder-scoped parent grant covers subagent invocation + +- **GIVEN** `tool-approvals.json` contains + `{"verb":"dotnet build","directory":"/home/user/repos/foo/"}` +- **AND** the parent session's resolved cwd at subagent spawn is + `/home/user/repos/foo/` +- **WHEN** the spawned subagent invokes `dotnet build` with no explicit + `WorkingDirectory` argument +- **THEN** the matcher returns approved +- **AND** no approval prompt is rendered to the user + +#### Scenario: Global grant covers subagent invocation with null cwd + +- **GIVEN** `tool-approvals.json` contains + `{"verb":"netclaw stats","directory":null}` +- **AND** the spawned subagent has no inherited cwd (the parent had none + either) +- **WHEN** the subagent invokes `netclaw stats` +- **THEN** the matcher returns approved regardless of the null cwd +- **AND** no approval prompt is rendered + +#### Scenario: Folder-scoped parent grant does not match subagent with null cwd + +- **GIVEN** `tool-approvals.json` contains + `{"verb":"dotnet build","directory":"/home/user/repos/foo/"}` +- **AND** the spawned subagent has no inherited cwd +- **WHEN** the subagent invokes `dotnet build` with no explicit + `WorkingDirectory` argument +- **THEN** the folder-scoped grant SHALL NOT match (no effective directory) +- **AND** the approval gate prompts the user with the header form + `Approve dotnet build in (no working directory)?` as documented in this + capability's "Five-button approval prompt with verb-and-directory framing" + requirement +- **AND** the daemon log SHALL emit an `approval_near_miss` diagnostic with + reason `NoCandidateDirectory` so the operator can see why the grant did + not match + +### Requirement: Subagent inherits parent session-scoped approvals + +The approval gate SHALL walk from a subagent's scope id toward its parent +session and SHALL treat any session-scoped approval (a `This chat` click) +recorded against the parent session id as also authorizing the subagent's +verbs. The subagent scope id has the form +`{parentSessionId}/subagent/{name}/{runId}`; the walk SHALL terminate at the +first non-`/subagent/` segment so unrelated sessions never share +session-scoped approvals. This requirement codifies the existing +`ToolApprovalActor.IsSessionApproved` scope-walk behavior so future +refactors SHALL NOT regress it; it does not introduce a new code path. + +#### Scenario: This-chat grant in parent authorizes subagent invocation + +- **GIVEN** the parent session granted `This chat` for verb `gh pr view` in + the current chat +- **WHEN** a spawned subagent in that chat invokes `gh pr view 123` +- **THEN** the matcher returns approved via the session-scoped grant +- **AND** no approval prompt is rendered diff --git a/openspec/changes/align-subagent-loading-and-parent-context/tasks.md b/openspec/changes/align-subagent-loading-and-parent-context/tasks.md index 3c5a35c81..bf32a4bbc 100644 --- a/openspec/changes/align-subagent-loading-and-parent-context/tasks.md +++ b/openspec/changes/align-subagent-loading-and-parent-context/tasks.md @@ -39,3 +39,15 @@ - [ ] 6.4 `/opsx-verify align-subagent-loading-and-parent-context` after implementation lands. - [ ] 6.5 `/opsx-sync align-subagent-loading-and-parent-context` to merge the deltas into the main specs. - [ ] 6.6 `/opsx-archive align-subagent-loading-and-parent-context` after merge. + +## 7. Subagent cwd snapshot and approval-gate alignment + +- [x] 7.1 Extend `session-cwd` delta with the "Resolved shell cwd flows to spawned subagents" requirement and scenarios. +- [x] 7.2 Add a `tool-approval-gates` delta covering subagent approval evaluation under inherited and null cwd. +- [x] 7.3 Add `ParentCwd` to the `RunSubAgent` protocol record. +- [x] 7.4 Populate `ParentCwd` from `context.ResolveShellCwd(null)` in `SubAgentSpawner`. +- [x] 7.5 Initialize `_toolExecutionContext.InheritedCwd` from `msg.ParentCwd` in `SubAgentActor.Idle`. +- [x] 7.6 Add `ShellApprovalMatcherTests` regression case for null-cwd + null-candidateDirectory + null-entry-directory. +- [x] 7.7 Add `SubAgentActor` context-init test that asserts `ToolExecutionContext.InheritedCwd` equals `RunSubAgent.ParentCwd`. +- [x] 7.8 Add `SubAgentSpawner` propagation test that asserts the dispatched `RunSubAgent` carries the parent's resolved cwd. +- [x] 7.9 Run targeted tests and quality gates (slopwatch, file headers). diff --git a/src/Netclaw.Actors.Tests/Channels/Contracts/SessionBindingContractTests.cs b/src/Netclaw.Actors.Tests/Channels/Contracts/SessionBindingContractTests.cs index 1a980a729..64f693d5f 100644 --- a/src/Netclaw.Actors.Tests/Channels/Contracts/SessionBindingContractTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/Contracts/SessionBindingContractTests.cs @@ -480,6 +480,76 @@ await AwaitAssertAsync(() => }, cancellationToken: ct); } + [Fact] + public async Task Cold_text_approval_response_forwards_to_session_when_binding_cold_spawned() + { + var ct = TestContext.Current.CancellationToken; + var detector = new ConfigurablePromptInjectionDetector(PromptInjectionResult.Safe()); + var sid = new SessionId("session-cold-text-approve"); + + // Empty output stream: the binding never sees the original approval prompt, + // so any text reply must be forwarded through the cold-path fallback. + var pipeline = new RecordingSessionPipeline(_ => []); + + var actor = CreateBindingActor(sid, pipeline, detector); + + actor.Tell(CreateInboundMessage("A", "user-1"), TestActor); + + await AwaitAssertAsync(() => + { + var feedback = pipeline.RecordedFeedback.OfType().ToList(); + Assert.Single(feedback); + Assert.Equal(sid, feedback[0].SessionId); + Assert.Equal("A", feedback[0].Text); + Assert.Equal("user-1", feedback[0].SenderId.Value); + }, cancellationToken: ct); + } + + [Fact] + public async Task Text_approval_response_uses_visible_option_order_when_option_set_is_pruned() + { + var ct = TestContext.Current.CancellationToken; + var detector = new ConfigurablePromptInjectionDetector(PromptInjectionResult.Safe()); + var sid = new SessionId("session-text-approve-pruned"); + var pipeline = new RecordingSessionPipeline(_ => + [ + new ToolInteractionRequest + { + SessionId = sid, + Kind = "approval", + CallId = new Netclaw.Tools.ToolCallId("call-3b"), + ToolName = new Netclaw.Tools.ToolName("shell_execute"), + DisplayText = "git push origin main", + RequesterSenderId = new SenderId("user-1"), + Options = + [ + new ToolInteractionOption(ApprovalOptionKeys.ApproveOnceKey, ApprovalOptionKeys.ApproveOnceLabel), + new ToolInteractionOption(ApprovalOptionKeys.ApproveSessionKey, ApprovalOptionKeys.ApproveSessionLabel), + new ToolInteractionOption(ApprovalOptionKeys.ApproveEverywhereKey, ApprovalOptionKeys.ApproveEverywhereLabel), + new ToolInteractionOption(ApprovalOptionKeys.DenyKey, ApprovalOptionKeys.DenyLabel) + ] + } + ]); + + var actor = CreateBindingActor(sid, pipeline, detector); + + await AwaitAssertAsync(() => + { + var texts = GetPostedTexts(); + Assert.Contains(texts, t => t.Contains("shell_execute")); + }, cancellationToken: ct); + + actor.Tell(CreateInboundMessage("C", "user-1"), TestActor); + + await AwaitAssertAsync(() => + { + var feedback = pipeline.RecordedFeedback.OfType().ToList(); + Assert.Single(feedback); + Assert.Equal("call-3b", feedback[0].CallId.Value); + Assert.Equal(ApprovalOptionKeys.ApproveEverywhere, feedback[0].SelectedKey.Value); + }, cancellationToken: ct); + } + [Fact] public async Task Approvals_cleared_on_turn_completed() { diff --git a/src/Netclaw.Actors.Tests/Channels/MattermostApprovalPromptBuilderTests.cs b/src/Netclaw.Actors.Tests/Channels/MattermostApprovalPromptBuilderTests.cs index b7f9fee76..f01e3aad7 100644 --- a/src/Netclaw.Actors.Tests/Channels/MattermostApprovalPromptBuilderTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/MattermostApprovalPromptBuilderTests.cs @@ -26,6 +26,7 @@ public void BuildTextPrompt_contains_tool_name_and_options() new ToolInteractionOption(ApprovalOptionKeys.ApproveOnceKey, ApprovalOptionKeys.ApproveOnceLabel), new ToolInteractionOption(ApprovalOptionKeys.ApproveSessionKey, ApprovalOptionKeys.ApproveSessionLabel), new ToolInteractionOption(ApprovalOptionKeys.ApproveAlwaysKey, ApprovalOptionKeys.ApproveAlwaysLabel), + new ToolInteractionOption(ApprovalOptionKeys.ApproveEverywhereKey, ApprovalOptionKeys.ApproveEverywhereLabel), new ToolInteractionOption(ApprovalOptionKeys.DenyKey, ApprovalOptionKeys.DenyLabel) ] }; @@ -39,6 +40,8 @@ public void BuildTextPrompt_contains_tool_name_and_options() Assert.Contains("B)", prompt); Assert.Contains("C)", prompt); Assert.Contains("D)", prompt); + Assert.Contains("E)", prompt); + Assert.Contains(ApprovalOptionKeys.ApproveEverywhereLabel, prompt); } [Fact] @@ -145,7 +148,7 @@ public void BuildResolvedPromptText_omits_patterns_when_empty() } [Fact] - public void BuildButtonPrompt_produces_attachment_with_four_buttons() + public void BuildButtonPrompt_produces_attachment_with_five_buttons() { var request = CreateStandardRequest(); @@ -154,12 +157,12 @@ public void BuildButtonPrompt_produces_attachment_with_four_buttons() Assert.Contains("Tool approval required", text); Assert.Contains("git_push", text); - Assert.Contains("reply with `A`, `B`, `C`, or `D`", text); + Assert.Contains("reply with `A`, `B`, `C`, `D`, `E`", text); Assert.Single(attachments); var attachment = attachments[0]; Assert.NotNull(attachment.Actions); - Assert.Equal(4, attachment.Actions!.Count); + Assert.Equal(5, attachment.Actions!.Count); } [Fact] @@ -293,6 +296,7 @@ private static ToolInteractionRequest CreateStandardRequest() new ToolInteractionOption(ApprovalOptionKeys.ApproveOnceKey, ApprovalOptionKeys.ApproveOnceLabel), new ToolInteractionOption(ApprovalOptionKeys.ApproveSessionKey, ApprovalOptionKeys.ApproveSessionLabel), new ToolInteractionOption(ApprovalOptionKeys.ApproveAlwaysKey, ApprovalOptionKeys.ApproveAlwaysLabel), + new ToolInteractionOption(ApprovalOptionKeys.ApproveEverywhereKey, ApprovalOptionKeys.ApproveEverywhereLabel), new ToolInteractionOption(ApprovalOptionKeys.DenyKey, ApprovalOptionKeys.DenyLabel) ] }; diff --git a/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs b/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs index 9777c2c17..4cdb8f410 100644 --- a/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs +++ b/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs @@ -714,6 +714,7 @@ public void ToolApprovalRequested_round_trips_all_persisted_context() RequesterSenderId = new SenderId("U12345"), RequesterPrincipal = Netclaw.Configuration.PrincipalClassification.Operator, Cwd = "/home/user/project", + OptionKeys = [ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.ApproveEverywhere, ApprovalOptionKeys.Deny], Candidates = [ new ToolApprovalRequested.ApprovalCandidateRecord { Verb = "git", Directory = "/home/user/project" }, @@ -736,6 +737,7 @@ public void ToolApprovalRequested_round_trips_all_persisted_context() Assert.Equal(wrapped.RequesterSenderId, result.RequesterSenderId); Assert.Equal(wrapped.RequesterPrincipal, result.RequesterPrincipal); Assert.Equal(wrapped.Cwd, result.Cwd); + Assert.Equal(wrapped.OptionKeys, result.OptionKeys); Assert.Equal(2, result.Candidates.Count); Assert.Equal("git", result.Candidates[0].Verb); Assert.Equal("/home/user/project", result.Candidates[0].Directory); diff --git a/src/Netclaw.Actors.Tests/Protocol/ToolInteractionResponseParserTests.cs b/src/Netclaw.Actors.Tests/Protocol/ToolInteractionResponseParserTests.cs index 4652d953d..d89efab56 100644 --- a/src/Netclaw.Actors.Tests/Protocol/ToolInteractionResponseParserTests.cs +++ b/src/Netclaw.Actors.Tests/Protocol/ToolInteractionResponseParserTests.cs @@ -21,27 +21,79 @@ public sealed class ToolInteractionResponseParserTests [InlineData("c", ApprovalOptionKeys.ApproveAlways)] [InlineData("3", ApprovalOptionKeys.ApproveAlways)] [InlineData("always", ApprovalOptionKeys.ApproveAlways)] - [InlineData("d", ApprovalOptionKeys.Deny)] - [InlineData("4", ApprovalOptionKeys.Deny)] + [InlineData("d", ApprovalOptionKeys.ApproveEverywhere)] + [InlineData("4", ApprovalOptionKeys.ApproveEverywhere)] + [InlineData("approve everywhere", ApprovalOptionKeys.ApproveEverywhere)] + [InlineData("always anywhere", ApprovalOptionKeys.ApproveEverywhere)] + [InlineData("e", ApprovalOptionKeys.Deny)] + [InlineData("5", ApprovalOptionKeys.Deny)] [InlineData("reject", ApprovalOptionKeys.Deny)] public void Parses_deterministic_approval_keywords(string input, string expected) { - var ok = ToolInteractionResponseParser.TryParseApprovalResponse(input, out var selectedKey); + var ok = ToolInteractionResponseParser.TryParseApprovalResponse(input, CreateFiveButtonOptions(), out var selectedKey); Assert.True(ok); Assert.Equal(expected, selectedKey); } + [Fact] + public void Letter_mapping_uses_visible_option_order_when_always_here_is_pruned() + { + var ok = ToolInteractionResponseParser.TryParseApprovalResponse( + "c", + [ + new ToolInteractionOption(ApprovalOptionKeys.ApproveOnceKey, ApprovalOptionKeys.ApproveOnceLabel), + new ToolInteractionOption(ApprovalOptionKeys.ApproveSessionKey, ApprovalOptionKeys.ApproveSessionLabel), + new ToolInteractionOption(ApprovalOptionKeys.ApproveEverywhereKey, ApprovalOptionKeys.ApproveEverywhereLabel), + new ToolInteractionOption(ApprovalOptionKeys.DenyKey, ApprovalOptionKeys.DenyLabel) + ], + out var selectedKey); + + Assert.True(ok); + Assert.Equal(ApprovalOptionKeys.ApproveEverywhere, selectedKey); + } + + [Theory] + [InlineData("A")] + [InlineData("5")] + [InlineData("approve everywhere")] + public void LooksLikeApprovalResponse_accepts_common_cold_path_inputs(string input) + => Assert.True(ToolInteractionResponseParser.LooksLikeApprovalResponse(input)); + + [Theory] + [InlineData("")] + [InlineData("maybe later")] + [InlineData("ship it")] + public void LooksLikeApprovalResponse_rejects_normal_chat_text(string input) + => Assert.False(ToolInteractionResponseParser.LooksLikeApprovalResponse(input)); + [Theory] [InlineData("")] [InlineData(" ")] [InlineData("maybe")] [InlineData("approve later")] + [InlineData("approve everywhere")] public void Rejects_unrecognized_responses(string input) { - var ok = ToolInteractionResponseParser.TryParseApprovalResponse(input, out var selectedKey); + var ok = ToolInteractionResponseParser.TryParseApprovalResponse( + input, + [ + new ToolInteractionOption(ApprovalOptionKeys.ApproveOnceKey, ApprovalOptionKeys.ApproveOnceLabel), + new ToolInteractionOption(ApprovalOptionKeys.DenyKey, ApprovalOptionKeys.DenyLabel) + ], + out var selectedKey); Assert.False(ok); Assert.Null(selectedKey); } + + private static IReadOnlyList CreateFiveButtonOptions() + => + [ + new ToolInteractionOption(ApprovalOptionKeys.ApproveOnceKey, ApprovalOptionKeys.ApproveOnceLabel), + new ToolInteractionOption(ApprovalOptionKeys.ApproveSessionKey, ApprovalOptionKeys.ApproveSessionLabel), + new ToolInteractionOption(ApprovalOptionKeys.ApproveAlwaysKey, ApprovalOptionKeys.ApproveAlwaysLabel), + new ToolInteractionOption(ApprovalOptionKeys.ApproveEverywhereKey, ApprovalOptionKeys.ApproveEverywhereLabel), + new ToolInteractionOption(ApprovalOptionKeys.DenyKey, ApprovalOptionKeys.DenyLabel) + ]; } diff --git a/src/Netclaw.Actors.Tests/Sessions/ApprovalRehydrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/ApprovalRehydrationTests.cs index f36ed60e9..30ada0c5f 100644 --- a/src/Netclaw.Actors.Tests/Sessions/ApprovalRehydrationTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/ApprovalRehydrationTests.cs @@ -150,6 +150,139 @@ await subscriberB.ExpectNoMsgAsync( TimeSpan.FromMilliseconds(300), TestContext.Current.CancellationToken); } + [Fact] + public async Task Pending_approval_rejects_option_that_was_not_offered() + { + const string callId = "call-shell-invalid-option"; + _toolExecutor.GatedTools.Add("shell_execute"); + + _fakeChatClient.ToolCallsOnFirstCall = + [ + new FunctionCallContent(callId, "shell_execute", + new Dictionary { ["command"] = "git status" }) + ]; + + var sessionId = new SessionId("test-channel/invalid-approval-option"); + var sessionManager = ActorRegistry.Get(); + var subscriber = CreateTestProbe("invalid-option-sub"); + + await sessionManager.Ask(new JoinSession(subscriber) + { + SessionId = sessionId, + Filter = OutputFilter.Full + }, TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + await subscriber.ExpectMsgAsync(cancellationToken: TestContext.Current.CancellationToken); + + await sessionManager.Ask(new SendUserMessage + { + SessionId = sessionId, + Content = "Run git status" + }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); + var request = await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(callId, request.CallId.Value); + Assert.Equal([ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.Deny], request.Options.Select(o => o.Key.Value).ToArray()); + + var invalidReply = await sessionManager.Ask(new ToolInteractionResponse + { + SessionId = sessionId, + CallId = new Netclaw.Tools.ToolCallId(callId), + SelectedKey = new ApprovalOptionKey(ApprovalOptionKeys.ApproveAlways), + SenderId = new SenderId("local-user") + }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + var nack = Assert.IsType(invalidReply); + Assert.Equal("approval_option_unavailable", nack.Reason); + + var warning = await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains("not available", warning.Text, StringComparison.OrdinalIgnoreCase); + + var validReply = await sessionManager.Ask(new ToolInteractionResponse + { + SessionId = sessionId, + CallId = new Netclaw.Tools.ToolCallId(callId), + SelectedKey = new ApprovalOptionKey(ApprovalOptionKeys.ApproveOnce), + SenderId = new SenderId("local-user") + }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.IsType(validReply); + await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); + await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); + await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(1, _toolExecutor.SuccessfulExecutions); + } + + [Fact] + public async Task Passivated_session_resumes_tool_batch_when_cold_text_approval_arrives() + { + const string callId = "call-shell-text-cold-1"; + _toolExecutor.GatedTools.Add("shell_execute"); + + _fakeChatClient.ToolCallsOnFirstCall = + [ + new FunctionCallContent(callId, "shell_execute", + new Dictionary { ["command"] = "git status" }) + ]; + + var sessionId = new SessionId("test-channel/cold-text-after-passivation"); + var sessionManager = ActorRegistry.Get(); + var subscriber = CreateTestProbe("cold-text-sub"); + + await sessionManager.Ask(new JoinSession(subscriber) + { + SessionId = sessionId, + Filter = OutputFilter.Full + }, TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + await subscriber.ExpectMsgAsync(cancellationToken: TestContext.Current.CancellationToken); + + await sessionManager.Ask(new SendUserMessage + { + SessionId = sessionId, + Content = "Run git status" + }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); + var request = await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(callId, request.CallId.Value); + Assert.Equal([ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.Deny], request.Options.Select(o => o.Key.Value).ToArray()); + + await ColdRespawnAsync(sessionId); + + var subscriberB = CreateTestProbe("cold-text-sub-b"); + await sessionManager.Ask(new JoinSession(subscriberB) + { + SessionId = sessionId, + Filter = OutputFilter.Full + }, TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + await subscriberB.ExpectMsgAsync(cancellationToken: TestContext.Current.CancellationToken); + + var reply = await sessionManager.Ask(new ToolInteractionTextResponse + { + SessionId = sessionId, + Text = "A", + SenderId = new SenderId("local-user") + }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.IsType(reply); + await subscriberB.ExpectMsgAsync( + TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); + await subscriberB.ExpectMsgAsync( + TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); + var completed = await subscriberB.ExpectMsgAsync( + TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(TurnOutcome.Completed, completed.Outcome); + Assert.Equal(1, _toolExecutor.SuccessfulExecutions); + } + [Fact] public async Task Recovered_batch_does_not_reexecute_completed_sibling_tool_call() { diff --git a/src/Netclaw.Actors.Tests/Sessions/ParentSessionApprovalBridgeTests.cs b/src/Netclaw.Actors.Tests/Sessions/ParentSessionApprovalBridgeTests.cs index 8d18517e2..6eea81289 100644 --- a/src/Netclaw.Actors.Tests/Sessions/ParentSessionApprovalBridgeTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/ParentSessionApprovalBridgeTests.cs @@ -38,6 +38,15 @@ public async Task Bridge_preserves_requester_identity_and_adopted_context() "grep timeout logs/app.log | wc -l", ["grep timeout logs/app.log | wc -l"], ["grep timeout logs/app.log"], + [new ParentApprovalCandidate("grep timeout logs/app.log", "/home/user/repos/foo")], + "/home/user/repos/foo", + [ + new ParentApprovalOption(ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.ApproveOnceLabel), + new ParentApprovalOption(ApprovalOptionKeys.ApproveSession, ApprovalOptionKeys.ApproveSessionLabel), + new ParentApprovalOption(ApprovalOptionKeys.ApproveAlways, ApprovalOptionKeys.ApproveAlwaysLabel), + new ParentApprovalOption(ApprovalOptionKeys.ApproveEverywhere, ApprovalOptionKeys.ApproveEverywhereLabel), + new ParentApprovalOption(ApprovalOptionKeys.Deny, ApprovalOptionKeys.DenyLabel), + ], isMessy: false, TestContext.Current.CancellationToken); @@ -51,8 +60,12 @@ public async Task Bridge_preserves_requester_identity_and_adopted_context() Assert.Equal(["user-123", "user-456"], emitted.AdoptedSpeakerIds); Assert.Equal(["grep timeout logs/app.log | wc -l"], emitted.Patterns); Assert.Equal(["grep timeout logs/app.log"], emitted.CandidateVerbs); + Assert.Equal("/home/user/repos/foo", emitted.Cwd); + Assert.Single(emitted.Candidates); + Assert.Equal("/home/user/repos/foo", emitted.Candidates[0].Directory); Assert.Equal(ApprovalOptionKeys.ApproveSessionLabel, emitted.Options.Single(o => o.Key.Value == ApprovalOptionKeys.ApproveSession).Label); Assert.Equal(ApprovalOptionKeys.ApproveAlwaysLabel, emitted.Options.Single(o => o.Key.Value == ApprovalOptionKeys.ApproveAlways).Label); + Assert.Equal(ApprovalOptionKeys.ApproveEverywhereLabel, emitted.Options.Single(o => o.Key.Value == ApprovalOptionKeys.ApproveEverywhere).Label); } [Fact] @@ -80,6 +93,9 @@ public async Task Bridge_preserves_self_only_adopted_context_without_third_party "cat logs/app.log", ["cat logs/app.log"], ["cat logs/app.log"], + [new ParentApprovalCandidate("cat logs/app.log", null)], + cwd: null, + [new ParentApprovalOption(ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.ApproveOnceLabel)], isMessy: false, TestContext.Current.CancellationToken); diff --git a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs index 513330630..76820ed10 100644 --- a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs +++ b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs @@ -12,6 +12,7 @@ using Netclaw.Actors.SubAgents; using Netclaw.Actors.Tools; using Netclaw.Actors.Tests.Memory; +using ApprovalOptionKeys = Netclaw.Actors.Protocol.ApprovalOptionKeys; using Netclaw.Configuration; using Netclaw.Security; using Netclaw.Tools; @@ -162,6 +163,97 @@ public async Task Tool_execution_with_no_parent_project_directory_passes_null_th Assert.Null(fakeTool.LastContext.ProjectDirectory); } + [Fact] + public async Task Tool_execution_inherits_parent_resolved_cwd_snapshot() + { + var fakeTool = new FakeNetclawTool("inspect_context", "ok"); + var fakeClient = new FakeChatClient + { + ToolCallsOnFirstCall = [new FunctionCallContent("call-cwd", "inspect_context")] + }; + + var agent = Sys.ActorOf(SubAgentActor.CreateProps(CreateDefinition([fakeTool]), fakeClient)); + + var result = await agent.Ask( + new RunSubAgent + { + Task = "Inspect inherited cwd.", + Timeout = TimeSpan.FromSeconds(5), + ParentSessionDirectory = "/tmp/netclaw/sessions/parent", + ParentProjectDirectory = "/home/user/repos/foo", + ParentCwd = "/home/user/repos/foo", + Audience = TrustAudience.Personal, + }, + TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.NotNull(fakeTool.LastContext); + Assert.Equal("/home/user/repos/foo", fakeTool.LastContext!.InheritedCwd); + // ProjectDirectory wins the resolve when set; this asserts that the + // inherited snapshot doesn't shadow it. + Assert.Equal("/home/user/repos/foo", fakeTool.LastContext.ResolveShellCwd(null)); + } + + [Fact] + public async Task Tool_execution_with_null_parent_cwd_resolves_to_session_dir_or_null() + { + var fakeTool = new FakeNetclawTool("inspect_context", "ok"); + var fakeClient = new FakeChatClient + { + ToolCallsOnFirstCall = [new FunctionCallContent("call-null-cwd", "inspect_context")] + }; + + var agent = Sys.ActorOf(SubAgentActor.CreateProps(CreateDefinition([fakeTool]), fakeClient)); + + var result = await agent.Ask( + new RunSubAgent + { + Task = "Inspect null cwd.", + Timeout = TimeSpan.FromSeconds(5), + ParentCwd = null, + Audience = TrustAudience.Personal, + }, + TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.NotNull(fakeTool.LastContext); + Assert.Null(fakeTool.LastContext!.InheritedCwd); + Assert.Null(fakeTool.LastContext.ResolveShellCwd(null)); + } + + [Fact] + public async Task Tool_execution_inherits_parent_cwd_when_child_has_no_project_or_session_dir() + { + // The original bug shape: a sub-agent whose parent had a resolved cwd + // but no ProjectDirectory/SessionDirectory propagating to the child. + // InheritedCwd is the only path that surfaces the parent's effective + // working directory to the approval gate; without it, the prompt + // header reads "(no working directory)". + var fakeTool = new FakeNetclawTool("inspect_context", "ok"); + var fakeClient = new FakeChatClient + { + ToolCallsOnFirstCall = [new FunctionCallContent("call-inherit-only", "inspect_context")] + }; + + var agent = Sys.ActorOf(SubAgentActor.CreateProps(CreateDefinition([fakeTool]), fakeClient)); + + var result = await agent.Ask( + new RunSubAgent + { + Task = "Inspect inherited cwd with no other sources.", + Timeout = TimeSpan.FromSeconds(5), + ParentSessionDirectory = null, + ParentProjectDirectory = null, + ParentCwd = "/home/user/repos/foo", + Audience = TrustAudience.Personal, + }, + TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.NotNull(fakeTool.LastContext); + Assert.Equal("/home/user/repos/foo", fakeTool.LastContext!.ResolveShellCwd(null)); + } + [Fact] public async Task Each_spawn_snapshots_its_own_parent_project_directory() { @@ -283,6 +375,68 @@ public async Task Approval_gated_tool_is_denied_inside_subagent() Assert.Contains("Response #2", result.Output); } + [Fact] + public async Task Subagent_approval_request_carries_cwd_candidates_and_full_button_set() + { + // Regression: the parent approval bridge previously hardcoded a + // 4-button list and dropped Cwd/Candidates entirely, so sub-agent + // approval prompts showed "(no working directory)" and were missing + // the Always-anywhere button regardless of what the sub-agent's + // resolved cwd was. + var fakeTool = new FakeNetclawTool("shell_execute", "ok"); + var toolConfig = new ToolConfig { ShellMode = ShellExecutionMode.HostAllowed }; + toolConfig.AudienceProfiles.Personal.ApprovalPolicy = new ToolApprovalConfig + { + ToolOverrides = new Dictionary(StringComparer.Ordinal) + { + ["shell_execute"] = ToolApprovalMode.Approval + } + }; + var policy = new ToolAccessPolicy( + toolConfig, + new EffectivePolicyDefaults( + DeploymentPosture.Personal, + TrustAudience.Personal, + ShellExecutionMode.HostAllowed, + UsedStrictFallback: false), + new ShellCommandPolicy()); + + var fakeClient = new FakeChatClient + { + ToolCallsOnFirstCall = + [ + new FunctionCallContent("call-cwd-prompt", "shell_execute", + new Dictionary { ["Command"] = "git push origin main" }) + ] + }; + + var approvalBridge = new RecordingParentApprovalBridge(ParentApprovalDecision.ApprovedOnce); + var definition = CreateDefinition([fakeTool]); + var agent = Sys.ActorOf(SubAgentActor.CreateProps(definition, fakeClient, policy, approvalService: null)); + + var result = await agent.Ask( + new RunSubAgent + { + Task = "Push to origin", + Timeout = TimeSpan.FromSeconds(5), + Audience = TrustAudience.Personal, + ParentSessionDirectory = "/tmp/netclaw/sessions/parent", + ParentProjectDirectory = "/home/user/repos/foo", + ParentCwd = "/home/user/repos/foo", + ApprovalBridge = approvalBridge, + }, + TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Equal(1, approvalBridge.RequestCount); + Assert.Equal("/home/user/repos/foo", approvalBridge.RequestedCwd); + Assert.Single(approvalBridge.RequestedCandidates); + Assert.Equal("git push origin main", approvalBridge.RequestedCandidates[0].Verb); + Assert.Contains(approvalBridge.RequestedOptions, o => o.Key == ApprovalOptionKeys.ApproveEverywhere); + Assert.Contains(approvalBridge.RequestedOptions, o => o.Key == ApprovalOptionKeys.ApproveAlways); + Assert.Contains(approvalBridge.RequestedOptions, o => o.Key == ApprovalOptionKeys.ApproveSession); + } + [Fact] public async Task Approve_once_does_not_leak_between_subagent_tool_calls() { @@ -691,6 +845,9 @@ internal sealed class RecordingParentApprovalBridge(ParentApprovalDecision decis { public int RequestCount { get; private set; } public List RequestedPatterns { get; } = []; + public string? RequestedCwd { get; private set; } + public IReadOnlyList RequestedCandidates { get; private set; } = []; + public IReadOnlyList RequestedOptions { get; private set; } = []; public Task RequestApprovalAsync( ToolCallId callId, @@ -698,11 +855,17 @@ public Task RequestApprovalAsync( string displayText, IReadOnlyList patterns, IReadOnlyList candidateVerbs, + IReadOnlyList candidates, + string? cwd, + IReadOnlyList options, bool isMessy, CancellationToken ct) { RequestCount++; RequestedPatterns.AddRange(patterns); + RequestedCwd = cwd; + RequestedCandidates = candidates; + RequestedOptions = options; return Task.FromResult(decisionToReturn); } } diff --git a/src/Netclaw.Actors.Tests/SubAgents/SubAgentSpawnerTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SubAgentSpawnerTests.cs new file mode 100644 index 000000000..2edec5489 --- /dev/null +++ b/src/Netclaw.Actors.Tests/SubAgents/SubAgentSpawnerTests.cs @@ -0,0 +1,114 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Akka.Actor; +using Akka.Hosting; +using Akka.Hosting.TestKit; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Netclaw.Actors.SubAgents; +using Netclaw.Actors.Tests.Memory; +using Netclaw.Actors.Tools; +using Netclaw.Configuration; +using Netclaw.Security; +using Netclaw.Tools; +using Xunit; + +namespace Netclaw.Actors.Tests.SubAgents; + +public sealed class SubAgentSpawnerTests : TestKit +{ + public SubAgentSpawnerTests(ITestOutputHelper output) : base(output: output) { } + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + // No hosting or persistence needed; the probe stands in for the child actor. + } + + [Fact] + public async Task Spawn_async_propagates_parent_resolved_cwd_on_run_message() + { + var toolRegistry = new ToolRegistry(); + toolRegistry.Register(new FakeNetclawTool("inspect_context", "ok")); + + var spawner = new SubAgentSpawner( + new SingleClientProvider(new NoOpChatClient()), + toolRegistry, + new ToolAccessPolicy( + new ToolConfig(), + new EffectivePolicyDefaults( + DeploymentPosture.Personal, + TrustAudience.Personal, + ShellExecutionMode.HostAllowed, + UsedStrictFallback: false), + new ShellCommandPolicy()), + approvalService: null, + new StaticSystemPromptProvider("You are a summarizer."), + NullLogger.Instance); + + var childProbe = CreateTestProbe("subagent-child"); + var context = new ToolExecutionContext("console/subagent-parent", "/tmp/netclaw/sessions/parent") + { + Audience = TrustAudience.Personal, + ProjectDirectory = "/home/user/repos/foo" + }; + context.SpawnChildActor = (_, _, _) => Task.FromResult(childProbe.Ref); + + var profile = new SubAgentProfile + { + Name = "summarizer", + Description = "Summarize content", + SystemPrompt = "You are a summarizer.", + ToolNames = ["inspect_context"], + Visibility = SubAgentVisibility.UserFacing + }; + + var spawnTask = spawner.SpawnAsync( + profile, + "Summarize the repo.", + runtimeContext: null, + context, + TestContext.Current.CancellationToken); + + var run = await childProbe.ExpectMsgAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("/tmp/netclaw/sessions/parent", run.ParentSessionDirectory); + Assert.Equal("/home/user/repos/foo", run.ParentProjectDirectory); + Assert.Equal("/home/user/repos/foo", run.ParentCwd); + + childProbe.Reply(new SubAgentResult + { + Success = true, + Output = "ok", + AgentName = new AgentName(profile.Name) + }); + + var result = await spawnTask; + Assert.True(result.Success); + } + + private sealed class NoOpChatClient : IChatClient + { + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + => Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "noop"))); + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + yield break; + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + } + } +} diff --git a/src/Netclaw.Actors.Tests/SubAgents/ToolExecutionContextResolveShellCwdTests.cs b/src/Netclaw.Actors.Tests/SubAgents/ToolExecutionContextResolveShellCwdTests.cs new file mode 100644 index 000000000..909498803 --- /dev/null +++ b/src/Netclaw.Actors.Tests/SubAgents/ToolExecutionContextResolveShellCwdTests.cs @@ -0,0 +1,96 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Configuration; +using Netclaw.Tools; +using Xunit; + +namespace Netclaw.Actors.Tests.SubAgents; + +/// +/// Locks in the fallback +/// order, including the +/// last-resort branch added so a sub-agent's parent-cwd snapshot is visible +/// to the approval gate when neither ProjectDirectory nor +/// SessionDirectory is available on the child. +/// +public class ToolExecutionContextResolveShellCwdTests +{ + [Fact] + public void Explicit_arg_wins_over_all_other_sources() + { + var context = new ToolExecutionContext("sess", "/tmp/sess") + { + Audience = TrustAudience.Personal, + ProjectDirectory = "/home/user/repos/foo", + InheritedCwd = "/home/user/repos/inherited", + }; + + Assert.Equal("/explicit/arg", context.ResolveShellCwd("/explicit/arg")); + } + + [Fact] + public void ProjectDirectory_wins_over_session_directory_and_inherited_cwd() + { + var context = new ToolExecutionContext("sess", "/tmp/sess") + { + Audience = TrustAudience.Personal, + ProjectDirectory = "/home/user/repos/foo", + InheritedCwd = "/home/user/repos/inherited", + }; + + Assert.Equal("/home/user/repos/foo", context.ResolveShellCwd(null)); + } + + [Fact] + public void SessionDirectory_wins_over_inherited_cwd() + { + var context = new ToolExecutionContext("sess", "/tmp/sess") + { + Audience = TrustAudience.Personal, + InheritedCwd = "/home/user/repos/inherited", + }; + + Assert.Equal("/tmp/sess", context.ResolveShellCwd(null)); + } + + [Fact] + public void Inherited_cwd_is_last_resort_fallback_before_null() + { + var context = new ToolExecutionContext("sess", sessionDirectory: null) + { + Audience = TrustAudience.Personal, + InheritedCwd = "/home/user/repos/inherited", + }; + + Assert.Equal("/home/user/repos/inherited", context.ResolveShellCwd(null)); + } + + [Fact] + public void Cwd_output_field_does_not_feed_resolve() + { + // Cwd is the per-call resolved output the approval gate writes; it + // must not feed back into ResolveShellCwd or a stale value could + // shadow a later ProjectDirectory/SessionDirectory change. + var context = new ToolExecutionContext("sess", sessionDirectory: null) + { + Audience = TrustAudience.Personal, + Cwd = "/stale/cwd", + }; + + Assert.Null(context.ResolveShellCwd(null)); + } + + [Fact] + public void Returns_null_when_no_source_is_available() + { + var context = new ToolExecutionContext("sess", sessionDirectory: null) + { + Audience = TrustAudience.Personal, + }; + + Assert.Null(context.ResolveShellCwd(null)); + } +} diff --git a/src/Netclaw.Actors/Protocol/Commands.cs b/src/Netclaw.Actors/Protocol/Commands.cs index 6bbc492d7..ad4d5a7a5 100644 --- a/src/Netclaw.Actors/Protocol/Commands.cs +++ b/src/Netclaw.Actors/Protocol/Commands.cs @@ -74,3 +74,23 @@ public sealed record ToolInteractionResponse : IWithSessionId, INoSerializationV /// public required SenderId SenderId { get; init; } } + +/// +/// Text-only approval reply for a pending +/// when the channel binding does not have the original prompt state locally. +/// The session resolves the applicable pending interaction from its own +/// journal-backed state and parses the text against that prompt's option order. +/// +public sealed record ToolInteractionTextResponse : IWithSessionId, INoSerializationVerificationNeeded +{ + public required SessionId SessionId { get; init; } + + /// The raw user reply, e.g. A or approve everywhere. + public required string Text { get; init; } + + /// + /// Identity of the user who sent the text reply. Used to resolve which + /// pending prompt they are allowed to answer. + /// + public required SenderId SenderId { get; init; } +} diff --git a/src/Netclaw.Actors/Protocol/Events.cs b/src/Netclaw.Actors/Protocol/Events.cs index a2b1fd10d..53d98cef3 100644 --- a/src/Netclaw.Actors/Protocol/Events.cs +++ b/src/Netclaw.Actors/Protocol/Events.cs @@ -107,6 +107,8 @@ public sealed record ApprovalCandidateRecord public string? Cwd { get; init; } + public IReadOnlyList OptionKeys { get; init; } = Array.Empty(); + public IReadOnlyList Candidates { get; init; } = Array.Empty(); diff --git a/src/Netclaw.Actors/Protocol/ToolInteractionResponseParser.cs b/src/Netclaw.Actors/Protocol/ToolInteractionResponseParser.cs index 222c905be..cf59e179a 100644 --- a/src/Netclaw.Actors/Protocol/ToolInteractionResponseParser.cs +++ b/src/Netclaw.Actors/Protocol/ToolInteractionResponseParser.cs @@ -12,21 +12,96 @@ namespace Netclaw.Actors.Protocol; /// public static class ToolInteractionResponseParser { - public static bool TryParseApprovalResponse(string text, out string? selectedKey) + public static bool LooksLikeApprovalResponse(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return false; + + var trimmed = text.Trim().ToLowerInvariant(); + + if (trimmed.Length == 1) + { + var ch = trimmed[0]; + if (ch is >= 'a' and <= 'e') + return true; + } + + if (int.TryParse(trimmed, out var numericIndex) + && numericIndex >= 1 + && numericIndex <= 5) + { + return true; + } + + return TryParseNamedSelection(trimmed, out _); + } + + public static bool TryParseApprovalResponse( + string text, + IReadOnlyList options, + out string? selectedKey) { selectedKey = null; - if (string.IsNullOrWhiteSpace(text)) + if (string.IsNullOrWhiteSpace(text) || options.Count == 0) return false; var trimmed = text.Trim().ToLowerInvariant(); + if (TryParseIndexedSelection(trimmed, options, out selectedKey)) + return true; + + if (!TryParseNamedSelection(trimmed, out var parsedKey)) + return false; + + if (!options.Any(option => string.Equals(option.Key.Value, parsedKey, StringComparison.Ordinal))) + return false; + + selectedKey = parsedKey; + return true; + } + + private static bool TryParseIndexedSelection( + string trimmed, + IReadOnlyList options, + out string? selectedKey) + { + selectedKey = null; + + if (trimmed.Length == 1) + { + var ch = trimmed[0]; + if (ch is >= 'a' and <= 'z') + { + var index = ch - 'a'; + if (index < options.Count) + { + selectedKey = options[index].Key.Value; + return true; + } + } + } + + if (int.TryParse(trimmed, out var numericIndex) + && numericIndex >= 1 + && numericIndex <= options.Count) + { + selectedKey = options[numericIndex - 1].Key.Value; + return true; + } + + return false; + } + + private static bool TryParseNamedSelection(string trimmed, out string? selectedKey) + { selectedKey = trimmed switch { "a" or "1" or "approve" or "approve once" or "approve_once" or "once" or "yes" => ApprovalOptionKeys.ApproveOnce, "b" or "2" or "approve session" or "approve_session" or "session" or "approve for this chat" or "this chat" or "approve for this thread" or "this thread" => ApprovalOptionKeys.ApproveSession, - "c" or "3" or "approve always" or "approve_always" or "always" => ApprovalOptionKeys.ApproveAlways, - "d" or "4" or "deny" or "no" or "reject" => ApprovalOptionKeys.Deny, + "approve always" or "approve_always" or "always" or "always here" => ApprovalOptionKeys.ApproveAlways, + "approve everywhere" or "approve_everywhere" or "everywhere" or "always anywhere" => ApprovalOptionKeys.ApproveEverywhere, + "deny" or "no" or "reject" => ApprovalOptionKeys.Deny, _ => null }; diff --git a/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs b/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs index 8eab275f1..165ae80d4 100644 --- a/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs +++ b/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs @@ -271,6 +271,7 @@ internal static Proto.ToolApprovalRequestedProto ToProto(ToolApprovalRequested e proto.ChannelType = evt.ChannelType; if (evt.SupportsInteractiveApproval is not null) proto.SupportsInteractiveApproval = evt.SupportsInteractiveApproval.Value; + proto.OptionKeys.AddRange(evt.OptionKeys); proto.Candidates.AddRange(evt.Candidates.Select(ToApprovalCandidateProto)); return proto; } @@ -291,6 +292,7 @@ internal static Proto.ToolApprovalRequestedProto ToProto(ToolApprovalRequested e Boundary = proto.HasBoundary ? new Configuration.TrustBoundary(proto.Boundary) : null, ChannelType = proto.HasChannelType ? proto.ChannelType : null, SupportsInteractiveApproval = proto.HasSupportsInteractiveApproval ? proto.SupportsInteractiveApproval : null, + OptionKeys = proto.OptionKeys.ToArray(), Candidates = proto.Candidates.Select(FromApprovalCandidateProto).ToArray(), RequestedAtMs = proto.RequestedAtMs }; diff --git a/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto b/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto index ebe491c06..ed80af600 100644 --- a/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto +++ b/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto @@ -145,6 +145,7 @@ message ToolApprovalRequestedProto { optional string channel_type = 12; optional bool supports_interactive_approval = 13; int64 requested_at_ms = 14; + repeated string option_keys = 15; } message ToolApprovalResolvedProto { diff --git a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs index 0dd0e14aa..287319631 100644 --- a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs +++ b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs @@ -92,11 +92,12 @@ public sealed class LlmSessionActor : ReceivePersistentActor, IWithTimers // continue the turn instead of transitioning to Ready. See #424. private bool _resumeToolLoopAfterCompaction; - // A tool-approval response that arrived while the session was Compacting. + // A tool-approval feedback command that arrived while the session was + // Compacting. // Re-driving a tool batch mid-compaction is unsafe — compaction rewrites // _state.History — so the response is buffered here and replayed via // Self.Tell once compaction finishes and the phase transition has run. - private ToolInteractionResponse? _deferredApprovalResponse; + private IWithSessionId? _deferredApprovalResponse; // Per-turn transient counters (tool budget, duplicate detection, empty-response retries) private readonly TurnStateTracker _turnState = new(); @@ -420,6 +421,7 @@ private void Ready() // live-Processing handler does not apply here — there is no in-flight // tool-loop task — so re-drive the parked batch from history. CommandAsync(HandleToolInteractionResponseWhenIdle); + CommandAsync(HandleToolInteractionTextResponseWhenIdle); Command(HandleIncomingUserMessage); } @@ -540,6 +542,7 @@ private void Processing() RequesterSenderId = msg.RequesterSenderId, RequesterPrincipal = msg.RequesterPrincipal, Cwd = msg.Cwd, + OptionKeys = msg.Options.Select(o => o.Key.Value).ToArray(), Candidates = msg.Candidates .Select(c => new ToolApprovalRequested.ApprovalCandidateRecord { @@ -557,34 +560,25 @@ private void Processing() }); }); - CommandAsync(async msg => + CommandAsync(HandleProcessingApprovalResponseAsync); + + CommandAsync(async msg => { - if (!_pendingToolInteractions.TryGetValue(msg.CallId.Value, out var pending)) + if (_restartDrainRequested) { - _log.Warning("Ignoring tool interaction response for unknown call {CallId}", msg.CallId); - TryReplyNack("approval_prompt_expired"); + _log.Info("Rejecting text tool interaction response while restart drain is in progress"); + TryReplyNack(SessionIngressGate.RestartInProgressMessage); return; } - try - { - if (await AuthorizeApprovalResponseAsync(pending, msg) is not { } decision) - { - TryReplyNack("approval_wrong_requester"); - return; - } - - PersistApprovalResolved(msg, decision, () => - { - _approvalChannel.Complete(msg.CallId, decision); - TryReplyAck(); - }); - } - catch (Exception ex) + if (!TryResolveTextApprovalResponse(msg, out var structured, out var nackReason) + || structured is null) { - FailCurrentTurn("I couldn't persist that approval decision. Please try again.", ex, ErrorCategory.ToolFailure); - TryReplyNack("approval_persist_failed"); + TryReplyNack(nackReason ?? "approval_prompt_expired"); + return; } + + await HandleProcessingApprovalResponseAsync(structured); }); Command(msg => @@ -1122,6 +1116,13 @@ private void Compacting() TryReplyAck(); }); + Command(msg => + { + _log.Info("Buffering text tool interaction response (compaction in progress)"); + _deferredApprovalResponse = msg; + TryReplyAck(); + }); + Command(HandleCompactionWatchdogExpired); Command(msg => Sender.Tell(Context.ActorOf(msg.Props, msg.ActorName))); Command(_ => RequestRestartDrain()); @@ -1451,6 +1452,21 @@ private void Passivating() await HandleToolInteractionResponseWhenIdle(msg); }); + CommandAsync(async msg => + { + if (_restartDrainRequested) + { + _log.Info("Rejecting text tool interaction response while restart passivation is in progress"); + TryReplyNack(SessionIngressGate.RestartInProgressMessage); + return; + } + + _log.Info("Aborting passivation due to text tool interaction response"); + AbortPassivationTimers(); + TransitionTo(SessionPhase.Ready); + await HandleToolInteractionTextResponseWhenIdle(msg); + }); + // Ignore stale processing/compaction messages Command(_ => { }); Command(_ => { }); @@ -3029,6 +3045,8 @@ private void ApplyToolApprovalRequested(ToolApprovalRequested evt) evt.RequesterSenderId?.Value, evt.RequesterPrincipal, evt.Cwd, + evt.RequestedAtMs, + evt.OptionKeys, evt.Candidates.Select(c => new ApprovalCandidate(c.Verb, c.Directory)).ToArray()); _resolvedToolApprovals.Remove(evt.CallId); } @@ -3205,7 +3223,7 @@ private void EmitExpiredPromptNotice() /// non-null return means authorization succeeded; the caller is /// responsible for journaling . /// - private async Task AuthorizeApprovalResponseAsync( + private async Task<(ApprovalDecision? Decision, string? NackReason)> AuthorizeApprovalResponseAsync( PendingToolInteraction pending, ToolInteractionResponse msg) { // Only the user who triggered the request may approve it — verified @@ -3216,12 +3234,23 @@ private void EmitExpiredPromptNotice() _log.Warning( "Ignoring tool interaction response for call {CallId} from sender {SenderId}; expected {ExpectedSenderId}", msg.CallId, msg.SenderId, pending.RequesterSenderId); - EmitOutput(new TextOutput( - "Approval response ignored: only the requesting user can approve this tool action.") - { - SessionId = _sessionId - }, OutputFilter.Text); - return null; + EmitWrongRequesterApprovalNotice(); + return (null, "approval_wrong_requester"); + } + + // Legacy journal entries created before option persistence landed have + // an empty OptionKeys list. Skip this check for that concrete recovery + // path only; live prompts always persist their offered option keys. + if (pending.OptionKeys.Count > 0 + && !pending.OptionKeys.Any(key => string.Equals(key, msg.SelectedKey.Value, StringComparison.Ordinal))) + { + _log.Warning( + "Ignoring unavailable approval option {SelectedKey} for call {CallId}; offered options were [{OptionKeys}]", + msg.SelectedKey, + msg.CallId, + string.Join(", ", pending.OptionKeys)); + EmitUnavailableApprovalOptionNotice(); + return (null, "approval_option_unavailable"); } var decision = MapApprovalDecision(msg.SelectedKey.Value); @@ -3237,7 +3266,125 @@ or ApprovalDecision.ApprovedEverywhere await PersistApprovalCandidatesAsync(pending, decision, CancellationToken.None); } - return decision; + return (decision, null); + } + + private void EmitWrongRequesterApprovalNotice() + => EmitOutput(new TextOutput( + "Approval response ignored: only the requesting user can approve this tool action.") + { + SessionId = _sessionId + }, OutputFilter.Text); + + private void EmitUnavailableApprovalOptionNotice() + => EmitOutput(new TextOutput( + "Approval response ignored: that option is not available for this tool action.") + { + SessionId = _sessionId + }, OutputFilter.Text); + + private bool TryResolveTextApprovalResponse( + ToolInteractionTextResponse msg, + out ToolInteractionResponse? structured, + out string? nackReason) + { + structured = null; + nackReason = null; + + var pending = ResolveLatestPendingApprovalForSender(msg.SenderId); + if (pending is null) + { + if (_pendingToolInteractions.Count == 0) + { + _log.Warning("Ignoring text tool interaction response with no pending approvals for sender {SenderId}", msg.SenderId); + EmitExpiredPromptNotice(); + nackReason = "approval_prompt_expired"; + } + else + { + _log.Warning("Ignoring text tool interaction response from unauthorized sender {SenderId}", msg.SenderId); + EmitWrongRequesterApprovalNotice(); + nackReason = "approval_wrong_requester"; + } + + return false; + } + + var optionKeys = pending.OptionKeys.Count > 0 + ? pending.OptionKeys + : + [ + ApprovalOptionKeys.ApproveOnce, + ApprovalOptionKeys.ApproveSession, + ApprovalOptionKeys.ApproveAlways, + ApprovalOptionKeys.Deny + ]; + + var options = optionKeys + .Select(key => new ToolInteractionOption(new ApprovalOptionKey(key), ApprovalOptionKeys.LabelFor(key))) + .ToArray(); + + if (!ToolInteractionResponseParser.TryParseApprovalResponse(msg.Text, options, out var selectedKey) + || selectedKey is null) + { + _log.Warning( + "Ignoring unparseable text tool interaction response '{Text}' for call {CallId}; offered options were [{OptionKeys}]", + msg.Text, + pending.CallId, + string.Join(", ", pending.OptionKeys)); + EmitUnavailableApprovalOptionNotice(); + nackReason = "approval_option_unavailable"; + return false; + } + + structured = new ToolInteractionResponse + { + SessionId = msg.SessionId, + CallId = new ToolCallId(pending.CallId), + SelectedKey = new ApprovalOptionKey(selectedKey), + SenderId = msg.SenderId + }; + return true; + } + + private PendingToolInteraction? ResolveLatestPendingApprovalForSender(SenderId senderId) + => _pendingToolInteractions.Values + .Where(pending => ApprovalButtonValueCodec.CanApprove( + pending.RequesterPrincipal, + pending.RequesterSenderId, + senderId.Value)) + .OrderBy(pending => pending.RequestedAtMs) + .LastOrDefault(); + + private async Task HandleProcessingApprovalResponseAsync(ToolInteractionResponse msg) + { + if (!_pendingToolInteractions.TryGetValue(msg.CallId.Value, out var pending)) + { + _log.Warning("Ignoring tool interaction response for unknown call {CallId}", msg.CallId); + TryReplyNack("approval_prompt_expired"); + return; + } + + try + { + var authorization = await AuthorizeApprovalResponseAsync(pending, msg); + if (authorization.Decision is not { } decision) + { + TryReplyNack(authorization.NackReason ?? "approval_wrong_requester"); + return; + } + + PersistApprovalResolved(msg, decision, () => + { + _approvalChannel.Complete(msg.CallId, decision); + TryReplyAck(); + }); + } + catch (Exception ex) + { + FailCurrentTurn("I couldn't persist that approval decision. Please try again.", ex, ErrorCategory.ToolFailure); + TryReplyNack("approval_persist_failed"); + } } private void PersistApprovalResolved( @@ -3287,9 +3434,10 @@ private async Task HandleToolInteractionResponseWhenIdle(ToolInteractionResponse ApprovalDecision decision; try { - if (await AuthorizeApprovalResponseAsync(pending, msg) is not { } authorizedDecision) + var authorization = await AuthorizeApprovalResponseAsync(pending, msg); + if (authorization.Decision is not { } authorizedDecision) { - TryReplyNack("approval_wrong_requester"); + TryReplyNack(authorization.NackReason ?? "approval_wrong_requester"); return; } decision = authorizedDecision; @@ -3315,6 +3463,18 @@ private async Task HandleToolInteractionResponseWhenIdle(ToolInteractionResponse }); } + private async Task HandleToolInteractionTextResponseWhenIdle(ToolInteractionTextResponse msg) + { + if (!TryResolveTextApprovalResponse(msg, out var structured, out var nackReason) + || structured is null) + { + TryReplyNack(nackReason ?? "approval_prompt_expired"); + return; + } + + await HandleToolInteractionResponseWhenIdle(structured); + } + private bool TryRedriveToolBatchAfterApproval(string callId) { var assistantMsg = ParkedToolBatchHistory.FindRedrivableAssistantMessage(_state.History, callId); diff --git a/src/Netclaw.Actors/Sessions/ParentSessionApprovalBridge.cs b/src/Netclaw.Actors/Sessions/ParentSessionApprovalBridge.cs index dda0eb2cd..e07cb103b 100644 --- a/src/Netclaw.Actors/Sessions/ParentSessionApprovalBridge.cs +++ b/src/Netclaw.Actors/Sessions/ParentSessionApprovalBridge.cs @@ -5,6 +5,7 @@ // ----------------------------------------------------------------------- using Netclaw.Actors.Protocol; using Netclaw.Configuration; +using Netclaw.Security; using Netclaw.Tools; namespace Netclaw.Actors.Sessions; @@ -51,13 +52,19 @@ public async Task RequestApprovalAsync( string displayText, IReadOnlyList patterns, IReadOnlyList candidateVerbs, + IReadOnlyList candidates, + string? cwd, + IReadOnlyList options, bool isMessy, CancellationToken ct) { var waitTask = _channel.WaitForApprovalAsync(callId, Timeout.InfiniteTimeSpan, ct); - // Labels are the fixed `ApprovalOptionKeys` constants — see that type - // for `MaxLabelLength` and the channel-cap rationale. + // Emit verbatim from the gate's computed options so persistent-grant + // buttons (Always here / Always anywhere) and the messy-command + // four-button fallback stay in lock-step with the parent path. The + // earlier hardcoded list silently dropped "Always anywhere" for + // sub-agents. _emitRequest(new ToolInteractionRequest { SessionId = _sessionId, @@ -69,18 +76,16 @@ public async Task RequestApprovalAsync( RequesterPrincipal = _requesterPrincipal, Patterns = patterns, CandidateVerbs = candidateVerbs, + Candidates = candidates.Select(c => new ApprovalCandidate(c.Verb, c.Directory)).ToList(), + Cwd = cwd, IsMessy = isMessy, HasAdoptedContext = _hasAdoptedContext, HasThirdPartyAdoptedContext = _hasThirdPartyAdoptedContext, AdoptedSpeakerIds = _adoptedSpeakerIds, PersistedAdoptedContext = _hasAdoptedContext, - Options = - [ - new ToolInteractionOption(ApprovalOptionKeys.ApproveOnceKey, ApprovalOptionKeys.ApproveOnceLabel), - new ToolInteractionOption(ApprovalOptionKeys.ApproveSessionKey, ApprovalOptionKeys.ApproveSessionLabel), - new ToolInteractionOption(ApprovalOptionKeys.ApproveAlwaysKey, ApprovalOptionKeys.ApproveAlwaysLabel), - new ToolInteractionOption(ApprovalOptionKeys.DenyKey, ApprovalOptionKeys.DenyLabel) - ] + Options = options + .Select(o => new ToolInteractionOption(new ApprovalOptionKey(o.Key), o.Label)) + .ToList() }); var decision = await waitTask; diff --git a/src/Netclaw.Actors/Sessions/ToolApprovalState.cs b/src/Netclaw.Actors/Sessions/ToolApprovalState.cs index 2ff94ef06..c79658973 100644 --- a/src/Netclaw.Actors/Sessions/ToolApprovalState.cs +++ b/src/Netclaw.Actors/Sessions/ToolApprovalState.cs @@ -24,6 +24,10 @@ internal sealed record PendingToolInteraction( string? RequesterSenderId, PrincipalClassification? RequesterPrincipal, string? Cwd, + long RequestedAtMs, + // Option keys that were actually offered to the user when the prompt was + // rendered. Persisted so a later response cannot select a pruned scope. + IReadOnlyList OptionKeys, // Per-clause (verb, directory) pairs preserved across the pause-for-approval // round trip so persistent approvals can write folder-scoped grants from the // path arguments the agent originally passed, rather than collapsing to cwd. diff --git a/src/Netclaw.Actors/SubAgents/SubAgentActor.cs b/src/Netclaw.Actors/SubAgents/SubAgentActor.cs index 6f0a957b7..9317356d9 100644 --- a/src/Netclaw.Actors/SubAgents/SubAgentActor.cs +++ b/src/Netclaw.Actors/SubAgents/SubAgentActor.cs @@ -130,6 +130,7 @@ private void Idle() _toolExecutionContext = new ToolExecutionContext(scopeId, msg.ParentSessionDirectory) { Audience = subAgentAudience, + InheritedCwd = msg.ParentCwd, }; _toolExecutionContext.Boundary = msg.Boundary; _toolExecutionContext.ChannelType = msg.ChannelType; @@ -485,12 +486,21 @@ private static async Task ExecuteToolsAsync( when (approvalBridge is not null) { var ctx = approvalEx.ApprovalContext; + var bridgeCandidates = ctx.Candidates is { Count: > 0 } c + ? c.Select(x => new ParentApprovalCandidate(x.Verb, x.Directory)).ToList() + : (IReadOnlyList)[]; + var bridgeOptions = ctx.Options + .Select(o => new ParentApprovalOption(o.Key.Value, o.Label)) + .ToList(); var decision = await approvalBridge.RequestApprovalAsync( new ToolCallId(tc.CallId), ctx.ToolName, ctx.DisplayText, ctx.Patterns, ctx.CandidateVerbs, + bridgeCandidates, + ctx.Cwd, + bridgeOptions, ctx.IsMessy, ct); @@ -560,6 +570,7 @@ private static ToolExecutionContext CreatePerToolExecutionContext(ToolExecutionC RequestedTimeoutSeconds = source.RequestedTimeoutSeconds, ChannelType = source.ChannelType, ProjectDirectory = source.ProjectDirectory, + InheritedCwd = source.InheritedCwd, SupportsInteractiveApproval = source.SupportsInteractiveApproval, OnSubAgentActivity = source.OnSubAgentActivity, SpawnChildActor = source.SpawnChildActor, diff --git a/src/Netclaw.Actors/SubAgents/SubAgentProtocol.cs b/src/Netclaw.Actors/SubAgents/SubAgentProtocol.cs index dc09aabc4..c2c2edd63 100644 --- a/src/Netclaw.Actors/SubAgents/SubAgentProtocol.cs +++ b/src/Netclaw.Actors/SubAgents/SubAgentProtocol.cs @@ -94,6 +94,13 @@ public sealed record RunSubAgent : INoSerializationVerificationNeeded /// public string? ParentProjectDirectory { get; init; } + /// + /// Snapshot of the parent's ToolExecutionContext.ResolveShellCwd(null) + /// at spawn time. Seeds the child's InheritedCwd. Null when the + /// parent itself had no resolvable cwd. + /// + public string? ParentCwd { get; init; } + /// /// Parent session's approval bridge. When provided, the sub-agent can route /// approval requests back to the interactive user instead of auto-denying. diff --git a/src/Netclaw.Actors/SubAgents/SubAgentSpawner.cs b/src/Netclaw.Actors/SubAgents/SubAgentSpawner.cs index 302942a65..84f559e1e 100644 --- a/src/Netclaw.Actors/SubAgents/SubAgentSpawner.cs +++ b/src/Netclaw.Actors/SubAgents/SubAgentSpawner.cs @@ -133,6 +133,7 @@ public async Task SpawnAsync( ChannelType = context.ChannelType, ParentSessionDirectory = context.SessionDirectory, ParentProjectDirectory = context.ProjectDirectory, + ParentCwd = context.ResolveShellCwd(null), Cancellation = ct, ApprovalBridge = context.ApprovalBridge, // Null for non-streaming callers such as routed skills and diff --git a/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs b/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs index 6e22cda91..1466baa8f 100644 --- a/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs +++ b/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs @@ -297,9 +297,7 @@ private async Task HandleInboundAsync(DiscordThreadInbound message) return; if (!string.IsNullOrWhiteSpace(message.Text) - && ToolInteractionResponseParser.TryParseApprovalResponse(message.Text, out var selectedKey) - && selectedKey is not null - && await TryHandleTextApprovalResponseAsync(message, selectedKey)) + && await TryHandleTextApprovalResponseAsync(message)) { return; } @@ -721,12 +719,15 @@ private async Task ApplyDeferredHydrationAsync( return MergeAdoptedContext(baseInput, classified.Gap, cursor); } - private async Task TryHandleTextApprovalResponseAsync(DiscordThreadInbound message, string selectedKey) + private async Task TryHandleTextApprovalResponseAsync(DiscordThreadInbound message) { var (result, pending) = ResolvePendingRequest(message.SenderId, callId: null); if (result is ApprovalLookupResult.NotFound) - return false; + { + return !_hasObservedApprovalRequest + && await TryHandleColdTextApprovalResponseAsync(message); + } if (result is ApprovalLookupResult.WrongRequester) { @@ -734,6 +735,15 @@ private async Task TryHandleTextApprovalResponseAsync(DiscordThreadInbound return true; } + if (!ToolInteractionResponseParser.TryParseApprovalResponse( + message.Text ?? string.Empty, + pending!.Request.Options, + out var selectedKey) + || selectedKey is null) + { + return false; + } + _pendingApprovalRequests.Remove(pending!); await _dependencies.Pipeline.SendFeedbackAsync(new ToolInteractionResponse @@ -748,6 +758,38 @@ await _dependencies.Pipeline.SendFeedbackAsync(new ToolInteractionResponse return true; } + private async Task TryHandleColdTextApprovalResponseAsync(DiscordThreadInbound message) + { + if (!ToolInteractionResponseParser.LooksLikeApprovalResponse(message.Text ?? string.Empty)) + return false; + + using var feedbackCts = new CancellationTokenSource(OperationTimeout); + try + { + var reply = await _dependencies.Pipeline.SendFeedbackAndWaitAsync(new ToolInteractionTextResponse + { + SessionId = _sessionId, + Text = message.Text ?? string.Empty, + SenderId = new SenderId(message.SenderId.Value) + }, feedbackCts.Token); + + if (reply is CommandAck) + { + _log.Info( + "Forwarded cold Discord text approval response from sender={SenderId} without local pending prompt state", + message.SenderId); + return true; + } + + return reply is CommandNack; + } + catch (Exception ex) + { + _log.Error(ex, "Failed to route cold Discord text approval response from sender {SenderId}", message.SenderId); + return false; + } + } + private async Task HandleApprovalResponseAsync(DiscordApprovalResponse message) { var (result, pending) = ResolvePendingRequest(message.SenderId, message.CallId); diff --git a/src/Netclaw.Channels.Mattermost/MattermostApprovalPromptBuilder.cs b/src/Netclaw.Channels.Mattermost/MattermostApprovalPromptBuilder.cs index 76268b877..87e8574cc 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostApprovalPromptBuilder.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostApprovalPromptBuilder.cs @@ -21,7 +21,7 @@ public static (string Text, IReadOnlyList Attachments) Bui sb.AppendLine(":lock: **Tool approval required**"); AppendToolSummary(sb, request); sb.AppendLine(); - sb.Append("You can also reply with `A`, `B`, `C`, or `D` in this thread."); + sb.Append("You can also reply with ").Append(FormatReplyLetters(request.Options)).Append(" in this thread."); var requesterSenderId = request.RequesterSenderId?.Value ?? string.Empty; var actions = request.Options @@ -51,7 +51,7 @@ public static (string Text, IReadOnlyList Attachments) Bui .ToList(); var attachment = new MattermostAttachment( - Fallback: "Tool approval required — reply with A, B, C, or D", + Fallback: $"Tool approval required — reply with {string.Join(", ", Enumerable.Range(0, actions.Count).Select(GetReplyLetter))}", Color: "#3AA3E3", Actions: actions); @@ -66,10 +66,7 @@ public static string BuildTextPrompt(ToolInteractionRequest request) sb.AppendLine(); sb.AppendLine("Reply with:"); - sb.Append("**A)** ").AppendLine(ApprovalOptionKeys.ApproveOnceLabel); - sb.Append("**B)** ").AppendLine(ApprovalOptionKeys.ApproveSessionLabel); - sb.Append("**C)** ").AppendLine(ApprovalOptionKeys.ApproveAlwaysLabel); - sb.Append("**D)** ").AppendLine(ApprovalOptionKeys.DenyLabel); + AppendReplyOptions(sb, request.Options); return sb.ToString().TrimEnd(); } @@ -143,6 +140,18 @@ private static void AppendAdoptedContextSummary(StringBuilder sb, ToolInteractio sb.Append("**Speakers:** `").Append(string.Join(", ", request.AdoptedSpeakerIds)).AppendLine("`"); } + private static void AppendReplyOptions(StringBuilder sb, IReadOnlyList options) + { + for (var i = 0; i < options.Count; i++) + sb.Append("**").Append(GetReplyLetter(i)).Append(")** ").AppendLine(options[i].Label); + } + + private static string FormatReplyLetters(IReadOnlyList options) + => string.Join(", ", Enumerable.Range(0, options.Count).Select(i => $"`{GetReplyLetter(i)}`")); + + private static string GetReplyLetter(int index) + => ((char)('A' + index)).ToString(); + private static string GetButtonStyle(string optionKey) => optionKey switch { diff --git a/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs b/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs index 4a671a6e5..035e5eac9 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs @@ -266,9 +266,7 @@ private async Task HandleInboundAsync(MattermostThreadInbound message) return; if (!string.IsNullOrWhiteSpace(message.Text) - && ToolInteractionResponseParser.TryParseApprovalResponse(message.Text, out var selectedKey) - && selectedKey is not null - && await TryHandleTextApprovalResponseAsync(message, selectedKey)) + && await TryHandleTextApprovalResponseAsync(message)) { return; } @@ -696,12 +694,15 @@ private static ChannelInput MergeAdoptedContext( }; } - private async Task TryHandleTextApprovalResponseAsync(MattermostThreadInbound message, string selectedKey) + private async Task TryHandleTextApprovalResponseAsync(MattermostThreadInbound message) { var (result, pending) = ResolvePendingRequest(message.SenderId, callId: null); if (result is ApprovalLookupResult.NotFound) - return false; + { + return !_hasObservedApprovalRequest + && await TryHandleColdTextApprovalResponseAsync(message); + } if (result is ApprovalLookupResult.WrongRequester) { @@ -709,6 +710,15 @@ private async Task TryHandleTextApprovalResponseAsync(MattermostThreadInbo return true; } + if (!ToolInteractionResponseParser.TryParseApprovalResponse( + message.Text ?? string.Empty, + pending!.Request.Options, + out var selectedKey) + || selectedKey is null) + { + return false; + } + _pendingApprovalRequests.Remove(pending!); await _dependencies.Pipeline.SendFeedbackAsync(new ToolInteractionResponse @@ -723,6 +733,38 @@ await _dependencies.Pipeline.SendFeedbackAsync(new ToolInteractionResponse return true; } + private async Task TryHandleColdTextApprovalResponseAsync(MattermostThreadInbound message) + { + if (!ToolInteractionResponseParser.LooksLikeApprovalResponse(message.Text ?? string.Empty)) + return false; + + using var feedbackCts = new CancellationTokenSource(OperationTimeout); + try + { + var reply = await _dependencies.Pipeline.SendFeedbackAndWaitAsync(new ToolInteractionTextResponse + { + SessionId = _sessionId, + Text = message.Text ?? string.Empty, + SenderId = new SenderId(message.SenderId.Value) + }, feedbackCts.Token); + + if (reply is CommandAck) + { + _log.Info( + "Forwarded cold Mattermost text approval response from sender={SenderId} without local pending prompt state", + message.SenderId); + return true; + } + + return reply is CommandNack; + } + catch (Exception ex) + { + _log.Error(ex, "Failed to route cold Mattermost text approval response from sender {SenderId}", message.SenderId); + return false; + } + } + private async Task HandleApprovalResponseAsync(MattermostApprovalResponse message) { var replyTo = Sender; diff --git a/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs b/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs index e15c5579f..7ae3f0af8 100644 --- a/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs +++ b/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs @@ -298,9 +298,7 @@ private async Task HandleInboundAsync(SlackThreadInbound message) var currentTs = new SlackEventId(message.EventId.Value).TryGetEventTs(); if (!string.IsNullOrWhiteSpace(message.Text) - && ToolInteractionResponseParser.TryParseApprovalResponse(message.Text, out var selectedKey) - && selectedKey is not null - && await TryHandleApprovalResponseAsync(message, selectedKey)) + && await TryHandleTextApprovalResponseAsync(message)) { return; } @@ -1223,10 +1221,13 @@ private async Task HandleOutputAsync(ThreadOutput threadOutput) } } - private async Task TryHandleApprovalResponseAsync(SlackThreadInbound message, string selectedKey) + private async Task TryHandleTextApprovalResponseAsync(SlackThreadInbound message) { if (_pendingApprovalRequests.Count == 0) - return false; + { + return !_hasObservedApprovalRequest + && await TryHandleColdTextApprovalResponseAsync(message); + } var pendingIndex = _pendingApprovalRequests.FindIndex(request => ApprovalButtonValueCodec.CanApprove(request.Request.RequesterPrincipal, request.Request.RequesterSenderId?.Value, message.SenderId.Value)); @@ -1239,6 +1240,15 @@ private async Task TryHandleApprovalResponseAsync(SlackThreadInbound messa var pending = _pendingApprovalRequests[pendingIndex]; + if (!ToolInteractionResponseParser.TryParseApprovalResponse( + message.Text ?? string.Empty, + pending.Request.Options, + out var selectedKey) + || selectedKey is null) + { + return false; + } + try { await _dependencies.Pipeline.SendFeedbackAsync(new ToolInteractionResponse @@ -1267,6 +1277,38 @@ await _dependencies.Pipeline.SendFeedbackAsync(new ToolInteractionResponse return true; } + private async Task TryHandleColdTextApprovalResponseAsync(SlackThreadInbound message) + { + if (!ToolInteractionResponseParser.LooksLikeApprovalResponse(message.Text ?? string.Empty)) + return false; + + using var feedbackCts = new CancellationTokenSource(OperationTimeout); + try + { + var reply = await _dependencies.Pipeline.SendFeedbackAndWaitAsync(new ToolInteractionTextResponse + { + SessionId = _sessionId, + Text = message.Text ?? string.Empty, + SenderId = message.SenderId + }, feedbackCts.Token); + + if (reply is CommandAck) + { + _log.Info( + "Forwarded cold Slack text approval response from sender={SenderId} without local pending prompt state", + message.SenderId); + return true; + } + + return reply is CommandNack; + } + catch (Exception ex) + { + _log.Error(ex, "Failed to route cold Slack text approval response from sender {SenderId}", message.SenderId); + return false; + } + } + private async Task HandleApprovalResponseAsync(SlackApprovalResponse message) { var pendingIndex = _pendingApprovalRequests.FindIndex(request => diff --git a/src/Netclaw.Cli.Tests/Cli/DaemonClientMappingTests.cs b/src/Netclaw.Cli.Tests/Cli/DaemonClientMappingTests.cs index f048f9128..7884d3cc8 100644 --- a/src/Netclaw.Cli.Tests/Cli/DaemonClientMappingTests.cs +++ b/src/Netclaw.Cli.Tests/Cli/DaemonClientMappingTests.cs @@ -275,6 +275,7 @@ public void ToolInteractionRequest_roundtrips_through_dto() new ToolInteractionOption(ApprovalOptionKeys.ApproveOnceKey, ApprovalOptionKeys.ApproveOnceLabel), new ToolInteractionOption(ApprovalOptionKeys.ApproveSessionKey, ApprovalOptionKeys.ApproveSessionLabel), new ToolInteractionOption(ApprovalOptionKeys.ApproveAlwaysKey, ApprovalOptionKeys.ApproveAlwaysLabel), + new ToolInteractionOption(ApprovalOptionKeys.ApproveEverywhereKey, ApprovalOptionKeys.ApproveEverywhereLabel), new ToolInteractionOption(ApprovalOptionKeys.DenyKey, ApprovalOptionKeys.DenyLabel) ] }; @@ -288,6 +289,7 @@ public void ToolInteractionRequest_roundtrips_through_dto() Assert.True(dto.InteractionHasThirdPartyAdoptedContext); Assert.Equal(["device-1", "device-2"], dto.InteractionAdoptedSpeakerIds); Assert.Equal(["git push"], dto.InteractionCandidateVerbs); + Assert.Equal(5, dto.InteractionOptions!.Count); var roundTripped = DaemonClient.FromDto(dto); var result = Assert.IsType(roundTripped); @@ -300,7 +302,7 @@ public void ToolInteractionRequest_roundtrips_through_dto() Assert.Equal(["device-1", "device-2"], result.AdoptedSpeakerIds); Assert.Equal(["git push"], result.Patterns); Assert.Equal(["git push"], result.CandidateVerbs); - Assert.Equal(4, result.Options.Count); + Assert.Equal(5, result.Options.Count); } [Fact] diff --git a/src/Netclaw.Cli/Tui/ChatPage.cs b/src/Netclaw.Cli/Tui/ChatPage.cs index d164d2236..ff6348571 100644 --- a/src/Netclaw.Cli/Tui/ChatPage.cs +++ b/src/Netclaw.Cli/Tui/ChatPage.cs @@ -361,7 +361,7 @@ private void HandleOutput(SessionOutput output) _chatHistory.AppendLine($" {msg.DisplayText}", Color.White); if (msg.Patterns.Count > 0) _chatHistory.AppendLine($" Patterns: {string.Join(", ", msg.Patterns)}", Color.BrightBlack); - _chatHistory.AppendLine(" Choose Once, This chat, Always here, Always anywhere, or Deny below.", Color.Yellow); + _chatHistory.AppendLine($" Options: {string.Join(", ", msg.Options.Select(o => o.Label))}", Color.Yellow); _chatHistory.ScrollToBottom(); break; diff --git a/src/Netclaw.Cli/Tui/ChatViewModel.cs b/src/Netclaw.Cli/Tui/ChatViewModel.cs index cc68c21ac..7badcb65a 100644 --- a/src/Netclaw.Cli/Tui/ChatViewModel.cs +++ b/src/Netclaw.Cli/Tui/ChatViewModel.cs @@ -199,9 +199,13 @@ private async Task SubmitInteractionResponseAsync(string text) return; } - if (!ToolInteractionResponseParser.TryParseApprovalResponse(text, out var selectedKey) || selectedKey is null) + var interaction = CurrentInteraction; + if (interaction is null) + return; + + if (!ToolInteractionResponseParser.TryParseApprovalResponse(text, interaction.Options, out var selectedKey) || selectedKey is null) { - StatusMessage.Value = "Approval required: reply A, B, C, or D."; + StatusMessage.Value = $"Approval required: reply with {FormatReplyLetters(interaction.Options)}."; RequestRedraw(); return; } @@ -396,4 +400,10 @@ private void RefreshApprovalOptions() UiVersion.Value++; } + + private static string FormatReplyLetters(IReadOnlyList options) + => string.Join(", ", Enumerable.Range(0, options.Count).Select(i => GetReplyLetter(i))); + + private static string GetReplyLetter(int index) + => ((char)('A' + index)).ToString(); } diff --git a/src/Netclaw.Daemon.Tests/Configuration/MattermostActionEndpointExtensionsTests.cs b/src/Netclaw.Daemon.Tests/Configuration/MattermostActionEndpointExtensionsTests.cs index e6643a11c..04a6abab8 100644 --- a/src/Netclaw.Daemon.Tests/Configuration/MattermostActionEndpointExtensionsTests.cs +++ b/src/Netclaw.Daemon.Tests/Configuration/MattermostActionEndpointExtensionsTests.cs @@ -161,6 +161,33 @@ public async Task Expired_prompt_returns_explicit_rejection_message() Assert.Contains("expired", payload.GetProperty("ephemeral_text").GetString(), StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task Unavailable_option_returns_explicit_rejection_message() + { + var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-05-20T12:00:00Z")); + var actionStore = new MattermostCallbackActionStore(time); + var token = actionStore.CreateAction("ch-1", "call-1", ApprovalOptionKeys.ApproveOnce, "root-1", "requester-1"); + + await using var app = await CreateHostAsync( + time, + actionStore, + gatewayResponseFactory: _ => CommandNack.For(new SessionId("ch-1/root-1"), "approval_option_unavailable"), + recorder: new GatewayInteractionRecorder()); + var client = app.GetTestClient(); + + var response = await client.PostAsJsonAsync("/api/mattermost/actions", new + { + user_id = "requester-1", + post_id = "prompt-55", + channel_id = "ch-1", + context = new Dictionary { ["action_token"] = token } + }, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var payload = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Contains("not available", payload.GetProperty("ephemeral_text").GetString(), StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task Oversized_body_returns_413_before_processing() { diff --git a/src/Netclaw.Daemon/Configuration/MattermostActionEndpointExtensions.cs b/src/Netclaw.Daemon/Configuration/MattermostActionEndpointExtensions.cs index c92c7035e..2bff39fda 100644 --- a/src/Netclaw.Daemon/Configuration/MattermostActionEndpointExtensions.cs +++ b/src/Netclaw.Daemon/Configuration/MattermostActionEndpointExtensions.cs @@ -235,6 +235,7 @@ private static string MapRejectMessage(string reason) { "approval_wrong_requester" => "Only the requesting user can approve this tool action.", "approval_prompt_expired" => "That approval prompt has expired. Please re-issue the request and try again.", + "approval_option_unavailable" => "That approval option is not available for this tool action. Please use one of the options shown on the prompt.", SessionIngressGate.RestartInProgressMessage => SessionIngressGate.RestartInProgressMessage, _ => "That approval could not be recorded. Please try again." }; diff --git a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs index 284a4ed4c..0e6cef56a 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs @@ -337,6 +337,41 @@ public void Null_directory_entry_matches_any_candidate() approvedEntries: [new ApprovalEntry("freshdesk") { Directory = null }])); } + [Fact] + public void Null_directory_entry_matches_subagent_with_null_cwd_for_netclaw_stats() + { + // Regression: a sub-agent that inherits no cwd (parent had none either) + // invokes `netclaw stats`. The persisted global grant in + // tool-approvals.json must still auto-approve. Bound to a real verb + // from the original bug report so a future refactor that re-orders the + // matcher loop trips this test specifically. + Assert.True(ApprovalPatternMatching.MatchesShellApproval( + candidateVerb: "netclaw stats", + candidateDirectory: null, + cwd: null, + approvedEntries: [new ApprovalEntry("netclaw stats") { Directory = null }])); + } + + [Fact] + public void Null_directory_entry_wins_over_folder_scoped_entry_with_null_cwd() + { + // Spec scenario "Global grant precedence over folder-scoped grants": + // when both grants exist for the same verb and the candidate has no + // cwd, the global grant must still win even though the folder-scoped + // grant gets skipped. + ApprovalEntry[] entries = + [ + new ApprovalEntry("dotnet") { Directory = "/home/user/repos/foo/" }, + new ApprovalEntry("dotnet") { Directory = null }, + ]; + + Assert.True(ApprovalPatternMatching.MatchesShellApproval( + candidateVerb: "dotnet", + candidateDirectory: null, + cwd: null, + approvedEntries: entries)); + } + [Fact] public void IsPureSideEffect_skips_echo_without_redirect() { diff --git a/src/Netclaw.Tools.Abstractions/IParentApprovalBridge.cs b/src/Netclaw.Tools.Abstractions/IParentApprovalBridge.cs index 9f6ff5560..52c0a874f 100644 --- a/src/Netclaw.Tools.Abstractions/IParentApprovalBridge.cs +++ b/src/Netclaw.Tools.Abstractions/IParentApprovalBridge.cs @@ -19,6 +19,21 @@ public enum ParentApprovalDecision TimedOut } +/// +/// One per-clause (verb, directory) pair extracted from a sub-agent's +/// invocation. Mirrors the persisted ApprovalEntry shape so the parent +/// session can record folder-scoped grants from the actual paths the sub-agent +/// touched, not just the cwd. +/// +public sealed record ParentApprovalCandidate(string Verb, string? Directory); + +/// +/// One button on the approval prompt. is the wire-stable +/// option key (e.g. approve_once); is the +/// human-readable button text. +/// +public sealed record ParentApprovalOption(string Key, string Label); + /// /// Bridge that allows sub-agents to route approval requests back to their parent /// interactive session. Defined in the tools abstraction layer so @@ -31,8 +46,15 @@ public interface IParentApprovalBridge /// are the exact blocked units shown in the /// prompt and reused for approve-once retries. /// are the verb chains the parent session records for broader-scope - /// approvals, evaluated against persisted ApprovalEntry records - /// using the candidate's cwd. + /// approvals; preserves the per-clause + /// (verb, directory) pairs so "Always here" persists with the + /// actual directory the sub-agent touched. is the + /// sub-agent's resolved working directory, surfaced in the prompt header + /// and used by the persistence path. is the + /// channel-agnostic button set computed by the approval gate + /// (BuildApprovalOptions) — implementations MUST emit it verbatim + /// rather than hardcoding a button list, or persistent grants like + /// Always anywhere would silently disappear from sub-agent prompts. /// Task RequestApprovalAsync( ToolCallId callId, @@ -40,6 +62,9 @@ Task RequestApprovalAsync( string displayText, IReadOnlyList patterns, IReadOnlyList candidateVerbs, + IReadOnlyList candidates, + string? cwd, + IReadOnlyList options, bool isMessy, CancellationToken ct); } diff --git a/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs b/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs index 0f1113582..480e4534d 100644 --- a/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs +++ b/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs @@ -165,6 +165,18 @@ public IReadOnlySet OneTimeApprovedPatterns /// public string? Cwd { get; set; } + /// + /// Parent session's resolved cwd snapshot, captured at spawn time for + /// sub-agent contexts. Read by as the + /// last-resort fallback so a sub-agent whose own + /// and + /// happen to be unset still surfaces the parent's effective working + /// directory to the approval gate. Distinct from : + /// Cwd is the per-call resolved output the approval gate + /// writes; InheritedCwd is a one-shot snapshot input. + /// + public string? InheritedCwd { get; init; } + /// /// Absolute path to the project directory the agent is currently working /// on, mirroring WorkingContext.ProjectDirectory from the session @@ -184,9 +196,13 @@ public IReadOnlySet OneTimeApprovedPatterns /// — the session's declared project /// root, populated from WorkingContext.ProjectDirectory; /// — the per-session scratch - /// directory under ~/.netclaw/sessions/<id>/. + /// directory under ~/.netclaw/sessions/<id>/; + /// — a sub-agent's snapshot of the + /// parent's resolved cwd, used when the child has no + /// or of + /// its own. /// - /// Returns null only when none of the three is available, which is + /// Returns null only when none of the four is available, which is /// the contract for tools that are not directory-anchored. Shell tools /// SHALL never inherit the daemon process's cwd — that defeats the /// approval policy's safe-space invariant because the daemon's cwd is @@ -200,6 +216,8 @@ public IReadOnlySet OneTimeApprovedPatterns return ProjectDirectory; if (!string.IsNullOrWhiteSpace(SessionDirectory)) return SessionDirectory; + if (!string.IsNullOrWhiteSpace(InheritedCwd)) + return InheritedCwd; return null; }