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
6 changes: 5 additions & 1 deletion IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ Done when:
each effective directory, across live, sub-agent, and redrive paths.
- [x] External paths, mismatched grants, dynamic syntax, and hard-deny rules
keep their strict behavior.
- [x] Directory operands preserve dotted directory names without weakening the
external-path or symlink checks.
- [x] The policy normalizes a variable `git ls-tree` tree operand to the
reviewed read-only verb. Other Git subcommands keep exact parser output.
- [ ] A constrained executable grammar proves any future safe `sed` form. The
`-n` option alone is not proof because a `sed` program can write files or
execute commands.
Expand All @@ -180,7 +184,7 @@ Done when:
patterns, redirect alternatives, and redirect-source alternatives. The
unchanged 225-test Bash, PowerShell 7, and Windows PowerShell 5.1 approval
matrix passes locally.
- [x] The expanded 244-test matrix covers command-substitution and PowerShell
- [x] The expanded 247-test matrix covers command-substitution and PowerShell
execution-region behavior. Known command-owned regions reuse independently
matched host and body grants after Netclaw accounts for the parsed body.
Unknown receivers and incomplete region facts remain prompt-only.
Expand Down
62 changes: 62 additions & 0 deletions src/Netclaw.Actors.Tests/Tools/ScopedShellSafeVerbPolicyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,68 @@ public void New_safe_verb_chained_with_mutating_verb_still_prompts()
Assert.False(policy.AllShortCircuit(Candidates("date", "git push origin main"), _projectDir, ctx));
}

[Fact]
public void Git_ls_tree_operand_normalizes_to_read_only_verb()
{
var policy = new ScopedShellSafeVerbPolicy(VerbList("git ls-tree"));
var candidate = new ApprovalCandidate("git ls-tree feature", _projectDir);

var normalized = policy.NormalizeCandidate(candidate);

Assert.Equal("git ls-tree", normalized.Verb);
Assert.Equal(_projectDir, normalized.Directory);
}

[Fact]
public void Git_subcommand_without_an_operand_rule_stays_exact()
{
var policy = new ScopedShellSafeVerbPolicy(VerbList("git remote"));
var candidate = new ApprovalCandidate("git remote add", _projectDir);

Assert.Equal(candidate, policy.NormalizeCandidate(candidate));
}

[Fact]
public void Same_length_safe_verb_does_not_normalize_to_git_ls_tree()
{
var policy = new ScopedShellSafeVerbPolicy(VerbList("git ls-tree", "gh run list"));
var candidate = new ApprovalCandidate("gh run list feature", _projectDir);

Assert.Equal(candidate, policy.NormalizeCandidate(candidate));
}

[Fact]
public void Git_ls_tree_normalization_requires_safe_list_membership()
{
var policy = new ScopedShellSafeVerbPolicy(VerbList("git status"));
var candidate = new ApprovalCandidate("git ls-tree feature", _projectDir);

Assert.Equal(candidate, policy.NormalizeCandidate(candidate));
}

[Fact]
public void Dotted_symlink_directory_does_not_short_circuit()
{
if (OperatingSystem.IsWindows())
return;

var target = CreateTempDir("dotted-target");
var link = Path.Combine(_projectDir, "service.repo");
try
{
Directory.CreateSymbolicLink(link, target);
var policy = new ScopedShellSafeVerbPolicy(VerbList("find"));
var ctx = PersonalContext(projectDir: _projectDir);
var candidate = new ApprovalCandidate("find", link);

Assert.False(policy.AllShortCircuit([candidate], _projectDir, ctx));
}
finally
{
SafeDelete(target);
}
}

[Fact]
public void Candidate_path_outside_safe_spaces_falls_through_to_prompt()
{
Expand Down
15 changes: 15 additions & 0 deletions src/Netclaw.Actors.Tests/Tools/ShellApprovalCaseCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,21 @@ public static class ShellApprovalCases
Bash("git status"),
Approvals.None,
ExpectedApproval.Allow(ToolAllowReason.SafeVerbInTrustedScope)),
Case(
"safe-git-ls-tree-ref-allows",
Bash("git ls-tree feature"),
Approvals.None,
ExpectedApproval.Allow(ToolAllowReason.SafeVerbInTrustedScope)),
Case(
"safe-git-ls-tree-external-prompts-with-canonical-verb",
Bash("git ls-tree feature", ApprovalDirectoryShape.External),
Approvals.None,
ExpectedApproval.Require(["git ls-tree"])),
Case(
"safe-git-ls-tree-external-reuses-canonical-grant",
Bash("git ls-tree feature", ApprovalDirectoryShape.External),
Approvals.PersistentHere(ApprovalDirectoryShape.External, "git ls-tree"),
ExpectedApproval.Allow(ToolAllowReason.StoredApproval, 1, "persistent:git ls-tree")),
Case(
"safe-verb-context-project-fallback-allows",
Bash("cat src/readme.txt", ApprovalDirectoryShape.None),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
| hard-deny-beats-stored-grant | Bash | Personal | Project | Interactive | netclaw daemon stop | persistent[anywhere]:netclaw daemon stop | Denied | hard_deny_self_destructive | none | Not applicable |
| compound-hard-deny-denies | Bash | Personal | Project | Interactive | git status && netclaw daemon stop | none | Denied | hard_deny_self_destructive | none | Not applicable |
| safe-verb-project-allows | Bash | Personal | Project | Interactive | git status | none | Allowed | SafeVerbInTrustedScope | none | Not applicable |
| safe-git-ls-tree-ref-allows | Bash | Personal | Project | Interactive | git ls-tree feature | none | Allowed | SafeVerbInTrustedScope | none | Not applicable |
| safe-git-ls-tree-external-prompts-with-canonical-verb | Bash | Personal | External | Interactive | git ls-tree feature | none | RequiresApproval | approval required | git ls-tree | No |
| safe-git-ls-tree-external-reuses-canonical-grant | Bash | Personal | External | Interactive | git ls-tree feature | persistent[external]:git ls-tree | Allowed | StoredApproval | none | Not applicable |
| safe-verb-context-project-fallback-allows | Bash | Personal | None | Interactive | cat src/readme.txt | none | Allowed | SafeVerbInTrustedScope | none | Not applicable |
| safe-verb-context-project-traversal-prompts | Bash | Personal | None | Interactive | cat ../secret.txt | none | RequiresApproval | approval required | cat | No |
| safe-verb-session-allows | Bash | Personal | Session | Interactive | git status | none | Allowed | SafeVerbInTrustedScope | none | Not applicable |
Expand Down
7 changes: 4 additions & 3 deletions src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,18 +103,19 @@ public void Missing_personal_approval_policy_fails_closed_for_shell()
[Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")]
public void Static_shell_glob_uses_covering_directory_and_offers_persistent_approval()
{
using var dir = new DisposableTempDir();
var policy = CreatePolicy(ToolApprovalMode.Approval);
var args = ToolInput.Create(
"Command", "rm /tmp/*.bak",
"WorkingDirectory", "/home/user/project");
"Command", $"rm {dir.Path}/*.bak",
"WorkingDirectory", dir.Path);

var decision = policy.AuthorizeInvocation(ShellTool(), PersonalContext(), args);

Assert.True(decision.NeedsApproval);
Assert.False(decision.ApprovalContext!.IsMessy);
var candidate = Assert.Single(decision.ApprovalContext.Candidates!);
Assert.Equal("rm", candidate.Verb);
Assert.Equal("/tmp", candidate.Directory);
Assert.Equal(dir.Path, candidate.Directory);
Assert.Contains(
decision.ApprovalContext.Options,
option => option.Key.Value == ApprovalOptionKeys.ApproveAlways);
Expand Down
16 changes: 16 additions & 0 deletions src/Netclaw.Actors/Tools/ScopedShellSafeVerbPolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ namespace Netclaw.Actors.Tools;
/// </summary>
internal sealed class ScopedShellSafeVerbPolicy
{
private const string GitLsTreeVerb = "git ls-tree";
private readonly SafeVerbList _safeVerbs;

public ScopedShellSafeVerbPolicy(SafeVerbList safeVerbs)
Expand All @@ -46,6 +47,21 @@ public ScopedShellSafeVerbPolicy(SafeVerbList safeVerbs)
public bool ShortCircuitsApproval(string candidateVerb, string? cwd, ToolInvocationContext context)
=> AllShortCircuit([new ApprovalCandidate(candidateVerb, Directory: null)], cwd, context);

/// <summary>
/// Removes the variable tree operand from a read-only <c>git ls-tree</c>
/// candidate. Other Git commands keep exact parser output because a
/// trailing token can name a mutating subcommand.
/// </summary>
public ApprovalCandidate NormalizeCandidate(ApprovalCandidate candidate)
{
if (_safeVerbs.IsOperandBearingMatch(candidate.Verb, GitLsTreeVerb))
{
return candidate with { Verb = GitLsTreeVerb };
}

return candidate;
}

/// <summary>
/// Returns true when each candidate has a safe verb and a safe effective
/// directory. The candidate directory takes precedence over the cwd.
Expand Down
8 changes: 5 additions & 3 deletions src/Netclaw.Actors/Tools/ToolAccessPolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,9 @@ private ToolAccessDecision CheckApprovalGate(
var isMessy = shellApproval?.IsMessy
?? matcher.IsMessy(toolName, analysisArguments);

IReadOnlyList<ApprovalCandidate> approvalCandidates = candidates;
IReadOnlyList<ApprovalCandidate> approvalCandidates = _safeVerbPolicy is not null && isShell
? candidates.Select(_safeVerbPolicy.NormalizeCandidate).Distinct().ToList()
: candidates;

// A clean shell command can combine safe candidates with candidates
// that need a stored grant. Remove only candidates that independently
Expand All @@ -397,9 +399,9 @@ private ToolAccessDecision CheckApprovalGate(
if (_safeVerbPolicy is not null
&& isShell
&& !isMessy
&& candidates.Count > 0)
&& approvalCandidates.Count > 0)
{
approvalCandidates = candidates
approvalCandidates = approvalCandidates
.Where(candidate => !_safeVerbPolicy.AllShortCircuit(
[candidate],
context.Approval.Cwd,
Expand Down
15 changes: 15 additions & 0 deletions src/Netclaw.Configuration.Tests/SafeVerbLoaderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public void Load_returns_bundled_linux_defaults()
Assert.True(list.Contains("uname"));
Assert.True(list.Contains("whoami"));
Assert.True(list.Contains("git describe"));
Assert.True(list.Contains("git ls-tree"));
Assert.True(list.Contains("gh pr view"));
Assert.True(list.Contains("gh run list"));

Expand Down Expand Up @@ -59,6 +60,7 @@ public void Load_returns_bundled_windows_defaults()
Assert.True(list.Contains("Get-Date"));
Assert.True(list.Contains("whoami"));
Assert.True(list.Contains("git describe"));
Assert.True(list.Contains("git ls-tree"));
Assert.True(list.Contains("gh pr view"));

// Excluded on purpose: gh api can issue any HTTP method; Get-Process
Expand Down Expand Up @@ -91,6 +93,19 @@ public void Contains_uses_platform_correct_case_rules()
Assert.True(windows.Contains("Get-Content"));
}

[Fact]
public void Operand_match_uses_platform_case_rules_and_exact_verb_identity()
{
var linux = SafeVerbLoader.Load(isWindows: false);
var windows = SafeVerbLoader.Load(isWindows: true);

Assert.True(linux.IsOperandBearingMatch("git ls-tree feature", "git ls-tree"));
Assert.False(linux.IsOperandBearingMatch("GIT LS-TREE feature", "git ls-tree"));
Assert.True(windows.IsOperandBearingMatch("GIT LS-TREE feature", "git ls-tree"));
Assert.False(windows.IsOperandBearingMatch("Get-Content X", "git ls-tree"));
Assert.False(windows.IsOperandBearingMatch("gh run list X", "git ls-tree"));
}

[Fact]
public void Load_has_no_disk_loading_surface()
{
Expand Down
10 changes: 10 additions & 0 deletions src/Netclaw.Configuration/SafeVerbList.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ public static SafeVerbList FromVerbs(IEnumerable<string> verbs)
public bool Contains(string candidateVerb)
=> !string.IsNullOrEmpty(candidateVerb) && _verbs.Contains(candidateVerb);

/// <summary>
/// Returns true when a listed verb starts the candidate and an operand
/// follows it. The comparison uses the selected platform rules.
/// </summary>
public bool IsOperandBearingMatch(string candidateVerb, string listedVerb)
=> _verbs.Contains(listedVerb)
&& candidateVerb.Length > listedVerb.Length
&& candidateVerb[listedVerb.Length] == ' '
&& _verbs.Comparer.Equals(candidateVerb[..listedVerb.Length], listedVerb);

/// <summary>The verbs in this list. Stable ordering; intended for diagnostics, not lookups.</summary>
public IReadOnlyCollection<string> Verbs => _verbs;
}
Expand Down
1 change: 1 addition & 0 deletions src/Netclaw.Configuration/SafeVerbs/safe-verbs.linux.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"git remote",
"git rev-parse",
"git ls-files",
"git ls-tree",
"git blame",
"git describe",
"git rev-list",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"git remote",
"git rev-parse",
"git ls-files",
"git ls-tree",
"git blame",
"git describe",
"git rev-list",
Expand Down
64 changes: 64 additions & 0 deletions src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,27 @@ public void Power_shell_matcher_uses_the_native_power_shell_grammar()
Assert.Equal("Get-ChildItem", Assert.Single(analysis.Candidates).Verb);
}

[Theory]
[InlineData(@"Set-Location C:\workspace\service.repo", "Set-Location")]
[InlineData(@"cd C:\workspace\service.repo", "Set-Location")]
[InlineData(@"Push-Location C:\workspace\service.repo", "Push-Location")]
public void Power_shell_location_command_preserves_dotted_directory(
string command,
string expectedVerb)
{
var matcher = new ShellApprovalMatcher(
ShellExecutionEnvironment.CreatePowerShell(
@"C:\Program Files\PowerShell\7\pwsh.exe",
PwshDialect.PowerShell7));

var candidate = Assert.Single(matcher.ExtractCandidates(
new ToolName("shell_execute"),
Args(command, @"C:\workspace")));

Assert.Equal(expectedVerb, candidate.Verb);
Assert.Equal("C:/workspace/service.repo", candidate.Directory);
}

[Theory]
[InlineData(@"Get-Content Env:\Path")]
[InlineData(@"Get-Item HKLM:\Software\Vendor")]
Expand Down Expand Up @@ -1331,6 +1352,49 @@ public void ExtractCandidates_extracts_cd_target_as_directory()
&& c.Directory == "/home/user/repos/example");
}

[Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")]
public void ExtractCandidates_preserves_dotted_cd_target_as_directory()
{
var candidate = Assert.Single(_matcher.ExtractCandidates(
new ToolName("shell_execute"),
new Dictionary<string, object?>
{
["Command"] = "cd /workspace/service.repo"
}));

Assert.Equal("cd", candidate.Verb);
Assert.Equal("/workspace/service.repo", candidate.Directory);
}

[Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")]
public void ExtractCandidates_find_dot_preserves_dotted_working_directory()
{
var candidate = Assert.Single(_matcher.ExtractCandidates(
new ToolName("shell_execute"),
new Dictionary<string, object?>
{
["Command"] = "find . -maxdepth 1 -type f",
["WorkingDirectory"] = "/workspace/service.repo"
}));

Assert.Equal("find", candidate.Verb);
Assert.Equal("/workspace/service.repo", candidate.Directory);
}

[Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")]
public void ExtractCandidates_file_operand_in_dotted_directory_uses_parent_directory()
{
var candidate = Assert.Single(_matcher.ExtractCandidates(
new ToolName("shell_execute"),
new Dictionary<string, object?>
{
["Command"] = "cat /workspace/service.repo/readme.md"
}));

Assert.Equal("cat", candidate.Verb);
Assert.Equal("/workspace/service.repo", candidate.Directory);
}

[Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")]
public void ExtractCandidates_propagates_cd_target_to_subsequent_clauses_with_no_path_arg()
{
Expand Down
18 changes: 18 additions & 0 deletions src/Netclaw.Security.Tests/ShellTokenizerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,24 @@ public void IsPathToken_classifies_correctly(string token, bool expected)
Assert.Equal(expected, ShellTokenizer.IsPathToken(token));
}

[Theory]
[InlineData(@"C:\workspace\service.repo", true)]
[InlineData("\"C:\\workspace\\service.repo\"", true)]
[InlineData("C:/workspace/service.repo", true)]
[InlineData("'/workspace/service.repo'", true)]
[InlineData(@"\\server\share\service.repo", true)]
[InlineData(@".\service.repo", true)]
[InlineData(@"..\service.repo", true)]
[InlineData(@"~\service.repo", true)]
[InlineData("origin/main", false)]
[InlineData(@"module\command", false)]
[InlineData("https://example.com/service", false)]
[InlineData(@"Env:\Path", false)]
public void Windows_path_style_recognizes_lexical_path_roots(string token, bool expected)
{
Assert.Equal(expected, ShellTokenizer.IsPathToken(token, ShellPathStyle.Windows));
}

// ── ExtractFirstPathArgument ──

[Theory]
Expand Down
Loading
Loading