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
2 changes: 2 additions & 0 deletions IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ Done when:
still requires approval.
- [x] A prompt excludes a safe stage from the approval candidates that the user
can persist.
- [x] A prompt excludes candidates that existing session or persistent grants
already cover, while it preserves exact directory-scoped occurrences.
- [x] A one-time retry is bound to the exact prompted candidate set, including
each effective directory, across live, sub-agent, and redrive paths.
- [x] External paths, mismatched grants, dynamic syntax, and hard-deny rules
Expand Down
259 changes: 257 additions & 2 deletions src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -555,9 +555,262 @@ public async Task Authorization_evaluation_preserves_partial_approval_matches()

Assert.Equal(ToolAuthorizationOutcome.RequiresApproval, decision.Outcome);
Assert.NotNull(decision.ApprovalContext);
Assert.Equal(["git status", "git push"], decision.ApprovalContext.CandidateVerbs);
Assert.Equal([approvedMatch], decision.ApprovalMatches);
}

[Fact]
public async Task Authorization_evaluation_prompts_only_for_exact_unapproved_candidates()
{
var config = new ToolConfig { ShellMode = ShellExecutionMode.HostAllowed };
config.AudienceProfiles.Personal.ApprovalPolicy = new ToolApprovalConfig
{
ToolOverrides = new Dictionary<string, ToolApprovalMode>(StringComparer.Ordinal)
{
["shell_execute"] = ToolApprovalMode.Approval
}
};
var registry = new ToolRegistry();
registry.WithFirstPartyTools(
config,
new NetclawPaths(),
new ToolPathPolicy([]),
new ShellCommandPolicy());
var approvedMatch = new ToolApprovalMatch("git status", "session", "this chat");
var approvedCandidate = new ApprovalCandidate("git status", Directory: null);
var unapprovedCandidate = new ApprovalCandidate("git push", Directory: null);
var approvalService = new FixedApprovalService(
new ToolApprovalCheckResult(
["git push"],
[approvedMatch])
{
CandidateChecks =
[
new ToolApprovalCandidateCheck(approvedCandidate, approvedMatch),
new ToolApprovalCandidateCheck(unapprovedCandidate, ApprovedMatch: null)
]
});
var executor = new DispatchingToolExecutor(
registry,
new ToolAccessPolicy(
config,
new EffectivePolicyDefaults(
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
UsedStrictFallback: false),
new ShellCommandPolicy(),
new ToolPathPolicy([])),
approvalService);
var call = new FunctionCallContent(
"call-exact-partial-approval",
"shell_execute",
ToolInput.Create("Command", "git status && git push"));
var context = CreateInteractivePersonalContext("signalr/thread-exact-partial-approval");

var decision = await executor.EvaluateAuthorizationAsync(
call,
context,
TestContext.Current.CancellationToken);

Assert.Equal(ToolAuthorizationOutcome.RequiresApproval, decision.Outcome);
var approvalContext = Assert.IsType<ToolApprovalContext>(decision.ApprovalContext);
Assert.Equal(["git push"], approvalContext.Patterns);
Assert.Equal(["git push"], approvalContext.CandidateVerbs);
Assert.Equal([unapprovedCandidate], approvalContext.Candidates);
Assert.Equal([approvedMatch], decision.ApprovalMatches);
}

[SlopwatchSuppress("SW001", "This test verifies Bash parser directory attribution, which does not apply to the Windows shell parser.")]
[Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only shell directory semantics")]
public async Task Authorization_evaluation_preserves_directory_for_duplicate_verb_candidates()
{
var root = Path.Combine(Path.GetTempPath(), $"netclaw-prompt-scope-{Guid.NewGuid():N}");
var approvedDirectory = Path.Combine(root, "approved");
var unapprovedDirectory = Path.Combine(root, "unapproved");
Directory.CreateDirectory(approvedDirectory);
Directory.CreateDirectory(unapprovedDirectory);

try
{
var config = new ToolConfig { ShellMode = ShellExecutionMode.HostAllowed };
config.AudienceProfiles.Personal.ApprovalPolicy = new ToolApprovalConfig
{
ToolOverrides = new Dictionary<string, ToolApprovalMode>(StringComparer.Ordinal)
{
["shell_execute"] = ToolApprovalMode.Approval
}
};
var registry = new ToolRegistry();
registry.WithFirstPartyTools(
config,
new NetclawPaths(),
new ToolPathPolicy([]),
new ShellCommandPolicy());
var approvedMatch = new ToolApprovalMatch("git push", "persistent", approvedDirectory);
var approvedCandidate = new ApprovalCandidate("git push", approvedDirectory);
var unapprovedCandidate = new ApprovalCandidate("git push", unapprovedDirectory);
var approvalService = new FixedApprovalService(
new ToolApprovalCheckResult(
["git push"],
[approvedMatch])
{
CandidateChecks =
[
new ToolApprovalCandidateCheck(approvedCandidate, approvedMatch),
new ToolApprovalCandidateCheck(unapprovedCandidate, ApprovedMatch: null)
]
});
var executor = new DispatchingToolExecutor(
registry,
new ToolAccessPolicy(
config,
new EffectivePolicyDefaults(
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
UsedStrictFallback: false),
new ShellCommandPolicy(),
new ToolPathPolicy([])),
approvalService);
var call = new FunctionCallContent(
"call-duplicate-verb-scopes",
"shell_execute",
ToolInput.Create(
"Command",
$"git -C {approvedDirectory} push && git -C {unapprovedDirectory} push"));
var context = CreateInteractivePersonalContext("signalr/thread-duplicate-verb-scopes");

var decision = await executor.EvaluateAuthorizationAsync(
call,
context,
TestContext.Current.CancellationToken);

Assert.Equal(ToolAuthorizationOutcome.RequiresApproval, decision.Outcome);
var approvalContext = Assert.IsType<ToolApprovalContext>(decision.ApprovalContext);
Assert.Equal(["git push"], approvalContext.Patterns);
Assert.Equal(["git push"], approvalContext.CandidateVerbs);
Assert.Equal([unapprovedCandidate], approvalContext.Candidates);
Assert.Equal([approvedMatch], decision.ApprovalMatches);
}
finally
{
Directory.Delete(root, recursive: true);
}
}

[Fact]
public async Task Authorization_evaluation_keeps_broad_prompt_for_inconsistent_candidate_result()
{
var config = new ToolConfig { ShellMode = ShellExecutionMode.HostAllowed };
config.AudienceProfiles.Personal.ApprovalPolicy = new ToolApprovalConfig
{
ToolOverrides = new Dictionary<string, ToolApprovalMode>(StringComparer.Ordinal)
{
["shell_execute"] = ToolApprovalMode.Approval
}
};
var registry = new ToolRegistry();
registry.WithFirstPartyTools(
config,
new NetclawPaths(),
new ToolPathPolicy([]),
new ShellCommandPolicy());
var approvalService = new FixedApprovalService(
new ToolApprovalCheckResult(
["git push"],
[])
{
CandidateChecks =
[
new ToolApprovalCandidateCheck(
new ApprovalCandidate("git push", Directory: null),
ApprovedMatch: null)
]
});
var executor = new DispatchingToolExecutor(
registry,
new ToolAccessPolicy(
config,
new EffectivePolicyDefaults(
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
UsedStrictFallback: false),
new ShellCommandPolicy(),
new ToolPathPolicy([])),
approvalService);
var call = new FunctionCallContent(
"call-inconsistent-partial-approval",
"shell_execute",
ToolInput.Create("Command", "git status && git push"));
var context = CreateInteractivePersonalContext("signalr/thread-inconsistent-partial-approval");

var decision = await executor.EvaluateAuthorizationAsync(
call,
context,
TestContext.Current.CancellationToken);

Assert.Equal(ToolAuthorizationOutcome.RequiresApproval, decision.Outcome);
Assert.Equal(["git status", "git push"], decision.ApprovalContext!.CandidateVerbs);
}

[Fact]
public async Task Authorization_evaluation_rejects_inconsistent_all_approved_result()
{
var config = new ToolConfig { ShellMode = ShellExecutionMode.HostAllowed };
config.AudienceProfiles.Personal.ApprovalPolicy = new ToolApprovalConfig
{
ToolOverrides = new Dictionary<string, ToolApprovalMode>(StringComparer.Ordinal)
{
["shell_execute"] = ToolApprovalMode.Approval
}
};
var registry = new ToolRegistry();
registry.WithFirstPartyTools(
config,
new NetclawPaths(),
new ToolPathPolicy([]),
new ShellCommandPolicy());
var approvalService = new FixedApprovalService(
new ToolApprovalCheckResult(
[],
[])
{
CandidateChecks =
[
new ToolApprovalCandidateCheck(
new ApprovalCandidate("git push", Directory: null),
ApprovedMatch: null)
]
});
var executor = new DispatchingToolExecutor(
registry,
new ToolAccessPolicy(
config,
new EffectivePolicyDefaults(
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
UsedStrictFallback: false),
new ShellCommandPolicy(),
new ToolPathPolicy([])),
approvalService);
var call = new FunctionCallContent(
"call-inconsistent-all-approved",
"shell_execute",
ToolInput.Create("Command", "git status && git push"));
var context = CreateInteractivePersonalContext("signalr/thread-inconsistent-all-approved");

var decision = await executor.EvaluateAuthorizationAsync(
call,
context,
TestContext.Current.CancellationToken);

Assert.Equal(ToolAuthorizationOutcome.RequiresApproval, decision.Outcome);
Assert.Equal(["git status", "git push"], decision.ApprovalContext!.CandidateVerbs);
}

[Fact]
public async Task Authorization_evaluation_logs_allow_reason_before_execution()
{
Expand Down Expand Up @@ -1091,8 +1344,8 @@ await approvalService.RecordApprovalAsync(
var firstAttempt = await Assert.ThrowsAsync<ToolApprovalRequiredException>(() =>
executor.ExecuteAsync(call, context, TestContext.Current.CancellationToken));

Assert.Contains("pwd", firstAttempt.ApprovalContext.Patterns);
Assert.Contains("ls", firstAttempt.ApprovalContext.Patterns);
Assert.Equal(["ls"], firstAttempt.ApprovalContext.Patterns);
Assert.Equal(["ls"], firstAttempt.ApprovalContext.CandidateVerbs);

context.OneTimeApprovedToolName = call.Name;
context.SetOneTimeApprovedPatterns(OneTimeApprovalKeys.Create(firstAttempt.ApprovalContext));
Expand Down Expand Up @@ -1463,6 +1716,8 @@ private static ToolExecutionContext CreateInteractivePersonalContext(string sess
InteractiveApproval = TestToolExecutionContext.InteractiveApproval(true)
});

public static bool IsPosix => !OperatingSystem.IsWindows();

private sealed class UnexpectedApprovalService : IToolApprovalService
{
public Task<ToolApprovalCheckResult> CheckApprovalAsync(
Expand Down
14 changes: 7 additions & 7 deletions src/Netclaw.Actors.Tests/Tools/ShellApprovalCaseCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,7 @@ public static class ShellApprovalCases
Bash("git push | curl https://example.com"),
Approvals.PersistentAnywhere("git push"),
ExpectedApproval.Require(
["git push", "curl"],
["curl"],
approvalMatches: ["persistent:git push"])),
Case(
"all-pipeline-clauses-approved",
Expand Down Expand Up @@ -603,7 +603,7 @@ public static class ShellApprovalCases
"side-effect-before-mutation-prompts",
Bash("echo ready && git push"),
Approvals.None,
ExpectedApproval.Require(["echo", "git push"])),
ExpectedApproval.Require(["git push"])),
Case(
"heredoc-prompts",
Bash("cat <<'EOF'\nhello\nEOF"),
Expand Down Expand Up @@ -698,7 +698,7 @@ public static class ShellApprovalCases
Bash("cat config.json | jq '.items[]'", ApprovalDirectoryShape.External),
Approvals.PersistentHere(ApprovalDirectoryShape.External, "jq"),
ExpectedApproval.Require(
["cat", "jq"],
["cat"],
approvalMatches: ["persistent:jq"])),
Case(
"workload-edit-grep-tee-pipeline-prompts",
Expand Down Expand Up @@ -986,7 +986,7 @@ public static class ShellApprovalCases
Bash("git add . && git commit -m fix && git push && gh pr merge 123"),
Approvals.PersistentAnywhere("git add", "git commit", "git push"),
ExpectedApproval.Require(
["git add", "git commit", "git push", "gh pr merge"],
["gh pr merge"],
approvalMatches:
[
"persistent:git add",
Expand Down Expand Up @@ -1020,7 +1020,7 @@ public static class ShellApprovalCases
"git push"),
Approvals.PersistentHere(ApprovalDirectoryShape.External, "gh pr merge")),
ExpectedApproval.Require(
["git add", "git commit", "git push", "gh pr merge"],
["gh pr merge"],
approvalMatches:
[
"persistent:git add",
Expand All @@ -1034,7 +1034,7 @@ public static class ShellApprovalCases
Approvals.Session("git add", "git commit", "git push"),
Approvals.SessionForOtherSession("gh pr merge")),
ExpectedApproval.Require(
["git add", "git commit", "git push", "gh pr merge"],
["gh pr merge"],
approvalMatches:
[
"session:git add",
Expand All @@ -1048,7 +1048,7 @@ public static class ShellApprovalCases
Approvals.PersistentAnywhere("git add", "git commit", "git push"),
Approvals.PersistentForOtherAudience("gh pr merge")),
ExpectedApproval.Require(
["git add", "git commit", "git push", "gh pr merge"],
["gh pr merge"],
approvalMatches:
[
"persistent:git add",
Expand Down
Loading
Loading