diff --git a/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs b/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs index 59c541831..63644f9fa 100644 --- a/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Netclaw.Actors.Hosting; +using Netclaw.Actors.Jobs; using Netclaw.Actors.Tools; using Netclaw.Configuration; using Netclaw.Security; @@ -1383,6 +1384,45 @@ await approvalService.RecordApprovalAsync( } } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Background_job_control_does_not_contact_approval_service(bool cancel) + { + var config = new ToolConfig { ShellMode = ShellExecutionMode.HostAllowed }; + config.AudienceProfiles.Personal.ApprovalPolicy = new ToolApprovalConfig + { + ToolOverrides = new Dictionary(StringComparer.Ordinal) + { + ["check_background_job"] = ToolApprovalMode.Approval + } + }; + var registry = new ToolRegistry(); + registry.WithBackgroundJobTools(ActorRefs.Nobody); + var executor = new DispatchingToolExecutor( + registry, + new ToolAccessPolicy( + config, + new EffectivePolicyDefaults( + DeploymentPosture.Personal, + TrustAudience.Personal, + ShellExecutionMode.HostAllowed, + UsedStrictFallback: false), + new ShellCommandPolicy(), + new ToolPathPolicy([])), + new UnexpectedApprovalService()); + var context = TestToolExecutionContext.CreateBound( + "slack/thread-1", + null, + new TestToolExecutionContextOptions { Audience = TrustAudience.Personal }); + var toolCall = new FunctionCallContent( + $"call-job-{cancel}", + CheckBackgroundJobTool.ToolName, + ToolInput.Create("JobId", "abc123", "Cancel", cancel)); + + await executor.AuthorizeAsync(toolCall, context, TestContext.Current.CancellationToken); + } + private static DispatchingToolExecutor CreateApprovalGatedShellExecutor() { var config = new ToolConfig { ShellMode = ShellExecutionMode.HostAllowed }; diff --git a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs index f33e43a46..77a6e2473 100644 --- a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs +++ b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs @@ -138,6 +138,12 @@ public ToolAccessDecision AuthorizeInvocation( if (shellAudience != TrustAudience.Personal) return ToolAccessDecision.Deny("shell_requires_personal_context"); + // shell_execute authorizes the process before the job starts. This tool + // can only control a job with the same session, audience, and boundary. + // It does not create a new shell invocation or require another approval. + if (string.Equals(tool.Name, CheckBackgroundJobTool.ToolName, StringComparison.Ordinal)) + return ToolAccessDecision.Allow(ToolAllowReason.BackgroundJobLifecycle); + var shellCommand = ExtractShellCommand(arguments); if (shellCommand is not null) { diff --git a/src/Netclaw.Actors/Tools/ToolAuthorizationDecision.cs b/src/Netclaw.Actors/Tools/ToolAuthorizationDecision.cs index 2e27da01d..dfd26514a 100644 --- a/src/Netclaw.Actors/Tools/ToolAuthorizationDecision.cs +++ b/src/Netclaw.Actors/Tools/ToolAuthorizationDecision.cs @@ -55,6 +55,11 @@ internal enum ToolAllowReason /// PolicyAuto, + /// + /// The initial shell approval covers control of the session-owned job. + /// + BackgroundJobLifecycle, + /// /// The shell safe-verb policy allows every command candidate. /// @@ -115,6 +120,8 @@ public static string GetDescription(this ToolAllowReason reason) { ToolAllowReason.PolicyAuto => "The resolved approval policy allowed the tool automatically.", + ToolAllowReason.BackgroundJobLifecycle => + "The initial shell approval covered control of the session-owned background job.", ToolAllowReason.SafeVerbInTrustedScope => "The shell safe-verb policy allowed every candidate inside a trusted scope.", ToolAllowReason.ApprovalExemptShellCandidates =>