Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, ToolApprovalMode>(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 };
Expand Down
6 changes: 6 additions & 0 deletions src/Netclaw.Actors/Tools/ToolAccessPolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
7 changes: 7 additions & 0 deletions src/Netclaw.Actors/Tools/ToolAuthorizationDecision.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ internal enum ToolAllowReason
/// </remarks>
PolicyAuto,

/// <summary>
/// The initial shell approval covers control of the session-owned job.
/// </summary>
BackgroundJobLifecycle,

/// <summary>
/// The shell safe-verb policy allows every command candidate.
/// </summary>
Expand Down Expand Up @@ -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 =>
Expand Down
Loading