From aa5a5059acd8e61530df091ad525a7352d421ec0 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 6 May 2026 19:26:22 +0000 Subject: [PATCH 01/22] feat: directory-scoped shell command approval patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-file approval patterns for path-aware verbs (ls, cat, grep, find, etc.) forced users to approve every individual file access separately — 11+ prompts in a single diagnostic session. Replace with directory-scoped patterns that cover all files under a parent directory when the user selects "Approve for this chat" or "Approve always". Key changes: - ShellTokenizer.ExtractDirectoryScope() scans all args for file paths (not just first positional), extracts parent directory with minimum depth of 2 segments to prevent overly broad scopes like / or /etc/ - ApprovalPatternMatching gains directory-prefix matching using PathUtility.IsWithinRoot() for boundary-safe containment - IToolApprovalMatcher extended with ExtractDirectoryPatterns(); implemented on ShellApprovalMatcher (with compound command + bash -c recursion), DefaultApprovalMatcher, and FilePathApprovalMatcher - DirectoryPatterns wired through ToolInteractionRequest → PendingToolInteraction → RecordApprovalAsync so B/C decisions store directory patterns - Approval option labels dynamically show directory scope (e.g., "Approve in /home/.netclaw/logs/ for this chat") Security: only relaxes the interactive approval gate. Hard deny list, ToolPathPolicy (protected paths), symlink resolution, and path traversal prevention layers remain unaffected. --- src/Netclaw.Actors/Protocol/SessionOutput.cs | 8 ++ .../Sessions/LlmSessionActor.cs | 7 +- .../Pipelines/SessionToolExecutionPipeline.cs | 1 + .../Tools/FilePathApprovalMatcher.cs | 3 + src/Netclaw.Actors/Tools/ToolAccessPolicy.cs | 26 +++++- .../ShellApprovalMatcherTests.cs | 6 ++ .../ShellTokenizerTests.cs | 49 +++++++++++ .../ApprovalPatternMatching.cs | 44 ++++++++-- src/Netclaw.Security/IToolApprovalMatcher.cs | 48 ++++++++++ src/Netclaw.Security/ShellTokenizer.cs | 88 +++++++++++++++++++ 10 files changed, 270 insertions(+), 10 deletions(-) diff --git a/src/Netclaw.Actors/Protocol/SessionOutput.cs b/src/Netclaw.Actors/Protocol/SessionOutput.cs index 08eb9a44..60641067 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutput.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutput.cs @@ -360,6 +360,14 @@ public sealed record ToolInteractionRequest : SessionOutput /// Patterns requiring approval (for shell: verb chains like "git push"). public IReadOnlyList Patterns { get; init; } = []; + /// + /// Directory-scoped patterns for session/persistent approval storage. + /// When non-empty and the user selects "Approve for this chat" or "Approve always", + /// these patterns are recorded instead of to provide + /// directory-level coverage (e.g., "grep /home/.netclaw/logs/"). + /// + public IReadOnlyList DirectoryPatterns { get; init; } = []; + /// Available response options (e.g., approve once, approve for this chat, approve always, deny). public required IReadOnlyList Options { get; init; } diff --git a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs index 9a2def20..5d0ff265 100644 --- a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs +++ b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs @@ -744,6 +744,7 @@ private void Processing() _pendingToolInteractions[msg.CallId] = new PendingToolInteraction( msg.ToolName, msg.Patterns, + msg.DirectoryPatterns, CurrentTurnAudience(), msg.RequesterSenderId, msg.RequesterPrincipal, @@ -793,11 +794,14 @@ private void Processing() if (decision is ApprovalDecision.ApprovedSession or ApprovalDecision.ApprovedAlways && _approvalService is not null) { + var patternsToRecord = pending.DirectoryPatterns.Count > 0 + ? pending.DirectoryPatterns + : pending.Patterns; await _approvalService.RecordApprovalAsync( _sessionId.Value, pending.Audience, new ToolName(pending.ToolName), - pending.Patterns, + patternsToRecord, persistent: decision == ApprovalDecision.ApprovedAlways, CancellationToken.None); } @@ -3039,6 +3043,7 @@ private void EmitOutput(SessionOutput output, OutputFilter requiredFlag = Output private sealed record PendingToolInteraction( string ToolName, IReadOnlyList Patterns, + IReadOnlyList DirectoryPatterns, TrustAudience Audience, string? RequesterSenderId, PrincipalClassification? RequesterPrincipal, diff --git a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs index 9605c7ce..e2e50a87 100644 --- a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs +++ b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs @@ -284,6 +284,7 @@ public static async Task ExecuteSingleToolAsync( AdoptedSpeakerIds = source?.AdoptedSpeakerIds ?? [], PersistedAdoptedContext = source?.HasAdoptedContext ?? false, Patterns = ctx.UnapprovedPatterns, + DirectoryPatterns = ctx.DirectoryPatterns ?? [], Options = ctx.Options .Select(o => new ToolInteractionOption(o.Key, o.Label)) .ToList() diff --git a/src/Netclaw.Actors/Tools/FilePathApprovalMatcher.cs b/src/Netclaw.Actors/Tools/FilePathApprovalMatcher.cs index 1e8f6cd2..9a950fcb 100644 --- a/src/Netclaw.Actors/Tools/FilePathApprovalMatcher.cs +++ b/src/Netclaw.Actors/Tools/FilePathApprovalMatcher.cs @@ -73,6 +73,9 @@ public string FormatForDisplay(ToolName toolName, IDictionary? return toolName.Value; } + public IReadOnlyList ExtractDirectoryPatterns(ToolName toolName, IDictionary? arguments) + => []; + private bool TryGetControlPlaneRelativePath( IDictionary? arguments, out string relativePath) diff --git a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs index 34b765b9..6d9e12ad 100644 --- a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs +++ b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs @@ -298,17 +298,34 @@ private ToolAccessDecision CheckApprovalGate( } var allPatterns = matcher.ExtractPatterns(toolName, arguments); + var directoryPatterns = matcher.ExtractDirectoryPatterns(toolName, arguments); var displayText = matcher.FormatForDisplay(toolName, arguments); + + var sessionLabel = ApprovalOptionKeys.ApproveSessionLabel; + var alwaysLabel = ApprovalOptionKeys.ApproveAlwaysLabel; + if (directoryPatterns.Count > 0) + { + var firstDir = directoryPatterns[0]; + var spaceIdx = firstDir.IndexOf(' ', StringComparison.Ordinal); + if (spaceIdx >= 0) + { + var dir = firstDir[(spaceIdx + 1)..]; + sessionLabel = $"Approve in {dir} for this chat"; + alwaysLabel = $"Approve in {dir} always"; + } + } + var approvalContext = new ToolApprovalContext( toolName.Value, displayText, allPatterns, [ new ToolApprovalOption(ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.ApproveOnceLabel), - new ToolApprovalOption(ApprovalOptionKeys.ApproveSession, ApprovalOptionKeys.ApproveSessionLabel), - new ToolApprovalOption(ApprovalOptionKeys.ApproveAlways, ApprovalOptionKeys.ApproveAlwaysLabel), + new ToolApprovalOption(ApprovalOptionKeys.ApproveSession, sessionLabel), + new ToolApprovalOption(ApprovalOptionKeys.ApproveAlways, alwaysLabel), new ToolApprovalOption(ApprovalOptionKeys.Deny, ApprovalOptionKeys.DenyLabel) - ]); + ], + directoryPatterns); return ToolAccessDecision.RequiresApproval(approvalContext); } @@ -455,7 +472,8 @@ public sealed record ToolApprovalContext( string ToolName, string DisplayText, IReadOnlyList UnapprovedPatterns, - IReadOnlyList Options); + IReadOnlyList Options, + IReadOnlyList? DirectoryPatterns = null); public sealed record ToolApprovalOption(string Key, string Label); diff --git a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs index fa23b69b..ab9cb49f 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs @@ -98,6 +98,12 @@ public void IsApproved_one_pattern_unapproved() [InlineData("cat /etc/passwd", "cat /etc/shadow", false)] [InlineData("bash /home/.netclaw/scripts/monitor.sh", "bash /home/.netclaw/scripts/monitor.sh", true)] [InlineData("bash /home/.netclaw/scripts/monitor.sh", "bash /tmp/evil.sh", false)] + // Directory-scoped patterns (trailing /) match files under that directory + [InlineData("cat /home/user/.netclaw/logs/", "cat /home/user/.netclaw/logs/crash.log", true)] + [InlineData("cat /home/user/.netclaw/logs/", "cat /home/user/.netclaw/logs/deep/nested.txt", true)] + [InlineData("cat /home/user/.netclaw/logs/", "cat /home/user/.netclaw/config/secret.json", false)] + [InlineData("grep /home/user/.netclaw/logs/", "cat /home/user/.netclaw/logs/crash.log", false)] // verb mismatch + [InlineData("ls /home/user/.netclaw/", "ls /home/user/.netclaw/logs/deep/file.txt", true)] // nested // Single-token path-aware verbs stay exact-only [InlineData("cat", "cat /etc/passwd", false)] [InlineData("grep", "grep TODO", false)] diff --git a/src/Netclaw.Security.Tests/ShellTokenizerTests.cs b/src/Netclaw.Security.Tests/ShellTokenizerTests.cs index c0f70f27..0f2a521c 100644 --- a/src/Netclaw.Security.Tests/ShellTokenizerTests.cs +++ b/src/Netclaw.Security.Tests/ShellTokenizerTests.cs @@ -270,4 +270,53 @@ public void LooksLikePath_backslash(string token) { Assert.True(ShellTokenizer.LooksLikePath(token)); } + + // ── ExtractDirectoryScope ── + + [Theory] + [InlineData("cat /home/user/.netclaw/logs/crash.log", "cat", "/home/user/.netclaw/logs/")] + [InlineData("ls -la /home/user/.netclaw/logs/", "ls", "/home/user/.netclaw/logs/")] + [InlineData("find /home/user/.netclaw/logs -name '*.log'", "find", "/home/user/.netclaw/")] + public void ExtractDirectoryScope_returns_verb_and_directory(string command, string expectedVerb, string expectedDirSuffix) + { + var result = ShellTokenizer.ExtractDirectoryScope(command); + Assert.NotNull(result); + Assert.StartsWith(expectedVerb + " ", result); + Assert.EndsWith("/", result); + + var dir = result[(expectedVerb.Length + 1)..]; + Assert.EndsWith(expectedDirSuffix, dir.Replace('\\', '/')); + } + + [Fact] + public void ExtractDirectoryScope_grep_finds_path_not_search_term() + { + var result = ShellTokenizer.ExtractDirectoryScope( + "grep -l \"timeout\" /home/user/.netclaw/logs/daemon.log"); + Assert.NotNull(result); + Assert.StartsWith("grep ", result); + Assert.EndsWith("/", result); + Assert.Contains(".netclaw/logs/", result.Replace('\\', '/')); + } + + [Fact] + public void ExtractDirectoryScope_handles_glob_paths() + { + var result = ShellTokenizer.ExtractDirectoryScope( + "ls /home/user/.netclaw/logs/crash-*.log"); + Assert.NotNull(result); + Assert.StartsWith("ls ", result); + Assert.EndsWith("/", result); + Assert.Contains(".netclaw/logs/", result.Replace('\\', '/')); + } + + [Theory] + [InlineData("echo hello")] // not a path-aware verb + [InlineData("git push origin main")] // not in PathAwareVerbs + [InlineData("grep --version")] // no path argument + [InlineData("cat /etc/passwd")] // too shallow (/etc/ = 1 segment) + public void ExtractDirectoryScope_returns_null(string command) + { + Assert.Null(ShellTokenizer.ExtractDirectoryScope(command)); + } } diff --git a/src/Netclaw.Security/ApprovalPatternMatching.cs b/src/Netclaw.Security/ApprovalPatternMatching.cs index 3f162e69..23b195da 100644 --- a/src/Netclaw.Security/ApprovalPatternMatching.cs +++ b/src/Netclaw.Security/ApprovalPatternMatching.cs @@ -9,7 +9,9 @@ namespace Netclaw.Security; /// Shared verb-chain prefix matcher used for tool approval grants. An approved /// pattern matches a candidate exactly or as a verb-chain prefix on a space /// boundary — so "git push" approves "git push origin main" but never -/// "github-cli". +/// "github-cli". Directory-scoped patterns (trailing /) match any +/// candidate whose path is within the approved directory, using +/// for boundary-safe containment. /// public static class ApprovalPatternMatching { @@ -20,19 +22,51 @@ public static bool MatchesAny(string candidate, IEnumerable approvedPatt if (string.Equals(candidate, approved, StringComparison.OrdinalIgnoreCase)) return true; - if (candidate.Length <= approved.Length || candidate[approved.Length] != ' ') + if (!approved.Contains(' ', StringComparison.Ordinal)) continue; - if (!candidate.StartsWith(approved, StringComparison.OrdinalIgnoreCase)) - continue; + // Directory-scoped patterns: "verb /dir/" matches "verb /dir/file.txt" + if (approved.EndsWith('/') && MatchesDirectoryScope(candidate, approved)) + return true; // Multi-token patterns prefix-match on a space boundary. Single-token // patterns remain exact-only so grants do not silently widen from // "cat" to every path-bearing cat invocation. - if (approved.Contains(' ', StringComparison.Ordinal)) + if (candidate.Length > approved.Length + && candidate[approved.Length] == ' ' + && candidate.StartsWith(approved, StringComparison.OrdinalIgnoreCase)) return true; } return false; } + + private static bool MatchesDirectoryScope(string candidate, string approvedDirPattern) + { + var approvedSpaceIdx = approvedDirPattern.IndexOf(' ', StringComparison.Ordinal); + if (approvedSpaceIdx < 0) + return false; + + var approvedVerb = approvedDirPattern[..approvedSpaceIdx]; + var approvedDir = approvedDirPattern[(approvedSpaceIdx + 1)..].TrimEnd('/'); + + var candidateSpaceIdx = candidate.IndexOf(' ', StringComparison.Ordinal); + if (candidateSpaceIdx < 0) + return false; + + var candidateVerb = candidate[..candidateSpaceIdx]; + var candidatePath = candidate[(candidateSpaceIdx + 1)..]; + + if (!string.Equals(approvedVerb, candidateVerb, StringComparison.OrdinalIgnoreCase)) + return false; + + try + { + return PathUtility.IsWithinRoot(candidatePath, approvedDir); + } + catch + { + return false; + } + } } diff --git a/src/Netclaw.Security/IToolApprovalMatcher.cs b/src/Netclaw.Security/IToolApprovalMatcher.cs index 706bbd6b..36108377 100644 --- a/src/Netclaw.Security/IToolApprovalMatcher.cs +++ b/src/Netclaw.Security/IToolApprovalMatcher.cs @@ -48,6 +48,13 @@ public interface IToolApprovalMatcher /// Formats the tool call for display in the approval prompt. /// string FormatForDisplay(ToolName toolName, IDictionary? arguments); + + /// + /// Extracts directory-scoped patterns for session/persistent approval storage. + /// For shell commands, returns "verb /parent-dir/" patterns derived from + /// file-path arguments. Returns empty when no directory scope is available. + /// + IReadOnlyList ExtractDirectoryPatterns(ToolName toolName, IDictionary? arguments); } /// @@ -97,6 +104,17 @@ public string FormatForDisplay(ToolName toolName, IDictionary? return GetCommand(arguments) ?? "(empty command)"; } + public IReadOnlyList ExtractDirectoryPatterns(ToolName toolName, IDictionary? arguments) + { + var command = GetCommand(arguments); + if (string.IsNullOrWhiteSpace(command)) + return []; + + var patterns = new HashSet(StringComparer.OrdinalIgnoreCase); + CollectDirectoryPatterns(command, patterns); + return patterns.ToList(); + } + private static string? GetCommand(IDictionary? arguments) { if (arguments is null) @@ -129,6 +147,33 @@ private static void CollectPatterns(string command, ISet patterns) patterns.Add(verbChain); } } + + private static void CollectDirectoryPatterns(string command, ISet patterns) + { + foreach (var segment in ShellTokenizer.SplitCompoundCommand(command)) + { + var innerCommands = ShellTokenizer.ExtractInnerCommands(segment); + if (innerCommands.Count > 0) + { + foreach (var inner in innerCommands) + CollectDirectoryPatterns(inner, patterns); + + continue; + } + + var dirScope = ShellTokenizer.ExtractDirectoryScope(segment); + if (dirScope is not null) + { + patterns.Add(dirScope); + } + else + { + var verbChain = ShellTokenizer.ExtractVerbChain(segment); + if (!string.IsNullOrEmpty(verbChain)) + patterns.Add(verbChain); + } + } + } } /// @@ -165,4 +210,7 @@ public string FormatForDisplay(ToolName toolName, IDictionary? { return toolName.Value; } + + public IReadOnlyList ExtractDirectoryPatterns(ToolName toolName, IDictionary? arguments) + => []; } diff --git a/src/Netclaw.Security/ShellTokenizer.cs b/src/Netclaw.Security/ShellTokenizer.cs index 4fbea4e6..7413e691 100644 --- a/src/Netclaw.Security/ShellTokenizer.cs +++ b/src/Netclaw.Security/ShellTokenizer.cs @@ -357,6 +357,94 @@ public static bool LooksLikePath(string token) return false; } + /// + /// Extracts a directory-scoped approval pattern from a shell command by + /// finding the first file-path argument (not just the first positional + /// argument) and returning "{verb} {parentDirectory}/". Returns + /// null when the command has no path-aware verb, no recognizable file-path + /// argument, or the resulting directory is too shallow (fewer than 2 + /// path segments below root). + /// + public static string? ExtractDirectoryScope(string command) + { + var tokens = Tokenize(command).ToList(); + if (tokens.Count == 0) + return null; + + var verb = TrimShellPunctuation(tokens[0]); + if (verb.Length == 0 || !PathAwareVerbs.Contains(verb)) + return null; + + for (var i = 1; i < tokens.Count; i++) + { + var trimmed = TrimShellPunctuation(tokens[i]); + if (trimmed.Length == 0 || trimmed.StartsWith('-')) + continue; + + if (!LooksLikePath(trimmed)) + continue; + + var expanded = PathUtility.ExpandHome(trimmed); + var dir = ExtractParentDirectory(expanded); + if (dir is null) + continue; + + if (!PathUtility.TryNormalize(dir, null, out var normalized)) + continue; + + // Enforce minimum depth — reject shallow scopes like / or /etc/ + if (CountPathSegments(normalized) < MinDirectoryScopeDepth) + return null; + + return verb + " " + normalized + "/"; + } + + return null; + } + + internal const int MinDirectoryScopeDepth = 2; + + private static string? ExtractParentDirectory(string path) + { + // Already a directory (trailing separator) + if (path.EndsWith('/') || path.EndsWith('\\')) + return path.TrimEnd('/', '\\'); + + // Glob: use directory portion before the glob + var globIdx = path.IndexOfAny(['*', '?', '[']); + if (globIdx >= 0) + { + var lastSep = path.LastIndexOf('/', globIdx); + if (lastSep < 0) + lastSep = path.LastIndexOf('\\', globIdx); + return lastSep > 0 ? path[..lastSep] : null; + } + + // Regular file: parent directory + var dir = Path.GetDirectoryName(path); + return string.IsNullOrEmpty(dir) ? null : dir; + } + + private static int CountPathSegments(string normalizedPath) + { + var trimmed = normalizedPath + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + if (trimmed.Length == 0) + return 0; + + // Strip root (e.g., "/" on Linux, "C:\" on Windows) + var root = Path.GetPathRoot(trimmed); + if (root is not null && trimmed.Length > root.Length) + trimmed = trimmed[root.Length..]; + else if (root is not null) + return 0; // path IS the root + + return trimmed.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries).Length; + } + /// /// Returns true when the pattern is a single-token shell approval for a /// path-aware verb such as cat or bash. From b16e4b79f9c88a746fa7f3ffdb8a2a2d23cb0f6c Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 6 May 2026 20:09:53 +0000 Subject: [PATCH 02/22] feat: add tests, fix label bug, and simplify directory-scoped approval code Add 8 new tests covering directory-scoped approval patterns: 3 in ToolApprovalGateTests (DirectoryPatterns population, directory-scoped labels, default labels for non-path commands) and 5 in ShellApprovalMatcherTests (ExtractDirectoryPatterns for simple paths, compound commands, verb-chain fallback, empty commands, mixed compounds). Fix label bug where verb-chain fallbacks like "git push" triggered directory-scope label formatting, producing nonsense labels like "Approve in push for this chat". Now checks for trailing '/' to identify actual directory-scoped patterns before customizing labels. Simplify code: unify CollectPatterns/CollectDirectoryPatterns into shared TraverseSegments helper with Func leaf extractor; narrow bare catch to ArgumentException|IOException; use PathUtility.ExpandAndNormalize instead of separate ExpandHome + TryNormalize calls; make DirectoryPatterns non-nullable on ToolApprovalContext. Include OpenSpec change artifacts (proposal, design, delta spec, tasks). --- .../.openspec.yaml | 2 + .../design.md | 90 ++++++ .../proposal.md | 53 ++++ .../specs/tool-approval-gates/spec.md | 278 ++++++++++++++++++ .../tasks.md | 38 +++ .../SessionToolExecutionPipelineTests.cs | 3 +- .../Tools/ToolApprovalGateTests.cs | 52 ++++ .../Pipelines/SessionToolExecutionPipeline.cs | 2 +- .../Tools/DispatchingToolExecutor.cs | 3 +- src/Netclaw.Actors/Tools/ToolAccessPolicy.cs | 10 +- .../ShellApprovalMatcherTests.cs | 52 ++++ .../ApprovalPatternMatching.cs | 2 +- src/Netclaw.Security/IToolApprovalMatcher.cs | 38 +-- src/Netclaw.Security/ShellTokenizer.cs | 6 +- 14 files changed, 588 insertions(+), 41 deletions(-) create mode 100644 openspec/changes/directory-scoped-approval-patterns/.openspec.yaml create mode 100644 openspec/changes/directory-scoped-approval-patterns/design.md create mode 100644 openspec/changes/directory-scoped-approval-patterns/proposal.md create mode 100644 openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md create mode 100644 openspec/changes/directory-scoped-approval-patterns/tasks.md diff --git a/openspec/changes/directory-scoped-approval-patterns/.openspec.yaml b/openspec/changes/directory-scoped-approval-patterns/.openspec.yaml new file mode 100644 index 00000000..2188dbdb --- /dev/null +++ b/openspec/changes/directory-scoped-approval-patterns/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-06 diff --git a/openspec/changes/directory-scoped-approval-patterns/design.md b/openspec/changes/directory-scoped-approval-patterns/design.md new file mode 100644 index 00000000..ee02c000 --- /dev/null +++ b/openspec/changes/directory-scoped-approval-patterns/design.md @@ -0,0 +1,90 @@ +## Context + +Shell command approval patterns are extracted by `ShellTokenizer.ExtractVerbChain()` +which, for path-aware verbs (`ls`, `cat`, `grep`, `find`, etc.), appends the +first file-path argument to the verb chain. This produces per-file patterns +like `cat /home/.netclaw/logs/crash-foo.log`. Combined with the single-token +exact-match restriction in `ApprovalPatternMatching` (which prevents bare `cat` +from silently approving `cat /etc/shadow`), each unique file path requires a +separate interactive approval. + +The approval system has three security layers: +1. Hard deny list (before approval gate) +2. Interactive approval gate (`ToolAccessPolicy` + `IToolApprovalService`) +3. `ToolPathPolicy` protected-path enforcement (at execution time, after approval) + +This change only relaxes layer 2. Layers 1 and 3 are unaffected. + +## Goals / Non-Goals + +**Goals:** +- Reduce per-file approval fatigue for diagnostic shell commands +- Store directory-scoped patterns when user selects B (session) or C (always) +- Maintain boundary-safe path matching (no `StartsWith` — use `PathUtility.IsWithinRoot`) +- Prevent overly broad directory scopes (minimum 2 path segments) +- Show directory context in approval option labels + +**Non-Goals:** +- Changing the hard deny list or `ToolPathPolicy` behavior +- Changing "Approve once" (A) behavior — it remains exact-pattern +- Glob-aware or regex-based pattern matching +- Cross-verb directory approvals (`cat /dir/` does not approve `grep /dir/`) + +## Decisions + +### Pattern convention: trailing `/` sentinel + +Directory-scoped patterns use a trailing `/` to distinguish from exact patterns: +`grep /home/.netclaw/logs/` vs `grep /home/.netclaw/logs/daemon.log`. + +**Why not a separate storage format?** The approval store (`tool-approvals.json`) +is a flat list of strings per tool per audience. A sentinel convention avoids +schema changes and keeps backward compatibility — existing non-slash patterns +work unchanged. + +### Extraction: scan all arguments, not just first positional + +`ShellTokenizer.ExtractDirectoryScope()` scans ALL non-flag arguments for the +first `LooksLikePath()` token, then extracts its parent directory. This solves +the grep problem where the search term is the first positional arg and the file +path is second (`grep -l "timeout" /home/.netclaw/logs/daemon.log`). + +**Alternative considered:** Always use first positional. Rejected because grep, +sed, and awk take non-path first arguments. + +### Matching: `PathUtility.IsWithinRoot()` not `StartsWith` + +Directory matching delegates to `PathUtility.IsWithinRoot()` which normalizes +both paths, uses platform-appropriate case sensitivity, and checks boundary +characters. This prevents `/home/usersecret` from matching an approval for +`/home/user`. + +### Minimum depth: 2 segments below root + +`CountPathSegments()` rejects scopes shallower than 2 segments (blocks `/`, +`/home/`, `/etc/`, `/tmp/`). This is a hard floor — the user cannot approve +at root-level directories even if they want to. + +### Verb isolation + +An approval for `cat /dir/` does NOT approve `grep /dir/`. The verb is part of +the pattern and checked explicitly. This limits blast radius — approving reads +doesn't silently approve writes or deletions. + +## Risks / Trade-offs + +**[Risk] Directory scope is broader than per-file** → Mitigated by +`ToolPathPolicy.CommandReferencesDeniedPath()` at execution time, which +independently blocks access to protected files (`config/netclaw.json`, keys, +`secrets.json`) regardless of approval state. + +**[Risk] `directoryPatterns[0]` drives UI label for compound commands** → +For compound commands with multiple path-aware segments targeting different +directories, only the first directory appears in the label. Acceptable because +compound commands targeting multiple directories are rare, and the approval +still covers the right patterns. + +**[Risk] Minimum depth too restrictive** → A 2-segment minimum blocks +`/etc/` and `/tmp/` which are legitimate diagnostic targets. This is intentional +— those directories contain sensitive system files. Users can still approve +individual files via "Approve once". diff --git a/openspec/changes/directory-scoped-approval-patterns/proposal.md b/openspec/changes/directory-scoped-approval-patterns/proposal.md new file mode 100644 index 00000000..fd9c9586 --- /dev/null +++ b/openspec/changes/directory-scoped-approval-patterns/proposal.md @@ -0,0 +1,53 @@ +## Why + +Path-aware shell verbs (`ls`, `cat`, `grep`, `find`, etc.) produce per-file +approval patterns (e.g., `cat /home/.netclaw/logs/crash-foo.log`). In a single +diagnostic session (D0AC6CKBK5K/1778085593.830269), the user was prompted **11 +separate times** for commands targeting different files in the same directory. +The single-token exact-match restriction prevents bare `cat` from silently +approving `cat /etc/shadow`, but the per-file granularity is too annoying for +legitimate diagnostic work. + +## What Changes + +- "Approve for this chat" (B) and "Approve always" (C) store **directory-scoped + patterns** (e.g., `grep /home/.netclaw/logs/`) instead of per-file patterns + when the command targets a recognizable file path. +- A trailing `/` on a stored approval pattern signals directory scope. Matching + uses `PathUtility.IsWithinRoot()` for boundary-safe containment instead of + naive `StartsWith`. +- Minimum depth enforcement (2 path segments below root) prevents overly broad + scopes like `/` or `/etc/`. +- `IToolApprovalMatcher` gains `ExtractDirectoryPatterns()` for tool-specific + directory pattern extraction. +- Approval option labels dynamically show the directory scope (e.g., "Approve + `grep` in /home/.netclaw/logs/ for this chat"). +- "Approve once" (A) continues to use exact patterns — directory scope only + applies to broader grants. + +## Capabilities + +### New Capabilities + +(none) + +### Modified Capabilities + +- `tool-approval-gates`: Adds directory-scoped pattern extraction, storage, + matching, and display for shell command approvals. Extends the existing + pattern matching, `IToolApprovalMatcher` interface, persistent approval + storage, and `ToolInteractionRequest` protocol requirements. + +## Impact + +- **Security**: Only relaxes the interactive approval gate. Hard deny list, + `ToolPathPolicy` (protected paths at execution time), symlink resolution, and + path traversal prevention layers are unaffected. Within an approved directory, + `ToolPathPolicy.CommandReferencesDeniedPath()` still independently blocks + access to protected files like `config/netclaw.json`. +- **Code**: `ShellTokenizer`, `ApprovalPatternMatching`, `IToolApprovalMatcher` + (+ all implementations), `ToolAccessPolicy`, `ToolApprovalContext`, + `ToolInteractionRequest`, `PendingToolInteraction`, `LlmSessionActor`, + `SessionToolExecutionPipeline`. +- **Backward compatibility**: Existing non-slash patterns continue to work + unchanged. `DirectoryPatterns` defaults to empty list on protocol types. diff --git a/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md b/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md new file mode 100644 index 00000000..420a3efa --- /dev/null +++ b/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md @@ -0,0 +1,278 @@ +## ADDED Requirements + +### Requirement: Directory-scoped approval patterns + +The system SHALL support directory-scoped approval patterns for shell commands +targeting path-aware verbs. When the user selects "Approve for this chat" (B) or +"Approve always" (C) for a shell command that targets a recognizable file path, +the system SHALL store a directory-scoped pattern (e.g., `grep /home/.netclaw/logs/`) +instead of the exact file-path pattern. "Approve once" (A) SHALL continue to use +exact patterns. + +A trailing `/` on a stored pattern SHALL signal directory scope. The system SHALL +use `PathUtility.IsWithinRoot()` for boundary-safe containment matching — not +naive string prefix comparison. + +The system SHALL enforce a minimum directory depth of 2 path segments below root. +Patterns targeting root-level directories (`/`, `/home/`, `/etc/`, `/tmp/`) SHALL +be rejected, falling back to exact-pattern behavior. + +Directory-scoped approvals SHALL be verb-isolated: an approval for +`cat /home/.netclaw/logs/` SHALL NOT match `grep /home/.netclaw/logs/`. + +#### Scenario: Directory-scoped pattern stored on Approve For This Chat + +- **GIVEN** a shell command `cat /home/.netclaw/logs/crash-foo.log` requires approval +- **WHEN** the user selects "Approve for this chat" +- **THEN** the session-scoped approval stores `cat /home/.netclaw/logs/` +- **AND** a subsequent `cat /home/.netclaw/logs/daemon.log` in the same session + does not prompt + +#### Scenario: Directory-scoped pattern stored on Approve Always + +- **GIVEN** a shell command `grep -l "timeout" /home/.netclaw/logs/daemon.log` + requires approval +- **WHEN** the user selects "Approve always" +- **THEN** `grep /home/.netclaw/logs/` is written to `tool-approvals.json` +- **AND** future sessions auto-approve grep commands targeting files under + `/home/.netclaw/logs/` + +#### Scenario: Approve Once uses exact pattern + +- **GIVEN** a shell command `cat /home/.netclaw/logs/crash.log` requires approval +- **WHEN** the user selects "Approve once" +- **THEN** only the current blocked call is retried +- **AND** a subsequent `cat /home/.netclaw/logs/other.log` prompts again + +#### Scenario: Directory scope does not cross verb boundaries + +- **GIVEN** `cat /home/.netclaw/logs/` is approved +- **WHEN** the agent runs `grep "error" /home/.netclaw/logs/app.log` +- **THEN** the command still requires approval (verb mismatch) + +#### Scenario: Nested files match directory scope + +- **GIVEN** `ls /home/.netclaw/` is approved +- **WHEN** the agent runs `ls /home/.netclaw/logs/deep/nested/file.txt` +- **THEN** the command is auto-approved (path is within approved directory) + +#### Scenario: Sibling directory does not match + +- **GIVEN** `cat /home/.netclaw/logs/` is approved +- **WHEN** the agent runs `cat /home/.netclaw/config/netclaw.json` +- **THEN** the command requires approval (different directory) +- **AND** `ToolPathPolicy` independently blocks the protected path at execution time + +#### Scenario: Shallow directory scope rejected + +- **GIVEN** a shell command `cat /etc/passwd` requires approval +- **WHEN** directory scope extraction runs +- **THEN** the parent directory `/etc/` has only 1 segment (below minimum of 2) +- **AND** the system falls back to exact-pattern behavior + +#### Scenario: Boundary-safe path matching prevents prefix collisions + +- **GIVEN** `cat /home/user/` is approved +- **WHEN** the agent runs `cat /home/usersecret/data.txt` +- **THEN** the command requires approval +- **AND** `PathUtility.IsWithinRoot` prevents the false positive + +### Requirement: Directory pattern extraction via IToolApprovalMatcher + +`IToolApprovalMatcher` SHALL define an `ExtractDirectoryPatterns()` method that +returns directory-scoped patterns for a tool invocation. `ShellApprovalMatcher` +SHALL implement this by scanning all non-flag arguments for the first +`LooksLikePath()` token, expanding home directory tokens, extracting the parent +directory, normalizing the path, and enforcing minimum depth. For compound +commands and `bash -c` wrappers, each segment SHALL be processed recursively. +When no directory scope is available for a segment, the segment's verb-chain +pattern SHALL be used as fallback. + +`DefaultApprovalMatcher` and `FilePathApprovalMatcher` SHALL return empty lists. + +#### Scenario: grep extracts path from second argument + +- **GIVEN** the command `grep -l "timeout" /home/.netclaw/logs/daemon.log` +- **WHEN** `ExtractDirectoryPatterns` runs +- **THEN** the pattern `grep /home/.netclaw/logs/` is extracted +- **AND** the search term `"timeout"` is skipped (not a path) + +#### Scenario: Compound command extracts patterns per segment + +- **GIVEN** the command `cat /home/.netclaw/logs/crash.log && grep "error" /var/log/syslog` +- **WHEN** `ExtractDirectoryPatterns` runs +- **THEN** `cat /home/.netclaw/logs/` is extracted for the first segment +- **AND** the second segment falls back to its verb chain (depth too shallow) + +#### Scenario: Glob paths use parent directory + +- **GIVEN** the command `ls /home/.netclaw/logs/crash-*.log` +- **WHEN** `ExtractDirectoryPatterns` runs +- **THEN** the pattern `ls /home/.netclaw/logs/` is extracted +- **AND** the glob component is stripped + +### Requirement: Dynamic approval option labels + +When directory patterns are available, the system SHALL customize the approval +option labels to show the directory scope. The labels SHALL follow the format: +- B: `"Approve in {directory} for this chat"` +- C: `"Approve in {directory} always"` + +Options A ("Approve once") and D ("Deny") SHALL retain their default labels. + +#### Scenario: Labels show directory scope for path-aware commands + +- **GIVEN** a shell command `grep "error" /home/.netclaw/logs/app.log` + requires approval +- **WHEN** the approval prompt is generated +- **THEN** option B reads "Approve in /home/.netclaw/logs/ for this chat" +- **AND** option C reads "Approve in /home/.netclaw/logs/ always" + +#### Scenario: Labels use defaults when no directory scope + +- **GIVEN** a shell command `git push origin main` requires approval +- **WHEN** the approval prompt is generated +- **THEN** option B reads the default "Approve for this chat" +- **AND** option C reads the default "Approve always" + +## MODIFIED Requirements + +### Requirement: ToolInteractionRequest/Response protocol + +The system SHALL define a `ToolInteractionRequest` session output and +`ToolInteractionResponse` session command for channel-mediated approval +interactions. +The interaction `Kind` SHALL identify the interaction type (`approval` for v1). +`ToolInteractionRequest` SHALL be a lifecycle output (always delivered regardless +of `OutputFilter`). + +`ToolInteractionRequest` SHALL include a `DirectoryPatterns` field containing +directory-scoped patterns extracted from the tool invocation. When non-empty and +the user selects "Approve for this chat" or "Approve always", the session actor +SHALL record the directory patterns instead of the exact file-path patterns. + +#### Scenario: Approval request emitted as session output + +- **GIVEN** a tool requires approval +- **WHEN** the pipeline detects the approval requirement +- **THEN** a `ToolInteractionRequest` with `Kind=approval` is emitted +- **AND** it includes `CallId`, `ToolName`, the command/pattern, and available + options (approve once, approve for this chat, approve always, deny) + +#### Scenario: Approval request includes directory patterns + +- **GIVEN** a shell command targets a file under `/home/.netclaw/logs/` +- **WHEN** the approval request is generated +- **THEN** `ToolInteractionRequest.DirectoryPatterns` contains the directory-scoped + pattern (e.g., `cat /home/.netclaw/logs/`) +- **AND** `ToolInteractionRequest.Patterns` contains the exact file-path pattern + +#### Scenario: Channel routes response back to session + +- **GIVEN** a `ToolInteractionRequest` has been emitted +- **WHEN** the user selects an option (for MVP Slack, via text reply) +- **THEN** the channel sends a `ToolInteractionResponse` to the session actor +- **AND** the response includes `CallId` and the selected option key + +### Requirement: Persistent approval storage + +The system SHALL store persistent approvals ("Approve Always" decisions) in +`~/.netclaw/config/tool-approvals.json`, separate from `netclaw.json`. The file +SHALL NOT be monitored by `ConfigWatcherService`. The file SHALL contain +per-audience sections with per-tool approval lists. For the shipped MVP shell +flow, the lists SHALL contain command patterns, including directory-scoped +patterns (trailing `/`). Approval lookup and recording +SHALL be mediated by `IToolApprovalService`. + +#### Scenario: Approve Always persists directory pattern to file + +- **GIVEN** the user clicks "Approve Always" for a command targeting + `/home/.netclaw/logs/crash.log` +- **WHEN** the approval is processed +- **THEN** `cat /home/.netclaw/logs/` is added to the Personal shell_execute list + in `tool-approvals.json` +- **AND** the daemon does NOT restart + +#### Scenario: Persistent approvals loaded at startup + +- **GIVEN** `tool-approvals.json` contains + `{"personal":{"shell_execute":["git push", "cat /home/.netclaw/logs/"]}}` +- **WHEN** the daemon starts +- **THEN** `git push` is pre-approved for Personal audience shell commands +- **AND** `cat` commands targeting files under `/home/.netclaw/logs/` are pre-approved + +#### Scenario: Approve Once is retry-scoped only + +- **GIVEN** the user clicks "Approve Once" for pattern `docker build` +- **WHEN** the approval is processed +- **THEN** the blocked `docker build` call is retried immediately +- **AND** a later `docker build` call in the same session prompts again +- **AND** `tool-approvals.json` is NOT modified + +#### Scenario: Approve For This Chat stores directory pattern in session + +- **GIVEN** the user clicks "Approve For This Chat" for a command targeting + `/home/.netclaw/logs/daemon.log` +- **WHEN** the approval is processed +- **THEN** the directory-scoped pattern is approved for the current session only +- **AND** `tool-approvals.json` is NOT modified +- **AND** a new session will prompt again + +### Requirement: Shell command pattern matching + +The system SHALL extract verb-chain prefix patterns from shell commands using +tokenization. The verb chain SHALL consist of non-flag tokens from the start of +the command until the first flag (`-`), path, or URL argument. For compound +commands (`&&`, `||`, `;`, `|`), each segment SHALL be evaluated independently. +For `bash -c` or `sh -c` wrappers, the inner command SHALL be extracted and +scanned recursively. + +The system SHALL support directory-scoped pattern matching. When an approved +pattern ends with `/`, the system SHALL match any candidate pattern with the same +verb whose path argument is within the approved directory, using +`PathUtility.IsWithinRoot()` for boundary-safe containment. + +#### Scenario: Verb chain extracted from simple command + +- **GIVEN** the command `git push origin main` +- **WHEN** the pattern is extracted +- **THEN** the pattern is `git push` + +#### Scenario: Verb chain stops at flag + +- **GIVEN** the command `ls -la /tmp` +- **WHEN** the pattern is extracted +- **THEN** the pattern is `ls /tmp` + +#### Scenario: Multi-level verb chain + +- **GIVEN** the command `docker compose up -d` +- **WHEN** the pattern is extracted +- **THEN** the pattern is `docker compose up` + +#### Scenario: Compound command segments evaluated independently + +- **GIVEN** the command `git add . && git commit -m "fix" && git push` +- **WHEN** approval is checked +- **THEN** patterns `git add`, `git commit`, and `git push` are each checked + independently against the approval state surfaced through `IToolApprovalService` + +#### Scenario: Unapproved compound segments batched in one prompt + +- **GIVEN** `git add` is approved but `git commit` and `git push` are not +- **WHEN** the command `git add . && git commit -m "fix" && git push` is checked +- **THEN** a single approval prompt lists both `git commit` and `git push` +- **AND** the full compound command is shown for context + +#### Scenario: bash -c inner command scanned recursively + +- **GIVEN** the command `bash -c "git push --force"` +- **WHEN** approval and hard deny are checked +- **THEN** the inner command `git push --force` is extracted and scanned +- **AND** pattern `git push` is checked through `IToolApprovalService` + +#### Scenario: Directory-scoped approved pattern matches file within directory + +- **GIVEN** `cat /home/.netclaw/logs/` is in the approved patterns +- **WHEN** the candidate pattern `cat /home/.netclaw/logs/crash.log` is checked +- **THEN** `ApprovalPatternMatching.MatchesAny` returns true diff --git a/openspec/changes/directory-scoped-approval-patterns/tasks.md b/openspec/changes/directory-scoped-approval-patterns/tasks.md new file mode 100644 index 00000000..035f4414 --- /dev/null +++ b/openspec/changes/directory-scoped-approval-patterns/tasks.md @@ -0,0 +1,38 @@ +## 1. Pattern Extraction + +- [x] 1.1 Add `ShellTokenizer.ExtractDirectoryScope()` — scan all args for first `LooksLikePath()` token, extract parent directory, normalize, enforce minimum depth +- [x] 1.2 Add `ExtractParentDirectory()` and `CountPathSegments()` helpers to `ShellTokenizer` +- [x] 1.3 Unit tests for `ExtractDirectoryScope` (verb+directory, grep path vs search term, glob handling, null cases) + +## 2. Pattern Matching + +- [x] 2.1 Add `MatchesDirectoryScope()` to `ApprovalPatternMatching` using `PathUtility.IsWithinRoot()` for boundary-safe containment +- [x] 2.2 Unit tests for directory-prefix matching (same dir, nested, sibling, verb mismatch) + +## 3. IToolApprovalMatcher Extension + +- [x] 3.1 Add `ExtractDirectoryPatterns()` to `IToolApprovalMatcher` interface +- [x] 3.2 Implement on `ShellApprovalMatcher` with compound command + `bash -c` recursion via shared `TraverseSegments` helper +- [x] 3.3 Implement on `DefaultApprovalMatcher` and `FilePathApprovalMatcher` (return empty list) + +## 4. Protocol and Pipeline Wiring + +- [x] 4.1 Add `DirectoryPatterns` property to `ToolInteractionRequest` in `SessionOutput.cs` +- [x] 4.2 Add `DirectoryPatterns` to `ToolApprovalContext` record in `ToolAccessPolicy.cs` +- [x] 4.3 Compute directory patterns and customize B/C labels in `CheckApprovalGate()` +- [x] 4.4 Pass `DirectoryPatterns` from `ToolApprovalContext` to `ToolInteractionRequest` in `SessionToolExecutionPipeline` +- [x] 4.5 Propagate `DirectoryPatterns` through `DispatchingToolExecutor` re-approval path + +## 5. Session Actor Recording + +- [x] 5.1 Add `DirectoryPatterns` field to `PendingToolInteraction` record in `LlmSessionActor` +- [x] 5.2 Store `DirectoryPatterns` from `ToolInteractionRequest` in pending interaction +- [x] 5.3 Record directory patterns (when non-empty) instead of exact patterns for B/C decisions in `RecordApprovalAsync` + +## 6. Code Quality + +- [x] 6.1 Narrow bare `catch` in `MatchesDirectoryScope` to `ArgumentException | IOException` +- [x] 6.2 Unify `CollectPatterns`/`CollectDirectoryPatterns` into shared `TraverseSegments` helper +- [x] 6.3 Use `PathUtility.ExpandAndNormalize()` in `ExtractDirectoryScope` instead of separate calls +- [x] 6.4 Make `DirectoryPatterns` non-nullable on `ToolApprovalContext` +- [x] 6.5 Verify: `dotnet slopwatch analyze` passes, copyright headers present, all tests green diff --git a/src/Netclaw.Actors.Tests/Sessions/SessionToolExecutionPipelineTests.cs b/src/Netclaw.Actors.Tests/Sessions/SessionToolExecutionPipelineTests.cs index f365577f..abbe77c0 100644 --- a/src/Netclaw.Actors.Tests/Sessions/SessionToolExecutionPipelineTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/SessionToolExecutionPipelineTests.cs @@ -154,7 +154,8 @@ public Task ExecuteAsync(FunctionCallContent toolCall, ToolExecutionCont new ToolApprovalOption(ApprovalOptionKeys.ApproveSession, ApprovalOptionKeys.ApproveSessionLabel), new ToolApprovalOption(ApprovalOptionKeys.ApproveAlways, ApprovalOptionKeys.ApproveAlwaysLabel), new ToolApprovalOption(ApprovalOptionKeys.Deny, ApprovalOptionKeys.DenyLabel) - ])); + ], + DirectoryPatterns: [])); } ct.ThrowIfCancellationRequested(); diff --git a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs index 33ffa67b..d1e00d61 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs @@ -4,6 +4,7 @@ // // ----------------------------------------------------------------------- using Microsoft.Extensions.AI; +using Netclaw.Actors.Protocol; using Netclaw.Actors.Tools; using Netclaw.Configuration; using Netclaw.Security; @@ -755,4 +756,55 @@ private sealed class FakeShellTrustZonePolicy : IShellTrustZonePolicy public IReadOnlyList GetTrustZoneRoots(ToolExecutionContext context) => _roots; } + + // ── Directory-scoped approval patterns ── + + [Fact] + public void Shell_path_command_populates_directory_patterns() + { + var policy = CreatePolicy(ToolApprovalMode.Approval); + var args = ToolInput.Create("Command", "cat /home/user/.netclaw/logs/crash.log"); + + var decision = policy.AuthorizeInvocation(ShellTool(), PersonalContext(), args); + + Assert.True(decision.NeedsApproval); + Assert.NotNull(decision.ApprovalContext); + Assert.NotEmpty(decision.ApprovalContext!.DirectoryPatterns); + Assert.Contains(decision.ApprovalContext.DirectoryPatterns, p => p.EndsWith("/")); + } + + [Fact] + public void Shell_path_command_labels_show_directory_scope() + { + var policy = CreatePolicy(ToolApprovalMode.Approval); + var args = ToolInput.Create("Command", "grep 'error' /home/user/.netclaw/logs/app.log"); + + var decision = policy.AuthorizeInvocation(ShellTool(), PersonalContext(), args); + + Assert.True(decision.NeedsApproval); + var options = decision.ApprovalContext!.Options; + var sessionOption = options.Single(o => o.Key == ApprovalOptionKeys.ApproveSession); + var alwaysOption = options.Single(o => o.Key == ApprovalOptionKeys.ApproveAlways); + Assert.StartsWith("Approve in ", sessionOption.Label); + Assert.Contains("for this chat", sessionOption.Label); + Assert.StartsWith("Approve in ", alwaysOption.Label); + Assert.Contains("always", alwaysOption.Label); + } + + [Fact] + public void Non_path_command_uses_default_labels() + { + var policy = CreatePolicy(ToolApprovalMode.Approval); + var args = ToolInput.Create("Command", "git push origin main"); + + var decision = policy.AuthorizeInvocation(ShellTool(), PersonalContext(), args); + + Assert.True(decision.NeedsApproval); + // DirectoryPatterns contains verb-chain fallback ("git push"), but no directory scope + Assert.DoesNotContain(decision.ApprovalContext!.DirectoryPatterns, p => p.EndsWith("/")); + var sessionOption = decision.ApprovalContext.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveSession); + var alwaysOption = decision.ApprovalContext.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveAlways); + Assert.Equal(ApprovalOptionKeys.ApproveSessionLabel, sessionOption.Label); + Assert.Equal(ApprovalOptionKeys.ApproveAlwaysLabel, alwaysOption.Label); + } } diff --git a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs index e2e50a87..b1921106 100644 --- a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs +++ b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs @@ -284,7 +284,7 @@ public static async Task ExecuteSingleToolAsync( AdoptedSpeakerIds = source?.AdoptedSpeakerIds ?? [], PersistedAdoptedContext = source?.HasAdoptedContext ?? false, Patterns = ctx.UnapprovedPatterns, - DirectoryPatterns = ctx.DirectoryPatterns ?? [], + DirectoryPatterns = ctx.DirectoryPatterns, Options = ctx.Options .Select(o => new ToolInteractionOption(o.Key, o.Label)) .ToList() diff --git a/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs b/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs index 89981f13..bcf5e37e 100644 --- a/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs +++ b/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs @@ -121,7 +121,8 @@ private async Task AuthorizeCoreAsync(FunctionCallContent toolCall approvalContext.ToolName, approvalContext.DisplayText, unapproved, - approvalContext.Options)); + approvalContext.Options, + approvalContext.DirectoryPatterns)); } if (accessDecision.NeedsApproval diff --git a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs index 6d9e12ad..68e1c708 100644 --- a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs +++ b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs @@ -303,13 +303,13 @@ private ToolAccessDecision CheckApprovalGate( var sessionLabel = ApprovalOptionKeys.ApproveSessionLabel; var alwaysLabel = ApprovalOptionKeys.ApproveAlwaysLabel; - if (directoryPatterns.Count > 0) + var firstDirScope = directoryPatterns.FirstOrDefault(p => p.EndsWith('/')); + if (firstDirScope is not null) { - var firstDir = directoryPatterns[0]; - var spaceIdx = firstDir.IndexOf(' ', StringComparison.Ordinal); + var spaceIdx = firstDirScope.IndexOf(' ', StringComparison.Ordinal); if (spaceIdx >= 0) { - var dir = firstDir[(spaceIdx + 1)..]; + var dir = firstDirScope[(spaceIdx + 1)..]; sessionLabel = $"Approve in {dir} for this chat"; alwaysLabel = $"Approve in {dir} always"; } @@ -473,7 +473,7 @@ public sealed record ToolApprovalContext( string DisplayText, IReadOnlyList UnapprovedPatterns, IReadOnlyList Options, - IReadOnlyList? DirectoryPatterns = null); + IReadOnlyList DirectoryPatterns); public sealed record ToolApprovalOption(string Key, string Label); diff --git a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs index ab9cb49f..40f7adc7 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs @@ -155,6 +155,58 @@ public void FormatForDisplay_returns_command() var display = _matcher.FormatForDisplay(new ToolName("shell_execute"), Args("git push origin main")); Assert.Equal("git push origin main", display); } + + // ── ExtractDirectoryPatterns ── + + [Fact] + public void ExtractDirectoryPatterns_simple_path_command() + { + var patterns = _matcher.ExtractDirectoryPatterns( + new ToolName("shell_execute"), + Args("cat /home/user/.netclaw/logs/crash.log")); + Assert.Single(patterns); + Assert.EndsWith("/", patterns[0]); + Assert.StartsWith("cat ", patterns[0]); + } + + [Fact] + public void ExtractDirectoryPatterns_compound_command() + { + var patterns = _matcher.ExtractDirectoryPatterns( + new ToolName("shell_execute"), + Args("cat /home/user/.netclaw/logs/crash.log && grep 'error' /home/user/.netclaw/logs/app.log")); + Assert.Equal(2, patterns.Count); + Assert.Contains(patterns, p => p.StartsWith("cat ")); + Assert.Contains(patterns, p => p.StartsWith("grep ")); + } + + [Fact] + public void ExtractDirectoryPatterns_falls_back_to_verb_chain_when_no_path() + { + var patterns = _matcher.ExtractDirectoryPatterns( + new ToolName("shell_execute"), + Args("git push origin main")); + Assert.Single(patterns); + Assert.Equal("git push", patterns[0]); + } + + [Fact] + public void ExtractDirectoryPatterns_empty_command() + { + var patterns = _matcher.ExtractDirectoryPatterns(new ToolName("shell_execute"), Args("")); + Assert.Empty(patterns); + } + + [Fact] + public void ExtractDirectoryPatterns_mixed_compound_with_fallback() + { + var patterns = _matcher.ExtractDirectoryPatterns( + new ToolName("shell_execute"), + Args("cat /home/user/.netclaw/logs/crash.log && git push origin main")); + Assert.Equal(2, patterns.Count); + Assert.Contains(patterns, p => p.StartsWith("cat ") && p.EndsWith("/")); + Assert.Contains(patterns, p => p == "git push"); + } } public sealed class DefaultApprovalMatcherTests diff --git a/src/Netclaw.Security/ApprovalPatternMatching.cs b/src/Netclaw.Security/ApprovalPatternMatching.cs index 23b195da..f14e221f 100644 --- a/src/Netclaw.Security/ApprovalPatternMatching.cs +++ b/src/Netclaw.Security/ApprovalPatternMatching.cs @@ -64,7 +64,7 @@ private static bool MatchesDirectoryScope(string candidate, string approvedDirPa { return PathUtility.IsWithinRoot(candidatePath, approvedDir); } - catch + catch (Exception ex) when (ex is ArgumentException or IOException) { return false; } diff --git a/src/Netclaw.Security/IToolApprovalMatcher.cs b/src/Netclaw.Security/IToolApprovalMatcher.cs index 36108377..89217485 100644 --- a/src/Netclaw.Security/IToolApprovalMatcher.cs +++ b/src/Netclaw.Security/IToolApprovalMatcher.cs @@ -130,25 +130,13 @@ private static bool PatternMatchesAny(string pattern, IReadOnlyList appr => ApprovalPatternMatching.MatchesAny(pattern, approvedPatterns); private static void CollectPatterns(string command, ISet patterns) - { - foreach (var segment in ShellTokenizer.SplitCompoundCommand(command)) - { - var innerCommands = ShellTokenizer.ExtractInnerCommands(segment); - if (innerCommands.Count > 0) - { - foreach (var inner in innerCommands) - CollectPatterns(inner, patterns); - - continue; - } - - var verbChain = ShellTokenizer.ExtractVerbChain(segment); - if (!string.IsNullOrEmpty(verbChain)) - patterns.Add(verbChain); - } - } + => TraverseSegments(command, patterns, static segment => ShellTokenizer.ExtractVerbChain(segment)); private static void CollectDirectoryPatterns(string command, ISet patterns) + => TraverseSegments(command, patterns, static segment => + ShellTokenizer.ExtractDirectoryScope(segment) ?? ShellTokenizer.ExtractVerbChain(segment)); + + private static void TraverseSegments(string command, ISet patterns, Func extractLeaf) { foreach (var segment in ShellTokenizer.SplitCompoundCommand(command)) { @@ -156,22 +144,14 @@ private static void CollectDirectoryPatterns(string command, ISet patter if (innerCommands.Count > 0) { foreach (var inner in innerCommands) - CollectDirectoryPatterns(inner, patterns); + TraverseSegments(inner, patterns, extractLeaf); continue; } - var dirScope = ShellTokenizer.ExtractDirectoryScope(segment); - if (dirScope is not null) - { - patterns.Add(dirScope); - } - else - { - var verbChain = ShellTokenizer.ExtractVerbChain(segment); - if (!string.IsNullOrEmpty(verbChain)) - patterns.Add(verbChain); - } + var pattern = extractLeaf(segment); + if (!string.IsNullOrEmpty(pattern)) + patterns.Add(pattern); } } } diff --git a/src/Netclaw.Security/ShellTokenizer.cs b/src/Netclaw.Security/ShellTokenizer.cs index 7413e691..1a0a123f 100644 --- a/src/Netclaw.Security/ShellTokenizer.cs +++ b/src/Netclaw.Security/ShellTokenizer.cs @@ -384,12 +384,12 @@ public static bool LooksLikePath(string token) if (!LooksLikePath(trimmed)) continue; - var expanded = PathUtility.ExpandHome(trimmed); - var dir = ExtractParentDirectory(expanded); + var dir = ExtractParentDirectory(PathUtility.ExpandHome(trimmed)); if (dir is null) continue; - if (!PathUtility.TryNormalize(dir, null, out var normalized)) + var normalized = PathUtility.ExpandAndNormalize(dir); + if (normalized is null) continue; // Enforce minimum depth — reject shallow scopes like / or /etc/ From 54f77b8c4a2f746ab50c6098286ea2db651377b4 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 6 May 2026 21:07:08 +0000 Subject: [PATCH 03/22] fix shell approval path resolution and prompt scoping Use resolved path operands for exact and directory approvals so grep, relative paths, and existing directory targets approve the intended scope. Keep prompt labels, DTO transport, and OpenSpec guidance aligned with the actual approval patterns users are granting. --- .../design.md | 62 ++++++++--- .../proposal.md | 18 +++- .../specs/tool-approval-gates/spec.md | 77 ++++++++++++-- .../tasks.md | 9 +- .../DiscordApprovalPromptBuilderTests.cs | 8 +- .../SlackApprovalBlockBuilderTests.cs | 42 ++++++++ .../Tools/ToolApprovalGateTests.cs | 43 ++++++++ .../Protocol/SessionOutputDto.cs | 1 + .../Protocol/SessionOutputDtoMapper.cs | 2 + src/Netclaw.Actors/Tools/ToolAccessPolicy.cs | 36 +++++-- .../DiscordApprovalPromptBuilder.cs | 19 +++- .../SlackApprovalBlockBuilder.cs | 20 +++- .../Cli/DaemonClientMappingTests.cs | 3 + .../ShellApprovalMatcherTests.cs | 97 +++++++++++++++++ .../ShellTokenizerTests.cs | 21 +++- src/Netclaw.Security/IToolApprovalMatcher.cs | 38 +++++-- src/Netclaw.Security/ShellTokenizer.cs | 100 +++++++++++++++--- 17 files changed, 525 insertions(+), 71 deletions(-) create mode 100644 src/Netclaw.Actors.Tests/Channels/SlackApprovalBlockBuilderTests.cs diff --git a/openspec/changes/directory-scoped-approval-patterns/design.md b/openspec/changes/directory-scoped-approval-patterns/design.md index ee02c000..0b5d1022 100644 --- a/openspec/changes/directory-scoped-approval-patterns/design.md +++ b/openspec/changes/directory-scoped-approval-patterns/design.md @@ -22,13 +22,17 @@ This change only relaxes layer 2. Layers 1 and 3 are unaffected. - Store directory-scoped patterns when user selects B (session) or C (always) - Maintain boundary-safe path matching (no `StartsWith` — use `PathUtility.IsWithinRoot`) - Prevent overly broad directory scopes (minimum 2 path segments) -- Show directory context in approval option labels +- Show directory context in approval option labels only when the entire request + maps cleanly to one directory scope **Non-Goals:** - Changing the hard deny list or `ToolPathPolicy` behavior - Changing "Approve once" (A) behavior — it remains exact-pattern - Glob-aware or regex-based pattern matching - Cross-verb directory approvals (`cat /dir/` does not approve `grep /dir/`) +- Inferring indirect path flow through shell constructs like `xargs`, `eval`, + loop variables, command substitution, or shell variables +- Windows-native shell path handling; tracked separately in issue #899 ## Decisions @@ -42,23 +46,42 @@ is a flat list of strings per tool per audience. A sentinel convention avoids schema changes and keeps backward compatibility — existing non-slash patterns work unchanged. -### Extraction: scan all arguments, not just first positional +### Extraction: shared path-operand resolution for exact and directory patterns -`ShellTokenizer.ExtractDirectoryScope()` scans ALL non-flag arguments for the -first `LooksLikePath()` token, then extracts its parent directory. This solves -the grep problem where the search term is the first positional arg and the file -path is second (`grep -l "timeout" /home/.netclaw/logs/daemon.log`). +`ShellTokenizer.TryExtractPathOperand()` is the shared primitive behind +`ExtractApprovalPattern()` and `ExtractDirectoryScope()`. It scans ALL non-flag +arguments for the first token that can be normalized into a path operand, using +the shell tool `WorkingDirectory` to resolve relative operands before approval +patterns are extracted or matched. This solves the grep problem where the search +term is the first positional arg and the file path is second +(`grep -l "timeout" logs/daemon.log`). + +When a recognizable path operand exists, exact approval patterns use the +normalized path operand itself (`grep /abs/path/logs/daemon.log`), not the raw +verb chain. Directory-scoped patterns then derive scope from that same operand. **Alternative considered:** Always use first positional. Rejected because grep, sed, and awk take non-path first arguments. -### Matching: `PathUtility.IsWithinRoot()` not `StartsWith` +### Existing directory operands stay directory-scoped + +`ExtractScopedDirectory()` preserves an operand that already denotes a +directory. For example, `find logs -name '*.log'` resolves `logs` against the +working directory and stores `find /abs/path/logs/` rather than widening to the +parent (`/abs/path/`). This keeps approval scope aligned with what the command +actually targets. + +### Matching: `PathUtility.IsWithinRoot()` with normalized operands Directory matching delegates to `PathUtility.IsWithinRoot()` which normalizes both paths, uses platform-appropriate case sensitivity, and checks boundary characters. This prevents `/home/usersecret` from matching an approval for `/home/user`. +Because both exact and directory extraction share normalized path operands, +relative requests such as `cat logs/app.log` are matched against approvals using +their resolved absolute path under the shell `WorkingDirectory`. + ### Minimum depth: 2 segments below root `CountPathSegments()` rejects scopes shallower than 2 segments (blocks `/`, @@ -71,6 +94,22 @@ An approval for `cat /dir/` does NOT approve `grep /dir/`. The verb is part of the pattern and checked explicitly. This limits blast radius — approving reads doesn't silently approve writes or deletions. +### Dynamic labels require a single clean directory scope + +`ToolAccessPolicy.TryGetSingleDirectoryScope()` only emits directory-specific B/C +labels when every approval pattern for the request is directory-scoped and all +of them resolve to the same directory. If any segment falls back to a generic +verb-chain pattern (for example `git push`) or multiple directory scopes are +present, labels stay generic. + +### Compound commands: direct path operands only + +Compound commands and pipe segments are still traversed segment-by-segment, so a +segment like `cat logs/app.log | jq .` can contribute directory scope for the +`cat` segment. MVP extraction stops there: it does not infer that a downstream +segment implicitly targets the same path through `xargs`, `eval`, loop +variables, shell variables, or similar constructs. + ## Risks / Trade-offs **[Risk] Directory scope is broader than per-file** → Mitigated by @@ -78,11 +117,10 @@ doesn't silently approve writes or deletions. independently blocks access to protected files (`config/netclaw.json`, keys, `secrets.json`) regardless of approval state. -**[Risk] `directoryPatterns[0]` drives UI label for compound commands** → -For compound commands with multiple path-aware segments targeting different -directories, only the first directory appears in the label. Acceptable because -compound commands targeting multiple directories are rare, and the approval -still covers the right patterns. +**[Risk] Compound commands often cannot use directory-specific labels** → +Mixed approval sets (directory patterns plus verb-chain fallbacks, or multiple +directories) keep the generic labels. This is intentional; a generic label is +less misleading than showing a partial directory scope for the whole request. **[Risk] Minimum depth too restrictive** → A 2-segment minimum blocks `/etc/` and `/tmp/` which are legitimate diagnostic targets. This is intentional diff --git a/openspec/changes/directory-scoped-approval-patterns/proposal.md b/openspec/changes/directory-scoped-approval-patterns/proposal.md index fd9c9586..516e3e12 100644 --- a/openspec/changes/directory-scoped-approval-patterns/proposal.md +++ b/openspec/changes/directory-scoped-approval-patterns/proposal.md @@ -13,6 +13,14 @@ legitimate diagnostic work. - "Approve for this chat" (B) and "Approve always" (C) store **directory-scoped patterns** (e.g., `grep /home/.netclaw/logs/`) instead of per-file patterns when the command targets a recognizable file path. +- Relative path operands are resolved against the shell tool `WorkingDirectory` + before both exact-pattern extraction and directory-scope extraction/matching. +- Path-aware exact approval patterns use the actual normalized path operand when + one exists, including commands like `grep -l "timeout" logs/app.log` where the + search term is not the path operand. +- Existing directory operands keep their directory scope (for example, + `find logs -name '*.log'` stores `find /logs/`) instead of widening to the + parent directory. - A trailing `/` on a stored approval pattern signals directory scope. Matching uses `PathUtility.IsWithinRoot()` for boundary-safe containment instead of naive `StartsWith`. @@ -20,10 +28,16 @@ legitimate diagnostic work. scopes like `/` or `/etc/`. - `IToolApprovalMatcher` gains `ExtractDirectoryPatterns()` for tool-specific directory pattern extraction. -- Approval option labels dynamically show the directory scope (e.g., "Approve - `grep` in /home/.netclaw/logs/ for this chat"). +- Approval option labels only show a directory-specific scope when the full + approval set for the request maps cleanly to a single directory; otherwise the + labels stay generic. +- Pipe segments can get directory scope when the segment has a direct path + operand, but MVP extraction does not infer indirect path flow through `xargs`, + `eval`, loop variables, or similar shell constructs. - "Approve once" (A) continues to use exact patterns — directory scope only applies to broader grants. +- Windows-native shell path semantics are out of scope for this change and are + tracked separately in issue #899. ## Capabilities diff --git a/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md b/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md index 420a3efa..ea904a9f 100644 --- a/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md +++ b/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md @@ -9,6 +9,11 @@ the system SHALL store a directory-scoped pattern (e.g., `grep /home/.netclaw/lo instead of the exact file-path pattern. "Approve once" (A) SHALL continue to use exact patterns. +When a recognizable path operand is relative, the system SHALL resolve it +against the shell tool `WorkingDirectory` before extracting either exact or +directory-scoped approval patterns. Existing operands that already denote a +directory SHALL preserve that directory scope instead of widening to the parent. + A trailing `/` on a stored pattern SHALL signal directory scope. The system SHALL use `PathUtility.IsWithinRoot()` for boundary-safe containment matching — not naive string prefix comparison. @@ -37,6 +42,22 @@ Directory-scoped approvals SHALL be verb-isolated: an approval for - **AND** future sessions auto-approve grep commands targeting files under `/home/.netclaw/logs/` +#### Scenario: Relative path resolves against working directory + +- **GIVEN** the shell tool `WorkingDirectory` is `/workspace/project` +- **AND** the command is `cat logs/app.log` +- **WHEN** exact and directory-scoped approval patterns are extracted +- **THEN** the exact pattern is `cat /workspace/project/logs/app.log` +- **AND** the directory-scoped pattern is `cat /workspace/project/logs/` + +#### Scenario: Existing directory operand preserves its scope + +- **GIVEN** the shell tool `WorkingDirectory` is `/workspace/project` +- **AND** the command is `find logs -name '*.log'` +- **WHEN** directory-scoped approval extraction runs +- **THEN** the extracted pattern is `find /workspace/project/logs/` +- **AND** the scope is not widened to `/workspace/project/` + #### Scenario: Approve Once uses exact pattern - **GIVEN** a shell command `cat /home/.netclaw/logs/crash.log` requires approval @@ -82,11 +103,12 @@ Directory-scoped approvals SHALL be verb-isolated: an approval for `IToolApprovalMatcher` SHALL define an `ExtractDirectoryPatterns()` method that returns directory-scoped patterns for a tool invocation. `ShellApprovalMatcher` SHALL implement this by scanning all non-flag arguments for the first -`LooksLikePath()` token, expanding home directory tokens, extracting the parent -directory, normalizing the path, and enforcing minimum depth. For compound -commands and `bash -c` wrappers, each segment SHALL be processed recursively. -When no directory scope is available for a segment, the segment's verb-chain -pattern SHALL be used as fallback. +recognizable path operand, resolving relative paths against `WorkingDirectory`, +expanding home directory tokens, extracting the scoped directory, normalizing +the path, and enforcing minimum depth. For compound commands and `bash -c` +wrappers, each segment SHALL be processed recursively. When no directory scope +is available for a segment, the segment's exact approval pattern SHALL be used +as fallback. `DefaultApprovalMatcher` and `FilePathApprovalMatcher` SHALL return empty lists. @@ -97,6 +119,14 @@ pattern SHALL be used as fallback. - **THEN** the pattern `grep /home/.netclaw/logs/` is extracted - **AND** the search term `"timeout"` is skipped (not a path) +#### Scenario: grep exact pattern uses normalized path operand + +- **GIVEN** the shell tool `WorkingDirectory` is `/workspace/project` +- **AND** the command is `grep -l "timeout" logs/daemon.log` +- **WHEN** exact approval pattern extraction runs +- **THEN** the pattern is `grep /workspace/project/logs/daemon.log` +- **AND** the search term `"timeout"` is not used as the exact operand + #### Scenario: Compound command extracts patterns per segment - **GIVEN** the command `cat /home/.netclaw/logs/crash.log && grep "error" /var/log/syslog` @@ -104,6 +134,21 @@ pattern SHALL be used as fallback. - **THEN** `cat /home/.netclaw/logs/` is extracted for the first segment - **AND** the second segment falls back to its verb chain (depth too shallow) +#### Scenario: Pipe segment can contribute direct directory scope + +- **GIVEN** the shell tool `WorkingDirectory` is `/workspace/project` +- **AND** the command is `cat logs/app.log | jq .message` +- **WHEN** `ExtractDirectoryPatterns` runs +- **THEN** `cat /workspace/project/logs/` is extracted for the direct path-aware segment +- **AND** the `jq` segment does not gain directory scope from piped input alone + +#### Scenario: Indirect path flow is not inferred for MVP + +- **GIVEN** the command is `find logs -name '*.log' | xargs grep timeout` +- **WHEN** `ExtractDirectoryPatterns` runs +- **THEN** the `find` segment may contribute `find /logs/` +- **AND** the downstream `grep` segment does not inherit that directory scope via `xargs` + #### Scenario: Glob paths use parent directory - **GIVEN** the command `ls /home/.netclaw/logs/crash-*.log` @@ -114,7 +159,8 @@ pattern SHALL be used as fallback. ### Requirement: Dynamic approval option labels When directory patterns are available, the system SHALL customize the approval -option labels to show the directory scope. The labels SHALL follow the format: +option labels to show the directory scope only when the full approval set for +the request maps cleanly to a single directory scope. The labels SHALL follow the format: - B: `"Approve in {directory} for this chat"` - C: `"Approve in {directory} always"` @@ -135,6 +181,15 @@ Options A ("Approve once") and D ("Deny") SHALL retain their default labels. - **THEN** option B reads the default "Approve for this chat" - **AND** option C reads the default "Approve always" +#### Scenario: Labels stay generic for mixed approval sets + +- **GIVEN** a shell command `cat /home/.netclaw/logs/crash.log && git push origin main` + requires approval +- **WHEN** the approval prompt is generated +- **THEN** option B reads the default "Approve for this chat" +- **AND** option C reads the default "Approve always" +- **AND** no partial directory-specific label is shown for the whole request + ## MODIFIED Requirements ### Requirement: ToolInteractionRequest/Response protocol @@ -232,6 +287,9 @@ pattern ends with `/`, the system SHALL match any candidate pattern with the sam verb whose path argument is within the approved directory, using `PathUtility.IsWithinRoot()` for boundary-safe containment. +For path-aware verbs with a recognizable path operand, exact approval pattern +extraction SHALL use the normalized path operand instead of the raw verb chain. + #### Scenario: Verb chain extracted from simple command - **GIVEN** the command `git push origin main` @@ -276,3 +334,10 @@ verb whose path argument is within the approved directory, using - **GIVEN** `cat /home/.netclaw/logs/` is in the approved patterns - **WHEN** the candidate pattern `cat /home/.netclaw/logs/crash.log` is checked - **THEN** `ApprovalPatternMatching.MatchesAny` returns true + +#### Scenario: Windows-native shell support is tracked separately + +- **GIVEN** this MVP change targets the current shell approval pipeline +- **WHEN** native Windows shell path semantics are considered +- **THEN** they are out of scope for this change +- **AND** follow-up work is tracked separately in issue #899 diff --git a/openspec/changes/directory-scoped-approval-patterns/tasks.md b/openspec/changes/directory-scoped-approval-patterns/tasks.md index 035f4414..b6e0b291 100644 --- a/openspec/changes/directory-scoped-approval-patterns/tasks.md +++ b/openspec/changes/directory-scoped-approval-patterns/tasks.md @@ -1,6 +1,6 @@ ## 1. Pattern Extraction -- [x] 1.1 Add `ShellTokenizer.ExtractDirectoryScope()` — scan all args for first `LooksLikePath()` token, extract parent directory, normalize, enforce minimum depth +- [x] 1.1 Add shared path-operand extraction for `ShellTokenizer.ExtractApprovalPattern()` and `ExtractDirectoryScope()` — scan all args for the first resolvable path operand, resolve relatives against `WorkingDirectory`, normalize, and enforce minimum depth for directory scope - [x] 1.2 Add `ExtractParentDirectory()` and `CountPathSegments()` helpers to `ShellTokenizer` - [x] 1.3 Unit tests for `ExtractDirectoryScope` (verb+directory, grep path vs search term, glob handling, null cases) @@ -19,7 +19,7 @@ - [x] 4.1 Add `DirectoryPatterns` property to `ToolInteractionRequest` in `SessionOutput.cs` - [x] 4.2 Add `DirectoryPatterns` to `ToolApprovalContext` record in `ToolAccessPolicy.cs` -- [x] 4.3 Compute directory patterns and customize B/C labels in `CheckApprovalGate()` +- [x] 4.3 Compute directory patterns and customize B/C labels in `CheckApprovalGate()` only when the full request maps cleanly to a single directory scope; otherwise keep generic labels - [x] 4.4 Pass `DirectoryPatterns` from `ToolApprovalContext` to `ToolInteractionRequest` in `SessionToolExecutionPipeline` - [x] 4.5 Propagate `DirectoryPatterns` through `DispatchingToolExecutor` re-approval path @@ -35,4 +35,7 @@ - [x] 6.2 Unify `CollectPatterns`/`CollectDirectoryPatterns` into shared `TraverseSegments` helper - [x] 6.3 Use `PathUtility.ExpandAndNormalize()` in `ExtractDirectoryScope` instead of separate calls - [x] 6.4 Make `DirectoryPatterns` non-nullable on `ToolApprovalContext` -- [x] 6.5 Verify: `dotnet slopwatch analyze` passes, copyright headers present, all tests green +- [x] 6.5 Preserve existing directory operands like `find logs -name '*.log'` instead of widening them to the parent directory +- [x] 6.6 Keep pipe/compound traversal MVP-scoped to direct path operands only; do not infer indirect flow through `xargs`, `eval`, or loop variables +- [x] 6.7 Note Windows-native shell support as out of scope for this change (tracked separately in issue #899) +- [x] 6.8 Verify: `dotnet slopwatch analyze` passes, copyright headers present, all tests green diff --git a/src/Netclaw.Actors.Tests/Channels/DiscordApprovalPromptBuilderTests.cs b/src/Netclaw.Actors.Tests/Channels/DiscordApprovalPromptBuilderTests.cs index e26311b4..e9e5d9d0 100644 --- a/src/Netclaw.Actors.Tests/Channels/DiscordApprovalPromptBuilderTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/DiscordApprovalPromptBuilderTests.cs @@ -14,6 +14,8 @@ public sealed class DiscordApprovalPromptBuilderTests [Fact] public void BuildTextPrompt_contains_tool_name_and_options() { + const string sessionLabel = "Approve in /home/user/.netclaw/logs/ for this chat"; + const string alwaysLabel = "Approve in /home/user/.netclaw/logs/ always"; var request = new ToolInteractionRequest { SessionId = new SessionId("test/session"), @@ -24,8 +26,8 @@ public void BuildTextPrompt_contains_tool_name_and_options() Patterns = ["origin/main"], Options = [ new ToolInteractionOption(ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.ApproveOnceLabel), - new ToolInteractionOption(ApprovalOptionKeys.ApproveSession, ApprovalOptionKeys.ApproveSessionLabel), - new ToolInteractionOption(ApprovalOptionKeys.ApproveAlways, ApprovalOptionKeys.ApproveAlwaysLabel), + new ToolInteractionOption(ApprovalOptionKeys.ApproveSession, sessionLabel), + new ToolInteractionOption(ApprovalOptionKeys.ApproveAlways, alwaysLabel), new ToolInteractionOption(ApprovalOptionKeys.Deny, ApprovalOptionKeys.DenyLabel) ] }; @@ -39,6 +41,8 @@ public void BuildTextPrompt_contains_tool_name_and_options() Assert.Contains("B)", prompt); Assert.Contains("C)", prompt); Assert.Contains("D)", prompt); + Assert.Contains(sessionLabel, prompt); + Assert.Contains(alwaysLabel, prompt); } [Fact] diff --git a/src/Netclaw.Actors.Tests/Channels/SlackApprovalBlockBuilderTests.cs b/src/Netclaw.Actors.Tests/Channels/SlackApprovalBlockBuilderTests.cs new file mode 100644 index 00000000..48d51f5a --- /dev/null +++ b/src/Netclaw.Actors.Tests/Channels/SlackApprovalBlockBuilderTests.cs @@ -0,0 +1,42 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Actors.Protocol; +using Netclaw.Channels.Slack; +using Xunit; + +namespace Netclaw.Actors.Tests.Channels; + +public sealed class SlackApprovalBlockBuilderTests +{ + [Fact] + public void BuildApprovalText_uses_request_option_labels() + { + const string sessionLabel = "Approve in /home/user/.netclaw/logs/ for this chat"; + const string alwaysLabel = "Approve in /home/user/.netclaw/logs/ always"; + + var request = new ToolInteractionRequest + { + SessionId = new SessionId("test/session"), + Kind = "approval", + CallId = "call-1", + ToolName = "shell_execute", + DisplayText = "grep 'error' /home/user/.netclaw/logs/app.log", + Patterns = ["grep /home/user/.netclaw/logs/app.log"], + Options = + [ + new ToolInteractionOption(ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.ApproveOnceLabel), + new ToolInteractionOption(ApprovalOptionKeys.ApproveSession, sessionLabel), + new ToolInteractionOption(ApprovalOptionKeys.ApproveAlways, alwaysLabel), + new ToolInteractionOption(ApprovalOptionKeys.Deny, ApprovalOptionKeys.DenyLabel) + ] + }; + + var text = SlackApprovalBlockBuilder.BuildApprovalText(request); + + Assert.Contains(sessionLabel, text); + Assert.Contains(alwaysLabel, text); + } +} diff --git a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs index d1e00d61..b06fd4ab 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs @@ -791,6 +791,49 @@ public void Shell_path_command_labels_show_directory_scope() Assert.Contains("always", alwaysOption.Label); } + [Fact] + public void Mixed_scope_and_fallback_uses_default_labels() + { + var policy = CreatePolicy(ToolApprovalMode.Approval); + var args = ToolInput.Create("Command", "cat /home/user/.netclaw/logs/crash.log | grep error"); + + var decision = policy.AuthorizeInvocation(ShellTool(), PersonalContext(), args); + + Assert.True(decision.NeedsApproval); + Assert.NotNull(decision.ApprovalContext); + Assert.Contains(decision.ApprovalContext!.DirectoryPatterns, p => p.EndsWith("/")); + Assert.Contains(decision.ApprovalContext.DirectoryPatterns, p => !p.EndsWith("/")); + var sessionOption = decision.ApprovalContext.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveSession); + var alwaysOption = decision.ApprovalContext.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveAlways); + Assert.Equal(ApprovalOptionKeys.ApproveSessionLabel, sessionOption.Label); + Assert.Equal(ApprovalOptionKeys.ApproveAlwaysLabel, alwaysOption.Label); + } + + [Fact] + public void Relative_path_command_uses_working_directory_for_directory_scope() + { + var policy = CreatePolicy(ToolApprovalMode.Approval); + var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + var logs = Path.Combine(root, "logs"); + Directory.CreateDirectory(logs); + var file = Path.Combine(logs, "app.log"); + File.WriteAllText(file, "hello"); + + try + { + var args = ToolInput.Create("Command", "cat logs/app.log", "WorkingDirectory", root); + + var decision = policy.AuthorizeInvocation(ShellTool(), PersonalContext(), args); + + Assert.True(decision.NeedsApproval); + Assert.Contains($"cat {PathUtility.Normalize(logs)}/", decision.ApprovalContext!.DirectoryPatterns); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + [Fact] public void Non_path_command_uses_default_labels() { diff --git a/src/Netclaw.Actors/Protocol/SessionOutputDto.cs b/src/Netclaw.Actors/Protocol/SessionOutputDto.cs index 7248ac19..3c81c1e9 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutputDto.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutputDto.cs @@ -106,6 +106,7 @@ public sealed record SessionOutputDto public string? InteractionDisplayText { get; init; } public string? RequesterSenderId { get; init; } public List? InteractionPatterns { get; init; } + public List? InteractionDirectoryPatterns { get; init; } public List? InteractionOptions { get; init; } public bool? InteractionHasAdoptedContext { get; init; } public List? InteractionAdoptedSpeakerIds { get; init; } diff --git a/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs b/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs index c3c058ad..c17234c7 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs @@ -179,6 +179,7 @@ public static class SessionOutputDtoMapper InteractionDisplayText = msg.DisplayText, RequesterSenderId = msg.RequesterSenderId, InteractionPatterns = [.. msg.Patterns], + InteractionDirectoryPatterns = [.. msg.DirectoryPatterns], InteractionOptions = [.. msg.Options], InteractionHasAdoptedContext = msg.HasAdoptedContext, InteractionAdoptedSpeakerIds = [.. msg.AdoptedSpeakerIds] @@ -336,6 +337,7 @@ public static SessionOutput FromDto(SessionOutputDto dto) HasAdoptedContext = dto.InteractionHasAdoptedContext ?? false, AdoptedSpeakerIds = dto.InteractionAdoptedSpeakerIds ?? [], Patterns = dto.InteractionPatterns ?? [], + DirectoryPatterns = dto.InteractionDirectoryPatterns ?? [], Options = dto.InteractionOptions ?? [] }, _ => new ErrorOutput diff --git a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs index 68e1c708..f9985ba6 100644 --- a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs +++ b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs @@ -263,6 +263,30 @@ private static bool ShellCommandHasPathArguments(string shellCommand) return ToolArgumentHelper.GetString(arguments, "WorkingDirectory"); } + private static bool TryGetSingleDirectoryScope(IReadOnlyList directoryPatterns, out string? directoryScope) + { + directoryScope = null; + + if (directoryPatterns.Count == 0 || directoryPatterns.Any(p => !p.EndsWith('/'))) + return false; + + var scopes = directoryPatterns + .Select(static pattern => + { + var spaceIdx = pattern.IndexOf(' ', StringComparison.Ordinal); + return spaceIdx >= 0 ? pattern[(spaceIdx + 1)..] : null; + }) + .Where(static scope => !string.IsNullOrWhiteSpace(scope)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (scopes.Count != 1) + return false; + + directoryScope = scopes[0]; + return true; + } + private ToolAccessDecision CheckApprovalGate( ToolName toolName, ToolExecutionContext? context, @@ -303,16 +327,10 @@ private ToolAccessDecision CheckApprovalGate( var sessionLabel = ApprovalOptionKeys.ApproveSessionLabel; var alwaysLabel = ApprovalOptionKeys.ApproveAlwaysLabel; - var firstDirScope = directoryPatterns.FirstOrDefault(p => p.EndsWith('/')); - if (firstDirScope is not null) + if (TryGetSingleDirectoryScope(directoryPatterns, out var directoryScope)) { - var spaceIdx = firstDirScope.IndexOf(' ', StringComparison.Ordinal); - if (spaceIdx >= 0) - { - var dir = firstDirScope[(spaceIdx + 1)..]; - sessionLabel = $"Approve in {dir} for this chat"; - alwaysLabel = $"Approve in {dir} always"; - } + sessionLabel = $"Approve in {directoryScope} for this chat"; + alwaysLabel = $"Approve in {directoryScope} always"; } var approvalContext = new ToolApprovalContext( diff --git a/src/Netclaw.Channels.Discord/DiscordApprovalPromptBuilder.cs b/src/Netclaw.Channels.Discord/DiscordApprovalPromptBuilder.cs index ba2245e7..071a6ddf 100644 --- a/src/Netclaw.Channels.Discord/DiscordApprovalPromptBuilder.cs +++ b/src/Netclaw.Channels.Discord/DiscordApprovalPromptBuilder.cs @@ -24,10 +24,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(); } @@ -39,7 +36,7 @@ public static (string Text, IReadOnlyList Buttons) BuildButto 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 buttons = request.Options .Select(option => new DiscordButtonSpec( @@ -107,6 +104,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(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 GetDecisionLabel(string selectedKey) => selectedKey switch { diff --git a/src/Netclaw.Channels.Slack/SlackApprovalBlockBuilder.cs b/src/Netclaw.Channels.Slack/SlackApprovalBlockBuilder.cs index dfa11a17..f3b985bf 100644 --- a/src/Netclaw.Channels.Slack/SlackApprovalBlockBuilder.cs +++ b/src/Netclaw.Channels.Slack/SlackApprovalBlockBuilder.cs @@ -38,10 +38,8 @@ public static string BuildApprovalText(ToolInteractionRequest request) lines.Add(""); lines.Add("Reply with:"); - lines.Add($" *A)* {ApprovalOptionKeys.ApproveOnceLabel}"); - lines.Add($" *B)* {ApprovalOptionKeys.ApproveSessionLabel}"); - lines.Add($" *C)* {ApprovalOptionKeys.ApproveAlwaysLabel}"); - lines.Add($" *D)* {ApprovalOptionKeys.DenyLabel}"); + foreach (var replyOption in EnumerateReplyOptions(request.Options)) + lines.Add($" *{replyOption.Letter})* {replyOption.Option.Label}"); return string.Join("\n", lines); } @@ -93,7 +91,7 @@ public static IReadOnlyList BuildApprovalBlocks(ToolInteractionRequest re blocks.Add(new SectionBlock { - Text = new Markdown("You can also reply with `A`, `B`, `C`, or `D` in this thread.") + Text = new Markdown($"You can also reply with {FormatReplyLetters(request.Options)} in this thread.") }); return blocks; @@ -174,6 +172,18 @@ private static void AppendAdoptedContextSummary(List lines, ToolInteract private static string BuildAdoptedContextMarkdown(ToolInteractionRequest request) => $"*Adopted context:* present\n*Speakers:* `{EscapeMarkdown(string.Join(", ", request.AdoptedSpeakerIds))}`"; + private static IEnumerable<(string Letter, ToolInteractionOption Option)> EnumerateReplyOptions(IReadOnlyList options) + { + for (var i = 0; i < options.Count; i++) + yield return (GetReplyLetter(i), options[i]); + } + + private static string FormatReplyLetters(IReadOnlyList options) + => string.Join(", ", EnumerateReplyOptions(options).Select(static x => $"`{x.Letter}`")); + + private static string GetReplyLetter(int index) + => ((char)('A' + index)).ToString(); + internal static string BuildButtonValue(ToolInteractionRequest request, ToolInteractionOption option) => ApprovalButtonValueCodec.Encode(request, option); diff --git a/src/Netclaw.Cli.Tests/Cli/DaemonClientMappingTests.cs b/src/Netclaw.Cli.Tests/Cli/DaemonClientMappingTests.cs index 838212fa..c2d362ad 100644 --- a/src/Netclaw.Cli.Tests/Cli/DaemonClientMappingTests.cs +++ b/src/Netclaw.Cli.Tests/Cli/DaemonClientMappingTests.cs @@ -266,6 +266,7 @@ public void ToolInteractionRequest_roundtrips_through_dto() DisplayText = "git push origin main", RequesterSenderId = "device-1", Patterns = ["git push"], + DirectoryPatterns = ["git /home/user/.netclaw/workspaces/"], Options = [ new ToolInteractionOption(ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.ApproveOnceLabel), @@ -280,6 +281,7 @@ public void ToolInteractionRequest_roundtrips_through_dto() Assert.Equal("approval", dto.InteractionKind); Assert.Equal("git push origin main", dto.InteractionDisplayText); Assert.Equal("device-1", dto.RequesterSenderId); + Assert.Equal(["git /home/user/.netclaw/workspaces/"], dto.InteractionDirectoryPatterns); var roundTripped = DaemonClient.FromDto(dto); var result = Assert.IsType(roundTripped); @@ -288,6 +290,7 @@ public void ToolInteractionRequest_roundtrips_through_dto() Assert.Equal("git push origin main", result.DisplayText); Assert.Equal("device-1", result.RequesterSenderId); Assert.Equal(["git push"], result.Patterns); + Assert.Equal(["git /home/user/.netclaw/workspaces/"], result.DirectoryPatterns); Assert.Equal(4, result.Options.Count); } diff --git a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs index 40f7adc7..82e82fc7 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs @@ -14,6 +14,13 @@ public sealed class ShellApprovalMatcherTests private static Dictionary Args(string command) => new() { ["Command"] = command }; + private static Dictionary Args(string command, string workingDirectory) + => new() + { + ["Command"] = command, + ["WorkingDirectory"] = workingDirectory + }; + [Fact] public void ExtractPatterns_simple_command() { @@ -127,6 +134,15 @@ public void IsApproved_recurses_into_bash_c_wrapper() Args("bash -c \"git push --force\""), approved)); } + [Fact] + public void IsApproved_directory_scope_matches_grep_path_operand() + { + var approved = new[] { "grep /home/user/.netclaw/logs/" }; + + Assert.True(_matcher.IsApproved(new ToolName("shell_execute"), + Args("grep -l \"timeout\" /home/user/.netclaw/logs/daemon.log"), approved)); + } + [Fact] public void ExtractPatterns_path_aware_verb_includes_path() { @@ -138,6 +154,41 @@ public void ExtractPatterns_path_aware_verb_includes_path() Assert.Contains("git push", patterns); } + [Fact] + public void ExtractPatterns_grep_uses_path_operand_instead_of_search_term() + { + var patterns = _matcher.ExtractPatterns( + new ToolName("shell_execute"), + Args("grep -l \"timeout\" /home/user/.netclaw/logs/daemon.log")); + + Assert.Single(patterns); + Assert.Equal("grep /home/user/.netclaw/logs/daemon.log", patterns[0]); + } + + [Fact] + public void ExtractPatterns_resolve_relative_paths_against_working_directory() + { + var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + var logs = Path.Combine(root, "logs"); + Directory.CreateDirectory(logs); + var file = Path.Combine(logs, "app.log"); + File.WriteAllText(file, "hello"); + + try + { + var patterns = _matcher.ExtractPatterns( + new ToolName("shell_execute"), + Args("cat logs/app.log", root)); + + Assert.Single(patterns); + Assert.Equal($"cat {PathUtility.Normalize(file)}", patterns[0]); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + [Fact] public void ExtractPatterns_pipe_with_path_aware_verbs() { @@ -207,6 +258,52 @@ public void ExtractDirectoryPatterns_mixed_compound_with_fallback() Assert.Contains(patterns, p => p.StartsWith("cat ") && p.EndsWith("/")); Assert.Contains(patterns, p => p == "git push"); } + + [Fact] + public void ExtractDirectoryPatterns_resolve_relative_paths_against_working_directory() + { + var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + var logs = Path.Combine(root, "logs"); + Directory.CreateDirectory(logs); + var file = Path.Combine(logs, "app.log"); + File.WriteAllText(file, "hello"); + + try + { + var patterns = _matcher.ExtractDirectoryPatterns( + new ToolName("shell_execute"), + Args("cat logs/app.log", root)); + + Assert.Single(patterns); + Assert.Equal($"cat {PathUtility.Normalize(logs)}/", patterns[0]); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void ExtractDirectoryPatterns_preserve_existing_directory_operand() + { + var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + var logs = Path.Combine(root, "logs"); + Directory.CreateDirectory(logs); + + try + { + var patterns = _matcher.ExtractDirectoryPatterns( + new ToolName("shell_execute"), + Args("find logs -name '*.log'", root)); + + Assert.Single(patterns); + Assert.Equal($"find {PathUtility.Normalize(logs)}/", patterns[0]); + } + finally + { + Directory.Delete(root, recursive: true); + } + } } public sealed class DefaultApprovalMatcherTests diff --git a/src/Netclaw.Security.Tests/ShellTokenizerTests.cs b/src/Netclaw.Security.Tests/ShellTokenizerTests.cs index 0f2a521c..b493ec20 100644 --- a/src/Netclaw.Security.Tests/ShellTokenizerTests.cs +++ b/src/Netclaw.Security.Tests/ShellTokenizerTests.cs @@ -276,7 +276,6 @@ public void LooksLikePath_backslash(string token) [Theory] [InlineData("cat /home/user/.netclaw/logs/crash.log", "cat", "/home/user/.netclaw/logs/")] [InlineData("ls -la /home/user/.netclaw/logs/", "ls", "/home/user/.netclaw/logs/")] - [InlineData("find /home/user/.netclaw/logs -name '*.log'", "find", "/home/user/.netclaw/")] public void ExtractDirectoryScope_returns_verb_and_directory(string command, string expectedVerb, string expectedDirSuffix) { var result = ShellTokenizer.ExtractDirectoryScope(command); @@ -288,6 +287,26 @@ public void ExtractDirectoryScope_returns_verb_and_directory(string command, str Assert.EndsWith(expectedDirSuffix, dir.Replace('\\', '/')); } + [Fact] + public void ExtractDirectoryScope_preserves_existing_directory_operand() + { + var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + var logs = Path.Combine(root, "logs"); + Directory.CreateDirectory(logs); + + try + { + var result = ShellTokenizer.ExtractDirectoryScope($"find {logs} -name '*.log'"); + + Assert.NotNull(result); + Assert.Equal($"find {PathUtility.Normalize(logs)}/", result); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + [Fact] public void ExtractDirectoryScope_grep_finds_path_not_search_term() { diff --git a/src/Netclaw.Security/IToolApprovalMatcher.cs b/src/Netclaw.Security/IToolApprovalMatcher.cs index 89217485..a4d94c5f 100644 --- a/src/Netclaw.Security/IToolApprovalMatcher.cs +++ b/src/Netclaw.Security/IToolApprovalMatcher.cs @@ -77,8 +77,9 @@ public IReadOnlyList ExtractPatterns(ToolName toolName, IDictionary(StringComparer.OrdinalIgnoreCase); - CollectPatterns(command, patterns); + CollectPatterns(command, workingDirectory, patterns); return patterns.ToList(); } @@ -110,8 +111,9 @@ public IReadOnlyList ExtractDirectoryPatterns(ToolName toolName, IDictio if (string.IsNullOrWhiteSpace(command)) return []; + var workingDirectory = GetWorkingDirectory(arguments); var patterns = new HashSet(StringComparer.OrdinalIgnoreCase); - CollectDirectoryPatterns(command, patterns); + CollectDirectoryPatterns(command, workingDirectory, patterns); return patterns.ToList(); } @@ -129,14 +131,30 @@ public IReadOnlyList ExtractDirectoryPatterns(ToolName toolName, IDictio private static bool PatternMatchesAny(string pattern, IReadOnlyList approvedPatterns) => ApprovalPatternMatching.MatchesAny(pattern, approvedPatterns); - private static void CollectPatterns(string command, ISet patterns) - => TraverseSegments(command, patterns, static segment => ShellTokenizer.ExtractVerbChain(segment)); + private static void CollectPatterns(string command, string? workingDirectory, ISet patterns) + => TraverseSegments(command, workingDirectory, patterns, + static (segment, wd) => ShellTokenizer.ExtractApprovalPattern(segment, wd)); - private static void CollectDirectoryPatterns(string command, ISet patterns) - => TraverseSegments(command, patterns, static segment => - ShellTokenizer.ExtractDirectoryScope(segment) ?? ShellTokenizer.ExtractVerbChain(segment)); + private static void CollectDirectoryPatterns(string command, string? workingDirectory, ISet patterns) + => TraverseSegments(command, workingDirectory, patterns, static (segment, wd) => + ShellTokenizer.ExtractDirectoryScope(segment, wd) ?? ShellTokenizer.ExtractApprovalPattern(segment, wd)); - private static void TraverseSegments(string command, ISet patterns, Func extractLeaf) + private static string? GetWorkingDirectory(IDictionary? arguments) + { + if (arguments is null) + return null; + + if (arguments.TryGetValue("WorkingDirectory", out var val) || arguments.TryGetValue("workingDirectory", out val)) + return val?.ToString(); + + return null; + } + + private static void TraverseSegments( + string command, + string? workingDirectory, + ISet patterns, + Func extractLeaf) { foreach (var segment in ShellTokenizer.SplitCompoundCommand(command)) { @@ -144,12 +162,12 @@ private static void TraverseSegments(string command, ISet patterns, Func if (innerCommands.Count > 0) { foreach (var inner in innerCommands) - TraverseSegments(inner, patterns, extractLeaf); + TraverseSegments(inner, workingDirectory, patterns, extractLeaf); continue; } - var pattern = extractLeaf(segment); + var pattern = extractLeaf(segment, workingDirectory); if (!string.IsNullOrEmpty(pattern)) patterns.Add(pattern); } diff --git a/src/Netclaw.Security/ShellTokenizer.cs b/src/Netclaw.Security/ShellTokenizer.cs index 1a0a123f..e2b73110 100644 --- a/src/Netclaw.Security/ShellTokenizer.cs +++ b/src/Netclaw.Security/ShellTokenizer.cs @@ -38,6 +38,20 @@ public static class ShellTokenizer "ls" }; + /// + /// Verbs whose first non-flag operand is expected to be a path. Existing bare + /// relative operands for these verbs can be treated as path targets even when + /// they do not include a slash or file extension. + /// + private static readonly HashSet LeadingPathVerbs = new(StringComparer.OrdinalIgnoreCase) + { + "bash", "sh", "zsh", + "cat", "less", "more", "head", "tail", + "find", "ls", + "cp", "mv", "tar", "zip", "unzip", + "python", "python3", "node", "ruby", "perl", "php" + }; + /// /// Tokenizes a shell command string, respecting single and double quotes. /// Strips quote delimiters from tokens. @@ -198,6 +212,16 @@ public static string ExtractVerbChain(string command, int maxDepth = 2) return string.Join(' ', verbParts); } + /// + /// Extracts the approval pattern used for exact matching. For path-aware verbs, + /// prefers the first recognizable path operand normalized against the working + /// directory; otherwise falls back to the verb-chain extraction. + /// + public static string ExtractApprovalPattern(string command, string? workingDirectory = null, int maxDepth = 2) + => TryExtractPathOperand(command, workingDirectory, out var operand) + ? operand.Verb + " " + operand.NormalizedPath + : ExtractVerbChain(command, maxDepth); + /// /// Extracts inner commands from bash -c / sh -c wrappers. Returns the /// inner command strings for recursive scanning. Returns an empty list @@ -365,15 +389,31 @@ public static bool LooksLikePath(string token) /// argument, or the resulting directory is too shallow (fewer than 2 /// path segments below root). /// - public static string? ExtractDirectoryScope(string command) + public static string? ExtractDirectoryScope(string command, string? workingDirectory = null) { + if (!TryExtractPathOperand(command, workingDirectory, out var operand)) + return null; + + // Enforce minimum depth — reject shallow scopes like / or /etc/ + if (CountPathSegments(operand.NormalizedDirectory) < MinDirectoryScopeDepth) + return null; + + return operand.Verb + " " + operand.NormalizedDirectory + "/"; + } + + internal const int MinDirectoryScopeDepth = 2; + + private static bool TryExtractPathOperand(string command, string? workingDirectory, out PathOperand operand) + { + operand = default; + var tokens = Tokenize(command).ToList(); if (tokens.Count == 0) - return null; + return false; var verb = TrimShellPunctuation(tokens[0]); if (verb.Length == 0 || !PathAwareVerbs.Contains(verb)) - return null; + return false; for (var i = 1; i < tokens.Count; i++) { @@ -381,28 +421,54 @@ public static bool LooksLikePath(string token) if (trimmed.Length == 0 || trimmed.StartsWith('-')) continue; - if (!LooksLikePath(trimmed)) + var normalizedPath = TryNormalizePathOperand(trimmed, verb, workingDirectory); + if (normalizedPath is null) continue; - var dir = ExtractParentDirectory(PathUtility.ExpandHome(trimmed)); - if (dir is null) + var normalizedDirectory = ExtractScopedDirectory(normalizedPath, trimmed); + if (normalizedDirectory is null) continue; - var normalized = PathUtility.ExpandAndNormalize(dir); - if (normalized is null) - continue; + operand = new PathOperand(verb, normalizedPath, normalizedDirectory); + return true; + } + + return false; + } - // Enforce minimum depth — reject shallow scopes like / or /etc/ - if (CountPathSegments(normalized) < MinDirectoryScopeDepth) - return null; + private static string? TryNormalizePathOperand(string token, string verb, string? workingDirectory) + { + if (LooksLikePath(token)) + return PathUtility.ExpandAndNormalize(token, workingDirectory); - return verb + " " + normalized + "/"; - } + if (!LeadingPathVerbs.Contains(verb)) + return null; - return null; + var normalizedPath = PathUtility.ExpandAndNormalize(token, workingDirectory); + if (normalizedPath is null) + return null; + + return File.Exists(normalizedPath) || Directory.Exists(normalizedPath) + ? normalizedPath + : null; } - internal const int MinDirectoryScopeDepth = 2; + private static string? ExtractScopedDirectory(string normalizedPath, string rawPath) + { + if (rawPath.EndsWith('/') || rawPath.EndsWith('\\')) + return normalizedPath; + + if (HasGlob(rawPath)) + return Path.GetDirectoryName(normalizedPath); + + if (Directory.Exists(normalizedPath)) + return normalizedPath; + + return Path.GetDirectoryName(normalizedPath); + } + + private static bool HasGlob(string path) + => path.IndexOfAny(['*', '?', '[']) >= 0; private static string? ExtractParentDirectory(string path) { @@ -487,4 +553,6 @@ private static void FlushSegment(StringBuilder current, List segments) segments.Add(trimmed); current.Clear(); } + + private readonly record struct PathOperand(string Verb, string NormalizedPath, string NormalizedDirectory); } From d40180f2a7a6a41cc6ce2a212011abbd832a25d3 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 6 May 2026 23:22:59 +0000 Subject: [PATCH 04/22] fix(security): narrow directory-scoped shell approvals Keep exact path-aware approval patterns for direct path operands while limiting directory-scoped grants to a small read/list allowlist. Fall back to exact patterns and generic prompts for non-allowlisted commands and redirected shell segments so broader approvals stay predictable and easier to reason about. --- .../design.md | 58 +++++++++---- .../proposal.md | 20 +++-- .../specs/tool-approval-gates/spec.md | 83 ++++++++++++++++--- .../tasks.md | 14 ++-- .../Tools/ToolApprovalGateTests.cs | 32 +++++++ .../ShellApprovalMatcherTests.cs | 43 ++++++++-- .../ShellTokenizerTests.cs | 22 +---- src/Netclaw.Security/ShellTokenizer.cs | 56 +++++++++++-- 8 files changed, 257 insertions(+), 71 deletions(-) diff --git a/openspec/changes/directory-scoped-approval-patterns/design.md b/openspec/changes/directory-scoped-approval-patterns/design.md index 0b5d1022..a7bb20c3 100644 --- a/openspec/changes/directory-scoped-approval-patterns/design.md +++ b/openspec/changes/directory-scoped-approval-patterns/design.md @@ -1,8 +1,8 @@ ## Context Shell command approval patterns are extracted by `ShellTokenizer.ExtractVerbChain()` -which, for path-aware verbs (`ls`, `cat`, `grep`, `find`, etc.), appends the -first file-path argument to the verb chain. This produces per-file patterns +which, for path-aware commands with a direct path operand, appends that +normalized operand to the verb chain. This produces per-file patterns like `cat /home/.netclaw/logs/crash-foo.log`. Combined with the single-token exact-match restriction in `ApprovalPatternMatching` (which prevents bare `cat` from silently approving `cat /etc/shadow`), each unique file path requires a @@ -20,16 +20,19 @@ This change only relaxes layer 2. Layers 1 and 3 are unaffected. **Goals:** - Reduce per-file approval fatigue for diagnostic shell commands - Store directory-scoped patterns when user selects B (session) or C (always) -- Maintain boundary-safe path matching (no `StartsWith` — use `PathUtility.IsWithinRoot`) + for a narrow direct read/list allowlist only +- Maintain boundary-safe path matching (no `StartsWith` - use `PathUtility.IsWithinRoot`) - Prevent overly broad directory scopes (minimum 2 path segments) - Show directory context in approval option labels only when the entire request maps cleanly to one directory scope **Non-Goals:** - Changing the hard deny list or `ToolPathPolicy` behavior -- Changing "Approve once" (A) behavior — it remains exact-pattern +- Changing "Approve once" (A) behavior - it remains exact-pattern - Glob-aware or regex-based pattern matching - Cross-verb directory approvals (`cat /dir/` does not approve `grep /dir/`) +- Directory-scoped approvals for non-allowlisted commands such as `find`, + `bash`, or `python3` - Inferring indirect path flow through shell constructs like `xargs`, `eval`, loop variables, command substitution, or shell variables - Windows-native shell path handling; tracked separately in issue #899 @@ -46,7 +49,7 @@ is a flat list of strings per tool per audience. A sentinel convention avoids schema changes and keeps backward compatibility — existing non-slash patterns work unchanged. -### Extraction: shared path-operand resolution for exact and directory patterns +### Extraction: shared path-operand resolution, narrower directory scope `ShellTokenizer.TryExtractPathOperand()` is the shared primitive behind `ExtractApprovalPattern()` and `ExtractDirectoryScope()`. It scans ALL non-flag @@ -58,18 +61,25 @@ term is the first positional arg and the file path is second When a recognizable path operand exists, exact approval patterns use the normalized path operand itself (`grep /abs/path/logs/daemon.log`), not the raw -verb chain. Directory-scoped patterns then derive scope from that same operand. +verb chain. That broader exact extraction still applies to commands like +`find`, `bash`, or `python3` when they include a direct path operand. +Directory-scoped patterns then derive scope from that same operand only when +the command verb is in the MVP allowlist: `cat`, `less`, `more`, `head`, +`tail`, `grep`, and `ls`. **Alternative considered:** Always use first positional. Rejected because grep, sed, and awk take non-path first arguments. -### Existing directory operands stay directory-scoped +### Existing directory operands stay directory-scoped for allowlisted verbs `ExtractScopedDirectory()` preserves an operand that already denotes a -directory. For example, `find logs -name '*.log'` resolves `logs` against the -working directory and stores `find /abs/path/logs/` rather than widening to the -parent (`/abs/path/`). This keeps approval scope aligned with what the command -actually targets. +directory. For example, `ls logs/` resolves `logs/` against the working +directory and stores `ls /abs/path/logs/` rather than widening to the parent +(`/abs/path/`). This keeps approval scope aligned with what the command actually +targets. + +Commands outside the allowlist still use the normalized exact path operand when +available, but they do not derive directory scope from it. ### Matching: `PathUtility.IsWithinRoot()` with normalized operands @@ -91,22 +101,42 @@ at root-level directories even if they want to. ### Verb isolation An approval for `cat /dir/` does NOT approve `grep /dir/`. The verb is part of -the pattern and checked explicitly. This limits blast radius — approving reads +the pattern and checked explicitly. This limits blast radius - approving reads doesn't silently approve writes or deletions. +### Directory scope is allowlist-based + +Directory scope is intentionally narrower than exact path-aware extraction. Only +the direct read/list verbs `cat`, `less`, `more`, `head`, `tail`, `grep`, and +`ls` can emit directory-scoped patterns in this MVP. + +Commands outside that allowlist, including `find`, keep exact path-aware +patterns when a direct path operand exists, but they fall back to generic B/C +labels and never store trailing-slash directory approvals. + ### Dynamic labels require a single clean directory scope `ToolAccessPolicy.TryGetSingleDirectoryScope()` only emits directory-specific B/C labels when every approval pattern for the request is directory-scoped and all of them resolve to the same directory. If any segment falls back to a generic -verb-chain pattern (for example `git push`) or multiple directory scopes are +exact pattern (for example `find logs -name '*.log'`, `git push`, or an +allowlisted read with shell redirection) or multiple directory scopes are present, labels stay generic. +### Shell redirection disables directory scope + +If a segment contains shell redirection operators, directory-scoped extraction +is disabled even when the verb itself is allowlisted. For example, +`cat logs/app.log > out.txt` falls back to the exact pattern and generic B/C +labels. This avoids granting directory scope when the command also writes or +rewires IO. + ### Compound commands: direct path operands only Compound commands and pipe segments are still traversed segment-by-segment, so a segment like `cat logs/app.log | jq .` can contribute directory scope for the -`cat` segment. MVP extraction stops there: it does not infer that a downstream +`cat` segment when there is no shell redirection on that segment and the verb is +allowlisted. MVP extraction stops there: it does not infer that a downstream segment implicitly targets the same path through `xargs`, `eval`, loop variables, shell variables, or similar constructs. diff --git a/openspec/changes/directory-scoped-approval-patterns/proposal.md b/openspec/changes/directory-scoped-approval-patterns/proposal.md index 516e3e12..f2ea4c44 100644 --- a/openspec/changes/directory-scoped-approval-patterns/proposal.md +++ b/openspec/changes/directory-scoped-approval-patterns/proposal.md @@ -1,7 +1,7 @@ ## Why -Path-aware shell verbs (`ls`, `cat`, `grep`, `find`, etc.) produce per-file -approval patterns (e.g., `cat /home/.netclaw/logs/crash-foo.log`). In a single +Path-aware shell commands can produce per-file exact approval patterns (e.g., +`cat /home/.netclaw/logs/crash-foo.log`). In a single diagnostic session (D0AC6CKBK5K/1778085593.830269), the user was prompted **11 separate times** for commands targeting different files in the same directory. The single-token exact-match restriction prevents bare `cat` from silently @@ -12,15 +12,16 @@ legitimate diagnostic work. - "Approve for this chat" (B) and "Approve always" (C) store **directory-scoped patterns** (e.g., `grep /home/.netclaw/logs/`) instead of per-file patterns - when the command targets a recognizable file path. + only for a narrow direct read/list allowlist: `cat`, `less`, `more`, `head`, + `tail`, `grep`, and `ls`. - Relative path operands are resolved against the shell tool `WorkingDirectory` before both exact-pattern extraction and directory-scope extraction/matching. - Path-aware exact approval patterns use the actual normalized path operand when one exists, including commands like `grep -l "timeout" logs/app.log` where the - search term is not the path operand. -- Existing directory operands keep their directory scope (for example, - `find logs -name '*.log'` stores `find /logs/`) instead of widening to the - parent directory. + search term is not the path operand and commands like `find`, `bash`, or + `python3` when a direct path operand exists. +- Existing directory operands keep their directory scope for allowlisted + directory-scoped verbs instead of widening to the parent directory. - A trailing `/` on a stored approval pattern signals directory scope. Matching uses `PathUtility.IsWithinRoot()` for boundary-safe containment instead of naive `StartsWith`. @@ -28,6 +29,11 @@ legitimate diagnostic work. scopes like `/` or `/etc/`. - `IToolApprovalMatcher` gains `ExtractDirectoryPatterns()` for tool-specific directory pattern extraction. +- Commands outside the directory-scope allowlist, including `find`, fall back to + exact approval patterns and generic B/C labels rather than directory scope. +- Shell redirection operators disable directory-scoped extraction even for + allowlisted verbs, causing fallback to exact approval patterns and generic + labels. - Approval option labels only show a directory-specific scope when the full approval set for the request maps cleanly to a single directory; otherwise the labels stay generic. diff --git a/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md b/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md index ea904a9f..4ac5619f 100644 --- a/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md +++ b/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md @@ -3,17 +3,26 @@ ### Requirement: Directory-scoped approval patterns The system SHALL support directory-scoped approval patterns for shell commands -targeting path-aware verbs. When the user selects "Approve for this chat" (B) or -"Approve always" (C) for a shell command that targets a recognizable file path, -the system SHALL store a directory-scoped pattern (e.g., `grep /home/.netclaw/logs/`) -instead of the exact file-path pattern. "Approve once" (A) SHALL continue to use -exact patterns. +targeting a narrow direct read/list allowlist: `cat`, `less`, `more`, `head`, +`tail`, `grep`, and `ls`. When the user selects "Approve for this chat" (B) or +"Approve always" (C) for one of those shell commands and the command targets a +recognizable direct path operand, the system SHALL store a directory-scoped +pattern (e.g., `grep /home/.netclaw/logs/`) instead of the exact file-path +pattern. "Approve once" (A) SHALL continue to use exact patterns. When a recognizable path operand is relative, the system SHALL resolve it against the shell tool `WorkingDirectory` before extracting either exact or directory-scoped approval patterns. Existing operands that already denote a directory SHALL preserve that directory scope instead of widening to the parent. +Commands outside that directory-scope allowlist, including `find`, SHALL fall +back to exact approval patterns and generic B/C labels even when they have a +recognizable direct path operand. + +If a shell segment contains redirection operators, the system SHALL disable +directory-scoped extraction for that segment even when the verb is allowlisted. +That segment SHALL fall back to exact approval patterns and generic B/C labels. + A trailing `/` on a stored pattern SHALL signal directory scope. The system SHALL use `PathUtility.IsWithinRoot()` for boundary-safe containment matching — not naive string prefix comparison. @@ -53,9 +62,9 @@ Directory-scoped approvals SHALL be verb-isolated: an approval for #### Scenario: Existing directory operand preserves its scope - **GIVEN** the shell tool `WorkingDirectory` is `/workspace/project` -- **AND** the command is `find logs -name '*.log'` +- **AND** the command is `ls logs/` - **WHEN** directory-scoped approval extraction runs -- **THEN** the extracted pattern is `find /workspace/project/logs/` +- **THEN** the extracted pattern is `ls /workspace/project/logs/` - **AND** the scope is not widened to `/workspace/project/` #### Scenario: Approve Once uses exact pattern @@ -91,6 +100,23 @@ Directory-scoped approvals SHALL be verb-isolated: an approval for - **THEN** the parent directory `/etc/` has only 1 segment (below minimum of 2) - **AND** the system falls back to exact-pattern behavior +#### Scenario: Non-allowlisted command falls back to exact pattern + +- **GIVEN** the shell tool `WorkingDirectory` is `/workspace/project` +- **AND** the command is `find logs -name '*.log'` +- **WHEN** exact and directory-scoped approval patterns are extracted +- **THEN** the exact pattern remains path-aware for the direct operand +- **AND** the extracted exact pattern is `find /workspace/project/logs` +- **AND** no directory-scoped pattern is extracted + +#### Scenario: Redirection disables directory scope for allowlisted verb + +- **GIVEN** the shell tool `WorkingDirectory` is `/workspace/project` +- **AND** the command is `cat logs/app.log > out.txt` +- **WHEN** exact and directory-scoped approval patterns are extracted +- **THEN** the exact pattern is `cat /workspace/project/logs/app.log` +- **AND** no directory-scoped pattern is extracted + #### Scenario: Boundary-safe path matching prevents prefix collisions - **GIVEN** `cat /home/user/` is approved @@ -105,10 +131,12 @@ returns directory-scoped patterns for a tool invocation. `ShellApprovalMatcher` SHALL implement this by scanning all non-flag arguments for the first recognizable path operand, resolving relative paths against `WorkingDirectory`, expanding home directory tokens, extracting the scoped directory, normalizing -the path, and enforcing minimum depth. For compound commands and `bash -c` -wrappers, each segment SHALL be processed recursively. When no directory scope -is available for a segment, the segment's exact approval pattern SHALL be used -as fallback. +the path, enforcing minimum depth, and applying directory scope only to the +allowlisted verbs `cat`, `less`, `more`, `head`, `tail`, `grep`, and `ls`. +Segments with shell redirection operators SHALL NOT emit directory scope. For +compound commands and `bash -c` wrappers, each segment SHALL be processed +recursively. When no directory scope is available for a segment, the segment's +exact approval pattern SHALL be used as fallback. `DefaultApprovalMatcher` and `FilePathApprovalMatcher` SHALL return empty lists. @@ -132,7 +160,7 @@ as fallback. - **GIVEN** the command `cat /home/.netclaw/logs/crash.log && grep "error" /var/log/syslog` - **WHEN** `ExtractDirectoryPatterns` runs - **THEN** `cat /home/.netclaw/logs/` is extracted for the first segment -- **AND** the second segment falls back to its verb chain (depth too shallow) +- **AND** the second segment falls back to its exact path-aware pattern (depth too shallow) #### Scenario: Pipe segment can contribute direct directory scope @@ -146,9 +174,23 @@ as fallback. - **GIVEN** the command is `find logs -name '*.log' | xargs grep timeout` - **WHEN** `ExtractDirectoryPatterns` runs -- **THEN** the `find` segment may contribute `find /logs/` +- **THEN** the `find` segment does not gain directory scope - **AND** the downstream `grep` segment does not inherit that directory scope via `xargs` +#### Scenario: Non-allowlisted command keeps exact path-aware extraction + +- **GIVEN** the shell tool `WorkingDirectory` is `/workspace/project` +- **AND** the command is `python3 scripts/check.py data/input.json` +- **WHEN** exact approval pattern extraction runs +- **THEN** the pattern uses the normalized direct path operand when one exists + +#### Scenario: Redirection blocks directory extraction for allowlisted segment + +- **GIVEN** the shell tool `WorkingDirectory` is `/workspace/project` +- **AND** the command is `grep error logs/app.log > report.txt` +- **WHEN** `ExtractDirectoryPatterns` runs +- **THEN** no directory-scoped pattern is extracted for that segment + #### Scenario: Glob paths use parent directory - **GIVEN** the command `ls /home/.netclaw/logs/crash-*.log` @@ -181,6 +223,21 @@ Options A ("Approve once") and D ("Deny") SHALL retain their default labels. - **THEN** option B reads the default "Approve for this chat" - **AND** option C reads the default "Approve always" +#### Scenario: Non-allowlisted path-aware command keeps generic labels + +- **GIVEN** a shell command `find logs -name '*.log'` requires approval +- **WHEN** the approval prompt is generated +- **THEN** option B reads the default "Approve for this chat" +- **AND** option C reads the default "Approve always" + +#### Scenario: Redirection keeps generic labels for allowlisted verb + +- **GIVEN** a shell command `cat /home/.netclaw/logs/app.log > /tmp/report.txt` + requires approval +- **WHEN** the approval prompt is generated +- **THEN** option B reads the default "Approve for this chat" +- **AND** option C reads the default "Approve always" + #### Scenario: Labels stay generic for mixed approval sets - **GIVEN** a shell command `cat /home/.netclaw/logs/crash.log && git push origin main` diff --git a/openspec/changes/directory-scoped-approval-patterns/tasks.md b/openspec/changes/directory-scoped-approval-patterns/tasks.md index b6e0b291..4182b475 100644 --- a/openspec/changes/directory-scoped-approval-patterns/tasks.md +++ b/openspec/changes/directory-scoped-approval-patterns/tasks.md @@ -1,8 +1,8 @@ ## 1. Pattern Extraction -- [x] 1.1 Add shared path-operand extraction for `ShellTokenizer.ExtractApprovalPattern()` and `ExtractDirectoryScope()` — scan all args for the first resolvable path operand, resolve relatives against `WorkingDirectory`, normalize, and enforce minimum depth for directory scope +- [x] 1.1 Add shared path-operand extraction for `ShellTokenizer.ExtractApprovalPattern()` and `ExtractDirectoryScope()` — scan all args for the first resolvable path operand, resolve relatives against `WorkingDirectory`, normalize, keep exact extraction broader than directory scope, and enforce minimum depth for directory scope - [x] 1.2 Add `ExtractParentDirectory()` and `CountPathSegments()` helpers to `ShellTokenizer` -- [x] 1.3 Unit tests for `ExtractDirectoryScope` (verb+directory, grep path vs search term, glob handling, null cases) +- [x] 1.3 Unit tests for `ExtractDirectoryScope` (allowlisted verb+directory, grep path vs search term, glob handling, non-allowlisted fallback, redirection null cases) ## 2. Pattern Matching @@ -12,14 +12,14 @@ ## 3. IToolApprovalMatcher Extension - [x] 3.1 Add `ExtractDirectoryPatterns()` to `IToolApprovalMatcher` interface -- [x] 3.2 Implement on `ShellApprovalMatcher` with compound command + `bash -c` recursion via shared `TraverseSegments` helper +- [x] 3.2 Implement on `ShellApprovalMatcher` with compound command + `bash -c` recursion via shared `TraverseSegments` helper, limited to the directory-scope allowlist and disabled by shell redirection - [x] 3.3 Implement on `DefaultApprovalMatcher` and `FilePathApprovalMatcher` (return empty list) ## 4. Protocol and Pipeline Wiring - [x] 4.1 Add `DirectoryPatterns` property to `ToolInteractionRequest` in `SessionOutput.cs` - [x] 4.2 Add `DirectoryPatterns` to `ToolApprovalContext` record in `ToolAccessPolicy.cs` -- [x] 4.3 Compute directory patterns and customize B/C labels in `CheckApprovalGate()` only when the full request maps cleanly to a single directory scope; otherwise keep generic labels +- [x] 4.3 Compute directory patterns and customize B/C labels in `CheckApprovalGate()` only when the full request maps cleanly to a single directory scope from the allowlisted direct read/list verbs; otherwise keep generic labels - [x] 4.4 Pass `DirectoryPatterns` from `ToolApprovalContext` to `ToolInteractionRequest` in `SessionToolExecutionPipeline` - [x] 4.5 Propagate `DirectoryPatterns` through `DispatchingToolExecutor` re-approval path @@ -35,7 +35,9 @@ - [x] 6.2 Unify `CollectPatterns`/`CollectDirectoryPatterns` into shared `TraverseSegments` helper - [x] 6.3 Use `PathUtility.ExpandAndNormalize()` in `ExtractDirectoryScope` instead of separate calls - [x] 6.4 Make `DirectoryPatterns` non-nullable on `ToolApprovalContext` -- [x] 6.5 Preserve existing directory operands like `find logs -name '*.log'` instead of widening them to the parent directory +- [x] 6.5 Preserve existing directory operands for allowlisted verbs instead of widening them to the parent directory - [x] 6.6 Keep pipe/compound traversal MVP-scoped to direct path operands only; do not infer indirect flow through `xargs`, `eval`, or loop variables - [x] 6.7 Note Windows-native shell support as out of scope for this change (tracked separately in issue #899) -- [x] 6.8 Verify: `dotnet slopwatch analyze` passes, copyright headers present, all tests green +- [x] 6.8 Restrict directory-scoped approvals to `cat`, `less`, `more`, `head`, `tail`, `grep`, and `ls`; keep commands like `find` on exact patterns and generic labels +- [x] 6.9 Disable directory-scoped extraction when shell redirection operators are present, even for allowlisted verbs +- [x] 6.10 Verify: `dotnet slopwatch analyze` passes, copyright headers present, all tests green diff --git a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs index b06fd4ab..a3aacb53 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs @@ -834,6 +834,38 @@ public void Relative_path_command_uses_working_directory_for_directory_scope() } } + [Fact] + public void Non_allowlisted_find_uses_default_labels() + { + var policy = CreatePolicy(ToolApprovalMode.Approval); + var args = ToolInput.Create("Command", "find /home/user/.netclaw/logs -name '*.log'"); + + var decision = policy.AuthorizeInvocation(ShellTool(), PersonalContext(), args); + + Assert.True(decision.NeedsApproval); + Assert.DoesNotContain(decision.ApprovalContext!.DirectoryPatterns, p => p.EndsWith("/")); + var sessionOption = decision.ApprovalContext.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveSession); + var alwaysOption = decision.ApprovalContext.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveAlways); + Assert.Equal(ApprovalOptionKeys.ApproveSessionLabel, sessionOption.Label); + Assert.Equal(ApprovalOptionKeys.ApproveAlwaysLabel, alwaysOption.Label); + } + + [Fact] + public void Redirection_disables_directory_scope_labels() + { + var policy = CreatePolicy(ToolApprovalMode.Approval); + var args = ToolInput.Create("Command", "cat /home/user/.netclaw/logs/app.log > /tmp/out.log"); + + var decision = policy.AuthorizeInvocation(ShellTool(), PersonalContext(), args); + + Assert.True(decision.NeedsApproval); + Assert.DoesNotContain(decision.ApprovalContext!.DirectoryPatterns, p => p.EndsWith("/")); + var sessionOption = decision.ApprovalContext.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveSession); + var alwaysOption = decision.ApprovalContext.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveAlways); + Assert.Equal(ApprovalOptionKeys.ApproveSessionLabel, sessionOption.Label); + Assert.Equal(ApprovalOptionKeys.ApproveAlwaysLabel, alwaysOption.Label); + } + [Fact] public void Non_path_command_uses_default_labels() { diff --git a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs index 82e82fc7..ef48904e 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs @@ -103,8 +103,6 @@ public void IsApproved_one_pattern_unapproved() // Path-aware patterns — exact path matches [InlineData("cat /etc/passwd", "cat /etc/passwd", true)] [InlineData("cat /etc/passwd", "cat /etc/shadow", false)] - [InlineData("bash /home/.netclaw/scripts/monitor.sh", "bash /home/.netclaw/scripts/monitor.sh", true)] - [InlineData("bash /home/.netclaw/scripts/monitor.sh", "bash /tmp/evil.sh", false)] // Directory-scoped patterns (trailing /) match files under that directory [InlineData("cat /home/user/.netclaw/logs/", "cat /home/user/.netclaw/logs/crash.log", true)] [InlineData("cat /home/user/.netclaw/logs/", "cat /home/user/.netclaw/logs/deep/nested.txt", true)] @@ -114,9 +112,9 @@ public void IsApproved_one_pattern_unapproved() // Single-token path-aware verbs stay exact-only [InlineData("cat", "cat /etc/passwd", false)] [InlineData("grep", "grep TODO", false)] - [InlineData("bash", "bash /tmp/script.sh", false)] [InlineData("find", "find /var/log", false)] // Non-path-aware single tokens still require exact match + [InlineData("bash", "bash /tmp/script.sh", false)] [InlineData("echo", "echo hello", false)] [InlineData("docker", "docker compose", false)] public void IsApproved_pattern_matching(string pattern, string command, bool expected) @@ -189,6 +187,17 @@ public void ExtractPatterns_resolve_relative_paths_against_working_directory() } } + [Fact] + public void ExtractPatterns_non_allowlisted_find_still_uses_exact_path_pattern() + { + var patterns = _matcher.ExtractPatterns( + new ToolName("shell_execute"), + Args("find /home/user/.netclaw/logs -name '*.log'")); + + Assert.Single(patterns); + Assert.Equal("find /home/user/.netclaw/logs", patterns[0]); + } + [Fact] public void ExtractPatterns_pipe_with_path_aware_verbs() { @@ -284,7 +293,7 @@ public void ExtractDirectoryPatterns_resolve_relative_paths_against_working_dire } [Fact] - public void ExtractDirectoryPatterns_preserve_existing_directory_operand() + public void ExtractDirectoryPatterns_non_allowlisted_find_falls_back_to_exact_path_pattern() { var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var logs = Path.Combine(root, "logs"); @@ -297,7 +306,31 @@ public void ExtractDirectoryPatterns_preserve_existing_directory_operand() Args("find logs -name '*.log'", root)); Assert.Single(patterns); - Assert.Equal($"find {PathUtility.Normalize(logs)}/", patterns[0]); + Assert.Equal($"find {PathUtility.Normalize(logs)}", patterns[0]); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void ExtractDirectoryPatterns_redirection_falls_back_to_exact_pattern() + { + var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + var logs = Path.Combine(root, "logs"); + Directory.CreateDirectory(logs); + var file = Path.Combine(logs, "app.log"); + File.WriteAllText(file, "hello"); + + try + { + var patterns = _matcher.ExtractDirectoryPatterns( + new ToolName("shell_execute"), + Args("cat logs/app.log > /tmp/out.log", root)); + + Assert.Single(patterns); + Assert.Equal($"cat {PathUtility.Normalize(file)}", patterns[0]); } finally { diff --git a/src/Netclaw.Security.Tests/ShellTokenizerTests.cs b/src/Netclaw.Security.Tests/ShellTokenizerTests.cs index b493ec20..ba67f927 100644 --- a/src/Netclaw.Security.Tests/ShellTokenizerTests.cs +++ b/src/Netclaw.Security.Tests/ShellTokenizerTests.cs @@ -287,26 +287,6 @@ public void ExtractDirectoryScope_returns_verb_and_directory(string command, str Assert.EndsWith(expectedDirSuffix, dir.Replace('\\', '/')); } - [Fact] - public void ExtractDirectoryScope_preserves_existing_directory_operand() - { - var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); - var logs = Path.Combine(root, "logs"); - Directory.CreateDirectory(logs); - - try - { - var result = ShellTokenizer.ExtractDirectoryScope($"find {logs} -name '*.log'"); - - Assert.NotNull(result); - Assert.Equal($"find {PathUtility.Normalize(logs)}/", result); - } - finally - { - Directory.Delete(root, recursive: true); - } - } - [Fact] public void ExtractDirectoryScope_grep_finds_path_not_search_term() { @@ -332,7 +312,9 @@ public void ExtractDirectoryScope_handles_glob_paths() [Theory] [InlineData("echo hello")] // not a path-aware verb [InlineData("git push origin main")] // not in PathAwareVerbs + [InlineData("find /home/user/.netclaw/logs -name '*.log'")] // not directory-scope eligible [InlineData("grep --version")] // no path argument + [InlineData("cat /home/user/.netclaw/logs/app.log > /tmp/out.log")] // redirection disables directory scope [InlineData("cat /etc/passwd")] // too shallow (/etc/ = 1 segment) public void ExtractDirectoryScope_returns_null(string command) { diff --git a/src/Netclaw.Security/ShellTokenizer.cs b/src/Netclaw.Security/ShellTokenizer.cs index e2b73110..7d52f891 100644 --- a/src/Netclaw.Security/ShellTokenizer.cs +++ b/src/Netclaw.Security/ShellTokenizer.cs @@ -31,13 +31,25 @@ public static class ShellTokenizer /// /// Verbs whose first positional argument is security-relevant for approval /// pattern extraction. Superset of — includes - /// benign-but-path-consuming verbs like ls. + /// benign-but-path-consuming verbs like ls. This governs exact + /// approval patterns, which may be narrower than generic verb-chain grants + /// even when directory-scoped approvals are disallowed. /// internal static readonly HashSet PathAwareVerbs = new(HighRiskVerbs, StringComparer.OrdinalIgnoreCase) { "ls" }; + /// + /// Commands eligible for directory-scoped approvals. This stays intentionally + /// narrow: only direct read/list shapes are allowed to widen from an exact + /// file approval to a directory-scoped grant. + /// + private static readonly HashSet DirectoryScopeVerbs = new(StringComparer.OrdinalIgnoreCase) + { + "cat", "less", "more", "head", "tail", "grep", "ls" + }; + /// /// Verbs whose first non-flag operand is expected to be a path. Existing bare /// relative operands for these verbs can be treated as path targets even when @@ -165,7 +177,7 @@ public static IReadOnlyList SplitCompoundCommand(string command) /// command. Stops at the first token that looks like a flag (starts with -) /// or an argument (path, URL, etc.), and caps at /// tokens (default: 2) to avoid capturing positional arguments as subcommands. - /// For path-aware verbs (cat, grep, bash, etc.), appends the first non-flag + /// For path-aware verbs (cat, grep, ls, etc.), appends the first non-flag /// argument so the approval pattern captures what the command operates on. /// public static string ExtractVerbChain(string command, int maxDepth = 2) @@ -218,7 +230,7 @@ public static string ExtractVerbChain(string command, int maxDepth = 2) /// directory; otherwise falls back to the verb-chain extraction. /// public static string ExtractApprovalPattern(string command, string? workingDirectory = null, int maxDepth = 2) - => TryExtractPathOperand(command, workingDirectory, out var operand) + => TryExtractPathOperand(command, workingDirectory, PathAwareVerbs, requireSimpleDirectoryShape: false, out var operand) ? operand.Verb + " " + operand.NormalizedPath : ExtractVerbChain(command, maxDepth); @@ -391,7 +403,7 @@ public static bool LooksLikePath(string token) /// public static string? ExtractDirectoryScope(string command, string? workingDirectory = null) { - if (!TryExtractPathOperand(command, workingDirectory, out var operand)) + if (!TryExtractPathOperand(command, workingDirectory, DirectoryScopeVerbs, requireSimpleDirectoryShape: true, out var operand)) return null; // Enforce minimum depth — reject shallow scopes like / or /etc/ @@ -403,7 +415,12 @@ public static bool LooksLikePath(string token) internal const int MinDirectoryScopeDepth = 2; - private static bool TryExtractPathOperand(string command, string? workingDirectory, out PathOperand operand) + private static bool TryExtractPathOperand( + string command, + string? workingDirectory, + IReadOnlySet allowedVerbs, + bool requireSimpleDirectoryShape, + out PathOperand operand) { operand = default; @@ -412,7 +429,10 @@ private static bool TryExtractPathOperand(string command, string? workingDirecto return false; var verb = TrimShellPunctuation(tokens[0]); - if (verb.Length == 0 || !PathAwareVerbs.Contains(verb)) + if (verb.Length == 0 || !allowedVerbs.Contains(verb)) + return false; + + if (requireSimpleDirectoryShape && !IsSimpleDirectoryScopeShape(tokens)) return false; for (var i = 1; i < tokens.Count; i++) @@ -436,6 +456,30 @@ private static bool TryExtractPathOperand(string command, string? workingDirecto return false; } + private static bool IsSimpleDirectoryScopeShape(IReadOnlyList tokens) + { + for (var i = 1; i < tokens.Count; i++) + { + if (IsRedirectionToken(TrimShellPunctuation(tokens[i]))) + return false; + } + + return true; + } + + private static bool IsRedirectionToken(string token) + { + if (string.IsNullOrWhiteSpace(token)) + return false; + + if (token[0] is '>' or '<') + return true; + + return token.Length >= 2 + && char.IsAsciiDigit(token[0]) + && token[1] is '>' or '<'; + } + private static string? TryNormalizePathOperand(string token, string verb, string? workingDirectory) { if (LooksLikePath(token)) From 1d3b2021dda70f11cc3cda37153fd760052c07f1 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 7 May 2026 03:13:19 +0000 Subject: [PATCH 05/22] fix(security): harden shell approval multi-path matching Preserve all directly identifiable path operands in exact shell approval patterns and keep those patterns exact-only instead of prefix-expanding to additional operands. Limit directory-scoped grants to single-path allowlisted reads while falling back to exact patterns and generic prompts for multi-path and redirected command shapes. --- .../design.md | 45 +++++++------ .../proposal.md | 20 ++++-- .../specs/tool-approval-gates/spec.md | 67 +++++++++++++++---- .../tasks.md | 7 +- .../Tools/ToolApprovalGateTests.cs | 31 +++++++++ .../ShellApprovalMatcherTests.cs | 55 ++++++++++++++- .../ApprovalPatternMatching.cs | 6 ++ src/Netclaw.Security/ShellTokenizer.cs | 57 ++++++++++++---- 8 files changed, 236 insertions(+), 52 deletions(-) diff --git a/openspec/changes/directory-scoped-approval-patterns/design.md b/openspec/changes/directory-scoped-approval-patterns/design.md index a7bb20c3..1c0c4f63 100644 --- a/openspec/changes/directory-scoped-approval-patterns/design.md +++ b/openspec/changes/directory-scoped-approval-patterns/design.md @@ -51,21 +51,25 @@ work unchanged. ### Extraction: shared path-operand resolution, narrower directory scope -`ShellTokenizer.TryExtractPathOperand()` is the shared primitive behind -`ExtractApprovalPattern()` and `ExtractDirectoryScope()`. It scans ALL non-flag -arguments for the first token that can be normalized into a path operand, using -the shell tool `WorkingDirectory` to resolve relative operands before approval -patterns are extracted or matched. This solves the grep problem where the search -term is the first positional arg and the file path is second +`ShellTokenizer.TryExtractPathOperands()` is the shared primitive behind +`ExtractApprovalPattern()` and `ExtractDirectoryScope()`. It scans all non-flag +arguments for directly identifiable path operands, using the shell tool +`WorkingDirectory` to resolve relative operands before approval patterns are +extracted or matched. This solves the grep problem where the search term is the +first positional arg and the file path is second (`grep -l "timeout" logs/daemon.log`). -When a recognizable path operand exists, exact approval patterns use the -normalized path operand itself (`grep /abs/path/logs/daemon.log`), not the raw -verb chain. That broader exact extraction still applies to commands like -`find`, `bash`, or `python3` when they include a direct path operand. -Directory-scoped patterns then derive scope from that same operand only when +When directly identifiable path operands exist, exact approval patterns use the +normalized operands themselves in detection order +(`cat /abs/path/first.log /abs/path/second.log`), not the raw verb chain. That +broader exact extraction still applies to commands like `find`, `bash`, or +`python3` when they include direct path operands. These exact patterns remain +exact-only: approving one concrete operand list does not prefix-expand to cover +additional operands appended later. + +Directory-scoped patterns then derive scope from that same extraction only when the command verb is in the MVP allowlist: `cat`, `less`, `more`, `head`, -`tail`, `grep`, and `ls`. +`tail`, `grep`, and `ls`, and exactly one direct path operand is detected. **Alternative considered:** Always use first positional. Rejected because grep, sed, and awk take non-path first arguments. @@ -78,8 +82,10 @@ directory and stores `ls /abs/path/logs/` rather than widening to the parent (`/abs/path/`). This keeps approval scope aligned with what the command actually targets. -Commands outside the allowlist still use the normalized exact path operand when -available, but they do not derive directory scope from it. +Commands outside the allowlist still use normalized exact path operands when +available, but they do not derive directory scope from them. Likewise, +allowlisted commands with multiple direct path operands stay on exact approval +patterns instead of widening one operand into a directory grant. ### Matching: `PathUtility.IsWithinRoot()` with normalized operands @@ -119,17 +125,18 @@ labels and never store trailing-slash directory approvals. `ToolAccessPolicy.TryGetSingleDirectoryScope()` only emits directory-specific B/C labels when every approval pattern for the request is directory-scoped and all of them resolve to the same directory. If any segment falls back to a generic -exact pattern (for example `find logs -name '*.log'`, `git push`, or an -allowlisted read with shell redirection) or multiple directory scopes are -present, labels stay generic. +exact pattern (for example `find logs -name '*.log'`, `git push`, an +allowlisted multi-path read, or an allowlisted read with shell redirection) or +multiple directory scopes are present, labels stay generic. ### Shell redirection disables directory scope If a segment contains shell redirection operators, directory-scoped extraction is disabled even when the verb itself is allowlisted. For example, `cat logs/app.log > out.txt` falls back to the exact pattern and generic B/C -labels. This avoids granting directory scope when the command also writes or -rewires IO. +labels. When both the input operand and redirected output target are directly +identifiable, both appear in the fallback exact pattern in command order. This +avoids granting directory scope when the command also writes or rewires IO. ### Compound commands: direct path operands only diff --git a/openspec/changes/directory-scoped-approval-patterns/proposal.md b/openspec/changes/directory-scoped-approval-patterns/proposal.md index f2ea4c44..123f6038 100644 --- a/openspec/changes/directory-scoped-approval-patterns/proposal.md +++ b/openspec/changes/directory-scoped-approval-patterns/proposal.md @@ -16,10 +16,15 @@ legitimate diagnostic work. `tail`, `grep`, and `ls`. - Relative path operands are resolved against the shell tool `WorkingDirectory` before both exact-pattern extraction and directory-scope extraction/matching. -- Path-aware exact approval patterns use the actual normalized path operand when - one exists, including commands like `grep -l "timeout" logs/app.log` where the - search term is not the path operand and commands like `find`, `bash`, or - `python3` when a direct path operand exists. +- Path-aware exact approval patterns use the actual normalized direct path + operands when present, preserving every directly identifiable operand in + command order rather than just the first one. This still covers commands like + `grep -l "timeout" logs/app.log` where the search term is not the path + operand, and commands like `find`, `bash`, or `python3` when a direct path + operand exists. +- Path-aware exact approval patterns remain exact-only. They do not + prefix-expand one approved path to cover additional path operands later added + to the command. - Existing directory operands keep their directory scope for allowlisted directory-scoped verbs instead of widening to the parent directory. - A trailing `/` on a stored approval pattern signals directory scope. Matching @@ -31,9 +36,16 @@ legitimate diagnostic work. directory pattern extraction. - Commands outside the directory-scope allowlist, including `find`, fall back to exact approval patterns and generic B/C labels rather than directory scope. +- Directory-scoped approvals now require exactly one detected direct path + operand in addition to the existing allowlist and redirection restrictions. + Multi-path invocations of otherwise allowlisted commands fall back to exact + approval patterns and generic B/C labels. - Shell redirection operators disable directory-scoped extraction even for allowlisted verbs, causing fallback to exact approval patterns and generic labels. +- When redirection disables directory scope, the fallback exact pattern may + include both the direct input path and the redirected output path when both + are directly identifiable. - Approval option labels only show a directory-specific scope when the full approval set for the request maps cleanly to a single directory; otherwise the labels stay generic. diff --git a/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md b/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md index 4ac5619f..b5188db4 100644 --- a/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md +++ b/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md @@ -5,10 +5,11 @@ The system SHALL support directory-scoped approval patterns for shell commands targeting a narrow direct read/list allowlist: `cat`, `less`, `more`, `head`, `tail`, `grep`, and `ls`. When the user selects "Approve for this chat" (B) or -"Approve always" (C) for one of those shell commands and the command targets a -recognizable direct path operand, the system SHALL store a directory-scoped -pattern (e.g., `grep /home/.netclaw/logs/`) instead of the exact file-path -pattern. "Approve once" (A) SHALL continue to use exact patterns. +"Approve always" (C) for one of those shell commands and the command targets +exactly one recognizable direct path operand, the system SHALL store a +directory-scoped pattern (e.g., `grep /home/.netclaw/logs/`) instead of the +exact file-path pattern. "Approve once" (A) SHALL continue to use exact +patterns. When a recognizable path operand is relative, the system SHALL resolve it against the shell tool `WorkingDirectory` before extracting either exact or @@ -19,9 +20,16 @@ Commands outside that directory-scope allowlist, including `find`, SHALL fall back to exact approval patterns and generic B/C labels even when they have a recognizable direct path operand. +Allowlisted commands with more than one recognizable direct path operand SHALL +also fall back to exact approval patterns and generic B/C labels instead of +directory scope. + If a shell segment contains redirection operators, the system SHALL disable directory-scoped extraction for that segment even when the verb is allowlisted. That segment SHALL fall back to exact approval patterns and generic B/C labels. +When both a direct input path and redirected output path are directly +identifiable, the fallback exact pattern SHALL include both operands in command +order. A trailing `/` on a stored pattern SHALL signal directory scope. The system SHALL use `PathUtility.IsWithinRoot()` for boundary-safe containment matching — not @@ -112,9 +120,17 @@ Directory-scoped approvals SHALL be verb-isolated: an approval for #### Scenario: Redirection disables directory scope for allowlisted verb - **GIVEN** the shell tool `WorkingDirectory` is `/workspace/project` -- **AND** the command is `cat logs/app.log > out.txt` +- **AND** the command is `cat logs/app.log > /tmp/out.log` - **WHEN** exact and directory-scoped approval patterns are extracted -- **THEN** the exact pattern is `cat /workspace/project/logs/app.log` +- **THEN** the exact pattern is `cat /workspace/project/logs/app.log /tmp/out.log` +- **AND** no directory-scoped pattern is extracted + +#### Scenario: Multi-path allowlisted command falls back to exact pattern + +- **GIVEN** the shell tool `WorkingDirectory` is `/workspace/project` +- **AND** the command is `cat logs/first.log logs/second.log` +- **WHEN** exact and directory-scoped approval patterns are extracted +- **THEN** the exact pattern is `cat /workspace/project/logs/first.log /workspace/project/logs/second.log` - **AND** no directory-scoped pattern is extracted #### Scenario: Boundary-safe path matching prevents prefix collisions @@ -128,15 +144,16 @@ Directory-scoped approvals SHALL be verb-isolated: an approval for `IToolApprovalMatcher` SHALL define an `ExtractDirectoryPatterns()` method that returns directory-scoped patterns for a tool invocation. `ShellApprovalMatcher` -SHALL implement this by scanning all non-flag arguments for the first -recognizable path operand, resolving relative paths against `WorkingDirectory`, +SHALL implement this by scanning all non-flag arguments for directly +identifiable path operands, resolving relative paths against `WorkingDirectory`, expanding home directory tokens, extracting the scoped directory, normalizing the path, enforcing minimum depth, and applying directory scope only to the -allowlisted verbs `cat`, `less`, `more`, `head`, `tail`, `grep`, and `ls`. -Segments with shell redirection operators SHALL NOT emit directory scope. For -compound commands and `bash -c` wrappers, each segment SHALL be processed -recursively. When no directory scope is available for a segment, the segment's -exact approval pattern SHALL be used as fallback. +allowlisted verbs `cat`, `less`, `more`, `head`, `tail`, `grep`, and `ls` when +exactly one direct path operand is detected. Segments with shell redirection +operators SHALL NOT emit directory scope. For compound commands and `bash -c` +wrappers, each segment SHALL be processed recursively. When no directory scope +is available for a segment, the segment's exact approval pattern SHALL be used +as fallback. `DefaultApprovalMatcher` and `FilePathApprovalMatcher` SHALL return empty lists. @@ -155,6 +172,21 @@ exact approval pattern SHALL be used as fallback. - **THEN** the pattern is `grep /workspace/project/logs/daemon.log` - **AND** the search term `"timeout"` is not used as the exact operand +#### Scenario: Exact pattern preserves all detected direct path operands in order + +- **GIVEN** the shell tool `WorkingDirectory` is `/workspace/project` +- **AND** the command is `cat logs/first.log logs/second.log` +- **WHEN** exact approval pattern extraction runs +- **THEN** the pattern is `cat /workspace/project/logs/first.log /workspace/project/logs/second.log` +- **AND** both directly identifiable path operands are preserved in command order + +#### Scenario: Exact approval does not widen to added path operands + +- **GIVEN** `cat /workspace/project/logs/first.log` is approved as an exact pattern +- **WHEN** the agent runs `cat /workspace/project/logs/first.log /workspace/project/logs/second.log` +- **THEN** the command still requires approval +- **AND** the existing exact approval does not prefix-expand to cover the added operand + #### Scenario: Compound command extracts patterns per segment - **GIVEN** the command `cat /home/.netclaw/logs/crash.log && grep "error" /var/log/syslog` @@ -190,6 +222,7 @@ exact approval pattern SHALL be used as fallback. - **AND** the command is `grep error logs/app.log > report.txt` - **WHEN** `ExtractDirectoryPatterns` runs - **THEN** no directory-scoped pattern is extracted for that segment +- **AND** the fallback exact pattern includes both the direct input path and the redirected output path when both are directly identifiable #### Scenario: Glob paths use parent directory @@ -230,6 +263,14 @@ Options A ("Approve once") and D ("Deny") SHALL retain their default labels. - **THEN** option B reads the default "Approve for this chat" - **AND** option C reads the default "Approve always" +#### Scenario: Multi-path allowlisted command keeps generic labels + +- **GIVEN** a shell command `cat /home/.netclaw/logs/first.log /home/.netclaw/logs/second.log` + requires approval +- **WHEN** the approval prompt is generated +- **THEN** option B reads the default "Approve for this chat" +- **AND** option C reads the default "Approve always" + #### Scenario: Redirection keeps generic labels for allowlisted verb - **GIVEN** a shell command `cat /home/.netclaw/logs/app.log > /tmp/report.txt` diff --git a/openspec/changes/directory-scoped-approval-patterns/tasks.md b/openspec/changes/directory-scoped-approval-patterns/tasks.md index 4182b475..54872d6c 100644 --- a/openspec/changes/directory-scoped-approval-patterns/tasks.md +++ b/openspec/changes/directory-scoped-approval-patterns/tasks.md @@ -1,6 +1,6 @@ ## 1. Pattern Extraction -- [x] 1.1 Add shared path-operand extraction for `ShellTokenizer.ExtractApprovalPattern()` and `ExtractDirectoryScope()` — scan all args for the first resolvable path operand, resolve relatives against `WorkingDirectory`, normalize, keep exact extraction broader than directory scope, and enforce minimum depth for directory scope +- [x] 1.1 Add shared path-operand extraction for `ShellTokenizer.ExtractApprovalPattern()` and `ExtractDirectoryScope()` — scan all args for directly identifiable path operands, resolve relatives against `WorkingDirectory`, normalize, keep exact extraction broader than directory scope, preserve all detected exact operands in order, and enforce minimum depth for directory scope - [x] 1.2 Add `ExtractParentDirectory()` and `CountPathSegments()` helpers to `ShellTokenizer` - [x] 1.3 Unit tests for `ExtractDirectoryScope` (allowlisted verb+directory, grep path vs search term, glob handling, non-allowlisted fallback, redirection null cases) @@ -19,7 +19,7 @@ - [x] 4.1 Add `DirectoryPatterns` property to `ToolInteractionRequest` in `SessionOutput.cs` - [x] 4.2 Add `DirectoryPatterns` to `ToolApprovalContext` record in `ToolAccessPolicy.cs` -- [x] 4.3 Compute directory patterns and customize B/C labels in `CheckApprovalGate()` only when the full request maps cleanly to a single directory scope from the allowlisted direct read/list verbs; otherwise keep generic labels +- [x] 4.3 Compute directory patterns and customize B/C labels in `CheckApprovalGate()` only when the full request maps cleanly to a single directory scope from the allowlisted direct read/list verbs with exactly one detected direct path operand; otherwise keep generic labels - [x] 4.4 Pass `DirectoryPatterns` from `ToolApprovalContext` to `ToolInteractionRequest` in `SessionToolExecutionPipeline` - [x] 4.5 Propagate `DirectoryPatterns` through `DispatchingToolExecutor` re-approval path @@ -40,4 +40,5 @@ - [x] 6.7 Note Windows-native shell support as out of scope for this change (tracked separately in issue #899) - [x] 6.8 Restrict directory-scoped approvals to `cat`, `less`, `more`, `head`, `tail`, `grep`, and `ls`; keep commands like `find` on exact patterns and generic labels - [x] 6.9 Disable directory-scoped extraction when shell redirection operators are present, even for allowlisted verbs -- [x] 6.10 Verify: `dotnet slopwatch analyze` passes, copyright headers present, all tests green +- [x] 6.10 Keep multi-path exact approval patterns exact-only; do not widen one approved operand list to cover added direct path operands +- [x] 6.11 Verify: `dotnet slopwatch analyze` passes, copyright headers present, all tests green diff --git a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs index a3aacb53..9d6870c6 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs @@ -866,6 +866,37 @@ public void Redirection_disables_directory_scope_labels() Assert.Equal(ApprovalOptionKeys.ApproveAlwaysLabel, alwaysOption.Label); } + [Fact] + public void Multi_path_allowlisted_command_uses_default_labels() + { + var policy = CreatePolicy(ToolApprovalMode.Approval); + var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + var first = Path.Combine(root, "first.log"); + var second = Path.Combine(root, "second.log"); + Directory.CreateDirectory(root); + File.WriteAllText(first, "one"); + File.WriteAllText(second, "two"); + + try + { + var args = ToolInput.Create("Command", "cat first.log second.log", "WorkingDirectory", root); + + var decision = policy.AuthorizeInvocation(ShellTool(), PersonalContext(), args); + + Assert.True(decision.NeedsApproval); + Assert.DoesNotContain(decision.ApprovalContext!.DirectoryPatterns, p => p.EndsWith("/")); + Assert.Contains($"cat {PathUtility.Normalize(first)} {PathUtility.Normalize(second)}", decision.ApprovalContext.DirectoryPatterns); + var sessionOption = decision.ApprovalContext.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveSession); + var alwaysOption = decision.ApprovalContext.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveAlways); + Assert.Equal(ApprovalOptionKeys.ApproveSessionLabel, sessionOption.Label); + Assert.Equal(ApprovalOptionKeys.ApproveAlwaysLabel, alwaysOption.Label); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + [Fact] public void Non_path_command_uses_default_labels() { diff --git a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs index ef48904e..412786c7 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs @@ -103,6 +103,9 @@ public void IsApproved_one_pattern_unapproved() // Path-aware patterns — exact path matches [InlineData("cat /etc/passwd", "cat /etc/passwd", true)] [InlineData("cat /etc/passwd", "cat /etc/shadow", false)] + [InlineData("cat /etc/passwd", "cat /etc/passwd /etc/shadow", false)] + [InlineData("cat /etc/passwd /etc/shadow", "cat /etc/passwd /etc/shadow", true)] + [InlineData("cat /etc/passwd /etc/shadow", "cat /etc/passwd /etc/shadow /etc/hosts", false)] // Directory-scoped patterns (trailing /) match files under that directory [InlineData("cat /home/user/.netclaw/logs/", "cat /home/user/.netclaw/logs/crash.log", true)] [InlineData("cat /home/user/.netclaw/logs/", "cat /home/user/.netclaw/logs/deep/nested.txt", true)] @@ -198,6 +201,31 @@ public void ExtractPatterns_non_allowlisted_find_still_uses_exact_path_pattern() Assert.Equal("find /home/user/.netclaw/logs", patterns[0]); } + [Fact] + public void ExtractPatterns_multi_path_allowlisted_command_includes_all_paths() + { + var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + var first = Path.Combine(root, "first.log"); + var second = Path.Combine(root, "second.log"); + Directory.CreateDirectory(root); + File.WriteAllText(first, "one"); + File.WriteAllText(second, "two"); + + try + { + var patterns = _matcher.ExtractPatterns( + new ToolName("shell_execute"), + Args("cat first.log second.log", root)); + + Assert.Single(patterns); + Assert.Equal($"cat {PathUtility.Normalize(first)} {PathUtility.Normalize(second)}", patterns[0]); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + [Fact] public void ExtractPatterns_pipe_with_path_aware_verbs() { @@ -330,7 +358,32 @@ public void ExtractDirectoryPatterns_redirection_falls_back_to_exact_pattern() Args("cat logs/app.log > /tmp/out.log", root)); Assert.Single(patterns); - Assert.Equal($"cat {PathUtility.Normalize(file)}", patterns[0]); + Assert.Equal($"cat {PathUtility.Normalize(file)} /tmp/out.log", patterns[0]); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void ExtractDirectoryPatterns_multi_path_falls_back_to_exact_multi_path_pattern() + { + var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + var first = Path.Combine(root, "first.log"); + var second = Path.Combine(root, "second.log"); + Directory.CreateDirectory(root); + File.WriteAllText(first, "one"); + File.WriteAllText(second, "two"); + + try + { + var patterns = _matcher.ExtractDirectoryPatterns( + new ToolName("shell_execute"), + Args("cat first.log second.log", root)); + + Assert.Single(patterns); + Assert.Equal($"cat {PathUtility.Normalize(first)} {PathUtility.Normalize(second)}", patterns[0]); } finally { diff --git a/src/Netclaw.Security/ApprovalPatternMatching.cs b/src/Netclaw.Security/ApprovalPatternMatching.cs index f14e221f..8a13ca8d 100644 --- a/src/Netclaw.Security/ApprovalPatternMatching.cs +++ b/src/Netclaw.Security/ApprovalPatternMatching.cs @@ -29,6 +29,12 @@ public static bool MatchesAny(string candidate, IEnumerable approvedPatt if (approved.EndsWith('/') && MatchesDirectoryScope(candidate, approved)) return true; + // Exact path-aware patterns must not widen by prefix. This prevents an + // approval for one direct operand from silently approving additional + // path operands appended to the same verb. + if (ShellTokenizer.IsPathAwareExactPattern(approved)) + continue; + // Multi-token patterns prefix-match on a space boundary. Single-token // patterns remain exact-only so grants do not silently widen from // "cat" to every path-bearing cat invocation. diff --git a/src/Netclaw.Security/ShellTokenizer.cs b/src/Netclaw.Security/ShellTokenizer.cs index 7d52f891..8ef0493a 100644 --- a/src/Netclaw.Security/ShellTokenizer.cs +++ b/src/Netclaw.Security/ShellTokenizer.cs @@ -227,11 +227,14 @@ public static string ExtractVerbChain(string command, int maxDepth = 2) /// /// Extracts the approval pattern used for exact matching. For path-aware verbs, /// prefers the first recognizable path operand normalized against the working - /// directory; otherwise falls back to the verb-chain extraction. + /// directory. When multiple direct path operands are present, includes all of + /// them in the exact pattern so approvals do not silently widen from one + /// resource to many. Falls back to the verb-chain extraction when no direct + /// path operand is recognized. /// public static string ExtractApprovalPattern(string command, string? workingDirectory = null, int maxDepth = 2) - => TryExtractPathOperand(command, workingDirectory, PathAwareVerbs, requireSimpleDirectoryShape: false, out var operand) - ? operand.Verb + " " + operand.NormalizedPath + => TryExtractPathOperands(command, workingDirectory, PathAwareVerbs, requireSimpleDirectoryShape: false, out var result) + ? result.Verb + " " + string.Join(' ', result.PathOperands.Select(static x => x.NormalizedPath)) : ExtractVerbChain(command, maxDepth); /// @@ -403,26 +406,29 @@ public static bool LooksLikePath(string token) /// public static string? ExtractDirectoryScope(string command, string? workingDirectory = null) { - if (!TryExtractPathOperand(command, workingDirectory, DirectoryScopeVerbs, requireSimpleDirectoryShape: true, out var operand)) + if (!TryExtractPathOperands(command, workingDirectory, DirectoryScopeVerbs, requireSimpleDirectoryShape: true, out var result) + || result.PathOperands.Count != 1) return null; + var operand = result.PathOperands[0]; + // Enforce minimum depth — reject shallow scopes like / or /etc/ if (CountPathSegments(operand.NormalizedDirectory) < MinDirectoryScopeDepth) return null; - return operand.Verb + " " + operand.NormalizedDirectory + "/"; + return result.Verb + " " + operand.NormalizedDirectory + "/"; } internal const int MinDirectoryScopeDepth = 2; - private static bool TryExtractPathOperand( + private static bool TryExtractPathOperands( string command, string? workingDirectory, IReadOnlySet allowedVerbs, bool requireSimpleDirectoryShape, - out PathOperand operand) + out PathScanResult result) { - operand = default; + result = default; var tokens = Tokenize(command).ToList(); if (tokens.Count == 0) @@ -435,6 +441,8 @@ private static bool TryExtractPathOperand( if (requireSimpleDirectoryShape && !IsSimpleDirectoryScopeShape(tokens)) return false; + var pathOperands = new List(); + for (var i = 1; i < tokens.Count; i++) { var trimmed = TrimShellPunctuation(tokens[i]); @@ -449,11 +457,14 @@ private static bool TryExtractPathOperand( if (normalizedDirectory is null) continue; - operand = new PathOperand(verb, normalizedPath, normalizedDirectory); - return true; + pathOperands.Add(new PathOperand(normalizedPath, normalizedDirectory)); } - return false; + if (pathOperands.Count == 0) + return false; + + result = new PathScanResult(verb, pathOperands); + return true; } private static bool IsSimpleDirectoryScopeShape(IReadOnlyList tokens) @@ -573,6 +584,26 @@ public static bool IsSingleTokenPathAwarePattern(string pattern) return PathAwareVerbs.Contains(trimmed); } + /// + /// Returns true when a pattern represents an exact path-aware approval with + /// one or more explicit path operands. These patterns should remain exact-only + /// and must not prefix-expand to additional operands. + /// + public static bool IsPathAwareExactPattern(string pattern) + { + if (string.IsNullOrWhiteSpace(pattern) || pattern.EndsWith('/')) + return false; + + var tokens = pattern.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (tokens.Length < 2) + return false; + + if (!PathAwareVerbs.Contains(TrimShellPunctuation(tokens[0]))) + return false; + + return tokens.Skip(1).All(LooksLikePath); + } + internal static string TrimShellPunctuation(string token) { return token.Trim().TrimStart(';', '|', '&').TrimEnd(';', '|', '&'); @@ -598,5 +629,7 @@ private static void FlushSegment(StringBuilder current, List segments) current.Clear(); } - private readonly record struct PathOperand(string Verb, string NormalizedPath, string NormalizedDirectory); + private readonly record struct PathOperand(string NormalizedPath, string NormalizedDirectory); + + private readonly record struct PathScanResult(string Verb, IReadOnlyList PathOperands); } From ed091a1bcb161eb5df109513ec60e5f02e828dcd Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 7 May 2026 04:59:22 +0000 Subject: [PATCH 06/22] Revert "fix(security): harden shell approval multi-path matching" This reverts commit 1d3b2021dda70f11cc3cda37153fd760052c07f1. --- .../design.md | 45 ++++++------- .../proposal.md | 20 ++---- .../specs/tool-approval-gates/spec.md | 67 ++++--------------- .../tasks.md | 7 +- .../Tools/ToolApprovalGateTests.cs | 31 --------- .../ShellApprovalMatcherTests.cs | 55 +-------------- .../ApprovalPatternMatching.cs | 6 -- src/Netclaw.Security/ShellTokenizer.cs | 57 ++++------------ 8 files changed, 52 insertions(+), 236 deletions(-) diff --git a/openspec/changes/directory-scoped-approval-patterns/design.md b/openspec/changes/directory-scoped-approval-patterns/design.md index 1c0c4f63..a7bb20c3 100644 --- a/openspec/changes/directory-scoped-approval-patterns/design.md +++ b/openspec/changes/directory-scoped-approval-patterns/design.md @@ -51,25 +51,21 @@ work unchanged. ### Extraction: shared path-operand resolution, narrower directory scope -`ShellTokenizer.TryExtractPathOperands()` is the shared primitive behind -`ExtractApprovalPattern()` and `ExtractDirectoryScope()`. It scans all non-flag -arguments for directly identifiable path operands, using the shell tool -`WorkingDirectory` to resolve relative operands before approval patterns are -extracted or matched. This solves the grep problem where the search term is the -first positional arg and the file path is second +`ShellTokenizer.TryExtractPathOperand()` is the shared primitive behind +`ExtractApprovalPattern()` and `ExtractDirectoryScope()`. It scans ALL non-flag +arguments for the first token that can be normalized into a path operand, using +the shell tool `WorkingDirectory` to resolve relative operands before approval +patterns are extracted or matched. This solves the grep problem where the search +term is the first positional arg and the file path is second (`grep -l "timeout" logs/daemon.log`). -When directly identifiable path operands exist, exact approval patterns use the -normalized operands themselves in detection order -(`cat /abs/path/first.log /abs/path/second.log`), not the raw verb chain. That -broader exact extraction still applies to commands like `find`, `bash`, or -`python3` when they include direct path operands. These exact patterns remain -exact-only: approving one concrete operand list does not prefix-expand to cover -additional operands appended later. - -Directory-scoped patterns then derive scope from that same extraction only when +When a recognizable path operand exists, exact approval patterns use the +normalized path operand itself (`grep /abs/path/logs/daemon.log`), not the raw +verb chain. That broader exact extraction still applies to commands like +`find`, `bash`, or `python3` when they include a direct path operand. +Directory-scoped patterns then derive scope from that same operand only when the command verb is in the MVP allowlist: `cat`, `less`, `more`, `head`, -`tail`, `grep`, and `ls`, and exactly one direct path operand is detected. +`tail`, `grep`, and `ls`. **Alternative considered:** Always use first positional. Rejected because grep, sed, and awk take non-path first arguments. @@ -82,10 +78,8 @@ directory and stores `ls /abs/path/logs/` rather than widening to the parent (`/abs/path/`). This keeps approval scope aligned with what the command actually targets. -Commands outside the allowlist still use normalized exact path operands when -available, but they do not derive directory scope from them. Likewise, -allowlisted commands with multiple direct path operands stay on exact approval -patterns instead of widening one operand into a directory grant. +Commands outside the allowlist still use the normalized exact path operand when +available, but they do not derive directory scope from it. ### Matching: `PathUtility.IsWithinRoot()` with normalized operands @@ -125,18 +119,17 @@ labels and never store trailing-slash directory approvals. `ToolAccessPolicy.TryGetSingleDirectoryScope()` only emits directory-specific B/C labels when every approval pattern for the request is directory-scoped and all of them resolve to the same directory. If any segment falls back to a generic -exact pattern (for example `find logs -name '*.log'`, `git push`, an -allowlisted multi-path read, or an allowlisted read with shell redirection) or -multiple directory scopes are present, labels stay generic. +exact pattern (for example `find logs -name '*.log'`, `git push`, or an +allowlisted read with shell redirection) or multiple directory scopes are +present, labels stay generic. ### Shell redirection disables directory scope If a segment contains shell redirection operators, directory-scoped extraction is disabled even when the verb itself is allowlisted. For example, `cat logs/app.log > out.txt` falls back to the exact pattern and generic B/C -labels. When both the input operand and redirected output target are directly -identifiable, both appear in the fallback exact pattern in command order. This -avoids granting directory scope when the command also writes or rewires IO. +labels. This avoids granting directory scope when the command also writes or +rewires IO. ### Compound commands: direct path operands only diff --git a/openspec/changes/directory-scoped-approval-patterns/proposal.md b/openspec/changes/directory-scoped-approval-patterns/proposal.md index 123f6038..f2ea4c44 100644 --- a/openspec/changes/directory-scoped-approval-patterns/proposal.md +++ b/openspec/changes/directory-scoped-approval-patterns/proposal.md @@ -16,15 +16,10 @@ legitimate diagnostic work. `tail`, `grep`, and `ls`. - Relative path operands are resolved against the shell tool `WorkingDirectory` before both exact-pattern extraction and directory-scope extraction/matching. -- Path-aware exact approval patterns use the actual normalized direct path - operands when present, preserving every directly identifiable operand in - command order rather than just the first one. This still covers commands like - `grep -l "timeout" logs/app.log` where the search term is not the path - operand, and commands like `find`, `bash`, or `python3` when a direct path - operand exists. -- Path-aware exact approval patterns remain exact-only. They do not - prefix-expand one approved path to cover additional path operands later added - to the command. +- Path-aware exact approval patterns use the actual normalized path operand when + one exists, including commands like `grep -l "timeout" logs/app.log` where the + search term is not the path operand and commands like `find`, `bash`, or + `python3` when a direct path operand exists. - Existing directory operands keep their directory scope for allowlisted directory-scoped verbs instead of widening to the parent directory. - A trailing `/` on a stored approval pattern signals directory scope. Matching @@ -36,16 +31,9 @@ legitimate diagnostic work. directory pattern extraction. - Commands outside the directory-scope allowlist, including `find`, fall back to exact approval patterns and generic B/C labels rather than directory scope. -- Directory-scoped approvals now require exactly one detected direct path - operand in addition to the existing allowlist and redirection restrictions. - Multi-path invocations of otherwise allowlisted commands fall back to exact - approval patterns and generic B/C labels. - Shell redirection operators disable directory-scoped extraction even for allowlisted verbs, causing fallback to exact approval patterns and generic labels. -- When redirection disables directory scope, the fallback exact pattern may - include both the direct input path and the redirected output path when both - are directly identifiable. - Approval option labels only show a directory-specific scope when the full approval set for the request maps cleanly to a single directory; otherwise the labels stay generic. diff --git a/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md b/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md index b5188db4..4ac5619f 100644 --- a/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md +++ b/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md @@ -5,11 +5,10 @@ The system SHALL support directory-scoped approval patterns for shell commands targeting a narrow direct read/list allowlist: `cat`, `less`, `more`, `head`, `tail`, `grep`, and `ls`. When the user selects "Approve for this chat" (B) or -"Approve always" (C) for one of those shell commands and the command targets -exactly one recognizable direct path operand, the system SHALL store a -directory-scoped pattern (e.g., `grep /home/.netclaw/logs/`) instead of the -exact file-path pattern. "Approve once" (A) SHALL continue to use exact -patterns. +"Approve always" (C) for one of those shell commands and the command targets a +recognizable direct path operand, the system SHALL store a directory-scoped +pattern (e.g., `grep /home/.netclaw/logs/`) instead of the exact file-path +pattern. "Approve once" (A) SHALL continue to use exact patterns. When a recognizable path operand is relative, the system SHALL resolve it against the shell tool `WorkingDirectory` before extracting either exact or @@ -20,16 +19,9 @@ Commands outside that directory-scope allowlist, including `find`, SHALL fall back to exact approval patterns and generic B/C labels even when they have a recognizable direct path operand. -Allowlisted commands with more than one recognizable direct path operand SHALL -also fall back to exact approval patterns and generic B/C labels instead of -directory scope. - If a shell segment contains redirection operators, the system SHALL disable directory-scoped extraction for that segment even when the verb is allowlisted. That segment SHALL fall back to exact approval patterns and generic B/C labels. -When both a direct input path and redirected output path are directly -identifiable, the fallback exact pattern SHALL include both operands in command -order. A trailing `/` on a stored pattern SHALL signal directory scope. The system SHALL use `PathUtility.IsWithinRoot()` for boundary-safe containment matching — not @@ -120,17 +112,9 @@ Directory-scoped approvals SHALL be verb-isolated: an approval for #### Scenario: Redirection disables directory scope for allowlisted verb - **GIVEN** the shell tool `WorkingDirectory` is `/workspace/project` -- **AND** the command is `cat logs/app.log > /tmp/out.log` -- **WHEN** exact and directory-scoped approval patterns are extracted -- **THEN** the exact pattern is `cat /workspace/project/logs/app.log /tmp/out.log` -- **AND** no directory-scoped pattern is extracted - -#### Scenario: Multi-path allowlisted command falls back to exact pattern - -- **GIVEN** the shell tool `WorkingDirectory` is `/workspace/project` -- **AND** the command is `cat logs/first.log logs/second.log` +- **AND** the command is `cat logs/app.log > out.txt` - **WHEN** exact and directory-scoped approval patterns are extracted -- **THEN** the exact pattern is `cat /workspace/project/logs/first.log /workspace/project/logs/second.log` +- **THEN** the exact pattern is `cat /workspace/project/logs/app.log` - **AND** no directory-scoped pattern is extracted #### Scenario: Boundary-safe path matching prevents prefix collisions @@ -144,16 +128,15 @@ Directory-scoped approvals SHALL be verb-isolated: an approval for `IToolApprovalMatcher` SHALL define an `ExtractDirectoryPatterns()` method that returns directory-scoped patterns for a tool invocation. `ShellApprovalMatcher` -SHALL implement this by scanning all non-flag arguments for directly -identifiable path operands, resolving relative paths against `WorkingDirectory`, +SHALL implement this by scanning all non-flag arguments for the first +recognizable path operand, resolving relative paths against `WorkingDirectory`, expanding home directory tokens, extracting the scoped directory, normalizing the path, enforcing minimum depth, and applying directory scope only to the -allowlisted verbs `cat`, `less`, `more`, `head`, `tail`, `grep`, and `ls` when -exactly one direct path operand is detected. Segments with shell redirection -operators SHALL NOT emit directory scope. For compound commands and `bash -c` -wrappers, each segment SHALL be processed recursively. When no directory scope -is available for a segment, the segment's exact approval pattern SHALL be used -as fallback. +allowlisted verbs `cat`, `less`, `more`, `head`, `tail`, `grep`, and `ls`. +Segments with shell redirection operators SHALL NOT emit directory scope. For +compound commands and `bash -c` wrappers, each segment SHALL be processed +recursively. When no directory scope is available for a segment, the segment's +exact approval pattern SHALL be used as fallback. `DefaultApprovalMatcher` and `FilePathApprovalMatcher` SHALL return empty lists. @@ -172,21 +155,6 @@ as fallback. - **THEN** the pattern is `grep /workspace/project/logs/daemon.log` - **AND** the search term `"timeout"` is not used as the exact operand -#### Scenario: Exact pattern preserves all detected direct path operands in order - -- **GIVEN** the shell tool `WorkingDirectory` is `/workspace/project` -- **AND** the command is `cat logs/first.log logs/second.log` -- **WHEN** exact approval pattern extraction runs -- **THEN** the pattern is `cat /workspace/project/logs/first.log /workspace/project/logs/second.log` -- **AND** both directly identifiable path operands are preserved in command order - -#### Scenario: Exact approval does not widen to added path operands - -- **GIVEN** `cat /workspace/project/logs/first.log` is approved as an exact pattern -- **WHEN** the agent runs `cat /workspace/project/logs/first.log /workspace/project/logs/second.log` -- **THEN** the command still requires approval -- **AND** the existing exact approval does not prefix-expand to cover the added operand - #### Scenario: Compound command extracts patterns per segment - **GIVEN** the command `cat /home/.netclaw/logs/crash.log && grep "error" /var/log/syslog` @@ -222,7 +190,6 @@ as fallback. - **AND** the command is `grep error logs/app.log > report.txt` - **WHEN** `ExtractDirectoryPatterns` runs - **THEN** no directory-scoped pattern is extracted for that segment -- **AND** the fallback exact pattern includes both the direct input path and the redirected output path when both are directly identifiable #### Scenario: Glob paths use parent directory @@ -263,14 +230,6 @@ Options A ("Approve once") and D ("Deny") SHALL retain their default labels. - **THEN** option B reads the default "Approve for this chat" - **AND** option C reads the default "Approve always" -#### Scenario: Multi-path allowlisted command keeps generic labels - -- **GIVEN** a shell command `cat /home/.netclaw/logs/first.log /home/.netclaw/logs/second.log` - requires approval -- **WHEN** the approval prompt is generated -- **THEN** option B reads the default "Approve for this chat" -- **AND** option C reads the default "Approve always" - #### Scenario: Redirection keeps generic labels for allowlisted verb - **GIVEN** a shell command `cat /home/.netclaw/logs/app.log > /tmp/report.txt` diff --git a/openspec/changes/directory-scoped-approval-patterns/tasks.md b/openspec/changes/directory-scoped-approval-patterns/tasks.md index 54872d6c..4182b475 100644 --- a/openspec/changes/directory-scoped-approval-patterns/tasks.md +++ b/openspec/changes/directory-scoped-approval-patterns/tasks.md @@ -1,6 +1,6 @@ ## 1. Pattern Extraction -- [x] 1.1 Add shared path-operand extraction for `ShellTokenizer.ExtractApprovalPattern()` and `ExtractDirectoryScope()` — scan all args for directly identifiable path operands, resolve relatives against `WorkingDirectory`, normalize, keep exact extraction broader than directory scope, preserve all detected exact operands in order, and enforce minimum depth for directory scope +- [x] 1.1 Add shared path-operand extraction for `ShellTokenizer.ExtractApprovalPattern()` and `ExtractDirectoryScope()` — scan all args for the first resolvable path operand, resolve relatives against `WorkingDirectory`, normalize, keep exact extraction broader than directory scope, and enforce minimum depth for directory scope - [x] 1.2 Add `ExtractParentDirectory()` and `CountPathSegments()` helpers to `ShellTokenizer` - [x] 1.3 Unit tests for `ExtractDirectoryScope` (allowlisted verb+directory, grep path vs search term, glob handling, non-allowlisted fallback, redirection null cases) @@ -19,7 +19,7 @@ - [x] 4.1 Add `DirectoryPatterns` property to `ToolInteractionRequest` in `SessionOutput.cs` - [x] 4.2 Add `DirectoryPatterns` to `ToolApprovalContext` record in `ToolAccessPolicy.cs` -- [x] 4.3 Compute directory patterns and customize B/C labels in `CheckApprovalGate()` only when the full request maps cleanly to a single directory scope from the allowlisted direct read/list verbs with exactly one detected direct path operand; otherwise keep generic labels +- [x] 4.3 Compute directory patterns and customize B/C labels in `CheckApprovalGate()` only when the full request maps cleanly to a single directory scope from the allowlisted direct read/list verbs; otherwise keep generic labels - [x] 4.4 Pass `DirectoryPatterns` from `ToolApprovalContext` to `ToolInteractionRequest` in `SessionToolExecutionPipeline` - [x] 4.5 Propagate `DirectoryPatterns` through `DispatchingToolExecutor` re-approval path @@ -40,5 +40,4 @@ - [x] 6.7 Note Windows-native shell support as out of scope for this change (tracked separately in issue #899) - [x] 6.8 Restrict directory-scoped approvals to `cat`, `less`, `more`, `head`, `tail`, `grep`, and `ls`; keep commands like `find` on exact patterns and generic labels - [x] 6.9 Disable directory-scoped extraction when shell redirection operators are present, even for allowlisted verbs -- [x] 6.10 Keep multi-path exact approval patterns exact-only; do not widen one approved operand list to cover added direct path operands -- [x] 6.11 Verify: `dotnet slopwatch analyze` passes, copyright headers present, all tests green +- [x] 6.10 Verify: `dotnet slopwatch analyze` passes, copyright headers present, all tests green diff --git a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs index 9d6870c6..a3aacb53 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs @@ -866,37 +866,6 @@ public void Redirection_disables_directory_scope_labels() Assert.Equal(ApprovalOptionKeys.ApproveAlwaysLabel, alwaysOption.Label); } - [Fact] - public void Multi_path_allowlisted_command_uses_default_labels() - { - var policy = CreatePolicy(ToolApprovalMode.Approval); - var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); - var first = Path.Combine(root, "first.log"); - var second = Path.Combine(root, "second.log"); - Directory.CreateDirectory(root); - File.WriteAllText(first, "one"); - File.WriteAllText(second, "two"); - - try - { - var args = ToolInput.Create("Command", "cat first.log second.log", "WorkingDirectory", root); - - var decision = policy.AuthorizeInvocation(ShellTool(), PersonalContext(), args); - - Assert.True(decision.NeedsApproval); - Assert.DoesNotContain(decision.ApprovalContext!.DirectoryPatterns, p => p.EndsWith("/")); - Assert.Contains($"cat {PathUtility.Normalize(first)} {PathUtility.Normalize(second)}", decision.ApprovalContext.DirectoryPatterns); - var sessionOption = decision.ApprovalContext.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveSession); - var alwaysOption = decision.ApprovalContext.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveAlways); - Assert.Equal(ApprovalOptionKeys.ApproveSessionLabel, sessionOption.Label); - Assert.Equal(ApprovalOptionKeys.ApproveAlwaysLabel, alwaysOption.Label); - } - finally - { - Directory.Delete(root, recursive: true); - } - } - [Fact] public void Non_path_command_uses_default_labels() { diff --git a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs index 412786c7..ef48904e 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs @@ -103,9 +103,6 @@ public void IsApproved_one_pattern_unapproved() // Path-aware patterns — exact path matches [InlineData("cat /etc/passwd", "cat /etc/passwd", true)] [InlineData("cat /etc/passwd", "cat /etc/shadow", false)] - [InlineData("cat /etc/passwd", "cat /etc/passwd /etc/shadow", false)] - [InlineData("cat /etc/passwd /etc/shadow", "cat /etc/passwd /etc/shadow", true)] - [InlineData("cat /etc/passwd /etc/shadow", "cat /etc/passwd /etc/shadow /etc/hosts", false)] // Directory-scoped patterns (trailing /) match files under that directory [InlineData("cat /home/user/.netclaw/logs/", "cat /home/user/.netclaw/logs/crash.log", true)] [InlineData("cat /home/user/.netclaw/logs/", "cat /home/user/.netclaw/logs/deep/nested.txt", true)] @@ -201,31 +198,6 @@ public void ExtractPatterns_non_allowlisted_find_still_uses_exact_path_pattern() Assert.Equal("find /home/user/.netclaw/logs", patterns[0]); } - [Fact] - public void ExtractPatterns_multi_path_allowlisted_command_includes_all_paths() - { - var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); - var first = Path.Combine(root, "first.log"); - var second = Path.Combine(root, "second.log"); - Directory.CreateDirectory(root); - File.WriteAllText(first, "one"); - File.WriteAllText(second, "two"); - - try - { - var patterns = _matcher.ExtractPatterns( - new ToolName("shell_execute"), - Args("cat first.log second.log", root)); - - Assert.Single(patterns); - Assert.Equal($"cat {PathUtility.Normalize(first)} {PathUtility.Normalize(second)}", patterns[0]); - } - finally - { - Directory.Delete(root, recursive: true); - } - } - [Fact] public void ExtractPatterns_pipe_with_path_aware_verbs() { @@ -358,32 +330,7 @@ public void ExtractDirectoryPatterns_redirection_falls_back_to_exact_pattern() Args("cat logs/app.log > /tmp/out.log", root)); Assert.Single(patterns); - Assert.Equal($"cat {PathUtility.Normalize(file)} /tmp/out.log", patterns[0]); - } - finally - { - Directory.Delete(root, recursive: true); - } - } - - [Fact] - public void ExtractDirectoryPatterns_multi_path_falls_back_to_exact_multi_path_pattern() - { - var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); - var first = Path.Combine(root, "first.log"); - var second = Path.Combine(root, "second.log"); - Directory.CreateDirectory(root); - File.WriteAllText(first, "one"); - File.WriteAllText(second, "two"); - - try - { - var patterns = _matcher.ExtractDirectoryPatterns( - new ToolName("shell_execute"), - Args("cat first.log second.log", root)); - - Assert.Single(patterns); - Assert.Equal($"cat {PathUtility.Normalize(first)} {PathUtility.Normalize(second)}", patterns[0]); + Assert.Equal($"cat {PathUtility.Normalize(file)}", patterns[0]); } finally { diff --git a/src/Netclaw.Security/ApprovalPatternMatching.cs b/src/Netclaw.Security/ApprovalPatternMatching.cs index 8a13ca8d..f14e221f 100644 --- a/src/Netclaw.Security/ApprovalPatternMatching.cs +++ b/src/Netclaw.Security/ApprovalPatternMatching.cs @@ -29,12 +29,6 @@ public static bool MatchesAny(string candidate, IEnumerable approvedPatt if (approved.EndsWith('/') && MatchesDirectoryScope(candidate, approved)) return true; - // Exact path-aware patterns must not widen by prefix. This prevents an - // approval for one direct operand from silently approving additional - // path operands appended to the same verb. - if (ShellTokenizer.IsPathAwareExactPattern(approved)) - continue; - // Multi-token patterns prefix-match on a space boundary. Single-token // patterns remain exact-only so grants do not silently widen from // "cat" to every path-bearing cat invocation. diff --git a/src/Netclaw.Security/ShellTokenizer.cs b/src/Netclaw.Security/ShellTokenizer.cs index 8ef0493a..7d52f891 100644 --- a/src/Netclaw.Security/ShellTokenizer.cs +++ b/src/Netclaw.Security/ShellTokenizer.cs @@ -227,14 +227,11 @@ public static string ExtractVerbChain(string command, int maxDepth = 2) /// /// Extracts the approval pattern used for exact matching. For path-aware verbs, /// prefers the first recognizable path operand normalized against the working - /// directory. When multiple direct path operands are present, includes all of - /// them in the exact pattern so approvals do not silently widen from one - /// resource to many. Falls back to the verb-chain extraction when no direct - /// path operand is recognized. + /// directory; otherwise falls back to the verb-chain extraction. /// public static string ExtractApprovalPattern(string command, string? workingDirectory = null, int maxDepth = 2) - => TryExtractPathOperands(command, workingDirectory, PathAwareVerbs, requireSimpleDirectoryShape: false, out var result) - ? result.Verb + " " + string.Join(' ', result.PathOperands.Select(static x => x.NormalizedPath)) + => TryExtractPathOperand(command, workingDirectory, PathAwareVerbs, requireSimpleDirectoryShape: false, out var operand) + ? operand.Verb + " " + operand.NormalizedPath : ExtractVerbChain(command, maxDepth); /// @@ -406,29 +403,26 @@ public static bool LooksLikePath(string token) /// public static string? ExtractDirectoryScope(string command, string? workingDirectory = null) { - if (!TryExtractPathOperands(command, workingDirectory, DirectoryScopeVerbs, requireSimpleDirectoryShape: true, out var result) - || result.PathOperands.Count != 1) + if (!TryExtractPathOperand(command, workingDirectory, DirectoryScopeVerbs, requireSimpleDirectoryShape: true, out var operand)) return null; - var operand = result.PathOperands[0]; - // Enforce minimum depth — reject shallow scopes like / or /etc/ if (CountPathSegments(operand.NormalizedDirectory) < MinDirectoryScopeDepth) return null; - return result.Verb + " " + operand.NormalizedDirectory + "/"; + return operand.Verb + " " + operand.NormalizedDirectory + "/"; } internal const int MinDirectoryScopeDepth = 2; - private static bool TryExtractPathOperands( + private static bool TryExtractPathOperand( string command, string? workingDirectory, IReadOnlySet allowedVerbs, bool requireSimpleDirectoryShape, - out PathScanResult result) + out PathOperand operand) { - result = default; + operand = default; var tokens = Tokenize(command).ToList(); if (tokens.Count == 0) @@ -441,8 +435,6 @@ private static bool TryExtractPathOperands( if (requireSimpleDirectoryShape && !IsSimpleDirectoryScopeShape(tokens)) return false; - var pathOperands = new List(); - for (var i = 1; i < tokens.Count; i++) { var trimmed = TrimShellPunctuation(tokens[i]); @@ -457,14 +449,11 @@ private static bool TryExtractPathOperands( if (normalizedDirectory is null) continue; - pathOperands.Add(new PathOperand(normalizedPath, normalizedDirectory)); + operand = new PathOperand(verb, normalizedPath, normalizedDirectory); + return true; } - if (pathOperands.Count == 0) - return false; - - result = new PathScanResult(verb, pathOperands); - return true; + return false; } private static bool IsSimpleDirectoryScopeShape(IReadOnlyList tokens) @@ -584,26 +573,6 @@ public static bool IsSingleTokenPathAwarePattern(string pattern) return PathAwareVerbs.Contains(trimmed); } - /// - /// Returns true when a pattern represents an exact path-aware approval with - /// one or more explicit path operands. These patterns should remain exact-only - /// and must not prefix-expand to additional operands. - /// - public static bool IsPathAwareExactPattern(string pattern) - { - if (string.IsNullOrWhiteSpace(pattern) || pattern.EndsWith('/')) - return false; - - var tokens = pattern.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (tokens.Length < 2) - return false; - - if (!PathAwareVerbs.Contains(TrimShellPunctuation(tokens[0]))) - return false; - - return tokens.Skip(1).All(LooksLikePath); - } - internal static string TrimShellPunctuation(string token) { return token.Trim().TrimStart(';', '|', '&').TrimEnd(';', '|', '&'); @@ -629,7 +598,5 @@ private static void FlushSegment(StringBuilder current, List segments) current.Clear(); } - private readonly record struct PathOperand(string NormalizedPath, string NormalizedDirectory); - - private readonly record struct PathScanResult(string Verb, IReadOnlyList PathOperands); + private readonly record struct PathOperand(string Verb, string NormalizedPath, string NormalizedDirectory); } From 47b09d88e2ff9c44df4d210bc391994069305bff Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 7 May 2026 04:59:22 +0000 Subject: [PATCH 07/22] Revert "fix(security): narrow directory-scoped shell approvals" This reverts commit d40180f2a7a6a41cc6ce2a212011abbd832a25d3. --- .../design.md | 58 ++++--------- .../proposal.md | 20 ++--- .../specs/tool-approval-gates/spec.md | 83 +++---------------- .../tasks.md | 14 ++-- .../Tools/ToolApprovalGateTests.cs | 32 ------- .../ShellApprovalMatcherTests.cs | 43 ++-------- .../ShellTokenizerTests.cs | 22 ++++- src/Netclaw.Security/ShellTokenizer.cs | 56 ++----------- 8 files changed, 71 insertions(+), 257 deletions(-) diff --git a/openspec/changes/directory-scoped-approval-patterns/design.md b/openspec/changes/directory-scoped-approval-patterns/design.md index a7bb20c3..0b5d1022 100644 --- a/openspec/changes/directory-scoped-approval-patterns/design.md +++ b/openspec/changes/directory-scoped-approval-patterns/design.md @@ -1,8 +1,8 @@ ## Context Shell command approval patterns are extracted by `ShellTokenizer.ExtractVerbChain()` -which, for path-aware commands with a direct path operand, appends that -normalized operand to the verb chain. This produces per-file patterns +which, for path-aware verbs (`ls`, `cat`, `grep`, `find`, etc.), appends the +first file-path argument to the verb chain. This produces per-file patterns like `cat /home/.netclaw/logs/crash-foo.log`. Combined with the single-token exact-match restriction in `ApprovalPatternMatching` (which prevents bare `cat` from silently approving `cat /etc/shadow`), each unique file path requires a @@ -20,19 +20,16 @@ This change only relaxes layer 2. Layers 1 and 3 are unaffected. **Goals:** - Reduce per-file approval fatigue for diagnostic shell commands - Store directory-scoped patterns when user selects B (session) or C (always) - for a narrow direct read/list allowlist only -- Maintain boundary-safe path matching (no `StartsWith` - use `PathUtility.IsWithinRoot`) +- Maintain boundary-safe path matching (no `StartsWith` — use `PathUtility.IsWithinRoot`) - Prevent overly broad directory scopes (minimum 2 path segments) - Show directory context in approval option labels only when the entire request maps cleanly to one directory scope **Non-Goals:** - Changing the hard deny list or `ToolPathPolicy` behavior -- Changing "Approve once" (A) behavior - it remains exact-pattern +- Changing "Approve once" (A) behavior — it remains exact-pattern - Glob-aware or regex-based pattern matching - Cross-verb directory approvals (`cat /dir/` does not approve `grep /dir/`) -- Directory-scoped approvals for non-allowlisted commands such as `find`, - `bash`, or `python3` - Inferring indirect path flow through shell constructs like `xargs`, `eval`, loop variables, command substitution, or shell variables - Windows-native shell path handling; tracked separately in issue #899 @@ -49,7 +46,7 @@ is a flat list of strings per tool per audience. A sentinel convention avoids schema changes and keeps backward compatibility — existing non-slash patterns work unchanged. -### Extraction: shared path-operand resolution, narrower directory scope +### Extraction: shared path-operand resolution for exact and directory patterns `ShellTokenizer.TryExtractPathOperand()` is the shared primitive behind `ExtractApprovalPattern()` and `ExtractDirectoryScope()`. It scans ALL non-flag @@ -61,25 +58,18 @@ term is the first positional arg and the file path is second When a recognizable path operand exists, exact approval patterns use the normalized path operand itself (`grep /abs/path/logs/daemon.log`), not the raw -verb chain. That broader exact extraction still applies to commands like -`find`, `bash`, or `python3` when they include a direct path operand. -Directory-scoped patterns then derive scope from that same operand only when -the command verb is in the MVP allowlist: `cat`, `less`, `more`, `head`, -`tail`, `grep`, and `ls`. +verb chain. Directory-scoped patterns then derive scope from that same operand. **Alternative considered:** Always use first positional. Rejected because grep, sed, and awk take non-path first arguments. -### Existing directory operands stay directory-scoped for allowlisted verbs +### Existing directory operands stay directory-scoped `ExtractScopedDirectory()` preserves an operand that already denotes a -directory. For example, `ls logs/` resolves `logs/` against the working -directory and stores `ls /abs/path/logs/` rather than widening to the parent -(`/abs/path/`). This keeps approval scope aligned with what the command actually -targets. - -Commands outside the allowlist still use the normalized exact path operand when -available, but they do not derive directory scope from it. +directory. For example, `find logs -name '*.log'` resolves `logs` against the +working directory and stores `find /abs/path/logs/` rather than widening to the +parent (`/abs/path/`). This keeps approval scope aligned with what the command +actually targets. ### Matching: `PathUtility.IsWithinRoot()` with normalized operands @@ -101,42 +91,22 @@ at root-level directories even if they want to. ### Verb isolation An approval for `cat /dir/` does NOT approve `grep /dir/`. The verb is part of -the pattern and checked explicitly. This limits blast radius - approving reads +the pattern and checked explicitly. This limits blast radius — approving reads doesn't silently approve writes or deletions. -### Directory scope is allowlist-based - -Directory scope is intentionally narrower than exact path-aware extraction. Only -the direct read/list verbs `cat`, `less`, `more`, `head`, `tail`, `grep`, and -`ls` can emit directory-scoped patterns in this MVP. - -Commands outside that allowlist, including `find`, keep exact path-aware -patterns when a direct path operand exists, but they fall back to generic B/C -labels and never store trailing-slash directory approvals. - ### Dynamic labels require a single clean directory scope `ToolAccessPolicy.TryGetSingleDirectoryScope()` only emits directory-specific B/C labels when every approval pattern for the request is directory-scoped and all of them resolve to the same directory. If any segment falls back to a generic -exact pattern (for example `find logs -name '*.log'`, `git push`, or an -allowlisted read with shell redirection) or multiple directory scopes are +verb-chain pattern (for example `git push`) or multiple directory scopes are present, labels stay generic. -### Shell redirection disables directory scope - -If a segment contains shell redirection operators, directory-scoped extraction -is disabled even when the verb itself is allowlisted. For example, -`cat logs/app.log > out.txt` falls back to the exact pattern and generic B/C -labels. This avoids granting directory scope when the command also writes or -rewires IO. - ### Compound commands: direct path operands only Compound commands and pipe segments are still traversed segment-by-segment, so a segment like `cat logs/app.log | jq .` can contribute directory scope for the -`cat` segment when there is no shell redirection on that segment and the verb is -allowlisted. MVP extraction stops there: it does not infer that a downstream +`cat` segment. MVP extraction stops there: it does not infer that a downstream segment implicitly targets the same path through `xargs`, `eval`, loop variables, shell variables, or similar constructs. diff --git a/openspec/changes/directory-scoped-approval-patterns/proposal.md b/openspec/changes/directory-scoped-approval-patterns/proposal.md index f2ea4c44..516e3e12 100644 --- a/openspec/changes/directory-scoped-approval-patterns/proposal.md +++ b/openspec/changes/directory-scoped-approval-patterns/proposal.md @@ -1,7 +1,7 @@ ## Why -Path-aware shell commands can produce per-file exact approval patterns (e.g., -`cat /home/.netclaw/logs/crash-foo.log`). In a single +Path-aware shell verbs (`ls`, `cat`, `grep`, `find`, etc.) produce per-file +approval patterns (e.g., `cat /home/.netclaw/logs/crash-foo.log`). In a single diagnostic session (D0AC6CKBK5K/1778085593.830269), the user was prompted **11 separate times** for commands targeting different files in the same directory. The single-token exact-match restriction prevents bare `cat` from silently @@ -12,16 +12,15 @@ legitimate diagnostic work. - "Approve for this chat" (B) and "Approve always" (C) store **directory-scoped patterns** (e.g., `grep /home/.netclaw/logs/`) instead of per-file patterns - only for a narrow direct read/list allowlist: `cat`, `less`, `more`, `head`, - `tail`, `grep`, and `ls`. + when the command targets a recognizable file path. - Relative path operands are resolved against the shell tool `WorkingDirectory` before both exact-pattern extraction and directory-scope extraction/matching. - Path-aware exact approval patterns use the actual normalized path operand when one exists, including commands like `grep -l "timeout" logs/app.log` where the - search term is not the path operand and commands like `find`, `bash`, or - `python3` when a direct path operand exists. -- Existing directory operands keep their directory scope for allowlisted - directory-scoped verbs instead of widening to the parent directory. + search term is not the path operand. +- Existing directory operands keep their directory scope (for example, + `find logs -name '*.log'` stores `find /logs/`) instead of widening to the + parent directory. - A trailing `/` on a stored approval pattern signals directory scope. Matching uses `PathUtility.IsWithinRoot()` for boundary-safe containment instead of naive `StartsWith`. @@ -29,11 +28,6 @@ legitimate diagnostic work. scopes like `/` or `/etc/`. - `IToolApprovalMatcher` gains `ExtractDirectoryPatterns()` for tool-specific directory pattern extraction. -- Commands outside the directory-scope allowlist, including `find`, fall back to - exact approval patterns and generic B/C labels rather than directory scope. -- Shell redirection operators disable directory-scoped extraction even for - allowlisted verbs, causing fallback to exact approval patterns and generic - labels. - Approval option labels only show a directory-specific scope when the full approval set for the request maps cleanly to a single directory; otherwise the labels stay generic. diff --git a/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md b/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md index 4ac5619f..ea904a9f 100644 --- a/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md +++ b/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md @@ -3,26 +3,17 @@ ### Requirement: Directory-scoped approval patterns The system SHALL support directory-scoped approval patterns for shell commands -targeting a narrow direct read/list allowlist: `cat`, `less`, `more`, `head`, -`tail`, `grep`, and `ls`. When the user selects "Approve for this chat" (B) or -"Approve always" (C) for one of those shell commands and the command targets a -recognizable direct path operand, the system SHALL store a directory-scoped -pattern (e.g., `grep /home/.netclaw/logs/`) instead of the exact file-path -pattern. "Approve once" (A) SHALL continue to use exact patterns. +targeting path-aware verbs. When the user selects "Approve for this chat" (B) or +"Approve always" (C) for a shell command that targets a recognizable file path, +the system SHALL store a directory-scoped pattern (e.g., `grep /home/.netclaw/logs/`) +instead of the exact file-path pattern. "Approve once" (A) SHALL continue to use +exact patterns. When a recognizable path operand is relative, the system SHALL resolve it against the shell tool `WorkingDirectory` before extracting either exact or directory-scoped approval patterns. Existing operands that already denote a directory SHALL preserve that directory scope instead of widening to the parent. -Commands outside that directory-scope allowlist, including `find`, SHALL fall -back to exact approval patterns and generic B/C labels even when they have a -recognizable direct path operand. - -If a shell segment contains redirection operators, the system SHALL disable -directory-scoped extraction for that segment even when the verb is allowlisted. -That segment SHALL fall back to exact approval patterns and generic B/C labels. - A trailing `/` on a stored pattern SHALL signal directory scope. The system SHALL use `PathUtility.IsWithinRoot()` for boundary-safe containment matching — not naive string prefix comparison. @@ -62,9 +53,9 @@ Directory-scoped approvals SHALL be verb-isolated: an approval for #### Scenario: Existing directory operand preserves its scope - **GIVEN** the shell tool `WorkingDirectory` is `/workspace/project` -- **AND** the command is `ls logs/` +- **AND** the command is `find logs -name '*.log'` - **WHEN** directory-scoped approval extraction runs -- **THEN** the extracted pattern is `ls /workspace/project/logs/` +- **THEN** the extracted pattern is `find /workspace/project/logs/` - **AND** the scope is not widened to `/workspace/project/` #### Scenario: Approve Once uses exact pattern @@ -100,23 +91,6 @@ Directory-scoped approvals SHALL be verb-isolated: an approval for - **THEN** the parent directory `/etc/` has only 1 segment (below minimum of 2) - **AND** the system falls back to exact-pattern behavior -#### Scenario: Non-allowlisted command falls back to exact pattern - -- **GIVEN** the shell tool `WorkingDirectory` is `/workspace/project` -- **AND** the command is `find logs -name '*.log'` -- **WHEN** exact and directory-scoped approval patterns are extracted -- **THEN** the exact pattern remains path-aware for the direct operand -- **AND** the extracted exact pattern is `find /workspace/project/logs` -- **AND** no directory-scoped pattern is extracted - -#### Scenario: Redirection disables directory scope for allowlisted verb - -- **GIVEN** the shell tool `WorkingDirectory` is `/workspace/project` -- **AND** the command is `cat logs/app.log > out.txt` -- **WHEN** exact and directory-scoped approval patterns are extracted -- **THEN** the exact pattern is `cat /workspace/project/logs/app.log` -- **AND** no directory-scoped pattern is extracted - #### Scenario: Boundary-safe path matching prevents prefix collisions - **GIVEN** `cat /home/user/` is approved @@ -131,12 +105,10 @@ returns directory-scoped patterns for a tool invocation. `ShellApprovalMatcher` SHALL implement this by scanning all non-flag arguments for the first recognizable path operand, resolving relative paths against `WorkingDirectory`, expanding home directory tokens, extracting the scoped directory, normalizing -the path, enforcing minimum depth, and applying directory scope only to the -allowlisted verbs `cat`, `less`, `more`, `head`, `tail`, `grep`, and `ls`. -Segments with shell redirection operators SHALL NOT emit directory scope. For -compound commands and `bash -c` wrappers, each segment SHALL be processed -recursively. When no directory scope is available for a segment, the segment's -exact approval pattern SHALL be used as fallback. +the path, and enforcing minimum depth. For compound commands and `bash -c` +wrappers, each segment SHALL be processed recursively. When no directory scope +is available for a segment, the segment's exact approval pattern SHALL be used +as fallback. `DefaultApprovalMatcher` and `FilePathApprovalMatcher` SHALL return empty lists. @@ -160,7 +132,7 @@ exact approval pattern SHALL be used as fallback. - **GIVEN** the command `cat /home/.netclaw/logs/crash.log && grep "error" /var/log/syslog` - **WHEN** `ExtractDirectoryPatterns` runs - **THEN** `cat /home/.netclaw/logs/` is extracted for the first segment -- **AND** the second segment falls back to its exact path-aware pattern (depth too shallow) +- **AND** the second segment falls back to its verb chain (depth too shallow) #### Scenario: Pipe segment can contribute direct directory scope @@ -174,23 +146,9 @@ exact approval pattern SHALL be used as fallback. - **GIVEN** the command is `find logs -name '*.log' | xargs grep timeout` - **WHEN** `ExtractDirectoryPatterns` runs -- **THEN** the `find` segment does not gain directory scope +- **THEN** the `find` segment may contribute `find /logs/` - **AND** the downstream `grep` segment does not inherit that directory scope via `xargs` -#### Scenario: Non-allowlisted command keeps exact path-aware extraction - -- **GIVEN** the shell tool `WorkingDirectory` is `/workspace/project` -- **AND** the command is `python3 scripts/check.py data/input.json` -- **WHEN** exact approval pattern extraction runs -- **THEN** the pattern uses the normalized direct path operand when one exists - -#### Scenario: Redirection blocks directory extraction for allowlisted segment - -- **GIVEN** the shell tool `WorkingDirectory` is `/workspace/project` -- **AND** the command is `grep error logs/app.log > report.txt` -- **WHEN** `ExtractDirectoryPatterns` runs -- **THEN** no directory-scoped pattern is extracted for that segment - #### Scenario: Glob paths use parent directory - **GIVEN** the command `ls /home/.netclaw/logs/crash-*.log` @@ -223,21 +181,6 @@ Options A ("Approve once") and D ("Deny") SHALL retain their default labels. - **THEN** option B reads the default "Approve for this chat" - **AND** option C reads the default "Approve always" -#### Scenario: Non-allowlisted path-aware command keeps generic labels - -- **GIVEN** a shell command `find logs -name '*.log'` requires approval -- **WHEN** the approval prompt is generated -- **THEN** option B reads the default "Approve for this chat" -- **AND** option C reads the default "Approve always" - -#### Scenario: Redirection keeps generic labels for allowlisted verb - -- **GIVEN** a shell command `cat /home/.netclaw/logs/app.log > /tmp/report.txt` - requires approval -- **WHEN** the approval prompt is generated -- **THEN** option B reads the default "Approve for this chat" -- **AND** option C reads the default "Approve always" - #### Scenario: Labels stay generic for mixed approval sets - **GIVEN** a shell command `cat /home/.netclaw/logs/crash.log && git push origin main` diff --git a/openspec/changes/directory-scoped-approval-patterns/tasks.md b/openspec/changes/directory-scoped-approval-patterns/tasks.md index 4182b475..b6e0b291 100644 --- a/openspec/changes/directory-scoped-approval-patterns/tasks.md +++ b/openspec/changes/directory-scoped-approval-patterns/tasks.md @@ -1,8 +1,8 @@ ## 1. Pattern Extraction -- [x] 1.1 Add shared path-operand extraction for `ShellTokenizer.ExtractApprovalPattern()` and `ExtractDirectoryScope()` — scan all args for the first resolvable path operand, resolve relatives against `WorkingDirectory`, normalize, keep exact extraction broader than directory scope, and enforce minimum depth for directory scope +- [x] 1.1 Add shared path-operand extraction for `ShellTokenizer.ExtractApprovalPattern()` and `ExtractDirectoryScope()` — scan all args for the first resolvable path operand, resolve relatives against `WorkingDirectory`, normalize, and enforce minimum depth for directory scope - [x] 1.2 Add `ExtractParentDirectory()` and `CountPathSegments()` helpers to `ShellTokenizer` -- [x] 1.3 Unit tests for `ExtractDirectoryScope` (allowlisted verb+directory, grep path vs search term, glob handling, non-allowlisted fallback, redirection null cases) +- [x] 1.3 Unit tests for `ExtractDirectoryScope` (verb+directory, grep path vs search term, glob handling, null cases) ## 2. Pattern Matching @@ -12,14 +12,14 @@ ## 3. IToolApprovalMatcher Extension - [x] 3.1 Add `ExtractDirectoryPatterns()` to `IToolApprovalMatcher` interface -- [x] 3.2 Implement on `ShellApprovalMatcher` with compound command + `bash -c` recursion via shared `TraverseSegments` helper, limited to the directory-scope allowlist and disabled by shell redirection +- [x] 3.2 Implement on `ShellApprovalMatcher` with compound command + `bash -c` recursion via shared `TraverseSegments` helper - [x] 3.3 Implement on `DefaultApprovalMatcher` and `FilePathApprovalMatcher` (return empty list) ## 4. Protocol and Pipeline Wiring - [x] 4.1 Add `DirectoryPatterns` property to `ToolInteractionRequest` in `SessionOutput.cs` - [x] 4.2 Add `DirectoryPatterns` to `ToolApprovalContext` record in `ToolAccessPolicy.cs` -- [x] 4.3 Compute directory patterns and customize B/C labels in `CheckApprovalGate()` only when the full request maps cleanly to a single directory scope from the allowlisted direct read/list verbs; otherwise keep generic labels +- [x] 4.3 Compute directory patterns and customize B/C labels in `CheckApprovalGate()` only when the full request maps cleanly to a single directory scope; otherwise keep generic labels - [x] 4.4 Pass `DirectoryPatterns` from `ToolApprovalContext` to `ToolInteractionRequest` in `SessionToolExecutionPipeline` - [x] 4.5 Propagate `DirectoryPatterns` through `DispatchingToolExecutor` re-approval path @@ -35,9 +35,7 @@ - [x] 6.2 Unify `CollectPatterns`/`CollectDirectoryPatterns` into shared `TraverseSegments` helper - [x] 6.3 Use `PathUtility.ExpandAndNormalize()` in `ExtractDirectoryScope` instead of separate calls - [x] 6.4 Make `DirectoryPatterns` non-nullable on `ToolApprovalContext` -- [x] 6.5 Preserve existing directory operands for allowlisted verbs instead of widening them to the parent directory +- [x] 6.5 Preserve existing directory operands like `find logs -name '*.log'` instead of widening them to the parent directory - [x] 6.6 Keep pipe/compound traversal MVP-scoped to direct path operands only; do not infer indirect flow through `xargs`, `eval`, or loop variables - [x] 6.7 Note Windows-native shell support as out of scope for this change (tracked separately in issue #899) -- [x] 6.8 Restrict directory-scoped approvals to `cat`, `less`, `more`, `head`, `tail`, `grep`, and `ls`; keep commands like `find` on exact patterns and generic labels -- [x] 6.9 Disable directory-scoped extraction when shell redirection operators are present, even for allowlisted verbs -- [x] 6.10 Verify: `dotnet slopwatch analyze` passes, copyright headers present, all tests green +- [x] 6.8 Verify: `dotnet slopwatch analyze` passes, copyright headers present, all tests green diff --git a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs index a3aacb53..b06fd4ab 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs @@ -834,38 +834,6 @@ public void Relative_path_command_uses_working_directory_for_directory_scope() } } - [Fact] - public void Non_allowlisted_find_uses_default_labels() - { - var policy = CreatePolicy(ToolApprovalMode.Approval); - var args = ToolInput.Create("Command", "find /home/user/.netclaw/logs -name '*.log'"); - - var decision = policy.AuthorizeInvocation(ShellTool(), PersonalContext(), args); - - Assert.True(decision.NeedsApproval); - Assert.DoesNotContain(decision.ApprovalContext!.DirectoryPatterns, p => p.EndsWith("/")); - var sessionOption = decision.ApprovalContext.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveSession); - var alwaysOption = decision.ApprovalContext.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveAlways); - Assert.Equal(ApprovalOptionKeys.ApproveSessionLabel, sessionOption.Label); - Assert.Equal(ApprovalOptionKeys.ApproveAlwaysLabel, alwaysOption.Label); - } - - [Fact] - public void Redirection_disables_directory_scope_labels() - { - var policy = CreatePolicy(ToolApprovalMode.Approval); - var args = ToolInput.Create("Command", "cat /home/user/.netclaw/logs/app.log > /tmp/out.log"); - - var decision = policy.AuthorizeInvocation(ShellTool(), PersonalContext(), args); - - Assert.True(decision.NeedsApproval); - Assert.DoesNotContain(decision.ApprovalContext!.DirectoryPatterns, p => p.EndsWith("/")); - var sessionOption = decision.ApprovalContext.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveSession); - var alwaysOption = decision.ApprovalContext.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveAlways); - Assert.Equal(ApprovalOptionKeys.ApproveSessionLabel, sessionOption.Label); - Assert.Equal(ApprovalOptionKeys.ApproveAlwaysLabel, alwaysOption.Label); - } - [Fact] public void Non_path_command_uses_default_labels() { diff --git a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs index ef48904e..82e82fc7 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs @@ -103,6 +103,8 @@ public void IsApproved_one_pattern_unapproved() // Path-aware patterns — exact path matches [InlineData("cat /etc/passwd", "cat /etc/passwd", true)] [InlineData("cat /etc/passwd", "cat /etc/shadow", false)] + [InlineData("bash /home/.netclaw/scripts/monitor.sh", "bash /home/.netclaw/scripts/monitor.sh", true)] + [InlineData("bash /home/.netclaw/scripts/monitor.sh", "bash /tmp/evil.sh", false)] // Directory-scoped patterns (trailing /) match files under that directory [InlineData("cat /home/user/.netclaw/logs/", "cat /home/user/.netclaw/logs/crash.log", true)] [InlineData("cat /home/user/.netclaw/logs/", "cat /home/user/.netclaw/logs/deep/nested.txt", true)] @@ -112,9 +114,9 @@ public void IsApproved_one_pattern_unapproved() // Single-token path-aware verbs stay exact-only [InlineData("cat", "cat /etc/passwd", false)] [InlineData("grep", "grep TODO", false)] + [InlineData("bash", "bash /tmp/script.sh", false)] [InlineData("find", "find /var/log", false)] // Non-path-aware single tokens still require exact match - [InlineData("bash", "bash /tmp/script.sh", false)] [InlineData("echo", "echo hello", false)] [InlineData("docker", "docker compose", false)] public void IsApproved_pattern_matching(string pattern, string command, bool expected) @@ -187,17 +189,6 @@ public void ExtractPatterns_resolve_relative_paths_against_working_directory() } } - [Fact] - public void ExtractPatterns_non_allowlisted_find_still_uses_exact_path_pattern() - { - var patterns = _matcher.ExtractPatterns( - new ToolName("shell_execute"), - Args("find /home/user/.netclaw/logs -name '*.log'")); - - Assert.Single(patterns); - Assert.Equal("find /home/user/.netclaw/logs", patterns[0]); - } - [Fact] public void ExtractPatterns_pipe_with_path_aware_verbs() { @@ -293,7 +284,7 @@ public void ExtractDirectoryPatterns_resolve_relative_paths_against_working_dire } [Fact] - public void ExtractDirectoryPatterns_non_allowlisted_find_falls_back_to_exact_path_pattern() + public void ExtractDirectoryPatterns_preserve_existing_directory_operand() { var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var logs = Path.Combine(root, "logs"); @@ -306,31 +297,7 @@ public void ExtractDirectoryPatterns_non_allowlisted_find_falls_back_to_exact_pa Args("find logs -name '*.log'", root)); Assert.Single(patterns); - Assert.Equal($"find {PathUtility.Normalize(logs)}", patterns[0]); - } - finally - { - Directory.Delete(root, recursive: true); - } - } - - [Fact] - public void ExtractDirectoryPatterns_redirection_falls_back_to_exact_pattern() - { - var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); - var logs = Path.Combine(root, "logs"); - Directory.CreateDirectory(logs); - var file = Path.Combine(logs, "app.log"); - File.WriteAllText(file, "hello"); - - try - { - var patterns = _matcher.ExtractDirectoryPatterns( - new ToolName("shell_execute"), - Args("cat logs/app.log > /tmp/out.log", root)); - - Assert.Single(patterns); - Assert.Equal($"cat {PathUtility.Normalize(file)}", patterns[0]); + Assert.Equal($"find {PathUtility.Normalize(logs)}/", patterns[0]); } finally { diff --git a/src/Netclaw.Security.Tests/ShellTokenizerTests.cs b/src/Netclaw.Security.Tests/ShellTokenizerTests.cs index ba67f927..b493ec20 100644 --- a/src/Netclaw.Security.Tests/ShellTokenizerTests.cs +++ b/src/Netclaw.Security.Tests/ShellTokenizerTests.cs @@ -287,6 +287,26 @@ public void ExtractDirectoryScope_returns_verb_and_directory(string command, str Assert.EndsWith(expectedDirSuffix, dir.Replace('\\', '/')); } + [Fact] + public void ExtractDirectoryScope_preserves_existing_directory_operand() + { + var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + var logs = Path.Combine(root, "logs"); + Directory.CreateDirectory(logs); + + try + { + var result = ShellTokenizer.ExtractDirectoryScope($"find {logs} -name '*.log'"); + + Assert.NotNull(result); + Assert.Equal($"find {PathUtility.Normalize(logs)}/", result); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + [Fact] public void ExtractDirectoryScope_grep_finds_path_not_search_term() { @@ -312,9 +332,7 @@ public void ExtractDirectoryScope_handles_glob_paths() [Theory] [InlineData("echo hello")] // not a path-aware verb [InlineData("git push origin main")] // not in PathAwareVerbs - [InlineData("find /home/user/.netclaw/logs -name '*.log'")] // not directory-scope eligible [InlineData("grep --version")] // no path argument - [InlineData("cat /home/user/.netclaw/logs/app.log > /tmp/out.log")] // redirection disables directory scope [InlineData("cat /etc/passwd")] // too shallow (/etc/ = 1 segment) public void ExtractDirectoryScope_returns_null(string command) { diff --git a/src/Netclaw.Security/ShellTokenizer.cs b/src/Netclaw.Security/ShellTokenizer.cs index 7d52f891..e2b73110 100644 --- a/src/Netclaw.Security/ShellTokenizer.cs +++ b/src/Netclaw.Security/ShellTokenizer.cs @@ -31,25 +31,13 @@ public static class ShellTokenizer /// /// Verbs whose first positional argument is security-relevant for approval /// pattern extraction. Superset of — includes - /// benign-but-path-consuming verbs like ls. This governs exact - /// approval patterns, which may be narrower than generic verb-chain grants - /// even when directory-scoped approvals are disallowed. + /// benign-but-path-consuming verbs like ls. /// internal static readonly HashSet PathAwareVerbs = new(HighRiskVerbs, StringComparer.OrdinalIgnoreCase) { "ls" }; - /// - /// Commands eligible for directory-scoped approvals. This stays intentionally - /// narrow: only direct read/list shapes are allowed to widen from an exact - /// file approval to a directory-scoped grant. - /// - private static readonly HashSet DirectoryScopeVerbs = new(StringComparer.OrdinalIgnoreCase) - { - "cat", "less", "more", "head", "tail", "grep", "ls" - }; - /// /// Verbs whose first non-flag operand is expected to be a path. Existing bare /// relative operands for these verbs can be treated as path targets even when @@ -177,7 +165,7 @@ public static IReadOnlyList SplitCompoundCommand(string command) /// command. Stops at the first token that looks like a flag (starts with -) /// or an argument (path, URL, etc.), and caps at /// tokens (default: 2) to avoid capturing positional arguments as subcommands. - /// For path-aware verbs (cat, grep, ls, etc.), appends the first non-flag + /// For path-aware verbs (cat, grep, bash, etc.), appends the first non-flag /// argument so the approval pattern captures what the command operates on. /// public static string ExtractVerbChain(string command, int maxDepth = 2) @@ -230,7 +218,7 @@ public static string ExtractVerbChain(string command, int maxDepth = 2) /// directory; otherwise falls back to the verb-chain extraction. /// public static string ExtractApprovalPattern(string command, string? workingDirectory = null, int maxDepth = 2) - => TryExtractPathOperand(command, workingDirectory, PathAwareVerbs, requireSimpleDirectoryShape: false, out var operand) + => TryExtractPathOperand(command, workingDirectory, out var operand) ? operand.Verb + " " + operand.NormalizedPath : ExtractVerbChain(command, maxDepth); @@ -403,7 +391,7 @@ public static bool LooksLikePath(string token) /// public static string? ExtractDirectoryScope(string command, string? workingDirectory = null) { - if (!TryExtractPathOperand(command, workingDirectory, DirectoryScopeVerbs, requireSimpleDirectoryShape: true, out var operand)) + if (!TryExtractPathOperand(command, workingDirectory, out var operand)) return null; // Enforce minimum depth — reject shallow scopes like / or /etc/ @@ -415,12 +403,7 @@ public static bool LooksLikePath(string token) internal const int MinDirectoryScopeDepth = 2; - private static bool TryExtractPathOperand( - string command, - string? workingDirectory, - IReadOnlySet allowedVerbs, - bool requireSimpleDirectoryShape, - out PathOperand operand) + private static bool TryExtractPathOperand(string command, string? workingDirectory, out PathOperand operand) { operand = default; @@ -429,10 +412,7 @@ private static bool TryExtractPathOperand( return false; var verb = TrimShellPunctuation(tokens[0]); - if (verb.Length == 0 || !allowedVerbs.Contains(verb)) - return false; - - if (requireSimpleDirectoryShape && !IsSimpleDirectoryScopeShape(tokens)) + if (verb.Length == 0 || !PathAwareVerbs.Contains(verb)) return false; for (var i = 1; i < tokens.Count; i++) @@ -456,30 +436,6 @@ private static bool TryExtractPathOperand( return false; } - private static bool IsSimpleDirectoryScopeShape(IReadOnlyList tokens) - { - for (var i = 1; i < tokens.Count; i++) - { - if (IsRedirectionToken(TrimShellPunctuation(tokens[i]))) - return false; - } - - return true; - } - - private static bool IsRedirectionToken(string token) - { - if (string.IsNullOrWhiteSpace(token)) - return false; - - if (token[0] is '>' or '<') - return true; - - return token.Length >= 2 - && char.IsAsciiDigit(token[0]) - && token[1] is '>' or '<'; - } - private static string? TryNormalizePathOperand(string token, string verb, string? workingDirectory) { if (LooksLikePath(token)) From 1f2859990d630b5a9c0cd28f173d266f2a5516a7 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 7 May 2026 04:59:22 +0000 Subject: [PATCH 08/22] Revert "fix shell approval path resolution and prompt scoping" This reverts commit 54f77b8c4a2f746ab50c6098286ea2db651377b4. --- .../design.md | 62 +++-------- .../proposal.md | 18 +--- .../specs/tool-approval-gates/spec.md | 77 ++------------ .../tasks.md | 9 +- .../DiscordApprovalPromptBuilderTests.cs | 8 +- .../SlackApprovalBlockBuilderTests.cs | 42 -------- .../Tools/ToolApprovalGateTests.cs | 43 -------- .../Protocol/SessionOutputDto.cs | 1 - .../Protocol/SessionOutputDtoMapper.cs | 2 - src/Netclaw.Actors/Tools/ToolAccessPolicy.cs | 36 ++----- .../DiscordApprovalPromptBuilder.cs | 19 +--- .../SlackApprovalBlockBuilder.cs | 20 +--- .../Cli/DaemonClientMappingTests.cs | 3 - .../ShellApprovalMatcherTests.cs | 97 ----------------- .../ShellTokenizerTests.cs | 21 +--- src/Netclaw.Security/IToolApprovalMatcher.cs | 38 ++----- src/Netclaw.Security/ShellTokenizer.cs | 100 +++--------------- 17 files changed, 71 insertions(+), 525 deletions(-) delete mode 100644 src/Netclaw.Actors.Tests/Channels/SlackApprovalBlockBuilderTests.cs diff --git a/openspec/changes/directory-scoped-approval-patterns/design.md b/openspec/changes/directory-scoped-approval-patterns/design.md index 0b5d1022..ee02c000 100644 --- a/openspec/changes/directory-scoped-approval-patterns/design.md +++ b/openspec/changes/directory-scoped-approval-patterns/design.md @@ -22,17 +22,13 @@ This change only relaxes layer 2. Layers 1 and 3 are unaffected. - Store directory-scoped patterns when user selects B (session) or C (always) - Maintain boundary-safe path matching (no `StartsWith` — use `PathUtility.IsWithinRoot`) - Prevent overly broad directory scopes (minimum 2 path segments) -- Show directory context in approval option labels only when the entire request - maps cleanly to one directory scope +- Show directory context in approval option labels **Non-Goals:** - Changing the hard deny list or `ToolPathPolicy` behavior - Changing "Approve once" (A) behavior — it remains exact-pattern - Glob-aware or regex-based pattern matching - Cross-verb directory approvals (`cat /dir/` does not approve `grep /dir/`) -- Inferring indirect path flow through shell constructs like `xargs`, `eval`, - loop variables, command substitution, or shell variables -- Windows-native shell path handling; tracked separately in issue #899 ## Decisions @@ -46,42 +42,23 @@ is a flat list of strings per tool per audience. A sentinel convention avoids schema changes and keeps backward compatibility — existing non-slash patterns work unchanged. -### Extraction: shared path-operand resolution for exact and directory patterns +### Extraction: scan all arguments, not just first positional -`ShellTokenizer.TryExtractPathOperand()` is the shared primitive behind -`ExtractApprovalPattern()` and `ExtractDirectoryScope()`. It scans ALL non-flag -arguments for the first token that can be normalized into a path operand, using -the shell tool `WorkingDirectory` to resolve relative operands before approval -patterns are extracted or matched. This solves the grep problem where the search -term is the first positional arg and the file path is second -(`grep -l "timeout" logs/daemon.log`). - -When a recognizable path operand exists, exact approval patterns use the -normalized path operand itself (`grep /abs/path/logs/daemon.log`), not the raw -verb chain. Directory-scoped patterns then derive scope from that same operand. +`ShellTokenizer.ExtractDirectoryScope()` scans ALL non-flag arguments for the +first `LooksLikePath()` token, then extracts its parent directory. This solves +the grep problem where the search term is the first positional arg and the file +path is second (`grep -l "timeout" /home/.netclaw/logs/daemon.log`). **Alternative considered:** Always use first positional. Rejected because grep, sed, and awk take non-path first arguments. -### Existing directory operands stay directory-scoped - -`ExtractScopedDirectory()` preserves an operand that already denotes a -directory. For example, `find logs -name '*.log'` resolves `logs` against the -working directory and stores `find /abs/path/logs/` rather than widening to the -parent (`/abs/path/`). This keeps approval scope aligned with what the command -actually targets. - -### Matching: `PathUtility.IsWithinRoot()` with normalized operands +### Matching: `PathUtility.IsWithinRoot()` not `StartsWith` Directory matching delegates to `PathUtility.IsWithinRoot()` which normalizes both paths, uses platform-appropriate case sensitivity, and checks boundary characters. This prevents `/home/usersecret` from matching an approval for `/home/user`. -Because both exact and directory extraction share normalized path operands, -relative requests such as `cat logs/app.log` are matched against approvals using -their resolved absolute path under the shell `WorkingDirectory`. - ### Minimum depth: 2 segments below root `CountPathSegments()` rejects scopes shallower than 2 segments (blocks `/`, @@ -94,22 +71,6 @@ An approval for `cat /dir/` does NOT approve `grep /dir/`. The verb is part of the pattern and checked explicitly. This limits blast radius — approving reads doesn't silently approve writes or deletions. -### Dynamic labels require a single clean directory scope - -`ToolAccessPolicy.TryGetSingleDirectoryScope()` only emits directory-specific B/C -labels when every approval pattern for the request is directory-scoped and all -of them resolve to the same directory. If any segment falls back to a generic -verb-chain pattern (for example `git push`) or multiple directory scopes are -present, labels stay generic. - -### Compound commands: direct path operands only - -Compound commands and pipe segments are still traversed segment-by-segment, so a -segment like `cat logs/app.log | jq .` can contribute directory scope for the -`cat` segment. MVP extraction stops there: it does not infer that a downstream -segment implicitly targets the same path through `xargs`, `eval`, loop -variables, shell variables, or similar constructs. - ## Risks / Trade-offs **[Risk] Directory scope is broader than per-file** → Mitigated by @@ -117,10 +78,11 @@ variables, shell variables, or similar constructs. independently blocks access to protected files (`config/netclaw.json`, keys, `secrets.json`) regardless of approval state. -**[Risk] Compound commands often cannot use directory-specific labels** → -Mixed approval sets (directory patterns plus verb-chain fallbacks, or multiple -directories) keep the generic labels. This is intentional; a generic label is -less misleading than showing a partial directory scope for the whole request. +**[Risk] `directoryPatterns[0]` drives UI label for compound commands** → +For compound commands with multiple path-aware segments targeting different +directories, only the first directory appears in the label. Acceptable because +compound commands targeting multiple directories are rare, and the approval +still covers the right patterns. **[Risk] Minimum depth too restrictive** → A 2-segment minimum blocks `/etc/` and `/tmp/` which are legitimate diagnostic targets. This is intentional diff --git a/openspec/changes/directory-scoped-approval-patterns/proposal.md b/openspec/changes/directory-scoped-approval-patterns/proposal.md index 516e3e12..fd9c9586 100644 --- a/openspec/changes/directory-scoped-approval-patterns/proposal.md +++ b/openspec/changes/directory-scoped-approval-patterns/proposal.md @@ -13,14 +13,6 @@ legitimate diagnostic work. - "Approve for this chat" (B) and "Approve always" (C) store **directory-scoped patterns** (e.g., `grep /home/.netclaw/logs/`) instead of per-file patterns when the command targets a recognizable file path. -- Relative path operands are resolved against the shell tool `WorkingDirectory` - before both exact-pattern extraction and directory-scope extraction/matching. -- Path-aware exact approval patterns use the actual normalized path operand when - one exists, including commands like `grep -l "timeout" logs/app.log` where the - search term is not the path operand. -- Existing directory operands keep their directory scope (for example, - `find logs -name '*.log'` stores `find /logs/`) instead of widening to the - parent directory. - A trailing `/` on a stored approval pattern signals directory scope. Matching uses `PathUtility.IsWithinRoot()` for boundary-safe containment instead of naive `StartsWith`. @@ -28,16 +20,10 @@ legitimate diagnostic work. scopes like `/` or `/etc/`. - `IToolApprovalMatcher` gains `ExtractDirectoryPatterns()` for tool-specific directory pattern extraction. -- Approval option labels only show a directory-specific scope when the full - approval set for the request maps cleanly to a single directory; otherwise the - labels stay generic. -- Pipe segments can get directory scope when the segment has a direct path - operand, but MVP extraction does not infer indirect path flow through `xargs`, - `eval`, loop variables, or similar shell constructs. +- Approval option labels dynamically show the directory scope (e.g., "Approve + `grep` in /home/.netclaw/logs/ for this chat"). - "Approve once" (A) continues to use exact patterns — directory scope only applies to broader grants. -- Windows-native shell path semantics are out of scope for this change and are - tracked separately in issue #899. ## Capabilities diff --git a/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md b/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md index ea904a9f..420a3efa 100644 --- a/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md +++ b/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md @@ -9,11 +9,6 @@ the system SHALL store a directory-scoped pattern (e.g., `grep /home/.netclaw/lo instead of the exact file-path pattern. "Approve once" (A) SHALL continue to use exact patterns. -When a recognizable path operand is relative, the system SHALL resolve it -against the shell tool `WorkingDirectory` before extracting either exact or -directory-scoped approval patterns. Existing operands that already denote a -directory SHALL preserve that directory scope instead of widening to the parent. - A trailing `/` on a stored pattern SHALL signal directory scope. The system SHALL use `PathUtility.IsWithinRoot()` for boundary-safe containment matching — not naive string prefix comparison. @@ -42,22 +37,6 @@ Directory-scoped approvals SHALL be verb-isolated: an approval for - **AND** future sessions auto-approve grep commands targeting files under `/home/.netclaw/logs/` -#### Scenario: Relative path resolves against working directory - -- **GIVEN** the shell tool `WorkingDirectory` is `/workspace/project` -- **AND** the command is `cat logs/app.log` -- **WHEN** exact and directory-scoped approval patterns are extracted -- **THEN** the exact pattern is `cat /workspace/project/logs/app.log` -- **AND** the directory-scoped pattern is `cat /workspace/project/logs/` - -#### Scenario: Existing directory operand preserves its scope - -- **GIVEN** the shell tool `WorkingDirectory` is `/workspace/project` -- **AND** the command is `find logs -name '*.log'` -- **WHEN** directory-scoped approval extraction runs -- **THEN** the extracted pattern is `find /workspace/project/logs/` -- **AND** the scope is not widened to `/workspace/project/` - #### Scenario: Approve Once uses exact pattern - **GIVEN** a shell command `cat /home/.netclaw/logs/crash.log` requires approval @@ -103,12 +82,11 @@ Directory-scoped approvals SHALL be verb-isolated: an approval for `IToolApprovalMatcher` SHALL define an `ExtractDirectoryPatterns()` method that returns directory-scoped patterns for a tool invocation. `ShellApprovalMatcher` SHALL implement this by scanning all non-flag arguments for the first -recognizable path operand, resolving relative paths against `WorkingDirectory`, -expanding home directory tokens, extracting the scoped directory, normalizing -the path, and enforcing minimum depth. For compound commands and `bash -c` -wrappers, each segment SHALL be processed recursively. When no directory scope -is available for a segment, the segment's exact approval pattern SHALL be used -as fallback. +`LooksLikePath()` token, expanding home directory tokens, extracting the parent +directory, normalizing the path, and enforcing minimum depth. For compound +commands and `bash -c` wrappers, each segment SHALL be processed recursively. +When no directory scope is available for a segment, the segment's verb-chain +pattern SHALL be used as fallback. `DefaultApprovalMatcher` and `FilePathApprovalMatcher` SHALL return empty lists. @@ -119,14 +97,6 @@ as fallback. - **THEN** the pattern `grep /home/.netclaw/logs/` is extracted - **AND** the search term `"timeout"` is skipped (not a path) -#### Scenario: grep exact pattern uses normalized path operand - -- **GIVEN** the shell tool `WorkingDirectory` is `/workspace/project` -- **AND** the command is `grep -l "timeout" logs/daemon.log` -- **WHEN** exact approval pattern extraction runs -- **THEN** the pattern is `grep /workspace/project/logs/daemon.log` -- **AND** the search term `"timeout"` is not used as the exact operand - #### Scenario: Compound command extracts patterns per segment - **GIVEN** the command `cat /home/.netclaw/logs/crash.log && grep "error" /var/log/syslog` @@ -134,21 +104,6 @@ as fallback. - **THEN** `cat /home/.netclaw/logs/` is extracted for the first segment - **AND** the second segment falls back to its verb chain (depth too shallow) -#### Scenario: Pipe segment can contribute direct directory scope - -- **GIVEN** the shell tool `WorkingDirectory` is `/workspace/project` -- **AND** the command is `cat logs/app.log | jq .message` -- **WHEN** `ExtractDirectoryPatterns` runs -- **THEN** `cat /workspace/project/logs/` is extracted for the direct path-aware segment -- **AND** the `jq` segment does not gain directory scope from piped input alone - -#### Scenario: Indirect path flow is not inferred for MVP - -- **GIVEN** the command is `find logs -name '*.log' | xargs grep timeout` -- **WHEN** `ExtractDirectoryPatterns` runs -- **THEN** the `find` segment may contribute `find /logs/` -- **AND** the downstream `grep` segment does not inherit that directory scope via `xargs` - #### Scenario: Glob paths use parent directory - **GIVEN** the command `ls /home/.netclaw/logs/crash-*.log` @@ -159,8 +114,7 @@ as fallback. ### Requirement: Dynamic approval option labels When directory patterns are available, the system SHALL customize the approval -option labels to show the directory scope only when the full approval set for -the request maps cleanly to a single directory scope. The labels SHALL follow the format: +option labels to show the directory scope. The labels SHALL follow the format: - B: `"Approve in {directory} for this chat"` - C: `"Approve in {directory} always"` @@ -181,15 +135,6 @@ Options A ("Approve once") and D ("Deny") SHALL retain their default labels. - **THEN** option B reads the default "Approve for this chat" - **AND** option C reads the default "Approve always" -#### Scenario: Labels stay generic for mixed approval sets - -- **GIVEN** a shell command `cat /home/.netclaw/logs/crash.log && git push origin main` - requires approval -- **WHEN** the approval prompt is generated -- **THEN** option B reads the default "Approve for this chat" -- **AND** option C reads the default "Approve always" -- **AND** no partial directory-specific label is shown for the whole request - ## MODIFIED Requirements ### Requirement: ToolInteractionRequest/Response protocol @@ -287,9 +232,6 @@ pattern ends with `/`, the system SHALL match any candidate pattern with the sam verb whose path argument is within the approved directory, using `PathUtility.IsWithinRoot()` for boundary-safe containment. -For path-aware verbs with a recognizable path operand, exact approval pattern -extraction SHALL use the normalized path operand instead of the raw verb chain. - #### Scenario: Verb chain extracted from simple command - **GIVEN** the command `git push origin main` @@ -334,10 +276,3 @@ extraction SHALL use the normalized path operand instead of the raw verb chain. - **GIVEN** `cat /home/.netclaw/logs/` is in the approved patterns - **WHEN** the candidate pattern `cat /home/.netclaw/logs/crash.log` is checked - **THEN** `ApprovalPatternMatching.MatchesAny` returns true - -#### Scenario: Windows-native shell support is tracked separately - -- **GIVEN** this MVP change targets the current shell approval pipeline -- **WHEN** native Windows shell path semantics are considered -- **THEN** they are out of scope for this change -- **AND** follow-up work is tracked separately in issue #899 diff --git a/openspec/changes/directory-scoped-approval-patterns/tasks.md b/openspec/changes/directory-scoped-approval-patterns/tasks.md index b6e0b291..035f4414 100644 --- a/openspec/changes/directory-scoped-approval-patterns/tasks.md +++ b/openspec/changes/directory-scoped-approval-patterns/tasks.md @@ -1,6 +1,6 @@ ## 1. Pattern Extraction -- [x] 1.1 Add shared path-operand extraction for `ShellTokenizer.ExtractApprovalPattern()` and `ExtractDirectoryScope()` — scan all args for the first resolvable path operand, resolve relatives against `WorkingDirectory`, normalize, and enforce minimum depth for directory scope +- [x] 1.1 Add `ShellTokenizer.ExtractDirectoryScope()` — scan all args for first `LooksLikePath()` token, extract parent directory, normalize, enforce minimum depth - [x] 1.2 Add `ExtractParentDirectory()` and `CountPathSegments()` helpers to `ShellTokenizer` - [x] 1.3 Unit tests for `ExtractDirectoryScope` (verb+directory, grep path vs search term, glob handling, null cases) @@ -19,7 +19,7 @@ - [x] 4.1 Add `DirectoryPatterns` property to `ToolInteractionRequest` in `SessionOutput.cs` - [x] 4.2 Add `DirectoryPatterns` to `ToolApprovalContext` record in `ToolAccessPolicy.cs` -- [x] 4.3 Compute directory patterns and customize B/C labels in `CheckApprovalGate()` only when the full request maps cleanly to a single directory scope; otherwise keep generic labels +- [x] 4.3 Compute directory patterns and customize B/C labels in `CheckApprovalGate()` - [x] 4.4 Pass `DirectoryPatterns` from `ToolApprovalContext` to `ToolInteractionRequest` in `SessionToolExecutionPipeline` - [x] 4.5 Propagate `DirectoryPatterns` through `DispatchingToolExecutor` re-approval path @@ -35,7 +35,4 @@ - [x] 6.2 Unify `CollectPatterns`/`CollectDirectoryPatterns` into shared `TraverseSegments` helper - [x] 6.3 Use `PathUtility.ExpandAndNormalize()` in `ExtractDirectoryScope` instead of separate calls - [x] 6.4 Make `DirectoryPatterns` non-nullable on `ToolApprovalContext` -- [x] 6.5 Preserve existing directory operands like `find logs -name '*.log'` instead of widening them to the parent directory -- [x] 6.6 Keep pipe/compound traversal MVP-scoped to direct path operands only; do not infer indirect flow through `xargs`, `eval`, or loop variables -- [x] 6.7 Note Windows-native shell support as out of scope for this change (tracked separately in issue #899) -- [x] 6.8 Verify: `dotnet slopwatch analyze` passes, copyright headers present, all tests green +- [x] 6.5 Verify: `dotnet slopwatch analyze` passes, copyright headers present, all tests green diff --git a/src/Netclaw.Actors.Tests/Channels/DiscordApprovalPromptBuilderTests.cs b/src/Netclaw.Actors.Tests/Channels/DiscordApprovalPromptBuilderTests.cs index e9e5d9d0..e26311b4 100644 --- a/src/Netclaw.Actors.Tests/Channels/DiscordApprovalPromptBuilderTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/DiscordApprovalPromptBuilderTests.cs @@ -14,8 +14,6 @@ public sealed class DiscordApprovalPromptBuilderTests [Fact] public void BuildTextPrompt_contains_tool_name_and_options() { - const string sessionLabel = "Approve in /home/user/.netclaw/logs/ for this chat"; - const string alwaysLabel = "Approve in /home/user/.netclaw/logs/ always"; var request = new ToolInteractionRequest { SessionId = new SessionId("test/session"), @@ -26,8 +24,8 @@ public void BuildTextPrompt_contains_tool_name_and_options() Patterns = ["origin/main"], Options = [ new ToolInteractionOption(ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.ApproveOnceLabel), - new ToolInteractionOption(ApprovalOptionKeys.ApproveSession, sessionLabel), - new ToolInteractionOption(ApprovalOptionKeys.ApproveAlways, alwaysLabel), + new ToolInteractionOption(ApprovalOptionKeys.ApproveSession, ApprovalOptionKeys.ApproveSessionLabel), + new ToolInteractionOption(ApprovalOptionKeys.ApproveAlways, ApprovalOptionKeys.ApproveAlwaysLabel), new ToolInteractionOption(ApprovalOptionKeys.Deny, ApprovalOptionKeys.DenyLabel) ] }; @@ -41,8 +39,6 @@ public void BuildTextPrompt_contains_tool_name_and_options() Assert.Contains("B)", prompt); Assert.Contains("C)", prompt); Assert.Contains("D)", prompt); - Assert.Contains(sessionLabel, prompt); - Assert.Contains(alwaysLabel, prompt); } [Fact] diff --git a/src/Netclaw.Actors.Tests/Channels/SlackApprovalBlockBuilderTests.cs b/src/Netclaw.Actors.Tests/Channels/SlackApprovalBlockBuilderTests.cs deleted file mode 100644 index 48d51f5a..00000000 --- a/src/Netclaw.Actors.Tests/Channels/SlackApprovalBlockBuilderTests.cs +++ /dev/null @@ -1,42 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using Netclaw.Actors.Protocol; -using Netclaw.Channels.Slack; -using Xunit; - -namespace Netclaw.Actors.Tests.Channels; - -public sealed class SlackApprovalBlockBuilderTests -{ - [Fact] - public void BuildApprovalText_uses_request_option_labels() - { - const string sessionLabel = "Approve in /home/user/.netclaw/logs/ for this chat"; - const string alwaysLabel = "Approve in /home/user/.netclaw/logs/ always"; - - var request = new ToolInteractionRequest - { - SessionId = new SessionId("test/session"), - Kind = "approval", - CallId = "call-1", - ToolName = "shell_execute", - DisplayText = "grep 'error' /home/user/.netclaw/logs/app.log", - Patterns = ["grep /home/user/.netclaw/logs/app.log"], - Options = - [ - new ToolInteractionOption(ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.ApproveOnceLabel), - new ToolInteractionOption(ApprovalOptionKeys.ApproveSession, sessionLabel), - new ToolInteractionOption(ApprovalOptionKeys.ApproveAlways, alwaysLabel), - new ToolInteractionOption(ApprovalOptionKeys.Deny, ApprovalOptionKeys.DenyLabel) - ] - }; - - var text = SlackApprovalBlockBuilder.BuildApprovalText(request); - - Assert.Contains(sessionLabel, text); - Assert.Contains(alwaysLabel, text); - } -} diff --git a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs index b06fd4ab..d1e00d61 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs @@ -791,49 +791,6 @@ public void Shell_path_command_labels_show_directory_scope() Assert.Contains("always", alwaysOption.Label); } - [Fact] - public void Mixed_scope_and_fallback_uses_default_labels() - { - var policy = CreatePolicy(ToolApprovalMode.Approval); - var args = ToolInput.Create("Command", "cat /home/user/.netclaw/logs/crash.log | grep error"); - - var decision = policy.AuthorizeInvocation(ShellTool(), PersonalContext(), args); - - Assert.True(decision.NeedsApproval); - Assert.NotNull(decision.ApprovalContext); - Assert.Contains(decision.ApprovalContext!.DirectoryPatterns, p => p.EndsWith("/")); - Assert.Contains(decision.ApprovalContext.DirectoryPatterns, p => !p.EndsWith("/")); - var sessionOption = decision.ApprovalContext.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveSession); - var alwaysOption = decision.ApprovalContext.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveAlways); - Assert.Equal(ApprovalOptionKeys.ApproveSessionLabel, sessionOption.Label); - Assert.Equal(ApprovalOptionKeys.ApproveAlwaysLabel, alwaysOption.Label); - } - - [Fact] - public void Relative_path_command_uses_working_directory_for_directory_scope() - { - var policy = CreatePolicy(ToolApprovalMode.Approval); - var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); - var logs = Path.Combine(root, "logs"); - Directory.CreateDirectory(logs); - var file = Path.Combine(logs, "app.log"); - File.WriteAllText(file, "hello"); - - try - { - var args = ToolInput.Create("Command", "cat logs/app.log", "WorkingDirectory", root); - - var decision = policy.AuthorizeInvocation(ShellTool(), PersonalContext(), args); - - Assert.True(decision.NeedsApproval); - Assert.Contains($"cat {PathUtility.Normalize(logs)}/", decision.ApprovalContext!.DirectoryPatterns); - } - finally - { - Directory.Delete(root, recursive: true); - } - } - [Fact] public void Non_path_command_uses_default_labels() { diff --git a/src/Netclaw.Actors/Protocol/SessionOutputDto.cs b/src/Netclaw.Actors/Protocol/SessionOutputDto.cs index 3c81c1e9..7248ac19 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutputDto.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutputDto.cs @@ -106,7 +106,6 @@ public sealed record SessionOutputDto public string? InteractionDisplayText { get; init; } public string? RequesterSenderId { get; init; } public List? InteractionPatterns { get; init; } - public List? InteractionDirectoryPatterns { get; init; } public List? InteractionOptions { get; init; } public bool? InteractionHasAdoptedContext { get; init; } public List? InteractionAdoptedSpeakerIds { get; init; } diff --git a/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs b/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs index c17234c7..c3c058ad 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs @@ -179,7 +179,6 @@ public static class SessionOutputDtoMapper InteractionDisplayText = msg.DisplayText, RequesterSenderId = msg.RequesterSenderId, InteractionPatterns = [.. msg.Patterns], - InteractionDirectoryPatterns = [.. msg.DirectoryPatterns], InteractionOptions = [.. msg.Options], InteractionHasAdoptedContext = msg.HasAdoptedContext, InteractionAdoptedSpeakerIds = [.. msg.AdoptedSpeakerIds] @@ -337,7 +336,6 @@ public static SessionOutput FromDto(SessionOutputDto dto) HasAdoptedContext = dto.InteractionHasAdoptedContext ?? false, AdoptedSpeakerIds = dto.InteractionAdoptedSpeakerIds ?? [], Patterns = dto.InteractionPatterns ?? [], - DirectoryPatterns = dto.InteractionDirectoryPatterns ?? [], Options = dto.InteractionOptions ?? [] }, _ => new ErrorOutput diff --git a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs index f9985ba6..68e1c708 100644 --- a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs +++ b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs @@ -263,30 +263,6 @@ private static bool ShellCommandHasPathArguments(string shellCommand) return ToolArgumentHelper.GetString(arguments, "WorkingDirectory"); } - private static bool TryGetSingleDirectoryScope(IReadOnlyList directoryPatterns, out string? directoryScope) - { - directoryScope = null; - - if (directoryPatterns.Count == 0 || directoryPatterns.Any(p => !p.EndsWith('/'))) - return false; - - var scopes = directoryPatterns - .Select(static pattern => - { - var spaceIdx = pattern.IndexOf(' ', StringComparison.Ordinal); - return spaceIdx >= 0 ? pattern[(spaceIdx + 1)..] : null; - }) - .Where(static scope => !string.IsNullOrWhiteSpace(scope)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - if (scopes.Count != 1) - return false; - - directoryScope = scopes[0]; - return true; - } - private ToolAccessDecision CheckApprovalGate( ToolName toolName, ToolExecutionContext? context, @@ -327,10 +303,16 @@ private ToolAccessDecision CheckApprovalGate( var sessionLabel = ApprovalOptionKeys.ApproveSessionLabel; var alwaysLabel = ApprovalOptionKeys.ApproveAlwaysLabel; - if (TryGetSingleDirectoryScope(directoryPatterns, out var directoryScope)) + var firstDirScope = directoryPatterns.FirstOrDefault(p => p.EndsWith('/')); + if (firstDirScope is not null) { - sessionLabel = $"Approve in {directoryScope} for this chat"; - alwaysLabel = $"Approve in {directoryScope} always"; + var spaceIdx = firstDirScope.IndexOf(' ', StringComparison.Ordinal); + if (spaceIdx >= 0) + { + var dir = firstDirScope[(spaceIdx + 1)..]; + sessionLabel = $"Approve in {dir} for this chat"; + alwaysLabel = $"Approve in {dir} always"; + } } var approvalContext = new ToolApprovalContext( diff --git a/src/Netclaw.Channels.Discord/DiscordApprovalPromptBuilder.cs b/src/Netclaw.Channels.Discord/DiscordApprovalPromptBuilder.cs index 071a6ddf..ba2245e7 100644 --- a/src/Netclaw.Channels.Discord/DiscordApprovalPromptBuilder.cs +++ b/src/Netclaw.Channels.Discord/DiscordApprovalPromptBuilder.cs @@ -24,7 +24,10 @@ public static string BuildTextPrompt(ToolInteractionRequest request) sb.AppendLine(); sb.AppendLine("Reply with:"); - AppendReplyOptions(sb, request.Options); + sb.Append("A) ").AppendLine(ApprovalOptionKeys.ApproveOnceLabel); + sb.Append("B) ").AppendLine(ApprovalOptionKeys.ApproveSessionLabel); + sb.Append("C) ").AppendLine(ApprovalOptionKeys.ApproveAlwaysLabel); + sb.Append("D) ").AppendLine(ApprovalOptionKeys.DenyLabel); return sb.ToString().TrimEnd(); } @@ -36,7 +39,7 @@ public static (string Text, IReadOnlyList Buttons) BuildButto AppendToolSummary(sb, request); sb.AppendLine(); - sb.Append("You can also reply with ").Append(FormatReplyLetters(request.Options)).Append(" in this thread."); + sb.Append("You can also reply with `A`, `B`, `C`, or `D` in this thread."); var buttons = request.Options .Select(option => new DiscordButtonSpec( @@ -104,18 +107,6 @@ 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(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 GetDecisionLabel(string selectedKey) => selectedKey switch { diff --git a/src/Netclaw.Channels.Slack/SlackApprovalBlockBuilder.cs b/src/Netclaw.Channels.Slack/SlackApprovalBlockBuilder.cs index f3b985bf..dfa11a17 100644 --- a/src/Netclaw.Channels.Slack/SlackApprovalBlockBuilder.cs +++ b/src/Netclaw.Channels.Slack/SlackApprovalBlockBuilder.cs @@ -38,8 +38,10 @@ public static string BuildApprovalText(ToolInteractionRequest request) lines.Add(""); lines.Add("Reply with:"); - foreach (var replyOption in EnumerateReplyOptions(request.Options)) - lines.Add($" *{replyOption.Letter})* {replyOption.Option.Label}"); + lines.Add($" *A)* {ApprovalOptionKeys.ApproveOnceLabel}"); + lines.Add($" *B)* {ApprovalOptionKeys.ApproveSessionLabel}"); + lines.Add($" *C)* {ApprovalOptionKeys.ApproveAlwaysLabel}"); + lines.Add($" *D)* {ApprovalOptionKeys.DenyLabel}"); return string.Join("\n", lines); } @@ -91,7 +93,7 @@ public static IReadOnlyList BuildApprovalBlocks(ToolInteractionRequest re blocks.Add(new SectionBlock { - Text = new Markdown($"You can also reply with {FormatReplyLetters(request.Options)} in this thread.") + Text = new Markdown("You can also reply with `A`, `B`, `C`, or `D` in this thread.") }); return blocks; @@ -172,18 +174,6 @@ private static void AppendAdoptedContextSummary(List lines, ToolInteract private static string BuildAdoptedContextMarkdown(ToolInteractionRequest request) => $"*Adopted context:* present\n*Speakers:* `{EscapeMarkdown(string.Join(", ", request.AdoptedSpeakerIds))}`"; - private static IEnumerable<(string Letter, ToolInteractionOption Option)> EnumerateReplyOptions(IReadOnlyList options) - { - for (var i = 0; i < options.Count; i++) - yield return (GetReplyLetter(i), options[i]); - } - - private static string FormatReplyLetters(IReadOnlyList options) - => string.Join(", ", EnumerateReplyOptions(options).Select(static x => $"`{x.Letter}`")); - - private static string GetReplyLetter(int index) - => ((char)('A' + index)).ToString(); - internal static string BuildButtonValue(ToolInteractionRequest request, ToolInteractionOption option) => ApprovalButtonValueCodec.Encode(request, option); diff --git a/src/Netclaw.Cli.Tests/Cli/DaemonClientMappingTests.cs b/src/Netclaw.Cli.Tests/Cli/DaemonClientMappingTests.cs index c2d362ad..838212fa 100644 --- a/src/Netclaw.Cli.Tests/Cli/DaemonClientMappingTests.cs +++ b/src/Netclaw.Cli.Tests/Cli/DaemonClientMappingTests.cs @@ -266,7 +266,6 @@ public void ToolInteractionRequest_roundtrips_through_dto() DisplayText = "git push origin main", RequesterSenderId = "device-1", Patterns = ["git push"], - DirectoryPatterns = ["git /home/user/.netclaw/workspaces/"], Options = [ new ToolInteractionOption(ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.ApproveOnceLabel), @@ -281,7 +280,6 @@ public void ToolInteractionRequest_roundtrips_through_dto() Assert.Equal("approval", dto.InteractionKind); Assert.Equal("git push origin main", dto.InteractionDisplayText); Assert.Equal("device-1", dto.RequesterSenderId); - Assert.Equal(["git /home/user/.netclaw/workspaces/"], dto.InteractionDirectoryPatterns); var roundTripped = DaemonClient.FromDto(dto); var result = Assert.IsType(roundTripped); @@ -290,7 +288,6 @@ public void ToolInteractionRequest_roundtrips_through_dto() Assert.Equal("git push origin main", result.DisplayText); Assert.Equal("device-1", result.RequesterSenderId); Assert.Equal(["git push"], result.Patterns); - Assert.Equal(["git /home/user/.netclaw/workspaces/"], result.DirectoryPatterns); Assert.Equal(4, result.Options.Count); } diff --git a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs index 82e82fc7..40f7adc7 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs @@ -14,13 +14,6 @@ public sealed class ShellApprovalMatcherTests private static Dictionary Args(string command) => new() { ["Command"] = command }; - private static Dictionary Args(string command, string workingDirectory) - => new() - { - ["Command"] = command, - ["WorkingDirectory"] = workingDirectory - }; - [Fact] public void ExtractPatterns_simple_command() { @@ -134,15 +127,6 @@ public void IsApproved_recurses_into_bash_c_wrapper() Args("bash -c \"git push --force\""), approved)); } - [Fact] - public void IsApproved_directory_scope_matches_grep_path_operand() - { - var approved = new[] { "grep /home/user/.netclaw/logs/" }; - - Assert.True(_matcher.IsApproved(new ToolName("shell_execute"), - Args("grep -l \"timeout\" /home/user/.netclaw/logs/daemon.log"), approved)); - } - [Fact] public void ExtractPatterns_path_aware_verb_includes_path() { @@ -154,41 +138,6 @@ public void ExtractPatterns_path_aware_verb_includes_path() Assert.Contains("git push", patterns); } - [Fact] - public void ExtractPatterns_grep_uses_path_operand_instead_of_search_term() - { - var patterns = _matcher.ExtractPatterns( - new ToolName("shell_execute"), - Args("grep -l \"timeout\" /home/user/.netclaw/logs/daemon.log")); - - Assert.Single(patterns); - Assert.Equal("grep /home/user/.netclaw/logs/daemon.log", patterns[0]); - } - - [Fact] - public void ExtractPatterns_resolve_relative_paths_against_working_directory() - { - var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); - var logs = Path.Combine(root, "logs"); - Directory.CreateDirectory(logs); - var file = Path.Combine(logs, "app.log"); - File.WriteAllText(file, "hello"); - - try - { - var patterns = _matcher.ExtractPatterns( - new ToolName("shell_execute"), - Args("cat logs/app.log", root)); - - Assert.Single(patterns); - Assert.Equal($"cat {PathUtility.Normalize(file)}", patterns[0]); - } - finally - { - Directory.Delete(root, recursive: true); - } - } - [Fact] public void ExtractPatterns_pipe_with_path_aware_verbs() { @@ -258,52 +207,6 @@ public void ExtractDirectoryPatterns_mixed_compound_with_fallback() Assert.Contains(patterns, p => p.StartsWith("cat ") && p.EndsWith("/")); Assert.Contains(patterns, p => p == "git push"); } - - [Fact] - public void ExtractDirectoryPatterns_resolve_relative_paths_against_working_directory() - { - var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); - var logs = Path.Combine(root, "logs"); - Directory.CreateDirectory(logs); - var file = Path.Combine(logs, "app.log"); - File.WriteAllText(file, "hello"); - - try - { - var patterns = _matcher.ExtractDirectoryPatterns( - new ToolName("shell_execute"), - Args("cat logs/app.log", root)); - - Assert.Single(patterns); - Assert.Equal($"cat {PathUtility.Normalize(logs)}/", patterns[0]); - } - finally - { - Directory.Delete(root, recursive: true); - } - } - - [Fact] - public void ExtractDirectoryPatterns_preserve_existing_directory_operand() - { - var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); - var logs = Path.Combine(root, "logs"); - Directory.CreateDirectory(logs); - - try - { - var patterns = _matcher.ExtractDirectoryPatterns( - new ToolName("shell_execute"), - Args("find logs -name '*.log'", root)); - - Assert.Single(patterns); - Assert.Equal($"find {PathUtility.Normalize(logs)}/", patterns[0]); - } - finally - { - Directory.Delete(root, recursive: true); - } - } } public sealed class DefaultApprovalMatcherTests diff --git a/src/Netclaw.Security.Tests/ShellTokenizerTests.cs b/src/Netclaw.Security.Tests/ShellTokenizerTests.cs index b493ec20..0f2a521c 100644 --- a/src/Netclaw.Security.Tests/ShellTokenizerTests.cs +++ b/src/Netclaw.Security.Tests/ShellTokenizerTests.cs @@ -276,6 +276,7 @@ public void LooksLikePath_backslash(string token) [Theory] [InlineData("cat /home/user/.netclaw/logs/crash.log", "cat", "/home/user/.netclaw/logs/")] [InlineData("ls -la /home/user/.netclaw/logs/", "ls", "/home/user/.netclaw/logs/")] + [InlineData("find /home/user/.netclaw/logs -name '*.log'", "find", "/home/user/.netclaw/")] public void ExtractDirectoryScope_returns_verb_and_directory(string command, string expectedVerb, string expectedDirSuffix) { var result = ShellTokenizer.ExtractDirectoryScope(command); @@ -287,26 +288,6 @@ public void ExtractDirectoryScope_returns_verb_and_directory(string command, str Assert.EndsWith(expectedDirSuffix, dir.Replace('\\', '/')); } - [Fact] - public void ExtractDirectoryScope_preserves_existing_directory_operand() - { - var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); - var logs = Path.Combine(root, "logs"); - Directory.CreateDirectory(logs); - - try - { - var result = ShellTokenizer.ExtractDirectoryScope($"find {logs} -name '*.log'"); - - Assert.NotNull(result); - Assert.Equal($"find {PathUtility.Normalize(logs)}/", result); - } - finally - { - Directory.Delete(root, recursive: true); - } - } - [Fact] public void ExtractDirectoryScope_grep_finds_path_not_search_term() { diff --git a/src/Netclaw.Security/IToolApprovalMatcher.cs b/src/Netclaw.Security/IToolApprovalMatcher.cs index a4d94c5f..89217485 100644 --- a/src/Netclaw.Security/IToolApprovalMatcher.cs +++ b/src/Netclaw.Security/IToolApprovalMatcher.cs @@ -77,9 +77,8 @@ public IReadOnlyList ExtractPatterns(ToolName toolName, IDictionary(StringComparer.OrdinalIgnoreCase); - CollectPatterns(command, workingDirectory, patterns); + CollectPatterns(command, patterns); return patterns.ToList(); } @@ -111,9 +110,8 @@ public IReadOnlyList ExtractDirectoryPatterns(ToolName toolName, IDictio if (string.IsNullOrWhiteSpace(command)) return []; - var workingDirectory = GetWorkingDirectory(arguments); var patterns = new HashSet(StringComparer.OrdinalIgnoreCase); - CollectDirectoryPatterns(command, workingDirectory, patterns); + CollectDirectoryPatterns(command, patterns); return patterns.ToList(); } @@ -131,30 +129,14 @@ public IReadOnlyList ExtractDirectoryPatterns(ToolName toolName, IDictio private static bool PatternMatchesAny(string pattern, IReadOnlyList approvedPatterns) => ApprovalPatternMatching.MatchesAny(pattern, approvedPatterns); - private static void CollectPatterns(string command, string? workingDirectory, ISet patterns) - => TraverseSegments(command, workingDirectory, patterns, - static (segment, wd) => ShellTokenizer.ExtractApprovalPattern(segment, wd)); + private static void CollectPatterns(string command, ISet patterns) + => TraverseSegments(command, patterns, static segment => ShellTokenizer.ExtractVerbChain(segment)); - private static void CollectDirectoryPatterns(string command, string? workingDirectory, ISet patterns) - => TraverseSegments(command, workingDirectory, patterns, static (segment, wd) => - ShellTokenizer.ExtractDirectoryScope(segment, wd) ?? ShellTokenizer.ExtractApprovalPattern(segment, wd)); + private static void CollectDirectoryPatterns(string command, ISet patterns) + => TraverseSegments(command, patterns, static segment => + ShellTokenizer.ExtractDirectoryScope(segment) ?? ShellTokenizer.ExtractVerbChain(segment)); - private static string? GetWorkingDirectory(IDictionary? arguments) - { - if (arguments is null) - return null; - - if (arguments.TryGetValue("WorkingDirectory", out var val) || arguments.TryGetValue("workingDirectory", out val)) - return val?.ToString(); - - return null; - } - - private static void TraverseSegments( - string command, - string? workingDirectory, - ISet patterns, - Func extractLeaf) + private static void TraverseSegments(string command, ISet patterns, Func extractLeaf) { foreach (var segment in ShellTokenizer.SplitCompoundCommand(command)) { @@ -162,12 +144,12 @@ private static void TraverseSegments( if (innerCommands.Count > 0) { foreach (var inner in innerCommands) - TraverseSegments(inner, workingDirectory, patterns, extractLeaf); + TraverseSegments(inner, patterns, extractLeaf); continue; } - var pattern = extractLeaf(segment, workingDirectory); + var pattern = extractLeaf(segment); if (!string.IsNullOrEmpty(pattern)) patterns.Add(pattern); } diff --git a/src/Netclaw.Security/ShellTokenizer.cs b/src/Netclaw.Security/ShellTokenizer.cs index e2b73110..1a0a123f 100644 --- a/src/Netclaw.Security/ShellTokenizer.cs +++ b/src/Netclaw.Security/ShellTokenizer.cs @@ -38,20 +38,6 @@ public static class ShellTokenizer "ls" }; - /// - /// Verbs whose first non-flag operand is expected to be a path. Existing bare - /// relative operands for these verbs can be treated as path targets even when - /// they do not include a slash or file extension. - /// - private static readonly HashSet LeadingPathVerbs = new(StringComparer.OrdinalIgnoreCase) - { - "bash", "sh", "zsh", - "cat", "less", "more", "head", "tail", - "find", "ls", - "cp", "mv", "tar", "zip", "unzip", - "python", "python3", "node", "ruby", "perl", "php" - }; - /// /// Tokenizes a shell command string, respecting single and double quotes. /// Strips quote delimiters from tokens. @@ -212,16 +198,6 @@ public static string ExtractVerbChain(string command, int maxDepth = 2) return string.Join(' ', verbParts); } - /// - /// Extracts the approval pattern used for exact matching. For path-aware verbs, - /// prefers the first recognizable path operand normalized against the working - /// directory; otherwise falls back to the verb-chain extraction. - /// - public static string ExtractApprovalPattern(string command, string? workingDirectory = null, int maxDepth = 2) - => TryExtractPathOperand(command, workingDirectory, out var operand) - ? operand.Verb + " " + operand.NormalizedPath - : ExtractVerbChain(command, maxDepth); - /// /// Extracts inner commands from bash -c / sh -c wrappers. Returns the /// inner command strings for recursive scanning. Returns an empty list @@ -389,31 +365,15 @@ public static bool LooksLikePath(string token) /// argument, or the resulting directory is too shallow (fewer than 2 /// path segments below root). /// - public static string? ExtractDirectoryScope(string command, string? workingDirectory = null) + public static string? ExtractDirectoryScope(string command) { - if (!TryExtractPathOperand(command, workingDirectory, out var operand)) - return null; - - // Enforce minimum depth — reject shallow scopes like / or /etc/ - if (CountPathSegments(operand.NormalizedDirectory) < MinDirectoryScopeDepth) - return null; - - return operand.Verb + " " + operand.NormalizedDirectory + "/"; - } - - internal const int MinDirectoryScopeDepth = 2; - - private static bool TryExtractPathOperand(string command, string? workingDirectory, out PathOperand operand) - { - operand = default; - var tokens = Tokenize(command).ToList(); if (tokens.Count == 0) - return false; + return null; var verb = TrimShellPunctuation(tokens[0]); if (verb.Length == 0 || !PathAwareVerbs.Contains(verb)) - return false; + return null; for (var i = 1; i < tokens.Count; i++) { @@ -421,54 +381,28 @@ private static bool TryExtractPathOperand(string command, string? workingDirecto if (trimmed.Length == 0 || trimmed.StartsWith('-')) continue; - var normalizedPath = TryNormalizePathOperand(trimmed, verb, workingDirectory); - if (normalizedPath is null) + if (!LooksLikePath(trimmed)) continue; - var normalizedDirectory = ExtractScopedDirectory(normalizedPath, trimmed); - if (normalizedDirectory is null) + var dir = ExtractParentDirectory(PathUtility.ExpandHome(trimmed)); + if (dir is null) continue; - operand = new PathOperand(verb, normalizedPath, normalizedDirectory); - return true; - } - - return false; - } - - private static string? TryNormalizePathOperand(string token, string verb, string? workingDirectory) - { - if (LooksLikePath(token)) - return PathUtility.ExpandAndNormalize(token, workingDirectory); - - if (!LeadingPathVerbs.Contains(verb)) - return null; - - var normalizedPath = PathUtility.ExpandAndNormalize(token, workingDirectory); - if (normalizedPath is null) - return null; - - return File.Exists(normalizedPath) || Directory.Exists(normalizedPath) - ? normalizedPath - : null; - } - - private static string? ExtractScopedDirectory(string normalizedPath, string rawPath) - { - if (rawPath.EndsWith('/') || rawPath.EndsWith('\\')) - return normalizedPath; + var normalized = PathUtility.ExpandAndNormalize(dir); + if (normalized is null) + continue; - if (HasGlob(rawPath)) - return Path.GetDirectoryName(normalizedPath); + // Enforce minimum depth — reject shallow scopes like / or /etc/ + if (CountPathSegments(normalized) < MinDirectoryScopeDepth) + return null; - if (Directory.Exists(normalizedPath)) - return normalizedPath; + return verb + " " + normalized + "/"; + } - return Path.GetDirectoryName(normalizedPath); + return null; } - private static bool HasGlob(string path) - => path.IndexOfAny(['*', '?', '[']) >= 0; + internal const int MinDirectoryScopeDepth = 2; private static string? ExtractParentDirectory(string path) { @@ -553,6 +487,4 @@ private static void FlushSegment(StringBuilder current, List segments) segments.Add(trimmed); current.Clear(); } - - private readonly record struct PathOperand(string Verb, string NormalizedPath, string NormalizedDirectory); } From 13aeaa30e667ac72320c12c1cade55cc9035d311 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 7 May 2026 16:40:01 +0000 Subject: [PATCH 09/22] fix(security): reuse shell approvals by directory root Reduce repeated shell approval prompts for local diagnostic work while keeping approve-once exact and falling back to exact approval when no reusable roots can be extracted. --- .../design.md | 144 ++++++----- .../proposal.md | 76 +++--- .../specs/tool-approval-gates/spec.md | 238 +++++++++--------- .../tasks.md | 38 +-- .../DiscordApprovalPromptBuilderTests.cs | 8 +- .../SessionToolExecutionPipelineTests.cs | 5 +- .../Tools/DispatchingToolExecutorTests.cs | 14 +- .../Tools/ToolApprovalActorTests.cs | 41 ++- .../Tools/ToolApprovalGateTests.cs | 47 ++-- src/Netclaw.Actors/Protocol/SessionOutput.cs | 14 +- .../Protocol/SessionOutputDto.cs | 2 + .../Protocol/SessionOutputDtoMapper.cs | 4 + .../Sessions/LlmSessionActor.cs | 11 +- .../Pipelines/SessionToolExecutionPipeline.cs | 13 +- src/Netclaw.Actors/SubAgents/SubAgentActor.cs | 4 +- .../Tools/DispatchingToolExecutor.cs | 13 +- .../Tools/FilePathApprovalMatcher.cs | 5 +- src/Netclaw.Actors/Tools/ToolAccessPolicy.cs | 33 +-- src/Netclaw.Actors/Tools/ToolApprovalActor.cs | 9 +- .../DiscordApprovalPromptBuilder.cs | 36 ++- .../SlackApprovalBlockBuilder.cs | 45 +++- .../Cli/DaemonClientMappingTests.cs | 6 + src/Netclaw.Cli/Tui/ChatPage.cs | 2 + src/Netclaw.Cli/Tui/ChatViewModel.cs | 5 +- .../ShellApprovalMatcherTests.cs | 121 ++++----- .../ShellTokenizerTests.cs | 82 +++--- .../ApprovalPatternMatching.cs | 40 +++ src/Netclaw.Security/DirectoryApprovalRoot.cs | 12 + src/Netclaw.Security/IToolApprovalMatcher.cs | 107 +++++--- src/Netclaw.Security/ShellTokenizer.cs | 118 ++++++++- 30 files changed, 844 insertions(+), 449 deletions(-) create mode 100644 src/Netclaw.Security/DirectoryApprovalRoot.cs diff --git a/openspec/changes/directory-scoped-approval-patterns/design.md b/openspec/changes/directory-scoped-approval-patterns/design.md index ee02c000..7c389149 100644 --- a/openspec/changes/directory-scoped-approval-patterns/design.md +++ b/openspec/changes/directory-scoped-approval-patterns/design.md @@ -1,12 +1,19 @@ ## Context -Shell command approval patterns are extracted by `ShellTokenizer.ExtractVerbChain()` -which, for path-aware verbs (`ls`, `cat`, `grep`, `find`, etc.), appends the -first file-path argument to the verb chain. This produces per-file patterns -like `cat /home/.netclaw/logs/crash-foo.log`. Combined with the single-token -exact-match restriction in `ApprovalPatternMatching` (which prevents bare `cat` -from silently approving `cat /etc/shadow`), each unique file path requires a -separate interactive approval. +The current shell approval flow reuses exact command patterns too narrowly for +diagnostic work. In session `D0AC6CKBK5K/1778163885.517639`, repeated shell work +inside the same directories still produced repeated prompts because each new +file path became a new approval target. Issue `#905` is only contextual here: +it explains why that one session had so many shell calls, but this change does +not attempt to solve the broader issue volume. + +This update shifts broader shell approvals away from command classification and +toward reusable local directory roots. The approval question becomes: can this +shell approval unit be shown to stay under roots the user already approved? + +When the answer is yes, later shell commands under those roots should not ask +again. When the answer is no, the system must fall back to exact approval +behavior. The approval system has three security layers: 1. Hard deny list (before approval gate) @@ -18,73 +25,98 @@ This change only relaxes layer 2. Layers 1 and 3 are unaffected. ## Goals / Non-Goals **Goals:** -- Reduce per-file approval fatigue for diagnostic shell commands -- Store directory-scoped patterns when user selects B (session) or C (always) -- Maintain boundary-safe path matching (no `StartsWith` — use `PathUtility.IsWithinRoot`) -- Prevent overly broad directory scopes (minimum 2 path segments) -- Show directory context in approval option labels +- Reduce repeated shell approval fatigue for later commands under the same local + directories +- Keep `Approve once` exact blocked-call retry only +- Store reusable directory roots for shell B/C approvals when local roots are + extractable +- Auto-approve later shell approval units only when all recognized local paths + stay under already approved roots +- Keep boundary-safe root matching and minimum-depth enforcement +- Show root context in approval option labels **Non-Goals:** - Changing the hard deny list or `ToolPathPolicy` behavior -- Changing "Approve once" (A) behavior — it remains exact-pattern -- Glob-aware or regex-based pattern matching -- Cross-verb directory approvals (`cat /dir/` does not approve `grep /dir/`) +- Changing `Approve once` (A) behavior +- Classifying shell safety by command verb families +- Using directory-root approvals when no reusable local roots can be extracted +- Glob-aware or regex-based approval matching ## Decisions -### Pattern convention: trailing `/` sentinel +### Approval units: split on shell control operators, not pipelines + +The broader approval model operates on shell approval units instead of whole +commands or individual tokens. + +- `&&`, `||`, and `;` start a new approval unit +- `|` stays inside the current approval unit + +This preserves the user's expectation that a pipeline like +`grep ... /home/.netclaw/logs/app.log | wc -l` is one piece of work, while still +preventing a later `&& rm ...` segment from inheriting that approval. + +### Directory roots replace verb-scoped directory patterns + +For `shell_execute`, B and C approvals store reusable local directory roots, not +verb-specific patterns. A later `ls`, `cat`, or `grep` can reuse the same root +approval as long as every recognized local filesystem path in that approval unit +resolves under approved roots. -Directory-scoped patterns use a trailing `/` to distinguish from exact patterns: -`grep /home/.netclaw/logs/` vs `grep /home/.netclaw/logs/daemon.log`. +This is intentionally verb-agnostic. The safety boundary moves from shell verb +classification to filesystem containment plus the existing backstops. -**Why not a separate storage format?** The approval store (`tool-approvals.json`) -is a flat list of strings per tool per audience. A sentinel convention avoids -schema changes and keeps backward compatibility — existing non-slash patterns -work unchanged. +### Extraction: recognized local filesystem paths across the whole unit -### Extraction: scan all arguments, not just first positional +Root extraction scans each approval unit for recognized local filesystem paths, +not just the first positional token. That covers forms like +`grep -l "timeout" /home/.netclaw/logs/daemon.log`, multiple path arguments, and +paths inside a pipeline. -`ShellTokenizer.ExtractDirectoryScope()` scans ALL non-flag arguments for the -first `LooksLikePath()` token, then extracts its parent directory. This solves -the grep problem where the search term is the first positional arg and the file -path is second (`grep -l "timeout" /home/.netclaw/logs/daemon.log`). +If one or more local paths are found, the system derives directory roots from +them. If none are found, directory-root approval is unavailable and the system +falls back to exact approval behavior for that unit. -**Alternative considered:** Always use first positional. Rejected because grep, -sed, and awk take non-path first arguments. +### Matching: all recognized local paths must stay under approved roots -### Matching: `PathUtility.IsWithinRoot()` not `StartsWith` +Auto-approval succeeds only when every recognized local filesystem path in the +candidate approval unit resolves under an already approved root. -Directory matching delegates to `PathUtility.IsWithinRoot()` which normalizes -both paths, uses platform-appropriate case sensitivity, and checks boundary -characters. This prevents `/home/usersecret` from matching an approval for -`/home/user`. +This avoids partial matches where one safe path could accidentally approve a +unit that also touches another directory the user never approved. + +### Root comparison remains boundary-safe + +Root matching delegates to `PathUtility.IsWithinRoot()`, which normalizes paths, +applies platform-appropriate case sensitivity, and checks boundaries. This keeps +`/home/usersecret` from matching a root approval for `/home/user`. ### Minimum depth: 2 segments below root -`CountPathSegments()` rejects scopes shallower than 2 segments (blocks `/`, -`/home/`, `/etc/`, `/tmp/`). This is a hard floor — the user cannot approve -at root-level directories even if they want to. +Derived roots shallower than 2 segments are rejected. That still blocks broad +roots such as `/`, `/etc/`, and `/tmp/` from becoming reusable directory +approvals. Those commands can still proceed through exact approval behavior. + +### Tiny internal representation: display path vs comparison root -### Verb isolation +Internally, each extracted directory root should carry a tiny pair: -An approval for `cat /dir/` does NOT approve `grep /dir/`. The verb is part of -the pattern and checked explicitly. This limits blast radius — approving reads -doesn't silently approve writes or deletions. +- a display path for approval labels +- a normalized comparison root used for containment checks + +This keeps the behavior model focused on roots while avoiding UI drift from the +path form used for comparisons. ## Risks / Trade-offs -**[Risk] Directory scope is broader than per-file** → Mitigated by -`ToolPathPolicy.CommandReferencesDeniedPath()` at execution time, which -independently blocks access to protected files (`config/netclaw.json`, keys, -`secrets.json`) regardless of approval state. - -**[Risk] `directoryPatterns[0]` drives UI label for compound commands** → -For compound commands with multiple path-aware segments targeting different -directories, only the first directory appears in the label. Acceptable because -compound commands targeting multiple directories are rare, and the approval -still covers the right patterns. - -**[Risk] Minimum depth too restrictive** → A 2-segment minimum blocks -`/etc/` and `/tmp/` which are legitimate diagnostic targets. This is intentional -— those directories contain sensitive system files. Users can still approve -individual files via "Approve once". +**[Risk] Root approvals are broader than per-file approvals** → Mitigated by +minimum depth enforcement, normalization, traversal checks, and +`ToolPathPolicy.CommandReferencesDeniedPath()` at execution time. + +**[Risk] Multi-path approval units can be harder to explain in the prompt** → +The prompt can display the primary extracted roots, but matching still requires +all recognized local paths in the unit to stay under approved roots. + +**[Risk] Some shell commands have no reusable local roots** → This is expected. +When no local roots are extractable, the system falls back to exact approval +behavior instead of inventing a broader approval class. diff --git a/openspec/changes/directory-scoped-approval-patterns/proposal.md b/openspec/changes/directory-scoped-approval-patterns/proposal.md index fd9c9586..8eed215a 100644 --- a/openspec/changes/directory-scoped-approval-patterns/proposal.md +++ b/openspec/changes/directory-scoped-approval-patterns/proposal.md @@ -1,29 +1,33 @@ ## Why -Path-aware shell verbs (`ls`, `cat`, `grep`, `find`, etc.) produce per-file -approval patterns (e.g., `cat /home/.netclaw/logs/crash-foo.log`). In a single -diagnostic session (D0AC6CKBK5K/1778085593.830269), the user was prompted **11 -separate times** for commands targeting different files in the same directory. -The single-token exact-match restriction prevents bare `cat` from silently -approving `cat /etc/shadow`, but the per-file granularity is too annoying for -legitimate diagnostic work. +In session `D0AC6CKBK5K/1778163885.517639`, repeated shell investigation under +the same working directories created approval fatigue because each new file path +produced another approval prompt. Issue `#905` helps explain why that session +had unusually high shell call volume, but the scope of this change is narrower: +reduce repeated approvals for later shell commands that stay within already +approved local directory roots. + +The exact-match restriction is still necessary when the system cannot identify a +safe reusable local root. What needs to change is the granularity of broader +shell approvals, not the safety backstops. ## What Changes -- "Approve for this chat" (B) and "Approve always" (C) store **directory-scoped - patterns** (e.g., `grep /home/.netclaw/logs/`) instead of per-file patterns - when the command targets a recognizable file path. -- A trailing `/` on a stored approval pattern signals directory scope. Matching - uses `PathUtility.IsWithinRoot()` for boundary-safe containment instead of - naive `StartsWith`. -- Minimum depth enforcement (2 path segments below root) prevents overly broad - scopes like `/` or `/etc/`. -- `IToolApprovalMatcher` gains `ExtractDirectoryPatterns()` for tool-specific - directory pattern extraction. -- Approval option labels dynamically show the directory scope (e.g., "Approve - `grep` in /home/.netclaw/logs/ for this chat"). -- "Approve once" (A) continues to use exact patterns — directory scope only - applies to broader grants. +- `Approve once` remains exact blocked-call retry only. +- For `shell_execute`, `Approve for this chat` (B) and `Approve always` (C) + store **directory roots** instead of verb-specific or command-pattern-specific + approvals when the approval unit contains recognizable local filesystem paths. +- Directory approvals are root-based and verb-agnostic: later shell approval + units are auto-approved when all recognized local filesystem paths in that + unit resolve under already approved roots. +- Shell approval units split on `&&`, `||`, and `;`, but keep pipelines joined + so commands like `grep ... | wc -l` are covered by one directory-root + approval. +- If a shell approval unit yields no local directory roots, broader directory + approval does not apply and the system falls back to exact approval behavior. +- Minimum directory depth, path normalization, path traversal checks, and + `ToolPathPolicy` remain the safety backstop. +- `DirectoryPatterns` is renamed to `DirectoryRoots` throughout this change. ## Capabilities @@ -33,21 +37,21 @@ legitimate diagnostic work. ### Modified Capabilities -- `tool-approval-gates`: Adds directory-scoped pattern extraction, storage, - matching, and display for shell command approvals. Extends the existing - pattern matching, `IToolApprovalMatcher` interface, persistent approval - storage, and `ToolInteractionRequest` protocol requirements. +- `tool-approval-gates`: Adds directory-root extraction, storage, matching, and + display for shell command approvals. Extends shell approval-unit parsing, + root matching, `IToolApprovalMatcher`, persistent approval storage, and the + `ToolInteractionRequest` protocol. ## Impact -- **Security**: Only relaxes the interactive approval gate. Hard deny list, - `ToolPathPolicy` (protected paths at execution time), symlink resolution, and - path traversal prevention layers are unaffected. Within an approved directory, - `ToolPathPolicy.CommandReferencesDeniedPath()` still independently blocks - access to protected files like `config/netclaw.json`. -- **Code**: `ShellTokenizer`, `ApprovalPatternMatching`, `IToolApprovalMatcher` - (+ all implementations), `ToolAccessPolicy`, `ToolApprovalContext`, - `ToolInteractionRequest`, `PendingToolInteraction`, `LlmSessionActor`, - `SessionToolExecutionPipeline`. -- **Backward compatibility**: Existing non-slash patterns continue to work - unchanged. `DirectoryPatterns` defaults to empty list on protocol types. +- **Security**: Only relaxes the interactive approval gate. Hard deny rules, + minimum root depth, normalization, traversal checks, and `ToolPathPolicy` + remain unchanged and continue to block protected targets even after a broader + root approval. +- **Code**: `ShellTokenizer`, shell approval-unit traversal, root matching, + `IToolApprovalMatcher` (+ implementations), `ToolAccessPolicy`, + `ToolApprovalContext`, `ToolInteractionRequest`, `PendingToolInteraction`, + `LlmSessionActor`, `SessionToolExecutionPipeline`. +- **Backward compatibility**: Existing exact approvals continue to work + unchanged. `DirectoryRoots` defaults to empty on protocol types when no local + reusable roots are available. diff --git a/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md b/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md index 420a3efa..ebf48d40 100644 --- a/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md +++ b/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md @@ -1,134 +1,145 @@ ## ADDED Requirements -### Requirement: Directory-scoped approval patterns +### Requirement: Directory-root approvals for shell_execute -The system SHALL support directory-scoped approval patterns for shell commands -targeting path-aware verbs. When the user selects "Approve for this chat" (B) or -"Approve always" (C) for a shell command that targets a recognizable file path, -the system SHALL store a directory-scoped pattern (e.g., `grep /home/.netclaw/logs/`) -instead of the exact file-path pattern. "Approve once" (A) SHALL continue to use -exact patterns. +For `shell_execute`, `Approve once` SHALL remain exact blocked-call retry only. +It SHALL NOT create a reusable session approval, persistent approval, or +directory-root approval. -A trailing `/` on a stored pattern SHALL signal directory scope. The system SHALL -use `PathUtility.IsWithinRoot()` for boundary-safe containment matching — not -naive string prefix comparison. +For `shell_execute`, when the user selects `Approve for this chat` (B) or +`Approve always` (C) and the shell approval unit contains one or more +recognized local filesystem paths, the system SHALL store directory roots for +that approval unit instead of verb-specific or command-pattern-specific shell +approvals. -The system SHALL enforce a minimum directory depth of 2 path segments below root. -Patterns targeting root-level directories (`/`, `/home/`, `/etc/`, `/tmp/`) SHALL -be rejected, falling back to exact-pattern behavior. +Directory approvals SHALL be root-based and verb-agnostic. A later shell +approval unit SHALL be auto-approved only when every recognized local +filesystem path in that unit resolves under already approved roots. -Directory-scoped approvals SHALL be verb-isolated: an approval for -`cat /home/.netclaw/logs/` SHALL NOT match `grep /home/.netclaw/logs/`. +If a shell approval unit yields no reusable local directory roots, directory +approval SHALL NOT apply and the system SHALL fall back to exact approval +behavior for that unit. -#### Scenario: Directory-scoped pattern stored on Approve For This Chat +The system SHALL enforce minimum directory depth, path normalization, +boundary-safe containment, path traversal checks, and `ToolPathPolicy` as the +safety backstop for directory-root approvals. + +#### Scenario: Approve once retries only the blocked call + +- **GIVEN** a shell command `cat /home/.netclaw/logs/crash.log` requires approval +- **WHEN** the user selects `Approve once` +- **THEN** only the current blocked call is retried +- **AND** no reusable approval is recorded +- **AND** a later `cat /home/.netclaw/logs/other.log` prompts again + +#### Scenario: Approve for this chat stores a reusable directory root - **GIVEN** a shell command `cat /home/.netclaw/logs/crash-foo.log` requires approval -- **WHEN** the user selects "Approve for this chat" -- **THEN** the session-scoped approval stores `cat /home/.netclaw/logs/` -- **AND** a subsequent `cat /home/.netclaw/logs/daemon.log` in the same session +- **WHEN** the user selects `Approve for this chat` +- **THEN** the session-scoped approval stores the directory root `/home/.netclaw/logs/` +- **AND** a later `grep "error" /home/.netclaw/logs/daemon.log` in the same session does not prompt -#### Scenario: Directory-scoped pattern stored on Approve Always +#### Scenario: Approve always stores a reusable directory root - **GIVEN** a shell command `grep -l "timeout" /home/.netclaw/logs/daemon.log` requires approval -- **WHEN** the user selects "Approve always" -- **THEN** `grep /home/.netclaw/logs/` is written to `tool-approvals.json` -- **AND** future sessions auto-approve grep commands targeting files under - `/home/.netclaw/logs/` - -#### Scenario: Approve Once uses exact pattern - -- **GIVEN** a shell command `cat /home/.netclaw/logs/crash.log` requires approval -- **WHEN** the user selects "Approve once" -- **THEN** only the current blocked call is retried -- **AND** a subsequent `cat /home/.netclaw/logs/other.log` prompts again - -#### Scenario: Directory scope does not cross verb boundaries +- **WHEN** the user selects `Approve always` +- **THEN** `/home/.netclaw/logs/` is written to `tool-approvals.json` for + `shell_execute` +- **AND** a future-session `ls /home/.netclaw/logs/archive.log` is auto-approved -- **GIVEN** `cat /home/.netclaw/logs/` is approved -- **WHEN** the agent runs `grep "error" /home/.netclaw/logs/app.log` -- **THEN** the command still requires approval (verb mismatch) +#### Scenario: All recognized local paths in a unit must be covered -#### Scenario: Nested files match directory scope +- **GIVEN** `/home/.netclaw/logs/` is approved for `shell_execute` +- **WHEN** the agent runs `cat /home/.netclaw/logs/app.log /home/.netclaw/config/netclaw.json` +- **THEN** the command still requires approval because not all recognized local + filesystem paths fall under approved roots -- **GIVEN** `ls /home/.netclaw/` is approved -- **WHEN** the agent runs `ls /home/.netclaw/logs/deep/nested/file.txt` -- **THEN** the command is auto-approved (path is within approved directory) +#### Scenario: No reusable local roots falls back to exact approval behavior -#### Scenario: Sibling directory does not match - -- **GIVEN** `cat /home/.netclaw/logs/` is approved -- **WHEN** the agent runs `cat /home/.netclaw/config/netclaw.json` -- **THEN** the command requires approval (different directory) -- **AND** `ToolPathPolicy` independently blocks the protected path at execution time +- **GIVEN** a shell command `git push origin main` requires approval +- **WHEN** the user selects `Approve for this chat` +- **THEN** no directory root is stored +- **AND** the system falls back to exact approval behavior for `git push` -#### Scenario: Shallow directory scope rejected +#### Scenario: Shallow directory root falls back to exact approval behavior - **GIVEN** a shell command `cat /etc/passwd` requires approval -- **WHEN** directory scope extraction runs -- **THEN** the parent directory `/etc/` has only 1 segment (below minimum of 2) -- **AND** the system falls back to exact-pattern behavior +- **WHEN** directory-root extraction runs +- **THEN** the derived root `/etc/` is rejected as too shallow +- **AND** the system falls back to exact approval behavior -#### Scenario: Boundary-safe path matching prevents prefix collisions +#### Scenario: Boundary-safe matching prevents prefix collisions -- **GIVEN** `cat /home/user/` is approved +- **GIVEN** `/home/user/` is approved for `shell_execute` - **WHEN** the agent runs `cat /home/usersecret/data.txt` - **THEN** the command requires approval - **AND** `PathUtility.IsWithinRoot` prevents the false positive -### Requirement: Directory pattern extraction via IToolApprovalMatcher +### Requirement: Directory root extraction via IToolApprovalMatcher -`IToolApprovalMatcher` SHALL define an `ExtractDirectoryPatterns()` method that -returns directory-scoped patterns for a tool invocation. `ShellApprovalMatcher` -SHALL implement this by scanning all non-flag arguments for the first -`LooksLikePath()` token, expanding home directory tokens, extracting the parent -directory, normalizing the path, and enforcing minimum depth. For compound -commands and `bash -c` wrappers, each segment SHALL be processed recursively. -When no directory scope is available for a segment, the segment's verb-chain -pattern SHALL be used as fallback. +`IToolApprovalMatcher` SHALL define an `ExtractDirectoryRoots()` method that +returns reusable directory roots for a tool invocation. + +For `shell_execute`, extraction SHALL operate on shell approval units. Units +SHALL split on `&&`, `||`, and `;`. Pipelines joined by `|` SHALL stay inside +the same approval unit. + +`ShellApprovalMatcher` SHALL scan each approval unit for recognized local +filesystem paths, expand and normalize them, derive reusable parent directory +roots, and enforce minimum depth and path-safety checks. For `bash -c` or +`sh -c` wrappers, the inner command SHALL be extracted and scanned recursively. `DefaultApprovalMatcher` and `FilePathApprovalMatcher` SHALL return empty lists. -#### Scenario: grep extracts path from second argument +#### Scenario: grep extracts a root from a later argument - **GIVEN** the command `grep -l "timeout" /home/.netclaw/logs/daemon.log` -- **WHEN** `ExtractDirectoryPatterns` runs -- **THEN** the pattern `grep /home/.netclaw/logs/` is extracted -- **AND** the search term `"timeout"` is skipped (not a path) +- **WHEN** `ExtractDirectoryRoots` runs +- **THEN** the root `/home/.netclaw/logs/` is extracted +- **AND** the search term `"timeout"` is ignored + +#### Scenario: Pipeline stays in one approval unit + +- **GIVEN** the command `grep "error" /home/.netclaw/logs/app.log | wc -l` +- **WHEN** `ExtractDirectoryRoots` runs +- **THEN** the pipeline is treated as one approval unit +- **AND** the root `/home/.netclaw/logs/` is extracted for that unit -#### Scenario: Compound command extracts patterns per segment +#### Scenario: Control operators split approval units -- **GIVEN** the command `cat /home/.netclaw/logs/crash.log && grep "error" /var/log/syslog` -- **WHEN** `ExtractDirectoryPatterns` runs -- **THEN** `cat /home/.netclaw/logs/` is extracted for the first segment -- **AND** the second segment falls back to its verb chain (depth too shallow) +- **GIVEN** the command `cat /home/.netclaw/logs/app.log && cat /home/.netclaw/config/netclaw.json` +- **WHEN** `ExtractDirectoryRoots` runs +- **THEN** the `&&` creates two approval units +- **AND** each unit is evaluated independently for reusable roots -#### Scenario: Glob paths use parent directory +#### Scenario: Glob paths use parent directory root - **GIVEN** the command `ls /home/.netclaw/logs/crash-*.log` -- **WHEN** `ExtractDirectoryPatterns` runs -- **THEN** the pattern `ls /home/.netclaw/logs/` is extracted -- **AND** the glob component is stripped +- **WHEN** `ExtractDirectoryRoots` runs +- **THEN** the root `/home/.netclaw/logs/` is extracted +- **AND** the glob component does not become part of the stored root ### Requirement: Dynamic approval option labels -When directory patterns are available, the system SHALL customize the approval -option labels to show the directory scope. The labels SHALL follow the format: -- B: `"Approve in {directory} for this chat"` -- C: `"Approve in {directory} always"` +When directory roots are available, the system SHALL customize the approval +option labels to show the reusable root scope. The labels SHALL follow the +format: +- B: `"Approve in {directory-root} for this chat"` +- C: `"Approve in {directory-root} always"` Options A ("Approve once") and D ("Deny") SHALL retain their default labels. -#### Scenario: Labels show directory scope for path-aware commands +#### Scenario: Labels show reusable root scope for shell commands - **GIVEN** a shell command `grep "error" /home/.netclaw/logs/app.log` requires approval - **WHEN** the approval prompt is generated -- **THEN** option B reads "Approve in /home/.netclaw/logs/ for this chat" -- **AND** option C reads "Approve in /home/.netclaw/logs/ always" +- **THEN** option B reads `Approve in /home/.netclaw/logs/ for this chat` +- **AND** option C reads `Approve in /home/.netclaw/logs/ always` -#### Scenario: Labels use defaults when no directory scope +#### Scenario: Labels use defaults when no reusable directory root exists - **GIVEN** a shell command `git push origin main` requires approval - **WHEN** the approval prompt is generated @@ -146,10 +157,10 @@ The interaction `Kind` SHALL identify the interaction type (`approval` for v1). `ToolInteractionRequest` SHALL be a lifecycle output (always delivered regardless of `OutputFilter`). -`ToolInteractionRequest` SHALL include a `DirectoryPatterns` field containing -directory-scoped patterns extracted from the tool invocation. When non-empty and -the user selects "Approve for this chat" or "Approve always", the session actor -SHALL record the directory patterns instead of the exact file-path patterns. +`ToolInteractionRequest` SHALL include a `DirectoryRoots` field containing +reusable directory roots extracted from the tool invocation. When non-empty and +the user selects `Approve for this chat` or `Approve always`, the session actor +SHALL record the directory roots instead of exact shell approval patterns. #### Scenario: Approval request emitted as session output @@ -159,13 +170,12 @@ SHALL record the directory patterns instead of the exact file-path patterns. - **AND** it includes `CallId`, `ToolName`, the command/pattern, and available options (approve once, approve for this chat, approve always, deny) -#### Scenario: Approval request includes directory patterns +#### Scenario: Approval request includes directory roots - **GIVEN** a shell command targets a file under `/home/.netclaw/logs/` - **WHEN** the approval request is generated -- **THEN** `ToolInteractionRequest.DirectoryPatterns` contains the directory-scoped - pattern (e.g., `cat /home/.netclaw/logs/`) -- **AND** `ToolInteractionRequest.Patterns` contains the exact file-path pattern +- **THEN** `ToolInteractionRequest.DirectoryRoots` contains `/home/.netclaw/logs/` +- **AND** the request still includes the exact blocked approval pattern for retry #### Scenario: Channel routes response back to session @@ -180,28 +190,28 @@ The system SHALL store persistent approvals ("Approve Always" decisions) in `~/.netclaw/config/tool-approvals.json`, separate from `netclaw.json`. The file SHALL NOT be monitored by `ConfigWatcherService`. The file SHALL contain per-audience sections with per-tool approval lists. For the shipped MVP shell -flow, the lists SHALL contain command patterns, including directory-scoped -patterns (trailing `/`). Approval lookup and recording -SHALL be mediated by `IToolApprovalService`. +flow, the lists SHALL contain exact approvals and directory roots as applicable. +Approval lookup and recording SHALL be mediated by `IToolApprovalService`. -#### Scenario: Approve Always persists directory pattern to file +#### Scenario: Approve always persists directory root to file - **GIVEN** the user clicks "Approve Always" for a command targeting `/home/.netclaw/logs/crash.log` - **WHEN** the approval is processed -- **THEN** `cat /home/.netclaw/logs/` is added to the Personal shell_execute list +- **THEN** `/home/.netclaw/logs/` is added to the Personal `shell_execute` list in `tool-approvals.json` - **AND** the daemon does NOT restart #### Scenario: Persistent approvals loaded at startup - **GIVEN** `tool-approvals.json` contains - `{"personal":{"shell_execute":["git push", "cat /home/.netclaw/logs/"]}}` + `{"personal":{"shell_execute":["git push", "/home/.netclaw/logs/"]}}` - **WHEN** the daemon starts - **THEN** `git push` is pre-approved for Personal audience shell commands -- **AND** `cat` commands targeting files under `/home/.netclaw/logs/` are pre-approved +- **AND** later shell approval units whose recognized local paths all stay under + `/home/.netclaw/logs/` are pre-approved -#### Scenario: Approve Once is retry-scoped only +#### Scenario: Approve once is retry-scoped only - **GIVEN** the user clicks "Approve Once" for pattern `docker build` - **WHEN** the approval is processed @@ -209,12 +219,12 @@ SHALL be mediated by `IToolApprovalService`. - **AND** a later `docker build` call in the same session prompts again - **AND** `tool-approvals.json` is NOT modified -#### Scenario: Approve For This Chat stores directory pattern in session +#### Scenario: Approve for this chat stores directory root in session - **GIVEN** the user clicks "Approve For This Chat" for a command targeting `/home/.netclaw/logs/daemon.log` - **WHEN** the approval is processed -- **THEN** the directory-scoped pattern is approved for the current session only +- **THEN** the directory root is approved for the current session only - **AND** `tool-approvals.json` is NOT modified - **AND** a new session will prompt again @@ -222,15 +232,14 @@ SHALL be mediated by `IToolApprovalService`. The system SHALL extract verb-chain prefix patterns from shell commands using tokenization. The verb chain SHALL consist of non-flag tokens from the start of -the command until the first flag (`-`), path, or URL argument. For compound -commands (`&&`, `||`, `;`, `|`), each segment SHALL be evaluated independently. +the command until the first flag (`-`), path, or URL argument. For shell +approval units, `&&`, `||`, and `;` SHALL split into separate units, while `|` +SHALL remain inside the current unit. For `bash -c` or `sh -c` wrappers, the inner command SHALL be extracted and scanned recursively. -The system SHALL support directory-scoped pattern matching. When an approved -pattern ends with `/`, the system SHALL match any candidate pattern with the same -verb whose path argument is within the approved directory, using -`PathUtility.IsWithinRoot()` for boundary-safe containment. +When a shell approval unit has no reusable directory roots, the system SHALL use +exact approval behavior for that unit. #### Scenario: Verb chain extracted from simple command @@ -250,12 +259,13 @@ verb whose path argument is within the approved directory, using - **WHEN** the pattern is extracted - **THEN** the pattern is `docker compose up` -#### Scenario: Compound command segments evaluated independently +#### Scenario: Control operators create separate approval units - **GIVEN** the command `git add . && git commit -m "fix" && git push` - **WHEN** approval is checked -- **THEN** patterns `git add`, `git commit`, and `git push` are each checked - independently against the approval state surfaced through `IToolApprovalService` +- **THEN** `git add`, `git commit`, and `git push` are checked as separate + approval units against the approval state surfaced through + `IToolApprovalService` #### Scenario: Unapproved compound segments batched in one prompt @@ -271,8 +281,10 @@ verb whose path argument is within the approved directory, using - **THEN** the inner command `git push --force` is extracted and scanned - **AND** pattern `git push` is checked through `IToolApprovalService` -#### Scenario: Directory-scoped approved pattern matches file within directory +#### Scenario: Pipeline stays in one approval unit for root matching -- **GIVEN** `cat /home/.netclaw/logs/` is in the approved patterns -- **WHEN** the candidate pattern `cat /home/.netclaw/logs/crash.log` is checked -- **THEN** `ApprovalPatternMatching.MatchesAny` returns true +- **GIVEN** `/home/.netclaw/logs/` is in the approved `shell_execute` roots +- **WHEN** the agent runs `grep "error" /home/.netclaw/logs/crash.log | wc -l` +- **THEN** the pipeline is treated as one approval unit +- **AND** the unit is auto-approved because its recognized local filesystem path + stays under the approved root diff --git a/openspec/changes/directory-scoped-approval-patterns/tasks.md b/openspec/changes/directory-scoped-approval-patterns/tasks.md index 035f4414..13325893 100644 --- a/openspec/changes/directory-scoped-approval-patterns/tasks.md +++ b/openspec/changes/directory-scoped-approval-patterns/tasks.md @@ -1,38 +1,38 @@ ## 1. Pattern Extraction -- [x] 1.1 Add `ShellTokenizer.ExtractDirectoryScope()` — scan all args for first `LooksLikePath()` token, extract parent directory, normalize, enforce minimum depth -- [x] 1.2 Add `ExtractParentDirectory()` and `CountPathSegments()` helpers to `ShellTokenizer` -- [x] 1.3 Unit tests for `ExtractDirectoryScope` (verb+directory, grep path vs search term, glob handling, null cases) +- [x] 1.1 Add `ShellTokenizer.ExtractDirectoryRoots()` and shell approval-unit traversal that splits on `&&`, `||`, and `;` but keeps `|` in the same unit +- [x] 1.2 Add helpers to derive reusable parent directory roots, normalize paths, and enforce minimum depth +- [x] 1.3 Unit tests for root extraction (later path args, pipelines, control-operator splits, glob handling, null cases) ## 2. Pattern Matching -- [x] 2.1 Add `MatchesDirectoryScope()` to `ApprovalPatternMatching` using `PathUtility.IsWithinRoot()` for boundary-safe containment -- [x] 2.2 Unit tests for directory-prefix matching (same dir, nested, sibling, verb mismatch) +- [x] 2.1 Add directory-root matching using `PathUtility.IsWithinRoot()` for boundary-safe containment +- [x] 2.2 Unit tests for root matching (same dir, nested, sibling, multi-path coverage, prefix collision) ## 3. IToolApprovalMatcher Extension -- [x] 3.1 Add `ExtractDirectoryPatterns()` to `IToolApprovalMatcher` interface -- [x] 3.2 Implement on `ShellApprovalMatcher` with compound command + `bash -c` recursion via shared `TraverseSegments` helper +- [x] 3.1 Rename `ExtractDirectoryPatterns()` to `ExtractDirectoryRoots()` on `IToolApprovalMatcher` +- [x] 3.2 Implement on `ShellApprovalMatcher` with approval-unit traversal and `bash -c` recursion via shared traversal helper - [x] 3.3 Implement on `DefaultApprovalMatcher` and `FilePathApprovalMatcher` (return empty list) ## 4. Protocol and Pipeline Wiring -- [x] 4.1 Add `DirectoryPatterns` property to `ToolInteractionRequest` in `SessionOutput.cs` -- [x] 4.2 Add `DirectoryPatterns` to `ToolApprovalContext` record in `ToolAccessPolicy.cs` -- [x] 4.3 Compute directory patterns and customize B/C labels in `CheckApprovalGate()` -- [x] 4.4 Pass `DirectoryPatterns` from `ToolApprovalContext` to `ToolInteractionRequest` in `SessionToolExecutionPipeline` -- [x] 4.5 Propagate `DirectoryPatterns` through `DispatchingToolExecutor` re-approval path +- [x] 4.1 Rename `DirectoryPatterns` to `DirectoryRoots` on `ToolInteractionRequest` in `SessionOutput.cs` +- [x] 4.2 Rename `DirectoryPatterns` to `DirectoryRoots` on `ToolApprovalContext` in `ToolAccessPolicy.cs` +- [x] 4.3 Compute directory roots and customize B/C labels in `CheckApprovalGate()` +- [x] 4.4 Pass `DirectoryRoots` from `ToolApprovalContext` to `ToolInteractionRequest` in `SessionToolExecutionPipeline` +- [x] 4.5 Propagate `DirectoryRoots` through `DispatchingToolExecutor` re-approval path ## 5. Session Actor Recording -- [x] 5.1 Add `DirectoryPatterns` field to `PendingToolInteraction` record in `LlmSessionActor` -- [x] 5.2 Store `DirectoryPatterns` from `ToolInteractionRequest` in pending interaction -- [x] 5.3 Record directory patterns (when non-empty) instead of exact patterns for B/C decisions in `RecordApprovalAsync` +- [x] 5.1 Rename the pending interaction field from `DirectoryPatterns` to `DirectoryRoots` in `LlmSessionActor` +- [x] 5.2 Store `DirectoryRoots` from `ToolInteractionRequest` in pending interaction +- [x] 5.3 Record directory roots (when non-empty) instead of exact patterns for B/C decisions in `RecordApprovalAsync` ## 6. Code Quality -- [x] 6.1 Narrow bare `catch` in `MatchesDirectoryScope` to `ArgumentException | IOException` -- [x] 6.2 Unify `CollectPatterns`/`CollectDirectoryPatterns` into shared `TraverseSegments` helper -- [x] 6.3 Use `PathUtility.ExpandAndNormalize()` in `ExtractDirectoryScope` instead of separate calls -- [x] 6.4 Make `DirectoryPatterns` non-nullable on `ToolApprovalContext` +- [x] 6.1 Narrow bare `catch` in directory-root matching to `ArgumentException | IOException` +- [x] 6.2 Unify exact-pattern collection and directory-root extraction into shared approval-unit traversal +- [x] 6.3 Use `PathUtility.ExpandAndNormalize()` in `ExtractDirectoryRoots` instead of separate calls +- [x] 6.4 Make `DirectoryRoots` non-nullable on `ToolApprovalContext` - [x] 6.5 Verify: `dotnet slopwatch analyze` passes, copyright headers present, all tests green diff --git a/src/Netclaw.Actors.Tests/Channels/DiscordApprovalPromptBuilderTests.cs b/src/Netclaw.Actors.Tests/Channels/DiscordApprovalPromptBuilderTests.cs index e26311b4..7e03271d 100644 --- a/src/Netclaw.Actors.Tests/Channels/DiscordApprovalPromptBuilderTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/DiscordApprovalPromptBuilderTests.cs @@ -14,6 +14,8 @@ public sealed class DiscordApprovalPromptBuilderTests [Fact] public void BuildTextPrompt_contains_tool_name_and_options() { + const string sessionLabel = "Approve shell access in logs/ for this chat"; + const string alwaysLabel = "Approve shell access in logs/ always"; var request = new ToolInteractionRequest { SessionId = new SessionId("test/session"), @@ -24,8 +26,8 @@ public void BuildTextPrompt_contains_tool_name_and_options() Patterns = ["origin/main"], Options = [ new ToolInteractionOption(ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.ApproveOnceLabel), - new ToolInteractionOption(ApprovalOptionKeys.ApproveSession, ApprovalOptionKeys.ApproveSessionLabel), - new ToolInteractionOption(ApprovalOptionKeys.ApproveAlways, ApprovalOptionKeys.ApproveAlwaysLabel), + new ToolInteractionOption(ApprovalOptionKeys.ApproveSession, sessionLabel), + new ToolInteractionOption(ApprovalOptionKeys.ApproveAlways, alwaysLabel), new ToolInteractionOption(ApprovalOptionKeys.Deny, ApprovalOptionKeys.DenyLabel) ] }; @@ -39,6 +41,8 @@ public void BuildTextPrompt_contains_tool_name_and_options() Assert.Contains("B)", prompt); Assert.Contains("C)", prompt); Assert.Contains("D)", prompt); + Assert.Contains(sessionLabel, prompt); + Assert.Contains(alwaysLabel, prompt); } [Fact] diff --git a/src/Netclaw.Actors.Tests/Sessions/SessionToolExecutionPipelineTests.cs b/src/Netclaw.Actors.Tests/Sessions/SessionToolExecutionPipelineTests.cs index abbe77c0..dd62b006 100644 --- a/src/Netclaw.Actors.Tests/Sessions/SessionToolExecutionPipelineTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/SessionToolExecutionPipelineTests.cs @@ -147,7 +147,8 @@ public Task ExecuteAsync(FunctionCallContent toolCall, ToolExecutionCont throw new ToolApprovalRequiredException(new ToolApprovalContext( ToolName: toolCall.Name, DisplayText: "git push origin dev", - UnapprovedPatterns: ["git push"], + Patterns: ["git push origin dev"], + ApprovalEntries: ["git push origin dev"], Options: [ new ToolApprovalOption(ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.ApproveOnceLabel), @@ -155,7 +156,7 @@ public Task ExecuteAsync(FunctionCallContent toolCall, ToolExecutionCont new ToolApprovalOption(ApprovalOptionKeys.ApproveAlways, ApprovalOptionKeys.ApproveAlwaysLabel), new ToolApprovalOption(ApprovalOptionKeys.Deny, ApprovalOptionKeys.DenyLabel) ], - DirectoryPatterns: [])); + DirectoryRoots: [])); } ct.ThrowIfCancellationRequested(); diff --git a/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs b/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs index db348ace..9793a7f7 100644 --- a/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs @@ -434,7 +434,7 @@ public async Task One_time_approval_allows_immediate_retry_only() executor.ExecuteAsync(toolCall, context, TestContext.Current.CancellationToken)); context.OneTimeApprovedToolName = toolCall.Name; - context.SetOneTimeApprovedPatterns(firstAttempt.ApprovalContext.UnapprovedPatterns); + context.SetOneTimeApprovedPatterns(firstAttempt.ApprovalContext.Patterns); var retryResult = await executor.ExecuteAsync(toolCall, context, TestContext.Current.CancellationToken); Assert.Contains("once", retryResult); @@ -493,7 +493,7 @@ public async Task One_time_approval_bypasses_policy_for_matching_shell_patterns( executor.ExecuteAsync(toolCall, context, TestContext.Current.CancellationToken)); context.OneTimeApprovedToolName = toolCall.Name; - context.SetOneTimeApprovedPatterns(firstAttempt.ApprovalContext.UnapprovedPatterns); + context.SetOneTimeApprovedPatterns(firstAttempt.ApprovalContext.Patterns); var retryResult = await executor.ExecuteAsync(toolCall, context, TestContext.Current.CancellationToken); @@ -550,7 +550,7 @@ public async Task One_time_approval_bypasses_policy_for_path_aware_file_patterns executor.ExecuteAsync(toolCall, context, TestContext.Current.CancellationToken)); context.OneTimeApprovedToolName = toolCall.Name; - context.SetOneTimeApprovedPatterns(firstAttempt.ApprovalContext.UnapprovedPatterns); + context.SetOneTimeApprovedPatterns(firstAttempt.ApprovalContext.Patterns); var retryResult = await executor.ExecuteAsync(toolCall, context, TestContext.Current.CancellationToken); Assert.Contains("Successfully wrote", retryResult, StringComparison.Ordinal); @@ -632,11 +632,11 @@ await approvalService.RecordApprovalAsync( var firstAttempt = await Assert.ThrowsAsync(() => executor.ExecuteAsync(call, context, TestContext.Current.CancellationToken)); - Assert.DoesNotContain("pwd", firstAttempt.ApprovalContext.UnapprovedPatterns); - Assert.Contains("ls", firstAttempt.ApprovalContext.UnapprovedPatterns); + Assert.Contains("pwd", firstAttempt.ApprovalContext.Patterns); + Assert.Contains("ls", firstAttempt.ApprovalContext.Patterns); context.OneTimeApprovedToolName = call.Name; - context.SetOneTimeApprovedPatterns(firstAttempt.ApprovalContext.UnapprovedPatterns); + context.SetOneTimeApprovedPatterns(firstAttempt.ApprovalContext.Patterns); var retryResult = await executor.ExecuteAsync(call, context, TestContext.Current.CancellationToken); Assert.Contains("Exit code: 0", retryResult, StringComparison.Ordinal); @@ -712,7 +712,7 @@ await approvalService.RecordApprovalAsync( "signalr/thread-1", TrustAudience.Personal, new ToolName(toolCall.Name), - firstAttempt.ApprovalContext.UnapprovedPatterns, + firstAttempt.ApprovalContext.ApprovalEntries, persistent: false, TestContext.Current.CancellationToken); diff --git a/src/Netclaw.Actors.Tests/Tools/ToolApprovalActorTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolApprovalActorTests.cs index 0647a278..286c2899 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolApprovalActorTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolApprovalActorTests.cs @@ -88,7 +88,7 @@ public async Task Single_token_approval_requires_exact_match() } [Fact] - public async Task Multi_token_approval_prefix_matches() + public async Task Shell_exact_approval_does_not_prefix_match() { var ct = TestContext.Current.CancellationToken; var actor = Sys.ActorOf(ToolApprovalActor.CreateProps()); @@ -96,9 +96,44 @@ public async Task Multi_token_approval_prefix_matches() await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], persistent: false, ct); - // Multi-token "git push" should match "git push origin" via prefix var unapproved = await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push origin"], ct); - Assert.Empty(unapproved); + Assert.Equal(["git push origin"], unapproved); + } + + [Fact] + public async Task Shell_directory_root_approval_covers_other_verbs_under_same_root() + { + var ct = TestContext.Current.CancellationToken; + var actor = Sys.ActorOf(ToolApprovalActor.CreateProps()); + var service = CreateService(actor); + + await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["/home/user/.netclaw/logs/"], persistent: false, ct); + + Assert.Empty(await service.GetUnapprovedPatternsAsync( + "session-a", + TrustAudience.Personal, + new ToolName("shell_execute"), + ["/home/user/.netclaw/logs/"], + ct)); + } + + [Fact] + public async Task Shell_directory_root_approval_requires_all_roots_to_be_covered() + { + var ct = TestContext.Current.CancellationToken; + var actor = Sys.ActorOf(ToolApprovalActor.CreateProps()); + var service = CreateService(actor); + + await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["/home/user/.netclaw/logs/"], persistent: false, ct); + + var unapproved = await service.GetUnapprovedPatternsAsync( + "session-a", + TrustAudience.Personal, + new ToolName("shell_execute"), + ["/home/user/.netclaw/logs/", "/home/user/.netclaw/output/"], + ct); + + Assert.Equal(["/home/user/.netclaw/output/"], unapproved); } [Fact] diff --git a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs index d1e00d61..e7333c4a 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs @@ -56,7 +56,7 @@ public void Shell_in_approval_mode_returns_RequiresApproval_when_unapproved() Assert.True(decision.NeedsApproval); Assert.NotNull(decision.ApprovalContext); Assert.Equal("shell_execute", decision.ApprovalContext!.ToolName); - Assert.Contains("git push", decision.ApprovalContext.UnapprovedPatterns); + Assert.Contains("git push origin main", decision.ApprovalContext.Patterns); } [Fact] @@ -118,7 +118,7 @@ public void Unsupported_channel_returns_requires_approval_for_store_check() // RequiresApproval so the executor can check the persistent approval store. Assert.True(decision.NeedsApproval); Assert.NotNull(decision.ApprovalContext); - Assert.Contains("git push", decision.ApprovalContext!.UnapprovedPatterns); + Assert.Contains("git push", decision.ApprovalContext!.Patterns); } [Fact] @@ -130,9 +130,9 @@ public void Compound_command_surfaces_all_approval_patterns_for_service_filterin var decision = policy.AuthorizeInvocation(ShellTool(), PersonalContext(), args); Assert.True(decision.NeedsApproval); - Assert.Contains("git add", decision.ApprovalContext!.UnapprovedPatterns); - Assert.Contains("git commit", decision.ApprovalContext!.UnapprovedPatterns); - Assert.Contains("git push", decision.ApprovalContext.UnapprovedPatterns); + Assert.Contains("git add .", decision.ApprovalContext!.Patterns); + Assert.Contains("git commit -m fix", decision.ApprovalContext!.Patterns); + Assert.Contains("git push", decision.ApprovalContext.Patterns); } [Fact] @@ -196,7 +196,7 @@ public void file_write_to_netclaw_json_requires_approval_under_fail_closed_defau Assert.True(decision.NeedsApproval); Assert.NotNull(decision.ApprovalContext); Assert.Contains( - decision.ApprovalContext!.UnapprovedPatterns, + decision.ApprovalContext!.Patterns, p => p.StartsWith("file_write:control-plane:", StringComparison.Ordinal)); } @@ -218,7 +218,7 @@ public void file_write_to_control_plane_still_requires_approval_when_policy_exis Assert.True(decision.NeedsApproval); Assert.NotNull(decision.ApprovalContext); Assert.Contains( - decision.ApprovalContext!.UnapprovedPatterns, + decision.ApprovalContext!.Patterns, p => p.StartsWith("file_write:control-plane:", StringComparison.Ordinal)); } @@ -244,7 +244,7 @@ public void file_edit_of_netclaw_json_requires_approval() Assert.True(decision.NeedsApproval); Assert.Contains( - decision.ApprovalContext!.UnapprovedPatterns, + decision.ApprovalContext!.Patterns, p => p.StartsWith("file_edit:control-plane:", StringComparison.Ordinal)); } @@ -757,10 +757,10 @@ private sealed class FakeShellTrustZonePolicy : IShellTrustZonePolicy public IReadOnlyList GetTrustZoneRoots(ToolExecutionContext context) => _roots; } - // ── Directory-scoped approval patterns ── + // ── Directory-root shell approvals ── [Fact] - public void Shell_path_command_populates_directory_patterns() + public void Shell_path_command_populates_directory_roots_and_root_entries() { var policy = CreatePolicy(ToolApprovalMode.Approval); var args = ToolInput.Create("Command", "cat /home/user/.netclaw/logs/crash.log"); @@ -769,8 +769,9 @@ public void Shell_path_command_populates_directory_patterns() Assert.True(decision.NeedsApproval); Assert.NotNull(decision.ApprovalContext); - Assert.NotEmpty(decision.ApprovalContext!.DirectoryPatterns); - Assert.Contains(decision.ApprovalContext.DirectoryPatterns, p => p.EndsWith("/")); + Assert.NotEmpty(decision.ApprovalContext!.DirectoryRoots); + Assert.Contains("/home/user/.netclaw/logs/", decision.ApprovalContext.DirectoryRoots.Select(p => p.Replace('\\', '/'))); + Assert.Contains("/home/user/.netclaw/logs/", decision.ApprovalContext.ApprovalEntries.Select(p => p.Replace('\\', '/'))); } [Fact] @@ -785,12 +786,26 @@ public void Shell_path_command_labels_show_directory_scope() var options = decision.ApprovalContext!.Options; var sessionOption = options.Single(o => o.Key == ApprovalOptionKeys.ApproveSession); var alwaysOption = options.Single(o => o.Key == ApprovalOptionKeys.ApproveAlways); - Assert.StartsWith("Approve in ", sessionOption.Label); + Assert.StartsWith("Approve shell access in ", sessionOption.Label); Assert.Contains("for this chat", sessionOption.Label); - Assert.StartsWith("Approve in ", alwaysOption.Label); + Assert.StartsWith("Approve shell access in ", alwaysOption.Label); Assert.Contains("always", alwaysOption.Label); } + [Fact] + public void Shell_multi_root_command_uses_plural_directory_labels() + { + var policy = CreatePolicy(ToolApprovalMode.Approval); + var args = ToolInput.Create("Command", "cat /home/user/.netclaw/logs/app.log > /home/user/.netclaw/output/report.txt"); + + var decision = policy.AuthorizeInvocation(ShellTool(), PersonalContext(), args); + + Assert.True(decision.NeedsApproval); + var options = decision.ApprovalContext!.Options; + Assert.Equal("Approve shell access in these directories for this chat", options.Single(o => o.Key == ApprovalOptionKeys.ApproveSession).Label); + Assert.Equal("Approve shell access in these directories always", options.Single(o => o.Key == ApprovalOptionKeys.ApproveAlways).Label); + } + [Fact] public void Non_path_command_uses_default_labels() { @@ -800,8 +815,8 @@ public void Non_path_command_uses_default_labels() var decision = policy.AuthorizeInvocation(ShellTool(), PersonalContext(), args); Assert.True(decision.NeedsApproval); - // DirectoryPatterns contains verb-chain fallback ("git push"), but no directory scope - Assert.DoesNotContain(decision.ApprovalContext!.DirectoryPatterns, p => p.EndsWith("/")); + Assert.Empty(decision.ApprovalContext!.DirectoryRoots); + Assert.Equal(["git push origin main"], decision.ApprovalContext.ApprovalEntries); var sessionOption = decision.ApprovalContext.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveSession); var alwaysOption = decision.ApprovalContext.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveAlways); Assert.Equal(ApprovalOptionKeys.ApproveSessionLabel, sessionOption.Label); diff --git a/src/Netclaw.Actors/Protocol/SessionOutput.cs b/src/Netclaw.Actors/Protocol/SessionOutput.cs index 60641067..fdc9eb5e 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutput.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutput.cs @@ -361,12 +361,16 @@ public sealed record ToolInteractionRequest : SessionOutput public IReadOnlyList Patterns { get; init; } = []; /// - /// Directory-scoped patterns for session/persistent approval storage. - /// When non-empty and the user selects "Approve for this chat" or "Approve always", - /// these patterns are recorded instead of to provide - /// directory-level coverage (e.g., "grep /home/.netclaw/logs/"). + /// Approval entries used for session and persistent approval lookup. + /// For shell commands these are directory roots when extractable and exact + /// fallback patterns otherwise. /// - public IReadOnlyList DirectoryPatterns { get; init; } = []; + public IReadOnlyList ApprovalEntries { get; init; } = []; + + /// + /// Directory roots extracted from the invocation for broader B/C shell approvals. + /// + public IReadOnlyList DirectoryRoots { get; init; } = []; /// Available response options (e.g., approve once, approve for this chat, approve always, deny). public required IReadOnlyList Options { get; init; } diff --git a/src/Netclaw.Actors/Protocol/SessionOutputDto.cs b/src/Netclaw.Actors/Protocol/SessionOutputDto.cs index 7248ac19..1d3c25ba 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutputDto.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutputDto.cs @@ -106,6 +106,8 @@ public sealed record SessionOutputDto public string? InteractionDisplayText { get; init; } public string? RequesterSenderId { get; init; } public List? InteractionPatterns { get; init; } + public List? InteractionApprovalEntries { get; init; } + public List? InteractionDirectoryRoots { get; init; } public List? InteractionOptions { get; init; } public bool? InteractionHasAdoptedContext { get; init; } public List? InteractionAdoptedSpeakerIds { get; init; } diff --git a/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs b/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs index c3c058ad..dff0a990 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs @@ -179,6 +179,8 @@ public static class SessionOutputDtoMapper InteractionDisplayText = msg.DisplayText, RequesterSenderId = msg.RequesterSenderId, InteractionPatterns = [.. msg.Patterns], + InteractionApprovalEntries = [.. msg.ApprovalEntries], + InteractionDirectoryRoots = [.. msg.DirectoryRoots], InteractionOptions = [.. msg.Options], InteractionHasAdoptedContext = msg.HasAdoptedContext, InteractionAdoptedSpeakerIds = [.. msg.AdoptedSpeakerIds] @@ -336,6 +338,8 @@ public static SessionOutput FromDto(SessionOutputDto dto) HasAdoptedContext = dto.InteractionHasAdoptedContext ?? false, AdoptedSpeakerIds = dto.InteractionAdoptedSpeakerIds ?? [], Patterns = dto.InteractionPatterns ?? [], + ApprovalEntries = dto.InteractionApprovalEntries ?? [], + DirectoryRoots = dto.InteractionDirectoryRoots ?? [], Options = dto.InteractionOptions ?? [] }, _ => new ErrorOutput diff --git a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs index 5e9db9ee..25898886 100644 --- a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs +++ b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs @@ -746,7 +746,8 @@ private void Processing() _pendingToolInteractions[msg.CallId] = new PendingToolInteraction( msg.ToolName, msg.Patterns, - msg.DirectoryPatterns, + msg.ApprovalEntries, + msg.DirectoryRoots, CurrentTurnAudience(), msg.RequesterSenderId, msg.RequesterPrincipal, @@ -796,14 +797,11 @@ private void Processing() if (decision is ApprovalDecision.ApprovedSession or ApprovalDecision.ApprovedAlways && _approvalService is not null) { - var patternsToRecord = pending.DirectoryPatterns.Count > 0 - ? pending.DirectoryPatterns - : pending.Patterns; await _approvalService.RecordApprovalAsync( _sessionId.Value, pending.Audience, new ToolName(pending.ToolName), - patternsToRecord, + pending.ApprovalEntries, persistent: decision == ApprovalDecision.ApprovedAlways, CancellationToken.None); } @@ -3046,7 +3044,8 @@ private void EmitOutput(SessionOutput output, OutputFilter requiredFlag = Output private sealed record PendingToolInteraction( string ToolName, IReadOnlyList Patterns, - IReadOnlyList DirectoryPatterns, + IReadOnlyList ApprovalEntries, + IReadOnlyList DirectoryRoots, TrustAudience Audience, string? RequesterSenderId, PrincipalClassification? RequesterPrincipal, diff --git a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs index b1921106..e0d76988 100644 --- a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs +++ b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs @@ -283,8 +283,9 @@ public static async Task ExecuteSingleToolAsync( HasAdoptedContext = source?.HasAdoptedContext ?? false, AdoptedSpeakerIds = source?.AdoptedSpeakerIds ?? [], PersistedAdoptedContext = source?.HasAdoptedContext ?? false, - Patterns = ctx.UnapprovedPatterns, - DirectoryPatterns = ctx.DirectoryPatterns, + Patterns = ctx.Patterns, + ApprovalEntries = ctx.ApprovalEntries, + DirectoryRoots = ctx.DirectoryRoots, Options = ctx.Options .Select(o => new ToolInteractionOption(o.Key, o.Label)) .ToList() @@ -302,7 +303,7 @@ public static async Task ExecuteSingleToolAsync( if (decision == ApprovalDecision.ApprovedOnce) { context.OneTimeApprovedToolName = tc.Name; - context.SetOneTimeApprovedPatterns(ctx.UnapprovedPatterns); + context.SetOneTimeApprovedPatterns(ctx.Patterns); } sw = Stopwatch.StartNew(); @@ -318,13 +319,13 @@ public static async Task ExecuteSingleToolAsync( meta.TimeoutHintSeconds ?? shellTimeoutSeconds, sw.Elapsed, logger, decision.ToString(), - string.Join(", ", ctx.UnapprovedPatterns)); + string.Join(", ", ctx.Patterns)); } resultText = await ExecuteToolAttemptAsync(executor, tc, context, timeout, ct); sw.Stop(); - var patternStr = string.Join(", ", ctx.UnapprovedPatterns); + var patternStr = string.Join(", ", ctx.Patterns); auditLogger?.Log(BuildAuditEntry(sessionId, tc, timeProvider, sw.Elapsed, meta) with { Allowed = true, @@ -339,7 +340,7 @@ public static async Task ExecuteSingleToolAsync( : $"Tool access denied: approval_denied_by_user ({tc.Name} requires interactive approval and the user declined it)"; resultText = reason; - var deniedPatternStr = string.Join(", ", ctx.UnapprovedPatterns); + var deniedPatternStr = string.Join(", ", ctx.Patterns); auditLogger?.Log(BuildAuditEntry(sessionId, tc, timeProvider, sw.Elapsed, meta) with { Allowed = false, diff --git a/src/Netclaw.Actors/SubAgents/SubAgentActor.cs b/src/Netclaw.Actors/SubAgents/SubAgentActor.cs index 0d62e2b0..1d1df3db 100644 --- a/src/Netclaw.Actors/SubAgents/SubAgentActor.cs +++ b/src/Netclaw.Actors/SubAgents/SubAgentActor.cs @@ -419,7 +419,7 @@ private static async Task ExecuteToolsAsync( new ToolCallId(tc.CallId), ctx.ToolName, ctx.DisplayText, - ctx.UnapprovedPatterns, + ctx.Patterns, ct); if (decision is ParentApprovalDecision.ApprovedOnce @@ -432,7 +432,7 @@ or ParentApprovalDecision.ApprovedSession // across parallel tool calls or later iterations. var retryContext = CreatePerToolExecutionContext(executionContext); retryContext.OneTimeApprovedToolName = tc.Name; - retryContext.SetOneTimeApprovedPatterns(ctx.UnapprovedPatterns); + retryContext.SetOneTimeApprovedPatterns(ctx.Patterns); var result = await executor.ExecuteAsync(tc, retryContext, ct); return new SerializableChatMessage diff --git a/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs b/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs index bcf5e37e..0497c40f 100644 --- a/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs +++ b/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs @@ -112,17 +112,12 @@ private async Task AuthorizeCoreAsync(FunctionCallContent toolCall context?.SessionId, audience, new ToolName(toolCall.Name), - approvalContext.UnapprovedPatterns, + approvalContext.ApprovalEntries, ct); accessDecision = unapproved.Count == 0 ? ToolAccessDecision.Allow() - : ToolAccessDecision.RequiresApproval(new ToolApprovalContext( - approvalContext.ToolName, - approvalContext.DisplayText, - unapproved, - approvalContext.Options, - approvalContext.DirectoryPatterns)); + : ToolAccessDecision.RequiresApproval(approvalContext); } if (accessDecision.NeedsApproval @@ -162,12 +157,12 @@ private static bool IsOneTimeApprovalSatisfied( if (context.OneTimeApprovedPatterns.Count == 0) return false; - if (approvalContext.UnapprovedPatterns.Count == 0) + if (approvalContext.Patterns.Count == 0) return false; if (!string.Equals(context.OneTimeApprovedToolName, toolCall.Name, StringComparison.Ordinal)) return false; - return approvalContext.UnapprovedPatterns.All(pattern => context.OneTimeApprovedPatterns.Contains(pattern)); + return approvalContext.Patterns.All(pattern => context.OneTimeApprovedPatterns.Contains(pattern)); } } diff --git a/src/Netclaw.Actors/Tools/FilePathApprovalMatcher.cs b/src/Netclaw.Actors/Tools/FilePathApprovalMatcher.cs index 9a950fcb..c620f6ea 100644 --- a/src/Netclaw.Actors/Tools/FilePathApprovalMatcher.cs +++ b/src/Netclaw.Actors/Tools/FilePathApprovalMatcher.cs @@ -43,6 +43,9 @@ public IReadOnlyList ExtractPatterns(ToolName toolName, IDictionary ExtractApprovalEntries(ToolName toolName, IDictionary? arguments) + => ExtractPatterns(toolName, arguments); + public bool IsApproved(ToolName toolName, IDictionary? arguments, IEnumerable approvedPatterns) { var patterns = ExtractPatterns(toolName, arguments); @@ -73,7 +76,7 @@ public string FormatForDisplay(ToolName toolName, IDictionary? return toolName.Value; } - public IReadOnlyList ExtractDirectoryPatterns(ToolName toolName, IDictionary? arguments) + public IReadOnlyList ExtractDirectoryRoots(ToolName toolName, IDictionary? arguments) => []; private bool TryGetControlPlaneRelativePath( diff --git a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs index 68e1c708..2cd0310b 100644 --- a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs +++ b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs @@ -297,35 +297,37 @@ private ToolAccessDecision CheckApprovalGate( return ToolAccessDecision.Allow(); } - var allPatterns = matcher.ExtractPatterns(toolName, arguments); - var directoryPatterns = matcher.ExtractDirectoryPatterns(toolName, arguments); + var patterns = matcher.ExtractPatterns(toolName, arguments); + var approvalEntries = matcher.ExtractApprovalEntries(toolName, arguments); + var directoryRoots = matcher.ExtractDirectoryRoots(toolName, arguments); var displayText = matcher.FormatForDisplay(toolName, arguments); var sessionLabel = ApprovalOptionKeys.ApproveSessionLabel; var alwaysLabel = ApprovalOptionKeys.ApproveAlwaysLabel; - var firstDirScope = directoryPatterns.FirstOrDefault(p => p.EndsWith('/')); - if (firstDirScope is not null) + if (directoryRoots.Count == 1) { - var spaceIdx = firstDirScope.IndexOf(' ', StringComparison.Ordinal); - if (spaceIdx >= 0) - { - var dir = firstDirScope[(spaceIdx + 1)..]; - sessionLabel = $"Approve in {dir} for this chat"; - alwaysLabel = $"Approve in {dir} always"; - } + var dir = directoryRoots[0].DisplayPath; + sessionLabel = $"Approve shell access in {dir} for this chat"; + alwaysLabel = $"Approve shell access in {dir} always"; + } + else if (directoryRoots.Count > 1) + { + sessionLabel = "Approve shell access in these directories for this chat"; + alwaysLabel = "Approve shell access in these directories always"; } var approvalContext = new ToolApprovalContext( toolName.Value, displayText, - allPatterns, + patterns, + approvalEntries, [ new ToolApprovalOption(ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.ApproveOnceLabel), new ToolApprovalOption(ApprovalOptionKeys.ApproveSession, sessionLabel), new ToolApprovalOption(ApprovalOptionKeys.ApproveAlways, alwaysLabel), new ToolApprovalOption(ApprovalOptionKeys.Deny, ApprovalOptionKeys.DenyLabel) ], - directoryPatterns); + [.. directoryRoots.Select(static x => x.ComparisonRoot)]); return ToolAccessDecision.RequiresApproval(approvalContext); } @@ -471,9 +473,10 @@ public sealed record ToolAccessDecision(bool Allowed, string? DenyReason = null, public sealed record ToolApprovalContext( string ToolName, string DisplayText, - IReadOnlyList UnapprovedPatterns, + IReadOnlyList Patterns, + IReadOnlyList ApprovalEntries, IReadOnlyList Options, - IReadOnlyList DirectoryPatterns); + IReadOnlyList DirectoryRoots); public sealed record ToolApprovalOption(string Key, string Label); diff --git a/src/Netclaw.Actors/Tools/ToolApprovalActor.cs b/src/Netclaw.Actors/Tools/ToolApprovalActor.cs index d7c7e266..1e401558 100644 --- a/src/Netclaw.Actors/Tools/ToolApprovalActor.cs +++ b/src/Netclaw.Actors/Tools/ToolApprovalActor.cs @@ -58,7 +58,7 @@ private bool IsApproved(SessionId? sessionId, TrustAudience audience, ToolName t if (_persistentStore is null) return false; - return ApprovalPatternMatching.MatchesAny(pattern, _persistentStore.GetApprovedPatterns(audience, toolName.Value)); + return MatchesApprovedEntry(toolName, pattern, _persistentStore.GetApprovedPatterns(audience, toolName.Value)); } private bool IsSessionApproved(SessionId sessionId, TrustAudience audience, ToolName toolName, string pattern) @@ -71,7 +71,7 @@ private bool IsSessionApproved(SessionId sessionId, TrustAudience audience, Tool var sessionKey = BuildSessionKey((SessionId)scopeId, audience); if (_sessionApprovals.TryGetValue(sessionKey, out var toolMap) && toolMap.TryGetValue(toolName.Value, out var patterns) - && ApprovalPatternMatching.MatchesAny(pattern, patterns)) + && MatchesApprovedEntry(toolName, pattern, patterns)) { return true; } @@ -104,6 +104,11 @@ private void AddSessionApproval(SessionId sessionId, TrustAudience audience, Too patterns.Add(pattern); } + private static bool MatchesApprovedEntry(ToolName toolName, string candidate, IEnumerable approvedEntries) + => string.Equals(toolName.Value, ShellTool.ToolName, StringComparison.Ordinal) + ? ApprovalPatternMatching.MatchesShellApprovalEntry(candidate, approvedEntries) + : ApprovalPatternMatching.MatchesAny(candidate, approvedEntries); + private static string BuildSessionKey(SessionId sessionId, TrustAudience audience) => $"{sessionId.Value}|{audience.ToWireValue()}"; } diff --git a/src/Netclaw.Channels.Discord/DiscordApprovalPromptBuilder.cs b/src/Netclaw.Channels.Discord/DiscordApprovalPromptBuilder.cs index ba2245e7..1b0fec98 100644 --- a/src/Netclaw.Channels.Discord/DiscordApprovalPromptBuilder.cs +++ b/src/Netclaw.Channels.Discord/DiscordApprovalPromptBuilder.cs @@ -20,14 +20,14 @@ public static string BuildTextPrompt(ToolInteractionRequest request) if (request.Patterns.Count > 0) sb.Append("Pattern: ").AppendLine(string.Join(", ", request.Patterns)); + if (request.DirectoryRoots.Count > 0) + sb.Append("Directory roots: ").AppendLine(string.Join(", ", request.DirectoryRoots)); + AppendAdoptedContextSummary(sb, 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(); } @@ -39,7 +39,7 @@ public static (string Text, IReadOnlyList Buttons) BuildButto 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 buttons = request.Options .Select(option => new DiscordButtonSpec( @@ -95,6 +95,20 @@ private static void AppendToolSummary(StringBuilder sb, ToolInteractionRequest r } } + if (request.DirectoryRoots.Count > 0) + { + if (request.DirectoryRoots.Count == 1) + { + sb.Append("**Directory Root:** `").Append(request.DirectoryRoots[0]).AppendLine("`"); + } + else + { + sb.AppendLine("**Directory Roots:**"); + foreach (var root in request.DirectoryRoots) + sb.Append(" • `").Append(root).AppendLine("`"); + } + } + AppendAdoptedContextSummary(sb, request); } @@ -107,6 +121,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(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 GetDecisionLabel(string selectedKey) => selectedKey switch { diff --git a/src/Netclaw.Channels.Slack/SlackApprovalBlockBuilder.cs b/src/Netclaw.Channels.Slack/SlackApprovalBlockBuilder.cs index dfa11a17..c4b0357b 100644 --- a/src/Netclaw.Channels.Slack/SlackApprovalBlockBuilder.cs +++ b/src/Netclaw.Channels.Slack/SlackApprovalBlockBuilder.cs @@ -34,14 +34,19 @@ public static string BuildApprovalText(ToolInteractionRequest request) } } + if (request.DirectoryRoots.Count > 0) + { + lines.Add("Directory roots:"); + foreach (var root in request.DirectoryRoots) + lines.Add($" • `{root}`"); + } + AppendAdoptedContextSummary(lines, request); lines.Add(""); lines.Add("Reply with:"); - lines.Add($" *A)* {ApprovalOptionKeys.ApproveOnceLabel}"); - lines.Add($" *B)* {ApprovalOptionKeys.ApproveSessionLabel}"); - lines.Add($" *C)* {ApprovalOptionKeys.ApproveAlwaysLabel}"); - lines.Add($" *D)* {ApprovalOptionKeys.DenyLabel}"); + foreach (var replyOption in EnumerateReplyOptions(request.Options)) + lines.Add($" *{replyOption.Letter})* {replyOption.Option.Label}"); return string.Join("\n", lines); } @@ -70,6 +75,15 @@ public static IReadOnlyList BuildApprovalBlocks(ToolInteractionRequest re }); } + if (request.DirectoryRoots.Count > 0) + { + var rootLines = request.DirectoryRoots.Select(root => $"• `{EscapeMarkdown(root)}`"); + blocks.Add(new SectionBlock + { + Text = new Markdown($"*Directory Roots*\n{string.Join("\n", rootLines)}") + }); + } + if (request.HasAdoptedContext) { blocks.Add(new SectionBlock @@ -93,7 +107,7 @@ public static IReadOnlyList BuildApprovalBlocks(ToolInteractionRequest re blocks.Add(new SectionBlock { - Text = new Markdown("You can also reply with `A`, `B`, `C`, or `D` in this thread.") + Text = new Markdown($"You can also reply with {FormatReplyLetters(request.Options)} in this thread.") }); return blocks; @@ -152,6 +166,15 @@ public static IReadOnlyList BuildResolvedApprovalBlocks( }); } + if (request.DirectoryRoots.Count > 0) + { + var rootLines = request.DirectoryRoots.Select(root => $"• `{EscapeMarkdown(root)}`"); + blocks.Add(new SectionBlock + { + Text = new Markdown($"*Directory Roots*\n{string.Join("\n", rootLines)}") + }); + } + if (request.HasAdoptedContext) { blocks.Add(new SectionBlock @@ -174,6 +197,18 @@ private static void AppendAdoptedContextSummary(List lines, ToolInteract private static string BuildAdoptedContextMarkdown(ToolInteractionRequest request) => $"*Adopted context:* present\n*Speakers:* `{EscapeMarkdown(string.Join(", ", request.AdoptedSpeakerIds))}`"; + private static IEnumerable<(string Letter, ToolInteractionOption Option)> EnumerateReplyOptions(IReadOnlyList options) + { + for (var i = 0; i < options.Count; i++) + yield return (GetReplyLetter(i), options[i]); + } + + private static string FormatReplyLetters(IReadOnlyList options) + => string.Join(", ", EnumerateReplyOptions(options).Select(static x => $"`{x.Letter}`")); + + private static string GetReplyLetter(int index) + => ((char)('A' + index)).ToString(); + internal static string BuildButtonValue(ToolInteractionRequest request, ToolInteractionOption option) => ApprovalButtonValueCodec.Encode(request, option); diff --git a/src/Netclaw.Cli.Tests/Cli/DaemonClientMappingTests.cs b/src/Netclaw.Cli.Tests/Cli/DaemonClientMappingTests.cs index 838212fa..2a343ff1 100644 --- a/src/Netclaw.Cli.Tests/Cli/DaemonClientMappingTests.cs +++ b/src/Netclaw.Cli.Tests/Cli/DaemonClientMappingTests.cs @@ -266,6 +266,8 @@ public void ToolInteractionRequest_roundtrips_through_dto() DisplayText = "git push origin main", RequesterSenderId = "device-1", Patterns = ["git push"], + ApprovalEntries = ["git push"], + DirectoryRoots = [], Options = [ new ToolInteractionOption(ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.ApproveOnceLabel), @@ -280,6 +282,8 @@ public void ToolInteractionRequest_roundtrips_through_dto() Assert.Equal("approval", dto.InteractionKind); Assert.Equal("git push origin main", dto.InteractionDisplayText); Assert.Equal("device-1", dto.RequesterSenderId); + Assert.Equal(["git push"], dto.InteractionApprovalEntries); + Assert.Equal([], dto.InteractionDirectoryRoots ?? []); var roundTripped = DaemonClient.FromDto(dto); var result = Assert.IsType(roundTripped); @@ -288,6 +292,8 @@ public void ToolInteractionRequest_roundtrips_through_dto() Assert.Equal("git push origin main", result.DisplayText); Assert.Equal("device-1", result.RequesterSenderId); Assert.Equal(["git push"], result.Patterns); + Assert.Equal(["git push"], result.ApprovalEntries); + Assert.Equal([], result.DirectoryRoots); Assert.Equal(4, result.Options.Count); } diff --git a/src/Netclaw.Cli/Tui/ChatPage.cs b/src/Netclaw.Cli/Tui/ChatPage.cs index 4bce9528..01a18673 100644 --- a/src/Netclaw.Cli/Tui/ChatPage.cs +++ b/src/Netclaw.Cli/Tui/ChatPage.cs @@ -359,6 +359,8 @@ 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); + if (msg.DirectoryRoots.Count > 0) + _chatHistory.AppendLine($" Directory roots: {string.Join(", ", msg.DirectoryRoots)}", Color.BrightBlack); _chatHistory.AppendLine(" Choose Approve once, Approve for this chat, Approve always, or Deny below.", Color.Yellow); _chatHistory.ScrollToBottom(); break; diff --git a/src/Netclaw.Cli/Tui/ChatViewModel.cs b/src/Netclaw.Cli/Tui/ChatViewModel.cs index 2ca700f8..2fcc80f0 100644 --- a/src/Netclaw.Cli/Tui/ChatViewModel.cs +++ b/src/Netclaw.Cli/Tui/ChatViewModel.cs @@ -255,7 +255,10 @@ public string GetApprovalPrompt() var patterns = interaction.Patterns.Count > 0 ? $" Patterns: {string.Join(", ", interaction.Patterns)}" : string.Empty; - return $"Approval required for {interaction.ToolName}. {interaction.DisplayText}{patterns}"; + var roots = interaction.DirectoryRoots.Count > 0 + ? $" Directory roots: {string.Join(", ", interaction.DirectoryRoots)}" + : string.Empty; + return $"Approval required for {interaction.ToolName}. {interaction.DisplayText}{patterns}{roots}"; } public string? GetApprovalHint() diff --git a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs index 40f7adc7..a2e12528 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs @@ -14,12 +14,19 @@ public sealed class ShellApprovalMatcherTests private static Dictionary Args(string command) => new() { ["Command"] = command }; + private static Dictionary Args(string command, string workingDirectory) + => new() + { + ["Command"] = command, + ["WorkingDirectory"] = workingDirectory + }; + [Fact] public void ExtractPatterns_simple_command() { var patterns = _matcher.ExtractPatterns(new ToolName("shell_execute"), Args("git push origin main")); Assert.Single(patterns); - Assert.Equal("git push", patterns[0]); + Assert.Equal("git push origin main", patterns[0]); } [Fact] @@ -28,8 +35,8 @@ public void ExtractPatterns_compound_command() var patterns = _matcher.ExtractPatterns(new ToolName("shell_execute"), Args("git add . && git commit -m fix && git push")); Assert.Equal(3, patterns.Count); - Assert.Contains("git add", patterns); - Assert.Contains("git commit", patterns); + Assert.Contains("git add .", patterns); + Assert.Contains("git commit -m fix", patterns); Assert.Contains("git push", patterns); } @@ -38,9 +45,9 @@ public void ExtractPatterns_deduplicates() { var patterns = _matcher.ExtractPatterns(new ToolName("shell_execute"), Args("git push && git push --tags")); - // Both segments produce "git push", should be deduplicated - Assert.Single(patterns); - Assert.Equal("git push", patterns[0]); + Assert.Equal(2, patterns.Count); + Assert.Contains("git push", patterns); + Assert.Contains("git push --tags", patterns); } [Fact] @@ -49,7 +56,7 @@ public void ExtractPatterns_recurses_into_bash_c_wrapper() var patterns = _matcher.ExtractPatterns(new ToolName("shell_execute"), Args("bash -c \"git push --force\"")); Assert.Single(patterns); - Assert.Equal("git push", patterns[0]); + Assert.Equal("git push --force", patterns[0]); } [Fact] @@ -59,7 +66,7 @@ public void ExtractPatterns_batches_outer_and_inner_segments() Assert.Equal(2, patterns.Count); Assert.Contains("echo ok", patterns); - Assert.Contains("git push", patterns); + Assert.Contains("git push --force", patterns); } [Fact] @@ -72,7 +79,7 @@ public void ExtractPatterns_empty_command() [Fact] public void IsApproved_all_patterns_approved() { - var approved = new[] { "git add", "git commit", "git push" }; + var approved = new[] { "git add .", "git commit -m fix", "git push" }; Assert.True(_matcher.IsApproved(new ToolName("shell_execute"), Args("git add . && git commit -m fix && git push"), approved)); } @@ -80,38 +87,18 @@ public void IsApproved_all_patterns_approved() [Fact] public void IsApproved_one_pattern_unapproved() { - var approved = new[] { "git add", "git push" }; + var approved = new[] { "git add .", "git push" }; Assert.False(_matcher.IsApproved(new ToolName("shell_execute"), Args("git add . && git commit -m fix && git push"), approved)); } [Theory] - [InlineData("gh", "gh --help", true)] // Single-token exact match - [InlineData("gh", "gh pr create", false)] // Single-token should NOT prefix match - [InlineData("git push", "git push origin main", true)] // Multi-token prefix match - [InlineData("git push", "git pull", false)] // Multi-token no match - [InlineData("git pu", "git push", false)] // Partial token no match (word boundary) - [InlineData("gh pr", "gh pr create", true)] // Multi-token prefix match - [InlineData("gh pr", "gh issue list", false)] // Multi-token no match - // Path-aware patterns — exact path matches - [InlineData("cat /etc/passwd", "cat /etc/passwd", true)] - [InlineData("cat /etc/passwd", "cat /etc/shadow", false)] - [InlineData("bash /home/.netclaw/scripts/monitor.sh", "bash /home/.netclaw/scripts/monitor.sh", true)] - [InlineData("bash /home/.netclaw/scripts/monitor.sh", "bash /tmp/evil.sh", false)] - // Directory-scoped patterns (trailing /) match files under that directory - [InlineData("cat /home/user/.netclaw/logs/", "cat /home/user/.netclaw/logs/crash.log", true)] - [InlineData("cat /home/user/.netclaw/logs/", "cat /home/user/.netclaw/logs/deep/nested.txt", true)] - [InlineData("cat /home/user/.netclaw/logs/", "cat /home/user/.netclaw/config/secret.json", false)] - [InlineData("grep /home/user/.netclaw/logs/", "cat /home/user/.netclaw/logs/crash.log", false)] // verb mismatch - [InlineData("ls /home/user/.netclaw/", "ls /home/user/.netclaw/logs/deep/file.txt", true)] // nested - // Single-token path-aware verbs stay exact-only - [InlineData("cat", "cat /etc/passwd", false)] - [InlineData("grep", "grep TODO", false)] - [InlineData("bash", "bash /tmp/script.sh", false)] - [InlineData("find", "find /var/log", false)] - // Non-path-aware single tokens still require exact match - [InlineData("echo", "echo hello", false)] - [InlineData("docker", "docker compose", false)] + [InlineData("git push", "git push", true)] + [InlineData("git push", "git push origin main", false)] + [InlineData("gh", "gh --help", false)] + [InlineData("/home/user/.netclaw/logs/", "cat /home/user/.netclaw/logs/crash.log", true)] + [InlineData("/home/user/.netclaw/logs/", "grep timeout /home/user/.netclaw/logs/crash.log", true)] + [InlineData("/home/user/.netclaw/logs/", "cat /home/user/.netclaw/config/secret.json", false)] public void IsApproved_pattern_matching(string pattern, string command, bool expected) { var approved = new[] { pattern }; @@ -121,32 +108,31 @@ public void IsApproved_pattern_matching(string pattern, string command, bool exp [Fact] public void IsApproved_recurses_into_bash_c_wrapper() { - var approved = new[] { "git push" }; + var approved = new[] { "git push --force" }; Assert.True(_matcher.IsApproved(new ToolName("shell_execute"), Args("bash -c \"git push --force\""), approved)); } [Fact] - public void ExtractPatterns_path_aware_verb_includes_path() + public void ExtractPatterns_normalize_paths_but_keep_full_unit_shape() { var patterns = _matcher.ExtractPatterns( new ToolName("shell_execute"), Args("cat /etc/hosts && git push origin main")); Assert.Equal(2, patterns.Count); Assert.Contains("cat /etc/hosts", patterns); - Assert.Contains("git push", patterns); + Assert.Contains("git push origin main", patterns); } [Fact] - public void ExtractPatterns_pipe_with_path_aware_verbs() + public void ExtractPatterns_keep_pipeline_together_as_one_unit() { var patterns = _matcher.ExtractPatterns( new ToolName("shell_execute"), Args("cat /var/log/syslog | grep error")); - Assert.Equal(2, patterns.Count); - Assert.Contains("cat /var/log/syslog", patterns); - Assert.Contains("grep error", patterns); + Assert.Single(patterns); + Assert.Equal("cat /var/log/syslog | grep error", patterns[0]); } [Fact] @@ -156,56 +142,57 @@ public void FormatForDisplay_returns_command() Assert.Equal("git push origin main", display); } - // ── ExtractDirectoryPatterns ── + // ── ExtractDirectoryRoots / ExtractApprovalEntries ── [Fact] - public void ExtractDirectoryPatterns_simple_path_command() + public void ExtractDirectoryRoots_simple_path_command() { - var patterns = _matcher.ExtractDirectoryPatterns( + var roots = _matcher.ExtractDirectoryRoots( new ToolName("shell_execute"), Args("cat /home/user/.netclaw/logs/crash.log")); - Assert.Single(patterns); - Assert.EndsWith("/", patterns[0]); - Assert.StartsWith("cat ", patterns[0]); + Assert.Single(roots); + Assert.Equal("/home/user/.netclaw/logs/", roots[0].ComparisonRoot.Replace('\\', '/')); } [Fact] - public void ExtractDirectoryPatterns_compound_command() + public void ExtractDirectoryRoots_pipeline_and_multiple_verbs_share_same_root() { - var patterns = _matcher.ExtractDirectoryPatterns( + var roots = _matcher.ExtractDirectoryRoots( new ToolName("shell_execute"), - Args("cat /home/user/.netclaw/logs/crash.log && grep 'error' /home/user/.netclaw/logs/app.log")); - Assert.Equal(2, patterns.Count); - Assert.Contains(patterns, p => p.StartsWith("cat ")); - Assert.Contains(patterns, p => p.StartsWith("grep ")); + Args("grep 'error' /home/user/.netclaw/logs/app.log | wc -l")); + Assert.Single(roots); + Assert.Equal("/home/user/.netclaw/logs/", roots[0].ComparisonRoot.Replace('\\', '/')); } [Fact] - public void ExtractDirectoryPatterns_falls_back_to_verb_chain_when_no_path() + public void ExtractDirectoryRoots_returns_empty_when_no_reusable_roots_exist() { - var patterns = _matcher.ExtractDirectoryPatterns( + var roots = _matcher.ExtractDirectoryRoots( new ToolName("shell_execute"), Args("git push origin main")); - Assert.Single(patterns); - Assert.Equal("git push", patterns[0]); + Assert.Empty(roots); } [Fact] - public void ExtractDirectoryPatterns_empty_command() + public void ExtractApprovalEntries_use_roots_when_available() { - var patterns = _matcher.ExtractDirectoryPatterns(new ToolName("shell_execute"), Args("")); - Assert.Empty(patterns); + var entries = _matcher.ExtractApprovalEntries( + new ToolName("shell_execute"), + Args("grep 'error' /home/user/.netclaw/logs/app.log | wc -l")); + + Assert.Single(entries); + Assert.Equal("/home/user/.netclaw/logs/", entries[0].Replace('\\', '/')); } [Fact] - public void ExtractDirectoryPatterns_mixed_compound_with_fallback() + public void ExtractApprovalEntries_fall_back_to_exact_unit_when_no_roots_exist() { - var patterns = _matcher.ExtractDirectoryPatterns( + var entries = _matcher.ExtractApprovalEntries( new ToolName("shell_execute"), Args("cat /home/user/.netclaw/logs/crash.log && git push origin main")); - Assert.Equal(2, patterns.Count); - Assert.Contains(patterns, p => p.StartsWith("cat ") && p.EndsWith("/")); - Assert.Contains(patterns, p => p == "git push"); + Assert.Equal(2, entries.Count); + Assert.Contains("/home/user/.netclaw/logs/", entries.Select(p => p.Replace('\\', '/'))); + Assert.Contains("git push origin main", entries); } } diff --git a/src/Netclaw.Security.Tests/ShellTokenizerTests.cs b/src/Netclaw.Security.Tests/ShellTokenizerTests.cs index 0f2a521c..cd5b1baf 100644 --- a/src/Netclaw.Security.Tests/ShellTokenizerTests.cs +++ b/src/Netclaw.Security.Tests/ShellTokenizerTests.cs @@ -70,10 +70,11 @@ public void SplitCompound_splits_on_semicolon() } [Fact] - public void SplitCompound_splits_on_pipe() + public void SplitCompound_keeps_pipeline_in_same_approval_unit() { var segments = ShellTokenizer.SplitCompoundCommand("cat file.txt | grep error"); - Assert.Equal(["cat file.txt", "grep error"], segments); + Assert.Single(segments); + Assert.Equal("cat file.txt | grep error", segments[0]); } [Fact] @@ -271,52 +272,65 @@ public void LooksLikePath_backslash(string token) Assert.True(ShellTokenizer.LooksLikePath(token)); } - // ── ExtractDirectoryScope ── + // ── ExtractDirectoryRoots ── - [Theory] - [InlineData("cat /home/user/.netclaw/logs/crash.log", "cat", "/home/user/.netclaw/logs/")] - [InlineData("ls -la /home/user/.netclaw/logs/", "ls", "/home/user/.netclaw/logs/")] - [InlineData("find /home/user/.netclaw/logs -name '*.log'", "find", "/home/user/.netclaw/")] - public void ExtractDirectoryScope_returns_verb_and_directory(string command, string expectedVerb, string expectedDirSuffix) + [Fact] + public void ExtractDirectoryRoots_returns_normalized_root_for_file_path() { - var result = ShellTokenizer.ExtractDirectoryScope(command); - Assert.NotNull(result); - Assert.StartsWith(expectedVerb + " ", result); - Assert.EndsWith("/", result); + var roots = ShellTokenizer.ExtractDirectoryRoots("cat /home/user/.netclaw/logs/crash.log"); + + Assert.Single(roots); + Assert.Equal("/home/user/.netclaw/logs/", roots[0].ComparisonRoot.Replace('\\', '/')); + Assert.Equal("/home/user/.netclaw/logs/", roots[0].DisplayPath.Replace('\\', '/')); + } - var dir = result[(expectedVerb.Length + 1)..]; - Assert.EndsWith(expectedDirSuffix, dir.Replace('\\', '/')); + [Fact] + public void ExtractDirectoryRoots_keeps_relative_display_path_and_normalized_comparison_root() + { + var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + var logs = Path.Combine(root, "logs"); + Directory.CreateDirectory(logs); + + try + { + var roots = ShellTokenizer.ExtractDirectoryRoots("grep timeout logs/app.log | wc -l", root); + + Assert.Single(roots); + Assert.Equal("logs/", roots[0].DisplayPath.Replace('\\', '/')); + Assert.Equal(PathUtility.Normalize(logs) + Path.DirectorySeparatorChar, roots[0].ComparisonRoot); + } + finally + { + Directory.Delete(root, recursive: true); + } } [Fact] - public void ExtractDirectoryScope_grep_finds_path_not_search_term() + public void ExtractDirectoryRoots_handles_glob_paths() { - var result = ShellTokenizer.ExtractDirectoryScope( - "grep -l \"timeout\" /home/user/.netclaw/logs/daemon.log"); - Assert.NotNull(result); - Assert.StartsWith("grep ", result); - Assert.EndsWith("/", result); - Assert.Contains(".netclaw/logs/", result.Replace('\\', '/')); + var roots = ShellTokenizer.ExtractDirectoryRoots("ls /home/user/.netclaw/logs/crash-*.log"); + + Assert.Single(roots); + Assert.Equal("/home/user/.netclaw/logs/", roots[0].ComparisonRoot.Replace('\\', '/')); } [Fact] - public void ExtractDirectoryScope_handles_glob_paths() + public void ExtractDirectoryRoots_returns_multiple_roots_for_multi_directory_command() { - var result = ShellTokenizer.ExtractDirectoryScope( - "ls /home/user/.netclaw/logs/crash-*.log"); - Assert.NotNull(result); - Assert.StartsWith("ls ", result); - Assert.EndsWith("/", result); - Assert.Contains(".netclaw/logs/", result.Replace('\\', '/')); + var roots = ShellTokenizer.ExtractDirectoryRoots("cat /home/user/.netclaw/logs/app.log > /home/user/.netclaw/output/report.txt"); + + Assert.Equal(2, roots.Count); + Assert.Contains(roots, r => r.ComparisonRoot.Replace('\\', '/') == "/home/user/.netclaw/logs/"); + Assert.Contains(roots, r => r.ComparisonRoot.Replace('\\', '/') == "/home/user/.netclaw/output/"); } [Theory] - [InlineData("echo hello")] // not a path-aware verb - [InlineData("git push origin main")] // not in PathAwareVerbs - [InlineData("grep --version")] // no path argument - [InlineData("cat /etc/passwd")] // too shallow (/etc/ = 1 segment) - public void ExtractDirectoryScope_returns_null(string command) + [InlineData("echo hello")] + [InlineData("git push origin main")] + [InlineData("grep --version")] + [InlineData("cat /etc/passwd")] + public void ExtractDirectoryRoots_returns_empty_when_no_reusable_roots_exist(string command) { - Assert.Null(ShellTokenizer.ExtractDirectoryScope(command)); + Assert.Empty(ShellTokenizer.ExtractDirectoryRoots(command)); } } diff --git a/src/Netclaw.Security/ApprovalPatternMatching.cs b/src/Netclaw.Security/ApprovalPatternMatching.cs index f14e221f..62429286 100644 --- a/src/Netclaw.Security/ApprovalPatternMatching.cs +++ b/src/Netclaw.Security/ApprovalPatternMatching.cs @@ -15,6 +15,20 @@ namespace Netclaw.Security; /// public static class ApprovalPatternMatching { + public static bool MatchesShellApprovalEntry(string candidate, IEnumerable approvedEntries) + { + foreach (var approved in approvedEntries) + { + if (string.Equals(candidate, approved, StringComparison.OrdinalIgnoreCase)) + return true; + + if (IsDirectoryRootEntry(candidate) && IsDirectoryRootEntry(approved) && MatchesDirectoryRoot(candidate, approved)) + return true; + } + + return false; + } + public static bool MatchesAny(string candidate, IEnumerable approvedPatterns) { foreach (var approved in approvedPatterns) @@ -69,4 +83,30 @@ private static bool MatchesDirectoryScope(string candidate, string approvedDirPa return false; } } + + private static bool MatchesDirectoryRoot(string candidateRoot, string approvedRoot) + { + try + { + return PathUtility.IsWithinRoot( + candidateRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + approvedRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + } + catch (Exception ex) when (ex is ArgumentException or IOException) + { + return false; + } + } + + private static bool IsDirectoryRootEntry(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + + if (!(value.EndsWith(Path.DirectorySeparatorChar) || value.EndsWith(Path.AltDirectorySeparatorChar))) + return false; + + var trimmed = value.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return Path.IsPathRooted(trimmed); + } } diff --git a/src/Netclaw.Security/DirectoryApprovalRoot.cs b/src/Netclaw.Security/DirectoryApprovalRoot.cs new file mode 100644 index 00000000..bd22e6eb --- /dev/null +++ b/src/Netclaw.Security/DirectoryApprovalRoot.cs @@ -0,0 +1,12 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- + +namespace Netclaw.Security; + +/// +/// Human-facing and comparison-safe representation of a directory approval root. +/// +public sealed record DirectoryApprovalRoot(string DisplayPath, string ComparisonRoot); diff --git a/src/Netclaw.Security/IToolApprovalMatcher.cs b/src/Netclaw.Security/IToolApprovalMatcher.cs index 89217485..158c27b8 100644 --- a/src/Netclaw.Security/IToolApprovalMatcher.cs +++ b/src/Netclaw.Security/IToolApprovalMatcher.cs @@ -33,12 +33,17 @@ public interface IToolApprovalMatcher bool IsFailClosedOnPersonal(ToolName toolName, IDictionary? arguments); /// - /// Extracts the intent-level pattern from a tool call's arguments. - /// For shell: verb-chain prefix (e.g., "git push" from "git push origin main"). - /// For other tools: the tool name itself. + /// Extracts the exact approval patterns shown to the user. + /// For shell: normalized approval units. For other tools: the tool name. /// IReadOnlyList ExtractPatterns(ToolName toolName, IDictionary? arguments); + /// + /// Extracts the reusable approval entries consulted for session and persistent + /// approval checks. + /// + IReadOnlyList ExtractApprovalEntries(ToolName toolName, IDictionary? arguments); + /// /// Checks if the tool call matches any approved pattern. /// @@ -50,16 +55,14 @@ public interface IToolApprovalMatcher string FormatForDisplay(ToolName toolName, IDictionary? arguments); /// - /// Extracts directory-scoped patterns for session/persistent approval storage. - /// For shell commands, returns "verb /parent-dir/" patterns derived from - /// file-path arguments. Returns empty when no directory scope is available. + /// Extracts reusable directory approval roots for the invocation. /// - IReadOnlyList ExtractDirectoryPatterns(ToolName toolName, IDictionary? arguments); + IReadOnlyList ExtractDirectoryRoots(ToolName toolName, IDictionary? arguments); } /// -/// Shell-specific approval matcher using verb-chain prefix extraction. -/// Handles compound commands by extracting patterns from each segment. +/// Shell-specific approval matcher using approval units bounded by &&, ||, and ;. +/// Pipelines remain inside the same approval unit. /// public sealed class ShellApprovalMatcher : IToolApprovalMatcher { @@ -78,21 +81,52 @@ public IReadOnlyList ExtractPatterns(ToolName toolName, IDictionary(StringComparer.OrdinalIgnoreCase); - CollectPatterns(command, patterns); + TraverseApprovalUnits(command, unit => + { + var normalized = ShellTokenizer.NormalizeApprovalUnit(unit, GetWorkingDirectory(arguments)); + if (!string.IsNullOrEmpty(normalized)) + patterns.Add(normalized); + }); return patterns.ToList(); } + public IReadOnlyList ExtractApprovalEntries(ToolName toolName, IDictionary? arguments) + { + var command = GetCommand(arguments); + if (string.IsNullOrWhiteSpace(command)) + return []; + + var workingDirectory = GetWorkingDirectory(arguments); + var entries = new HashSet(StringComparer.OrdinalIgnoreCase); + TraverseApprovalUnits(command, unit => + { + var roots = ShellTokenizer.ExtractDirectoryRoots(unit, workingDirectory); + if (roots.Count > 0) + { + foreach (var root in roots) + entries.Add(root.ComparisonRoot); + return; + } + + var normalized = ShellTokenizer.NormalizeApprovalUnit(unit, workingDirectory); + if (!string.IsNullOrEmpty(normalized)) + entries.Add(normalized); + }); + + return entries.ToList(); + } + public bool IsApproved(ToolName toolName, IDictionary? arguments, IEnumerable approvedPatterns) { - var commandPatterns = ExtractPatterns(toolName, arguments); - if (commandPatterns.Count == 0) + var approvalEntries = ExtractApprovalEntries(toolName, arguments); + if (approvalEntries.Count == 0) return true; // Empty command, nothing to approve var approvedList = approvedPatterns as IReadOnlyList ?? approvedPatterns.ToList(); - foreach (var pattern in commandPatterns) + foreach (var entry in approvalEntries) { - if (!PatternMatchesAny(pattern, approvedList)) + if (!ApprovalPatternMatching.MatchesShellApprovalEntry(entry, approvedList)) return false; } @@ -104,15 +138,24 @@ public string FormatForDisplay(ToolName toolName, IDictionary? return GetCommand(arguments) ?? "(empty command)"; } - public IReadOnlyList ExtractDirectoryPatterns(ToolName toolName, IDictionary? arguments) + public IReadOnlyList ExtractDirectoryRoots(ToolName toolName, IDictionary? arguments) { var command = GetCommand(arguments); if (string.IsNullOrWhiteSpace(command)) return []; - var patterns = new HashSet(StringComparer.OrdinalIgnoreCase); - CollectDirectoryPatterns(command, patterns); - return patterns.ToList(); + var roots = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + TraverseApprovalUnits(command, unit => + { + foreach (var root in ShellTokenizer.ExtractDirectoryRoots(unit, GetWorkingDirectory(arguments))) + { + if (seen.Add(root.ComparisonRoot)) + roots.Add(root); + } + }); + + return roots; } private static string? GetCommand(IDictionary? arguments) @@ -126,17 +169,18 @@ public IReadOnlyList ExtractDirectoryPatterns(ToolName toolName, IDictio return null; } - private static bool PatternMatchesAny(string pattern, IReadOnlyList approvedPatterns) - => ApprovalPatternMatching.MatchesAny(pattern, approvedPatterns); + private static string? GetWorkingDirectory(IDictionary? arguments) + { + if (arguments is null) + return null; - private static void CollectPatterns(string command, ISet patterns) - => TraverseSegments(command, patterns, static segment => ShellTokenizer.ExtractVerbChain(segment)); + if (arguments.TryGetValue("WorkingDirectory", out var val) || arguments.TryGetValue("workingDirectory", out val)) + return val?.ToString(); - private static void CollectDirectoryPatterns(string command, ISet patterns) - => TraverseSegments(command, patterns, static segment => - ShellTokenizer.ExtractDirectoryScope(segment) ?? ShellTokenizer.ExtractVerbChain(segment)); + return null; + } - private static void TraverseSegments(string command, ISet patterns, Func extractLeaf) + private static void TraverseApprovalUnits(string command, Action visitUnit) { foreach (var segment in ShellTokenizer.SplitCompoundCommand(command)) { @@ -144,14 +188,12 @@ private static void TraverseSegments(string command, ISet patterns, Func if (innerCommands.Count > 0) { foreach (var inner in innerCommands) - TraverseSegments(inner, patterns, extractLeaf); + TraverseApprovalUnits(inner, visitUnit); continue; } - var pattern = extractLeaf(segment); - if (!string.IsNullOrEmpty(pattern)) - patterns.Add(pattern); + visitUnit(segment); } } } @@ -175,6 +217,9 @@ public IReadOnlyList ExtractPatterns(ToolName toolName, IDictionary ExtractApprovalEntries(ToolName toolName, IDictionary? arguments) + => ExtractPatterns(toolName, arguments); + public bool IsApproved(ToolName toolName, IDictionary? arguments, IEnumerable approvedPatterns) { foreach (var approved in approvedPatterns) @@ -191,6 +236,6 @@ public string FormatForDisplay(ToolName toolName, IDictionary? return toolName.Value; } - public IReadOnlyList ExtractDirectoryPatterns(ToolName toolName, IDictionary? arguments) + public IReadOnlyList ExtractDirectoryRoots(ToolName toolName, IDictionary? arguments) => []; } diff --git a/src/Netclaw.Security/ShellTokenizer.cs b/src/Netclaw.Security/ShellTokenizer.cs index 1a0a123f..e78c61d1 100644 --- a/src/Netclaw.Security/ShellTokenizer.cs +++ b/src/Netclaw.Security/ShellTokenizer.cs @@ -80,10 +80,10 @@ public static IEnumerable Tokenize(string command) } /// - /// Splits a compound command on &&, ||, ;, and | - /// operators, returning each individual command segment trimmed. - /// Pipe (|) is treated as a segment boundary because each side - /// may invoke a different program. + /// Splits a compound command on &&, ||, and ; + /// operators, returning each approval unit trimmed. Pipes remain inside the + /// same unit so shell pipelines can be approved as one piece of directory + /// work. /// public static IReadOnlyList SplitCompoundCommand(string command) { @@ -132,8 +132,8 @@ public static IReadOnlyList SplitCompoundCommand(string command) } } - // Single-char operators: ; and | - if (ch is ';' or '|') + // Single-char operator: ; + if (ch == ';') { FlushSegment(current, segments); continue; @@ -198,6 +198,67 @@ public static string ExtractVerbChain(string command, int maxDepth = 2) return string.Join(' ', verbParts); } + /// + /// Produces an exact shell approval unit string with recognizable local paths + /// normalized against the working directory. Non-path tokens remain in order. + /// + public static string NormalizeApprovalUnit(string command, string? workingDirectory = null) + { + var tokens = Tokenize(command).ToList(); + if (tokens.Count == 0) + return string.Empty; + + var normalizedTokens = new List(tokens.Count); + foreach (var token in tokens) + { + if (LooksLikePath(token)) + { + var normalized = PathUtility.ExpandAndNormalize(token, workingDirectory); + normalizedTokens.Add(normalized ?? token); + } + else + { + normalizedTokens.Add(token); + } + } + + return string.Join(' ', normalizedTokens); + } + + /// + /// Extracts reusable directory approval roots from a shell approval unit. + /// Returns an empty list when no reusable roots can be extracted. + /// + public static IReadOnlyList ExtractDirectoryRoots(string command, string? workingDirectory = null) + { + var tokens = Tokenize(command).ToList(); + if (tokens.Count == 0) + return []; + + var roots = new List(); + var comparisonRoots = new HashSet(StringComparer.OrdinalIgnoreCase); + var sawPathToken = false; + + foreach (var token in tokens) + { + if (token.Length == 0 || token.StartsWith('-')) + continue; + + if (!LooksLikePath(token)) + continue; + + sawPathToken = true; + var root = TryCreateDirectoryApprovalRoot(token, workingDirectory); + if (root is null) + return []; + + if (comparisonRoots.Add(root.ComparisonRoot)) + roots.Add(root); + } + + return sawPathToken ? roots : []; + } + /// /// Extracts inner commands from bash -c / sh -c wrappers. Returns the /// inner command strings for recursive scanning. Returns an empty list @@ -404,6 +465,51 @@ public static bool LooksLikePath(string token) internal const int MinDirectoryScopeDepth = 2; + private static DirectoryApprovalRoot? TryCreateDirectoryApprovalRoot(string rawPath, string? workingDirectory) + { + var displayRoot = ExtractDisplayDirectory(rawPath, workingDirectory); + if (displayRoot is null) + return null; + + var comparisonRoot = PathUtility.ExpandAndNormalize(displayRoot, workingDirectory); + if (comparisonRoot is null) + return null; + + if (Directory.Exists(comparisonRoot)) + comparisonRoot = PathUtility.Normalize(new DirectoryInfo(comparisonRoot).ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? comparisonRoot); + + if (CountPathSegments(comparisonRoot) < MinDirectoryScopeDepth) + return null; + + return new DirectoryApprovalRoot(EnsureTrailingSeparator(displayRoot), EnsureTrailingSeparator(comparisonRoot)); + } + + private static string? ExtractDisplayDirectory(string path, string? workingDirectory) + { + if (path.EndsWith('/') || path.EndsWith('\\')) + return path.TrimEnd('/', '\\'); + + var globIdx = path.IndexOfAny(['*', '?', '[']); + if (globIdx >= 0) + { + var lastSep = path.LastIndexOf('/', globIdx); + if (lastSep < 0) + lastSep = path.LastIndexOf('\\', globIdx); + return lastSep > 0 ? path[..lastSep] : null; + } + + var normalizedCandidate = PathUtility.ExpandAndNormalize(path, workingDirectory); + if (normalizedCandidate is not null && Directory.Exists(normalizedCandidate)) + return path; + + return ExtractParentDirectory(path); + } + + private static string EnsureTrailingSeparator(string path) + => path.EndsWith('/') || path.EndsWith('\\') + ? path + : path + Path.DirectorySeparatorChar; + private static string? ExtractParentDirectory(string path) { // Already a directory (trailing separator) From 9245aeeaa7a7ff94e27863ebaf8b2744c8f00154 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 7 May 2026 16:50:50 +0000 Subject: [PATCH 10/22] fix(tests): update subagent approval expectation Align the approve-once subagent test with the new exact shell approval pattern shape so CI validates the current behavior instead of the old verb-chain normalization. --- src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs index 9620499c..49658f88 100644 --- a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs +++ b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs @@ -170,7 +170,7 @@ public async Task Approve_once_does_not_leak_between_subagent_tool_calls() Assert.True(result.Success); Assert.Equal(2, approvalBridge.RequestCount); - Assert.Equal(["git push", "git push"], approvalBridge.RequestedPatterns); + Assert.Equal(["git push origin main", "git push origin main"], approvalBridge.RequestedPatterns); } [Fact] From 8f1c0e9dceb4245a611891ea652329be6cbd94a3 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 7 May 2026 17:09:57 +0000 Subject: [PATCH 11/22] fix(security): preserve shell approval roots across session flows Keep directory-scoped shell approvals human-readable in prompts and make sure parent-session approvals reuse the same root-based entries for subagent tool calls. --- .../ParentSessionApprovalBridgeTests.cs | 11 ++++- .../SubAgents/SubAgentActorTests.cs | 6 ++- .../Tools/ToolApprovalGateTests.cs | 29 ++++++++++++ src/Netclaw.Actors/Protocol/SessionOutput.cs | 14 +++--- .../Sessions/ParentSessionApprovalBridge.cs | 26 +++++++++-- .../Pipelines/SessionToolExecutionPipeline.cs | 3 ++ src/Netclaw.Actors/SubAgents/SubAgentActor.cs | 2 + src/Netclaw.Actors/Tools/ToolAccessPolicy.cs | 15 ++++++- .../ApprovalPatternMatching.cs | 12 ++--- src/Netclaw.Security/DirectoryApprovalRoot.cs | 4 ++ src/Netclaw.Security/IToolApprovalMatcher.cs | 22 +++++++++ src/Netclaw.Security/ShellTokenizer.cs | 45 ------------------- .../IParentApprovalBridge.cs | 9 +++- 13 files changed, 132 insertions(+), 66 deletions(-) diff --git a/src/Netclaw.Actors.Tests/Sessions/ParentSessionApprovalBridgeTests.cs b/src/Netclaw.Actors.Tests/Sessions/ParentSessionApprovalBridgeTests.cs index 148bfff5..08a05780 100644 --- a/src/Netclaw.Actors.Tests/Sessions/ParentSessionApprovalBridgeTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/ParentSessionApprovalBridgeTests.cs @@ -34,8 +34,10 @@ public async Task Bridge_preserves_requester_identity_and_adopted_context() var decision = await bridge.RequestApprovalAsync( new ToolCallId("call-1"), "shell_execute", - "git push origin main", - ["git push"], + "grep timeout logs/app.log | wc -l", + ["grep timeout logs/app.log | wc -l"], + ["/tmp/work/logs/"], + ["logs/"], TestContext.Current.CancellationToken); Assert.Equal(ParentApprovalDecision.ApprovedOnce, decision); @@ -45,5 +47,10 @@ public async Task Bridge_preserves_requester_identity_and_adopted_context() Assert.True(emitted.HasAdoptedContext); Assert.True(emitted.PersistedAdoptedContext); Assert.Equal(["user-123", "user-456"], emitted.AdoptedSpeakerIds); + Assert.Equal(["grep timeout logs/app.log | wc -l"], emitted.Patterns); + Assert.Equal(["/tmp/work/logs/"], emitted.ApprovalEntries); + Assert.Equal(["logs/"], emitted.DirectoryRoots); + Assert.Equal("Approve shell access in logs/ for this chat", emitted.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveSession).Label); + Assert.Equal("Approve shell access in logs/ always", emitted.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveAlways).Label); } } diff --git a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs index 49658f88..11ddc7e9 100644 --- a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs +++ b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs @@ -533,11 +533,13 @@ public Task RequestApprovalAsync( ToolCallId callId, string toolName, string displayText, - IReadOnlyList unapprovedPatterns, + IReadOnlyList patterns, + IReadOnlyList approvalEntries, + IReadOnlyList directoryRoots, CancellationToken ct) { RequestCount++; - RequestedPatterns.AddRange(unapprovedPatterns); + RequestedPatterns.AddRange(patterns); return Task.FromResult(decisionToReturn); } } diff --git a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs index e7333c4a..feb48e4b 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs @@ -806,6 +806,35 @@ public void Shell_multi_root_command_uses_plural_directory_labels() Assert.Equal("Approve shell access in these directories always", options.Single(o => o.Key == ApprovalOptionKeys.ApproveAlways).Label); } + [Fact] + public void Shell_relative_path_command_keeps_relative_directory_root_for_prompt() + { + var policy = CreatePolicy(ToolApprovalMode.Approval); + var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + var logs = Path.Combine(root, "logs"); + Directory.CreateDirectory(logs); + + try + { + var args = ToolInput.Create( + "Command", "grep timeout logs/app.log | wc -l", + "WorkingDirectory", root); + + var decision = policy.AuthorizeInvocation(ShellTool(), PersonalContext(), args); + + Assert.True(decision.NeedsApproval); + Assert.Equal(["logs/"], decision.ApprovalContext!.DirectoryRoots); + Assert.Contains(PathUtility.Normalize(logs) + Path.DirectorySeparatorChar, decision.ApprovalContext.ApprovalEntries); + Assert.Equal( + "Approve shell access in logs/ for this chat", + decision.ApprovalContext.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveSession).Label); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + [Fact] public void Non_path_command_uses_default_labels() { diff --git a/src/Netclaw.Actors/Protocol/SessionOutput.cs b/src/Netclaw.Actors/Protocol/SessionOutput.cs index fdc9eb5e..663c44cc 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutput.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutput.cs @@ -357,18 +357,22 @@ public sealed record ToolInteractionRequest : SessionOutput /// public PrincipalClassification? RequesterPrincipal { get; init; } - /// Patterns requiring approval (for shell: verb chains like "git push"). + /// + /// Exact blocked approval units shown in the prompt. For shell these are + /// the normalized command units that approve-once can retry. + /// public IReadOnlyList Patterns { get; init; } = []; /// - /// Approval entries used for session and persistent approval lookup. - /// For shell commands these are directory roots when extractable and exact - /// fallback patterns otherwise. + /// Entries checked against the accumulated session/persistent approval + /// state. For shell commands these are reusable directory roots when local + /// roots can be extracted, and exact fallback units otherwise. /// public IReadOnlyList ApprovalEntries { get; init; } = []; /// - /// Directory roots extracted from the invocation for broader B/C shell approvals. + /// Human-visible reusable roots extracted from the current invocation so the + /// prompt can explain what broader B/C approvals would cover. /// public IReadOnlyList DirectoryRoots { get; init; } = []; diff --git a/src/Netclaw.Actors/Sessions/ParentSessionApprovalBridge.cs b/src/Netclaw.Actors/Sessions/ParentSessionApprovalBridge.cs index 7c6e131b..8a9457e7 100644 --- a/src/Netclaw.Actors/Sessions/ParentSessionApprovalBridge.cs +++ b/src/Netclaw.Actors/Sessions/ParentSessionApprovalBridge.cs @@ -46,11 +46,27 @@ public async Task RequestApprovalAsync( ToolCallId callId, string toolName, string displayText, - IReadOnlyList unapprovedPatterns, + IReadOnlyList patterns, + IReadOnlyList approvalEntries, + IReadOnlyList directoryRoots, CancellationToken ct) { var waitTask = _channel.WaitForApprovalAsync(callId, Timeout.InfiniteTimeSpan, ct); + var sessionLabel = ApprovalOptionKeys.ApproveSessionLabel; + var alwaysLabel = ApprovalOptionKeys.ApproveAlwaysLabel; + if (directoryRoots.Count == 1) + { + var dir = directoryRoots[0]; + sessionLabel = $"Approve shell access in {dir} for this chat"; + alwaysLabel = $"Approve shell access in {dir} always"; + } + else if (directoryRoots.Count > 1) + { + sessionLabel = "Approve shell access in these directories for this chat"; + alwaysLabel = "Approve shell access in these directories always"; + } + _emitRequest(new ToolInteractionRequest { SessionId = _sessionId, @@ -60,15 +76,17 @@ public async Task RequestApprovalAsync( DisplayText = displayText, RequesterSenderId = _requesterSenderId, RequesterPrincipal = _requesterPrincipal, - Patterns = unapprovedPatterns, + Patterns = patterns, + ApprovalEntries = approvalEntries, + DirectoryRoots = directoryRoots, HasAdoptedContext = _hasAdoptedContext, AdoptedSpeakerIds = _adoptedSpeakerIds, PersistedAdoptedContext = _hasAdoptedContext, Options = [ new ToolInteractionOption(ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.ApproveOnceLabel), - new ToolInteractionOption(ApprovalOptionKeys.ApproveSession, ApprovalOptionKeys.ApproveSessionLabel), - new ToolInteractionOption(ApprovalOptionKeys.ApproveAlways, ApprovalOptionKeys.ApproveAlwaysLabel), + new ToolInteractionOption(ApprovalOptionKeys.ApproveSession, sessionLabel), + new ToolInteractionOption(ApprovalOptionKeys.ApproveAlways, alwaysLabel), new ToolInteractionOption(ApprovalOptionKeys.Deny, ApprovalOptionKeys.DenyLabel) ] }); diff --git a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs index e0d76988..58a7dc4d 100644 --- a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs +++ b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs @@ -340,6 +340,9 @@ public static async Task ExecuteSingleToolAsync( : $"Tool access denied: approval_denied_by_user ({tc.Name} requires interactive approval and the user declined it)"; resultText = reason; + // Denied audit entries should describe the exact blocked units + // the user saw in the prompt. Broader reusable approval entries + // are only relevant when B/C is granted. var deniedPatternStr = string.Join(", ", ctx.Patterns); auditLogger?.Log(BuildAuditEntry(sessionId, tc, timeProvider, sw.Elapsed, meta) with { diff --git a/src/Netclaw.Actors/SubAgents/SubAgentActor.cs b/src/Netclaw.Actors/SubAgents/SubAgentActor.cs index 1d1df3db..3f0b44a4 100644 --- a/src/Netclaw.Actors/SubAgents/SubAgentActor.cs +++ b/src/Netclaw.Actors/SubAgents/SubAgentActor.cs @@ -420,6 +420,8 @@ private static async Task ExecuteToolsAsync( ctx.ToolName, ctx.DisplayText, ctx.Patterns, + ctx.ApprovalEntries, + ctx.DirectoryRoots, ct); if (decision is ParentApprovalDecision.ApprovedOnce diff --git a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs index 2cd0310b..0596f1a7 100644 --- a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs +++ b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs @@ -297,6 +297,14 @@ private ToolAccessDecision CheckApprovalGate( return ToolAccessDecision.Allow(); } + // Approval prompts carry three related views of the invocation: + // - `patterns`: the exact blocked units shown to the user and reused by + // approve-once retries. + // - `approvalEntries`: what broader B/C approvals actually record and + // later compare against. For shell this prefers reusable directory + // roots and falls back to the exact unit when no reusable roots exist. + // - `directoryRoots`: the human-facing root list used only to explain + // the broader B/C label in the prompt. var patterns = matcher.ExtractPatterns(toolName, arguments); var approvalEntries = matcher.ExtractApprovalEntries(toolName, arguments); var directoryRoots = matcher.ExtractDirectoryRoots(toolName, arguments); @@ -312,6 +320,9 @@ private ToolAccessDecision CheckApprovalGate( } else if (directoryRoots.Count > 1) { + // Listing only the first root would be misleading for multi-path + // commands, so switch to a generic label once the command spans + // more than one reusable directory. sessionLabel = "Approve shell access in these directories for this chat"; alwaysLabel = "Approve shell access in these directories always"; } @@ -327,7 +338,7 @@ private ToolAccessDecision CheckApprovalGate( new ToolApprovalOption(ApprovalOptionKeys.ApproveAlways, alwaysLabel), new ToolApprovalOption(ApprovalOptionKeys.Deny, ApprovalOptionKeys.DenyLabel) ], - [.. directoryRoots.Select(static x => x.ComparisonRoot)]); + [.. directoryRoots.Select(static x => x.DisplayPath)]); return ToolAccessDecision.RequiresApproval(approvalContext); } @@ -476,6 +487,8 @@ public sealed record ToolApprovalContext( IReadOnlyList Patterns, IReadOnlyList ApprovalEntries, IReadOnlyList Options, + // Human-facing roots shown in the prompt. Approval lookups use + // ApprovalEntries instead so display formatting never leaks into matching. IReadOnlyList DirectoryRoots); public sealed record ToolApprovalOption(string Key, string Label); diff --git a/src/Netclaw.Security/ApprovalPatternMatching.cs b/src/Netclaw.Security/ApprovalPatternMatching.cs index 62429286..b5aa4352 100644 --- a/src/Netclaw.Security/ApprovalPatternMatching.cs +++ b/src/Netclaw.Security/ApprovalPatternMatching.cs @@ -6,17 +6,17 @@ namespace Netclaw.Security; /// -/// Shared verb-chain prefix matcher used for tool approval grants. An approved -/// pattern matches a candidate exactly or as a verb-chain prefix on a space -/// boundary — so "git push" approves "git push origin main" but never -/// "github-cli". Directory-scoped patterns (trailing /) match any -/// candidate whose path is within the approved directory, using -/// for boundary-safe containment. +/// Shared approval matching helpers. Shell approvals use +/// , which only compares exact approval +/// units and normalized directory roots. Other tools continue to use +/// for exact and prefix-style matching. /// public static class ApprovalPatternMatching { public static bool MatchesShellApprovalEntry(string candidate, IEnumerable approvedEntries) { + // Shell approvals never widen by verb prefix here. Reusable entries are + // either exact normalized units or normalized directory roots. foreach (var approved in approvedEntries) { if (string.Equals(candidate, approved, StringComparison.OrdinalIgnoreCase)) diff --git a/src/Netclaw.Security/DirectoryApprovalRoot.cs b/src/Netclaw.Security/DirectoryApprovalRoot.cs index bd22e6eb..2403ddcb 100644 --- a/src/Netclaw.Security/DirectoryApprovalRoot.cs +++ b/src/Netclaw.Security/DirectoryApprovalRoot.cs @@ -8,5 +8,9 @@ namespace Netclaw.Security; /// /// Human-facing and comparison-safe representation of a directory approval root. +/// preserves the shape we want to show back to the +/// user (which may stay relative if that is how the command was written), while +/// is the normalized root used for approval-store +/// lookups and containment checks. /// public sealed record DirectoryApprovalRoot(string DisplayPath, string ComparisonRoot); diff --git a/src/Netclaw.Security/IToolApprovalMatcher.cs b/src/Netclaw.Security/IToolApprovalMatcher.cs index 158c27b8..4d061541 100644 --- a/src/Netclaw.Security/IToolApprovalMatcher.cs +++ b/src/Netclaw.Security/IToolApprovalMatcher.cs @@ -97,6 +97,25 @@ public IReadOnlyList ExtractApprovalEntries(ToolName toolName, IDictiona if (string.IsNullOrWhiteSpace(command)) return []; + // Shell approvals intentionally keep two parallel views of the same + // invocation: + // + // 1. `Patterns` are the exact normalized approval units shown in the + // prompt and reused only for approve-once retries. + // 2. `ApprovalEntries` are the broader entries consulted for session + // and persistent approval reuse. + // + // The directory-scoping algorithm starts here. We first break the + // command into approval units: &&, ||, and ; split into separate units, + // while pipelines joined by | stay together as one piece of work. + // `bash -c` / `sh -c` wrappers recurse into the inner command and feed + // those inner units back through the same logic. + // + // For each unit we try to derive reusable local directory roots. If we + // can do that safely, those roots become the approval entries recorded + // for B/C approvals. If we cannot, we fall back to the exact normalized + // unit. That keeps approve-once exact while letting broader approvals + // reuse local directory access without introducing verb allowlists. var workingDirectory = GetWorkingDirectory(arguments); var entries = new HashSet(StringComparer.OrdinalIgnoreCase); TraverseApprovalUnits(command, unit => @@ -182,6 +201,9 @@ public IReadOnlyList ExtractDirectoryRoots(ToolName toolN private static void TraverseApprovalUnits(string command, Action visitUnit) { + // Approval units recurse through shell wrappers but keep the outer + // splitting rules stable, so `bash -c "grep ... | wc -l" && git push` + // still becomes two independent approval decisions. foreach (var segment in ShellTokenizer.SplitCompoundCommand(command)) { var innerCommands = ShellTokenizer.ExtractInnerCommands(segment); diff --git a/src/Netclaw.Security/ShellTokenizer.cs b/src/Netclaw.Security/ShellTokenizer.cs index e78c61d1..16b1ea2b 100644 --- a/src/Netclaw.Security/ShellTokenizer.cs +++ b/src/Netclaw.Security/ShellTokenizer.cs @@ -418,51 +418,6 @@ public static bool LooksLikePath(string token) return false; } - /// - /// Extracts a directory-scoped approval pattern from a shell command by - /// finding the first file-path argument (not just the first positional - /// argument) and returning "{verb} {parentDirectory}/". Returns - /// null when the command has no path-aware verb, no recognizable file-path - /// argument, or the resulting directory is too shallow (fewer than 2 - /// path segments below root). - /// - public static string? ExtractDirectoryScope(string command) - { - var tokens = Tokenize(command).ToList(); - if (tokens.Count == 0) - return null; - - var verb = TrimShellPunctuation(tokens[0]); - if (verb.Length == 0 || !PathAwareVerbs.Contains(verb)) - return null; - - for (var i = 1; i < tokens.Count; i++) - { - var trimmed = TrimShellPunctuation(tokens[i]); - if (trimmed.Length == 0 || trimmed.StartsWith('-')) - continue; - - if (!LooksLikePath(trimmed)) - continue; - - var dir = ExtractParentDirectory(PathUtility.ExpandHome(trimmed)); - if (dir is null) - continue; - - var normalized = PathUtility.ExpandAndNormalize(dir); - if (normalized is null) - continue; - - // Enforce minimum depth — reject shallow scopes like / or /etc/ - if (CountPathSegments(normalized) < MinDirectoryScopeDepth) - return null; - - return verb + " " + normalized + "/"; - } - - return null; - } - internal const int MinDirectoryScopeDepth = 2; private static DirectoryApprovalRoot? TryCreateDirectoryApprovalRoot(string rawPath, string? workingDirectory) diff --git a/src/Netclaw.Tools.Abstractions/IParentApprovalBridge.cs b/src/Netclaw.Tools.Abstractions/IParentApprovalBridge.cs index d5afbce1..a59e16da 100644 --- a/src/Netclaw.Tools.Abstractions/IParentApprovalBridge.cs +++ b/src/Netclaw.Tools.Abstractions/IParentApprovalBridge.cs @@ -27,11 +27,18 @@ public interface IParentApprovalBridge { /// /// Emits an approval request to the parent session and waits for the user's decision. + /// are the exact blocked units shown in the + /// prompt and reused for approve-once retries. + /// are the broader entries the parent session should record for B/C + /// decisions, and are the human-facing + /// roots used to explain those broader approvals in the prompt. /// Task RequestApprovalAsync( ToolCallId callId, string toolName, string displayText, - IReadOnlyList unapprovedPatterns, + IReadOnlyList patterns, + IReadOnlyList approvalEntries, + IReadOnlyList directoryRoots, CancellationToken ct); } From fb3056a6684c65bf88509620a0ba1fd084c6e96d Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 7 May 2026 17:19:09 +0000 Subject: [PATCH 12/22] fix(security): preserve shell path semantics in approvals Keep POSIX shell paths in approval normalization and directory-root extraction instead of routing them through host Windows filesystem semantics. This adds a small shell-path seam for the follow-up platform-specific approval work. --- .../ShellTokenizerTests.cs | 10 +++ src/Netclaw.Security/ShellTokenizer.cs | 79 +++++++++++++++++-- 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/src/Netclaw.Security.Tests/ShellTokenizerTests.cs b/src/Netclaw.Security.Tests/ShellTokenizerTests.cs index cd5b1baf..b7f3e574 100644 --- a/src/Netclaw.Security.Tests/ShellTokenizerTests.cs +++ b/src/Netclaw.Security.Tests/ShellTokenizerTests.cs @@ -284,6 +284,16 @@ public void ExtractDirectoryRoots_returns_normalized_root_for_file_path() Assert.Equal("/home/user/.netclaw/logs/", roots[0].DisplayPath.Replace('\\', '/')); } + [Fact] + public void ExtractDirectoryRoots_preserves_posix_absolute_shell_paths_on_windows_hosts() + { + var roots = ShellTokenizer.ExtractDirectoryRoots("cat /home/user/.netclaw/logs/crash.log"); + + Assert.Single(roots); + Assert.DoesNotContain(":/home/", roots[0].ComparisonRoot.Replace('\\', '/')); + Assert.Equal("/home/user/.netclaw/logs/", roots[0].ComparisonRoot.Replace('\\', '/')); + } + [Fact] public void ExtractDirectoryRoots_keeps_relative_display_path_and_normalized_comparison_root() { diff --git a/src/Netclaw.Security/ShellTokenizer.cs b/src/Netclaw.Security/ShellTokenizer.cs index 16b1ea2b..bcfb7718 100644 --- a/src/Netclaw.Security/ShellTokenizer.cs +++ b/src/Netclaw.Security/ShellTokenizer.cs @@ -213,7 +213,7 @@ public static string NormalizeApprovalUnit(string command, string? workingDirect { if (LooksLikePath(token)) { - var normalized = PathUtility.ExpandAndNormalize(token, workingDirectory); + var normalized = NormalizeShellPathToken(token, workingDirectory); normalizedTokens.Add(normalized ?? token); } else @@ -426,7 +426,7 @@ public static bool LooksLikePath(string token) if (displayRoot is null) return null; - var comparisonRoot = PathUtility.ExpandAndNormalize(displayRoot, workingDirectory); + var comparisonRoot = NormalizeShellPathToken(displayRoot, workingDirectory); if (comparisonRoot is null) return null; @@ -453,17 +453,75 @@ public static bool LooksLikePath(string token) return lastSep > 0 ? path[..lastSep] : null; } - var normalizedCandidate = PathUtility.ExpandAndNormalize(path, workingDirectory); + var normalizedCandidate = NormalizeShellPathToken(path, workingDirectory); if (normalizedCandidate is not null && Directory.Exists(normalizedCandidate)) return path; return ExtractParentDirectory(path); } + private static string? NormalizeShellPathToken(string path, string? workingDirectory) + { + var expanded = PathUtility.ExpandHome(path); + + // Shell approval extraction is based on the command's path language, not + // the host runtime's filesystem parser. A POSIX shell path like + // `/home/user/...` should stay POSIX-shaped even when the daemon runs on + // Windows, otherwise `Path.GetFullPath()` rewrites it to `D:\home\...` + // and both matching and prompt text drift away from what the shell + // command actually means. + if (IsPosixAbsoluteShellPath(expanded)) + return NormalizePosixShellPath(expanded); + + return PathUtility.TryNormalize(expanded, workingDirectory, out var normalized) + ? normalized + : null; + } + + private static bool IsPosixAbsoluteShellPath(string path) + { + return path.Length > 0 && path[0] == '/' + && !path.StartsWith("//", StringComparison.Ordinal) + && path.IndexOf('\\', StringComparison.Ordinal) < 0 + && !path.Contains("://", StringComparison.Ordinal); + } + + private static string NormalizePosixShellPath(string path) + { + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + var normalized = new List(segments.Length); + foreach (var segment in segments) + { + if (segment == ".") + continue; + + if (segment == "..") + { + if (normalized.Count > 0) + normalized.RemoveAt(normalized.Count - 1); + + continue; + } + + normalized.Add(segment); + } + + return normalized.Count == 0 ? "/" : "/" + string.Join('/', normalized); + } + private static string EnsureTrailingSeparator(string path) - => path.EndsWith('/') || path.EndsWith('\\') - ? path - : path + Path.DirectorySeparatorChar; + { + if (path.EndsWith('/') || path.EndsWith('\\')) + return path; + + if (path.Contains('/', StringComparison.Ordinal) && !path.Contains('\\', StringComparison.Ordinal)) + return path + '/'; + + if (path.Contains('\\', StringComparison.Ordinal) && !path.Contains('/', StringComparison.Ordinal)) + return path + '\\'; + + return path + Path.DirectorySeparatorChar; + } private static string? ExtractParentDirectory(string path) { @@ -481,6 +539,15 @@ private static string EnsureTrailingSeparator(string path) return lastSep > 0 ? path[..lastSep] : null; } + // Preserve POSIX-style shell paths instead of routing them through the + // host filesystem parser, which would rewrite `/dir/file` into a drive- + // rooted Windows path when tests run on Windows. + if (path.Contains('/', StringComparison.Ordinal) && !path.Contains('\\', StringComparison.Ordinal)) + { + var lastSlash = path.LastIndexOf('/'); + return lastSlash > 0 ? path[..lastSlash] : null; + } + // Regular file: parent directory var dir = Path.GetDirectoryName(path); return string.IsNullOrEmpty(dir) ? null : dir; From 9827c0a0c5ac201ad7edcc0f3987bf47b4deefd2 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 7 May 2026 17:34:05 +0000 Subject: [PATCH 13/22] fix(security): preserve shell separators in display roots Keep shell-style relative display roots using forward slashes so approval prompts stay stable across host platforms while comparison roots remain normalized for matching. --- src/Netclaw.Security/ShellTokenizer.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Netclaw.Security/ShellTokenizer.cs b/src/Netclaw.Security/ShellTokenizer.cs index bcfb7718..1186e6fe 100644 --- a/src/Netclaw.Security/ShellTokenizer.cs +++ b/src/Netclaw.Security/ShellTokenizer.cs @@ -444,6 +444,9 @@ public static bool LooksLikePath(string token) if (path.EndsWith('/') || path.EndsWith('\\')) return path.TrimEnd('/', '\\'); + if (path.Contains('/', StringComparison.Ordinal) && !path.Contains('\\', StringComparison.Ordinal)) + return ExtractParentDirectory(path); + var globIdx = path.IndexOfAny(['*', '?', '[']); if (globIdx >= 0) { From e740419852985be6e992a3ef684e152ebd032b51 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 7 May 2026 18:29:29 +0000 Subject: [PATCH 14/22] feat(security): split shell approvals by shell family Separate POSIX and Windows shell approval semantics so path detection, directory-root extraction, and approval matching use the active shell family's rules instead of one mixed cross-platform tokenizer. --- .../Tools/ToolApprovalActorTests.cs | 36 +- src/Netclaw.Actors/Tools/ToolAccessPolicy.cs | 2 +- .../ShellApprovalMatcherTests.cs | 32 +- .../ShellTokenizerTests.cs | 127 +++- .../ShellApprovalSemantics.cs | 645 ++++++++++++++++++ src/Netclaw.Security/ShellTokenizer.cs | 458 +------------ src/Netclaw.Security/ToolPathPolicy.cs | 6 +- 7 files changed, 825 insertions(+), 481 deletions(-) create mode 100644 src/Netclaw.Security/ShellApprovalSemantics.cs diff --git a/src/Netclaw.Actors.Tests/Tools/ToolApprovalActorTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolApprovalActorTests.cs index 286c2899..f3928c75 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolApprovalActorTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolApprovalActorTests.cs @@ -17,6 +17,20 @@ namespace Netclaw.Actors.Tests.Tools; public sealed class ToolApprovalActorTests : TestKit { + public static TheoryData DirectoryRootCoverageCases + { + get + { + var data = new TheoryData(); + if (OperatingSystem.IsWindows()) + data.Add(@"C:\Users\petabridge\.netclaw\logs\", @"C:\Users\petabridge\.netclaw\output\", @"C:\Users\petabridge\.netclaw\output\"); + else + data.Add("/home/user/.netclaw/logs/", "/home/user/.netclaw/output/", "/home/user/.netclaw/output/"); + + return data; + } + } + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) { } @@ -100,40 +114,44 @@ public async Task Shell_exact_approval_does_not_prefix_match() Assert.Equal(["git push origin"], unapproved); } - [Fact] - public async Task Shell_directory_root_approval_covers_other_verbs_under_same_root() + [Theory] + [MemberData(nameof(DirectoryRootCoverageCases))] + public async Task Shell_directory_root_approval_covers_other_verbs_under_same_root(string approvedRoot, string otherRoot, string expectedUnapproved) { + _ = otherRoot; + _ = expectedUnapproved; var ct = TestContext.Current.CancellationToken; var actor = Sys.ActorOf(ToolApprovalActor.CreateProps()); var service = CreateService(actor); - await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["/home/user/.netclaw/logs/"], persistent: false, ct); + await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), [approvedRoot], persistent: false, ct); Assert.Empty(await service.GetUnapprovedPatternsAsync( "session-a", TrustAudience.Personal, new ToolName("shell_execute"), - ["/home/user/.netclaw/logs/"], + [approvedRoot], ct)); } - [Fact] - public async Task Shell_directory_root_approval_requires_all_roots_to_be_covered() + [Theory] + [MemberData(nameof(DirectoryRootCoverageCases))] + public async Task Shell_directory_root_approval_requires_all_roots_to_be_covered(string approvedRoot, string otherRoot, string expectedUnapproved) { var ct = TestContext.Current.CancellationToken; var actor = Sys.ActorOf(ToolApprovalActor.CreateProps()); var service = CreateService(actor); - await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["/home/user/.netclaw/logs/"], persistent: false, ct); + await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), [approvedRoot], persistent: false, ct); var unapproved = await service.GetUnapprovedPatternsAsync( "session-a", TrustAudience.Personal, new ToolName("shell_execute"), - ["/home/user/.netclaw/logs/", "/home/user/.netclaw/output/"], + [approvedRoot, otherRoot], ct); - Assert.Equal(["/home/user/.netclaw/output/"], unapproved); + Assert.Equal([expectedUnapproved], unapproved); } [Fact] diff --git a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs index 0596f1a7..72e45a79 100644 --- a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs +++ b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs @@ -197,7 +197,7 @@ public ToolAccessDecision AuthorizeInvocation( foreach (var pathToken in pathTokens) { - var expanded = PathUtility.ExpandAndNormalize(pathToken, workingDirectory); + var expanded = ShellTokenizer.NormalizePathToken(pathToken, workingDirectory); if (expanded is null) continue; diff --git a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs index a2e12528..142549eb 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs @@ -12,6 +12,20 @@ public sealed class ShellApprovalMatcherTests { private readonly ShellApprovalMatcher _matcher = ShellApprovalMatcher.Instance; + public static TheoryData PlatformDirectoryMatchCases + { + get + { + var data = new TheoryData(); + if (OperatingSystem.IsWindows()) + data.Add(@"C:\Users\petabridge\.netclaw\logs\", @"type C:\Users\petabridge\.netclaw\logs\crash.log", true); + else + data.Add("/home/user/.netclaw/logs/", "cat /home/user/.netclaw/logs/crash.log", true); + + return data; + } + } + private static Dictionary Args(string command) => new() { ["Command"] = command }; private static Dictionary Args(string command, string workingDirectory) @@ -96,7 +110,6 @@ public void IsApproved_one_pattern_unapproved() [InlineData("git push", "git push", true)] [InlineData("git push", "git push origin main", false)] [InlineData("gh", "gh --help", false)] - [InlineData("/home/user/.netclaw/logs/", "cat /home/user/.netclaw/logs/crash.log", true)] [InlineData("/home/user/.netclaw/logs/", "grep timeout /home/user/.netclaw/logs/crash.log", true)] [InlineData("/home/user/.netclaw/logs/", "cat /home/user/.netclaw/config/secret.json", false)] public void IsApproved_pattern_matching(string pattern, string command, bool expected) @@ -105,6 +118,14 @@ public void IsApproved_pattern_matching(string pattern, string command, bool exp Assert.Equal(expected, _matcher.IsApproved(new ToolName("shell_execute"), Args(command), approved)); } + [Theory] + [MemberData(nameof(PlatformDirectoryMatchCases))] + public void IsApproved_platform_specific_directory_root_matching(string pattern, string command, bool expected) + { + var approved = new[] { pattern }; + Assert.Equal(expected, _matcher.IsApproved(new ToolName("shell_execute"), Args(command), approved)); + } + [Fact] public void IsApproved_recurses_into_bash_c_wrapper() { @@ -144,14 +165,15 @@ public void FormatForDisplay_returns_command() // ── ExtractDirectoryRoots / ExtractApprovalEntries ── - [Fact] - public void ExtractDirectoryRoots_simple_path_command() + [Theory] + [MemberData(nameof(PlatformDirectoryMatchCases))] + public void ExtractDirectoryRoots_simple_path_command(string expectedRoot, string command, bool _) { var roots = _matcher.ExtractDirectoryRoots( new ToolName("shell_execute"), - Args("cat /home/user/.netclaw/logs/crash.log")); + Args(command)); Assert.Single(roots); - Assert.Equal("/home/user/.netclaw/logs/", roots[0].ComparisonRoot.Replace('\\', '/')); + Assert.Equal(expectedRoot, roots[0].ComparisonRoot); } [Fact] diff --git a/src/Netclaw.Security.Tests/ShellTokenizerTests.cs b/src/Netclaw.Security.Tests/ShellTokenizerTests.cs index b7f3e574..cdcea322 100644 --- a/src/Netclaw.Security.Tests/ShellTokenizerTests.cs +++ b/src/Netclaw.Security.Tests/ShellTokenizerTests.cs @@ -9,6 +9,76 @@ namespace Netclaw.Security.Tests; public sealed class ShellTokenizerTests { + public static TheoryData AbsoluteRootCases + { + get + { + var data = new TheoryData(); + if (OperatingSystem.IsWindows()) + data.Add(@"type C:\Users\petabridge\.netclaw\logs\crash.log", @"C:\Users\petabridge\.netclaw\logs\"); + else + data.Add("cat /home/user/.netclaw/logs/crash.log", "/home/user/.netclaw/logs/"); + + return data; + } + } + + public static TheoryData RelativeRootCases + { + get + { + var data = new TheoryData(); + if (OperatingSystem.IsWindows()) + data.Add("findstr timeout logs\\app.log | find /c \"timeout\"", @"logs\"); + else + data.Add("grep timeout logs/app.log | wc -l", "logs/"); + + return data; + } + } + + public static TheoryData WindowsAnchoredPathCases + { + get + { + var expected = OperatingSystem.IsWindows(); + return new TheoryData + { + { @"C:\Users\file.txt", expected }, + { @"c:\users\documents", expected }, + { "D:/Projects/src", expected }, + { "C:/Windows/System32", expected }, + { @"\\server\share\file.txt", expected }, + { @"\\nas\backups", expected } + }; + } + } + + public static TheoryData BackslashPathCases + { + get + { + var expected = OperatingSystem.IsWindows(); + return new TheoryData + { + { @"src\main.cs", expected }, + { @"folder\subfolder", expected } + }; + } + } + + public static TheoryData WindowsAbsoluteDirectoryRootCases + { + get + { + var data = new TheoryData(); + data.Add( + @"type C:\Users\petabridge\.netclaw\logs\crash.log", + OperatingSystem.IsWindows() ? @"C:\Users\petabridge\.netclaw\logs\" : null); + return data; + } + } + // ── Tokenize ── [Fact] @@ -207,17 +277,18 @@ public void GetAllSegments_simple_command() [InlineData("~")] [InlineData("$HOME/.config/app.toml")] [InlineData("${HOME}/workspace")] - [InlineData("C:\\Users\\file.txt")] - [InlineData("c:\\users\\documents")] - [InlineData("D:/Projects/src")] - [InlineData("C:/Windows/System32")] - [InlineData("\\\\server\\share\\file.txt")] - [InlineData("\\\\nas\\backups")] public void LooksLikePath_anchored_paths(string token) { Assert.True(ShellTokenizer.LooksLikePath(token)); } + [Theory] + [MemberData(nameof(WindowsAnchoredPathCases))] + public void LooksLikePath_windows_anchored_paths_follow_active_shell_family(string token, bool expected) + { + Assert.Equal(expected, ShellTokenizer.LooksLikePath(token)); + } + // Non-paths — always false [Theory] [InlineData("https://api.github.com/repos/foo")] @@ -265,23 +336,23 @@ public void LooksLikePath_traversal_component(string token) // Backslash always indicates Windows path [Theory] - [InlineData("src\\main.cs")] - [InlineData("folder\\subfolder")] - public void LooksLikePath_backslash(string token) + [MemberData(nameof(BackslashPathCases))] + public void LooksLikePath_backslash(string token, bool expected) { - Assert.True(ShellTokenizer.LooksLikePath(token)); + Assert.Equal(expected, ShellTokenizer.LooksLikePath(token)); } // ── ExtractDirectoryRoots ── - [Fact] - public void ExtractDirectoryRoots_returns_normalized_root_for_file_path() + [Theory] + [MemberData(nameof(AbsoluteRootCases))] + public void ExtractDirectoryRoots_returns_normalized_root_for_file_path(string command, string expectedRoot) { - var roots = ShellTokenizer.ExtractDirectoryRoots("cat /home/user/.netclaw/logs/crash.log"); + var roots = ShellTokenizer.ExtractDirectoryRoots(command); Assert.Single(roots); - Assert.Equal("/home/user/.netclaw/logs/", roots[0].ComparisonRoot.Replace('\\', '/')); - Assert.Equal("/home/user/.netclaw/logs/", roots[0].DisplayPath.Replace('\\', '/')); + Assert.Equal(expectedRoot, roots[0].ComparisonRoot); + Assert.Equal(expectedRoot, roots[0].DisplayPath); } [Fact] @@ -294,8 +365,9 @@ public void ExtractDirectoryRoots_preserves_posix_absolute_shell_paths_on_window Assert.Equal("/home/user/.netclaw/logs/", roots[0].ComparisonRoot.Replace('\\', '/')); } - [Fact] - public void ExtractDirectoryRoots_keeps_relative_display_path_and_normalized_comparison_root() + [Theory] + [MemberData(nameof(RelativeRootCases))] + public void ExtractDirectoryRoots_keeps_relative_display_path_and_normalized_comparison_root(string command, string expectedDisplayRoot) { var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var logs = Path.Combine(root, "logs"); @@ -303,10 +375,10 @@ public void ExtractDirectoryRoots_keeps_relative_display_path_and_normalized_com try { - var roots = ShellTokenizer.ExtractDirectoryRoots("grep timeout logs/app.log | wc -l", root); + var roots = ShellTokenizer.ExtractDirectoryRoots(command, root); Assert.Single(roots); - Assert.Equal("logs/", roots[0].DisplayPath.Replace('\\', '/')); + Assert.Equal(expectedDisplayRoot, roots[0].DisplayPath); Assert.Equal(PathUtility.Normalize(logs) + Path.DirectorySeparatorChar, roots[0].ComparisonRoot); } finally @@ -324,6 +396,23 @@ public void ExtractDirectoryRoots_handles_glob_paths() Assert.Equal("/home/user/.netclaw/logs/", roots[0].ComparisonRoot.Replace('\\', '/')); } + [Theory] + [MemberData(nameof(WindowsAbsoluteDirectoryRootCases))] + public void ExtractDirectoryRoots_handles_windows_absolute_paths(string command, string? expectedRoot) + { + var roots = ShellTokenizer.ExtractDirectoryRoots(command); + + if (expectedRoot is null) + { + Assert.Empty(roots); + return; + } + + Assert.Single(roots); + Assert.Equal(expectedRoot, roots[0].ComparisonRoot); + Assert.Equal(expectedRoot, roots[0].DisplayPath); + } + [Fact] public void ExtractDirectoryRoots_returns_multiple_roots_for_multi_directory_command() { diff --git a/src/Netclaw.Security/ShellApprovalSemantics.cs b/src/Netclaw.Security/ShellApprovalSemantics.cs new file mode 100644 index 00000000..ab410df6 --- /dev/null +++ b/src/Netclaw.Security/ShellApprovalSemantics.cs @@ -0,0 +1,645 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text; + +namespace Netclaw.Security; + +internal interface IShellApprovalSemantics +{ + IReadOnlyList SplitCompoundCommand(string command); + + string ExtractVerbChain(string command, int maxDepth); + + IReadOnlyList ExtractInnerCommands(string command); + + bool LooksLikePath(string token); + + string NormalizeApprovalUnit(string command, string? workingDirectory); + + IReadOnlyList ExtractDirectoryRoots(string command, string? workingDirectory); + + string? NormalizePathToken(string path, string? workingDirectory); +} + +internal static class ShellApprovalSemantics +{ + public static IShellApprovalSemantics Current { get; } = OperatingSystem.IsWindows() + ? WindowsShellApprovalSemantics.Instance + : PosixShellApprovalSemantics.Instance; +} + +internal abstract class ShellApprovalSemanticsBase : IShellApprovalSemantics +{ + public abstract IReadOnlyList SplitCompoundCommand(string command); + + public abstract IReadOnlyList ExtractInnerCommands(string command); + + public abstract bool LooksLikePath(string token); + + protected abstract bool IsShellSeparator(char ch); + + protected abstract bool IsAnchoredPath(string token); + + public string ExtractVerbChain(string command, int maxDepth) + { + var tokens = ShellTokenizer.Tokenize(command).ToList(); + if (tokens.Count == 0) + return string.Empty; + + var verbParts = new List(); + foreach (var token in tokens) + { + if (verbParts.Count >= maxDepth) + break; + + var trimmed = ShellTokenizer.TrimShellPunctuation(token); + if (trimmed.Length == 0) + continue; + + if (trimmed.StartsWith('-')) + break; + + if (LooksLikeArgument(trimmed)) + break; + + verbParts.Add(trimmed); + } + + if (verbParts.Count == 1 && ShellTokenizer.PathAwareVerbs.Contains(verbParts[0])) + { + for (var i = 1; i < tokens.Count; i++) + { + var trimmed = ShellTokenizer.TrimShellPunctuation(tokens[i]); + if (trimmed.Length == 0) + continue; + + if (trimmed.StartsWith('-')) + continue; + + verbParts.Add(trimmed); + break; + } + } + + return string.Join(' ', verbParts); + } + + public string NormalizeApprovalUnit(string command, string? workingDirectory) + { + var tokens = ShellTokenizer.Tokenize(command).ToList(); + if (tokens.Count == 0) + return string.Empty; + + var normalizedTokens = new List(tokens.Count); + foreach (var token in tokens) + { + if (!LooksLikePath(token)) + { + normalizedTokens.Add(token); + continue; + } + + var normalized = NormalizePathToken(token, workingDirectory); + normalizedTokens.Add(normalized ?? token); + } + + return string.Join(' ', normalizedTokens); + } + + public IReadOnlyList ExtractDirectoryRoots(string command, string? workingDirectory) + { + var tokens = ShellTokenizer.Tokenize(command).ToList(); + if (tokens.Count == 0) + return []; + + var roots = new List(); + var comparisonRoots = new HashSet(StringComparer.OrdinalIgnoreCase); + var sawPathToken = false; + + foreach (var token in tokens) + { + if (token.Length == 0 || token.StartsWith('-')) + continue; + + if (!LooksLikePath(token)) + continue; + + sawPathToken = true; + var root = TryCreateDirectoryApprovalRoot(token, workingDirectory); + if (root is null) + return []; + + if (comparisonRoots.Add(root.ComparisonRoot)) + roots.Add(root); + } + + return sawPathToken ? roots : []; + } + + public virtual string? NormalizePathToken(string path, string? workingDirectory) + => PathUtility.ExpandAndNormalize(path, workingDirectory); + + protected virtual bool LooksLikeArgument(string token) + { + return ContainsShellPathSeparator(token) + || token.StartsWith('~') + || token.StartsWith('.') + || token.Contains("://", StringComparison.Ordinal) + || token.Contains(':', StringComparison.Ordinal) + || token.StartsWith('$') + || token.StartsWith('%') + || token.Contains('*', StringComparison.Ordinal); + } + + protected bool ContainsShellPathSeparator(string token) + { + foreach (var ch in token) + { + if (IsShellSeparator(ch)) + return true; + } + + return false; + } + + protected bool HasTraversalComponent(string token) + { + return token.Contains("/../", StringComparison.Ordinal) + || token.EndsWith("/..", StringComparison.Ordinal) + || token.Contains("\\..\\", StringComparison.Ordinal) + || token.EndsWith("\\..", StringComparison.Ordinal); + } + + protected static bool HasFileExtensionInLastComponent(string token) + { + var lastComponent = Path.GetFileName(token); + if (string.IsNullOrWhiteSpace(lastComponent)) + return false; + + var ext = Path.GetExtension(lastComponent); + return ext.Length > 1; + } + + protected int GetFirstShellSeparatorIndex(string token) + { + for (var i = 0; i < token.Length; i++) + { + if (IsShellSeparator(token[i])) + return i; + } + + return -1; + } + + protected int GetLastShellSeparatorIndex(string token, int startExclusive) + { + for (var i = Math.Min(startExclusive - 1, token.Length - 1); i >= 0; i--) + { + if (IsShellSeparator(token[i])) + return i; + } + + return -1; + } + + protected static IReadOnlyList SplitCompoundCommand(string command, bool splitOnSemicolon, bool splitOnSingleAmpersand) + { + if (string.IsNullOrWhiteSpace(command)) + return []; + + var segments = new List(); + var current = new StringBuilder(); + char? quote = null; + var span = command.AsSpan(); + + for (var i = 0; i < span.Length; i++) + { + var ch = span[i]; + + if (quote is null && (ch == '\'' || ch == '"')) + { + quote = ch; + current.Append(ch); + continue; + } + + if (quote is not null && ch == quote) + { + quote = null; + current.Append(ch); + continue; + } + + if (quote is not null) + { + current.Append(ch); + continue; + } + + if (i + 1 < span.Length) + { + var twoChar = span.Slice(i, 2); + if (twoChar is "&&" or "||") + { + FlushSegment(current, segments); + i++; + continue; + } + } + + if (splitOnSingleAmpersand && ch == '&') + { + FlushSegment(current, segments); + continue; + } + + if (splitOnSemicolon && ch == ';') + { + FlushSegment(current, segments); + continue; + } + + current.Append(ch); + } + + FlushSegment(current, segments); + return segments; + } + + protected DirectoryApprovalRoot? TryCreateDirectoryApprovalRoot(string rawPath, string? workingDirectory) + { + var displayRoot = ExtractDisplayDirectory(rawPath, workingDirectory); + if (displayRoot is null) + return null; + + var comparisonRoot = NormalizePathToken(displayRoot, workingDirectory); + if (comparisonRoot is null) + return null; + + if (Directory.Exists(comparisonRoot)) + comparisonRoot = PathUtility.Normalize(new DirectoryInfo(comparisonRoot).ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? comparisonRoot); + + if (CountPathSegments(comparisonRoot) < ShellTokenizer.MinDirectoryScopeDepth) + return null; + + return new DirectoryApprovalRoot(EnsureTrailingSeparator(displayRoot), EnsureTrailingSeparator(comparisonRoot)); + } + + protected virtual string? ExtractDisplayDirectory(string path, string? workingDirectory) + { + string? candidate; + if (path.EndsWith('/') || path.EndsWith('\\')) + { + candidate = path.TrimEnd('/', '\\'); + } + else + { + var globIdx = path.IndexOfAny(['*', '?', '[']); + if (globIdx >= 0) + { + var lastSep = GetLastShellSeparatorIndex(path, globIdx); + candidate = lastSep > 0 ? path[..lastSep] : null; + } + else + { + var normalizedCandidate = NormalizePathToken(path, workingDirectory); + if (normalizedCandidate is not null && Directory.Exists(normalizedCandidate)) + candidate = path; + else + candidate = Path.GetDirectoryName(path); + } + } + + return NormalizeDisplayDirectory(candidate, workingDirectory); + } + + protected virtual string? NormalizeDisplayDirectory(string? candidate, string? workingDirectory) + { + if (string.IsNullOrWhiteSpace(candidate)) + return null; + + var trimmed = Path.TrimEndingDirectorySeparator(candidate); + if (IsRelativeDisplayPath(trimmed)) + return trimmed; + + return NormalizePathToken(trimmed, workingDirectory); + } + + protected virtual bool IsRelativeDisplayPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return false; + + if (path.StartsWith('~') + || path.StartsWith("$HOME", StringComparison.Ordinal) + || path.StartsWith("${HOME}", StringComparison.Ordinal) + || path.StartsWith("%USERPROFILE%", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return !Path.IsPathRooted(path); + } + + protected virtual string EnsureTrailingSeparator(string path) + => Path.EndsInDirectorySeparator(path) ? path : path + Path.DirectorySeparatorChar; + + protected virtual int CountPathSegments(string normalizedPath) + { + var trimmed = normalizedPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (trimmed.Length == 0) + return 0; + + var root = Path.GetPathRoot(trimmed); + if (root is not null && trimmed.Length > root.Length) + trimmed = trimmed[root.Length..]; + else if (root is not null) + return 0; + + return trimmed.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries).Length; + } + + private static void FlushSegment(StringBuilder current, List segments) + { + var trimmed = current.ToString().Trim(); + if (trimmed.Length > 0) + segments.Add(trimmed); + + current.Clear(); + } +} + +internal sealed class PosixShellApprovalSemantics : ShellApprovalSemanticsBase +{ + public static readonly PosixShellApprovalSemantics Instance = new(); + + public override IReadOnlyList SplitCompoundCommand(string command) + => SplitCompoundCommand(command, splitOnSemicolon: true, splitOnSingleAmpersand: false); + + public override IReadOnlyList ExtractInnerCommands(string command) + { + var tokens = ShellTokenizer.Tokenize(command).ToList(); + var results = new List(); + + for (var i = 0; i < tokens.Count - 1; i++) + { + var verb = ShellTokenizer.TrimShellPunctuation(tokens[i]); + if (!IsShellInvoker(verb)) + continue; + + if (i + 1 < tokens.Count && IsShellCommandFlag(tokens[i + 1]) && i + 2 < tokens.Count) + results.Add(tokens[i + 2]); + } + + return results; + } + + public override bool LooksLikePath(string token) + { + if (string.IsNullOrWhiteSpace(token) || token.StartsWith('-')) + return false; + + if (token.Contains("://", StringComparison.Ordinal)) + return false; + + if (IsAnchoredPath(token)) + return true; + + if (!ContainsShellPathSeparator(token)) + return false; + + var firstSlash = GetFirstShellSeparatorIndex(token); + if (token.IndexOf(':', StringComparison.Ordinal) is var colonIdx && colonIdx >= 0 && colonIdx < firstSlash) + return false; + + if (token.StartsWith('@') && token.IndexOf('/', 1) == token.LastIndexOf('/')) + return false; + + if ((token.StartsWith("s/", StringComparison.Ordinal) || token.StartsWith("y/", StringComparison.Ordinal)) + && CountChar(token, '/') >= 3) + { + return false; + } + + return HasTraversalComponent(token) || HasFileExtensionInLastComponent(token); + } + + protected override bool IsShellSeparator(char ch) => ch == '/'; + + protected override bool IsAnchoredPath(string token) + { + return Path.IsPathRooted(token) + || token.StartsWith("./", StringComparison.Ordinal) + || token.StartsWith("../", StringComparison.Ordinal) + || token.StartsWith('~') + || token.StartsWith("$HOME", StringComparison.Ordinal) + || token.StartsWith("${HOME}", StringComparison.Ordinal); + } + + private static bool IsShellInvoker(string verb) + { + return verb is "bash" or "sh" or "/bin/bash" or "/bin/sh" + or "/usr/bin/bash" or "/usr/bin/sh" or "zsh" or "/bin/zsh"; + } + + private static bool IsShellCommandFlag(string token) + { + if (token.Length == 0 || token[0] != '-' || token.StartsWith("--", StringComparison.Ordinal)) + return false; + + return token.AsSpan(1).IndexOf('c') >= 0; + } + + private static int CountChar(string value, char target) + { + var count = 0; + foreach (var c in value) + { + if (c == target) + count++; + } + + return count; + } +} + +internal sealed class WindowsShellApprovalSemantics : ShellApprovalSemanticsBase +{ + public static readonly WindowsShellApprovalSemantics Instance = new(); + + public override IReadOnlyList SplitCompoundCommand(string command) + // Windows approval splitting handles both cmd.exe control operators (`&`, `&&`, `||`) + // and PowerShell's `;` because nested PowerShell invocations are common under `cmd /c`. + => SplitCompoundCommand(command, splitOnSemicolon: true, splitOnSingleAmpersand: true); + + public override IReadOnlyList ExtractInnerCommands(string command) + { + var tokens = ShellTokenizer.Tokenize(command).ToList(); + var results = new List(); + + for (var i = 0; i < tokens.Count - 1; i++) + { + var verb = ShellTokenizer.TrimShellPunctuation(tokens[i]); + if (IsCmdInvoker(verb)) + { + if (i + 1 < tokens.Count && IsCmdCommandFlag(tokens[i + 1]) && i + 2 < tokens.Count) + results.Add(tokens[i + 2]); + + continue; + } + + if (IsPowerShellInvoker(verb) + && i + 1 < tokens.Count + && IsPowerShellCommandFlag(tokens[i + 1]) + && i + 2 < tokens.Count) + { + results.Add(tokens[i + 2]); + } + } + + return results; + } + + public override bool LooksLikePath(string token) + { + if (string.IsNullOrWhiteSpace(token) || token.StartsWith('-')) + return false; + + if (token.Contains("://", StringComparison.Ordinal)) + return false; + + if (IsAnchoredPath(token)) + return true; + + if (!ContainsShellPathSeparator(token)) + return false; + + var firstSeparator = GetFirstShellSeparatorIndex(token); + if (token.IndexOf(':', StringComparison.Ordinal) is var colonIdx && colonIdx >= 0 && colonIdx < firstSeparator) + { + var isDrivePrefix = colonIdx == 1 && char.IsAsciiLetter(token[0]); + if (!isDrivePrefix) + return false; + } + + if (token.StartsWith('@') && token.IndexOf('/', 1) == token.LastIndexOf('/')) + return false; + + if ((token.StartsWith("s/", StringComparison.Ordinal) || token.StartsWith("y/", StringComparison.Ordinal)) + && CountChar(token, '/') >= 3) + { + return false; + } + + return HasTraversalComponent(token) || HasFileExtensionInLastComponent(token); + } + + protected override bool IsShellSeparator(char ch) => ch is '/' or '\\'; + + protected override bool IsAnchoredPath(string token) + { + return token.Length > 0 && token[0] == '/' + || IsWindowsRootedPath(token) + || token.StartsWith("./", StringComparison.Ordinal) + || token.StartsWith("../", StringComparison.Ordinal) + || token.StartsWith(@".\", StringComparison.Ordinal) + || token.StartsWith(@"..\", StringComparison.Ordinal) + || token.StartsWith('~') + || token.StartsWith("$HOME", StringComparison.Ordinal) + || token.StartsWith("${HOME}", StringComparison.Ordinal) + || token.StartsWith("%USERPROFILE%", StringComparison.OrdinalIgnoreCase); + } + + public override string? NormalizePathToken(string path, string? workingDirectory) + { + var expanded = PathUtility.ExpandHome(path); + + if (LooksLikePosixAbsoluteShellPath(expanded)) + return NormalizePosixShellPath(expanded); + + return PathUtility.ExpandAndNormalize(expanded, workingDirectory); + } + + private static bool IsCmdInvoker(string verb) + => verb.Equals("cmd", StringComparison.OrdinalIgnoreCase) + || verb.Equals("cmd.exe", StringComparison.OrdinalIgnoreCase); + + private static bool IsWindowsRootedPath(string token) + { + if (token.StartsWith("\\\\", StringComparison.Ordinal)) + return true; + + return token.Length >= 3 + && char.IsAsciiLetter(token[0]) + && token[1] == ':' + && (token[2] == '\\' || token[2] == '/'); + } + + private static bool LooksLikePosixAbsoluteShellPath(string path) + { + return path.Length > 0 && path[0] == '/' + && !path.StartsWith("//", StringComparison.Ordinal) + && path.IndexOf('\\', StringComparison.Ordinal) < 0 + && !path.Contains("://", StringComparison.Ordinal); + } + + private static string NormalizePosixShellPath(string path) + { + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + var normalized = new List(segments.Length); + foreach (var segment in segments) + { + if (segment == ".") + continue; + + if (segment == "..") + { + if (normalized.Count > 0) + normalized.RemoveAt(normalized.Count - 1); + + continue; + } + + normalized.Add(segment); + } + + return normalized.Count == 0 ? "/" : "/" + string.Join('/', normalized); + } + + private static bool IsPowerShellInvoker(string verb) + { + return verb.Equals("powershell", StringComparison.OrdinalIgnoreCase) + || verb.Equals("powershell.exe", StringComparison.OrdinalIgnoreCase) + || verb.Equals("pwsh", StringComparison.OrdinalIgnoreCase) + || verb.Equals("pwsh.exe", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsCmdCommandFlag(string token) + { + return token.Equals("/c", StringComparison.OrdinalIgnoreCase) + || token.Equals("/k", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsPowerShellCommandFlag(string token) + { + return token.Equals("-c", StringComparison.OrdinalIgnoreCase) + || token.Equals("-command", StringComparison.OrdinalIgnoreCase); + } + + private static int CountChar(string value, char target) + { + var count = 0; + foreach (var c in value) + { + if (c == target) + count++; + } + + return count; + } +} diff --git a/src/Netclaw.Security/ShellTokenizer.cs b/src/Netclaw.Security/ShellTokenizer.cs index 1186e6fe..4f792803 100644 --- a/src/Netclaw.Security/ShellTokenizer.cs +++ b/src/Netclaw.Security/ShellTokenizer.cs @@ -24,6 +24,7 @@ public static class ShellTokenizer { "cat", "less", "more", "head", "tail", "grep", "rg", "find", "jq", "awk", "sed", "strings", "xxd", "hexdump", "cp", "mv", "tar", "zip", "unzip", "scp", "rsync", "curl", "wget", "nc", "ncat", + "type", "findstr", "copy", "move", "xcopy", "robocopy", "del", "erase", "ren", "powershell", "powershell.exe", "pwsh", "pwsh.exe", "python", "python3", "node", "ruby", "perl", "php", "bash", "sh", "zsh" }; @@ -35,7 +36,7 @@ public static class ShellTokenizer /// internal static readonly HashSet PathAwareVerbs = new(HighRiskVerbs, StringComparer.OrdinalIgnoreCase) { - "ls" + "ls", "dir" }; /// @@ -86,65 +87,7 @@ public static IEnumerable Tokenize(string command) /// work. /// public static IReadOnlyList SplitCompoundCommand(string command) - { - if (string.IsNullOrWhiteSpace(command)) - return []; - - var segments = new List(); - var current = new StringBuilder(); - char? quote = null; - var span = command.AsSpan(); - - for (var i = 0; i < span.Length; i++) - { - var ch = span[i]; - - // Track quoting so we don't split inside strings - if (quote is null && (ch == '\'' || ch == '"')) - { - quote = ch; - current.Append(ch); - continue; - } - - if (quote is not null && ch == quote) - { - quote = null; - current.Append(ch); - continue; - } - - if (quote is not null) - { - current.Append(ch); - continue; - } - - // Check for two-char operators: && and || - if (i + 1 < span.Length) - { - var twoChar = span.Slice(i, 2); - if (twoChar is "&&" or "||") - { - FlushSegment(current, segments); - i++; // skip second char - continue; - } - } - - // Single-char operator: ; - if (ch == ';') - { - FlushSegment(current, segments); - continue; - } - - current.Append(ch); - } - - FlushSegment(current, segments); - return segments; - } + => ShellApprovalSemantics.Current.SplitCompoundCommand(command); /// /// Extracts the verb chain (command name + subcommands) from a tokenized @@ -155,109 +98,28 @@ public static IReadOnlyList SplitCompoundCommand(string command) /// argument so the approval pattern captures what the command operates on. /// public static string ExtractVerbChain(string command, int maxDepth = 2) - { - var tokens = Tokenize(command).ToList(); - if (tokens.Count == 0) - return string.Empty; - - var verbParts = new List(); - foreach (var token in tokens) - { - if (verbParts.Count >= maxDepth) - break; - - var trimmed = TrimShellPunctuation(token); - if (trimmed.Length == 0) - continue; - - if (trimmed.StartsWith('-')) - break; - - if (LooksLikeArgument(trimmed)) - break; - - verbParts.Add(trimmed); - } - - if (verbParts.Count == 1 && PathAwareVerbs.Contains(verbParts[0])) - { - for (var i = 1; i < tokens.Count; i++) - { - var trimmed = TrimShellPunctuation(tokens[i]); - if (trimmed.Length == 0) - continue; - - if (trimmed.StartsWith('-')) - continue; - - verbParts.Add(trimmed); - break; - } - } - - return string.Join(' ', verbParts); - } + => ShellApprovalSemantics.Current.ExtractVerbChain(command, maxDepth); /// /// Produces an exact shell approval unit string with recognizable local paths /// normalized against the working directory. Non-path tokens remain in order. /// public static string NormalizeApprovalUnit(string command, string? workingDirectory = null) - { - var tokens = Tokenize(command).ToList(); - if (tokens.Count == 0) - return string.Empty; - - var normalizedTokens = new List(tokens.Count); - foreach (var token in tokens) - { - if (LooksLikePath(token)) - { - var normalized = NormalizeShellPathToken(token, workingDirectory); - normalizedTokens.Add(normalized ?? token); - } - else - { - normalizedTokens.Add(token); - } - } - - return string.Join(' ', normalizedTokens); - } + => ShellApprovalSemantics.Current.NormalizeApprovalUnit(command, workingDirectory); /// /// Extracts reusable directory approval roots from a shell approval unit. /// Returns an empty list when no reusable roots can be extracted. /// public static IReadOnlyList ExtractDirectoryRoots(string command, string? workingDirectory = null) - { - var tokens = Tokenize(command).ToList(); - if (tokens.Count == 0) - return []; - - var roots = new List(); - var comparisonRoots = new HashSet(StringComparer.OrdinalIgnoreCase); - var sawPathToken = false; - - foreach (var token in tokens) - { - if (token.Length == 0 || token.StartsWith('-')) - continue; - - if (!LooksLikePath(token)) - continue; + => ShellApprovalSemantics.Current.ExtractDirectoryRoots(command, workingDirectory); - sawPathToken = true; - var root = TryCreateDirectoryApprovalRoot(token, workingDirectory); - if (root is null) - return []; - - if (comparisonRoots.Add(root.ComparisonRoot)) - roots.Add(root); - } - - return sawPathToken ? roots : []; - } + /// + /// Normalizes a path token using the active shell family's path semantics. + /// Returns null when the token cannot be normalized as a local path. + /// + public static string? NormalizePathToken(string path, string? workingDirectory = null) + => ShellApprovalSemantics.Current.NormalizePathToken(path, workingDirectory); /// /// Extracts inner commands from bash -c / sh -c wrappers. Returns the @@ -265,25 +127,7 @@ public static IReadOnlyList ExtractDirectoryRoots(string /// if the command does not use a shell wrapper. /// public static IReadOnlyList ExtractInnerCommands(string command) - { - var tokens = Tokenize(command).ToList(); - var results = new List(); - - for (var i = 0; i < tokens.Count - 1; i++) - { - var verb = TrimShellPunctuation(tokens[i]); - if (!IsShellInvoker(verb)) - continue; - - // Look for -c flag - if (i + 1 < tokens.Count && IsShellCommandFlag(tokens[i + 1]) && i + 2 < tokens.Count) - { - results.Add(tokens[i + 2]); - } - } - - return results; - } + => ShellApprovalSemantics.Current.ExtractInnerCommands(command); /// /// Returns all command strings that should be evaluated, including the @@ -310,35 +154,6 @@ public static IReadOnlyList GetAllCommandSegments(string command) return allSegments; } - private static bool IsShellInvoker(string verb) - { - return verb is "bash" or "sh" or "/bin/bash" or "/bin/sh" - or "/usr/bin/bash" or "/usr/bin/sh" or "zsh" or "/bin/zsh"; - } - - private static bool IsShellCommandFlag(string token) - { - if (token.Length == 0 || token[0] != '-' || token.StartsWith("--", StringComparison.Ordinal)) - return false; - - return token.AsSpan(1).IndexOf('c') >= 0; - } - - private static bool LooksLikeArgument(string token) - { - // Paths, URLs, filenames, dotfiles, home-relative - return token.Contains('/', StringComparison.Ordinal) - || token.Contains('\\', StringComparison.Ordinal) - || token.StartsWith('~') - || token.StartsWith('.') - || token.Contains("://", StringComparison.Ordinal) - || token.Contains(':', StringComparison.Ordinal) - // Environment variable references - || token.StartsWith('$') - // Glob patterns - || token.Contains('*', StringComparison.Ordinal); - } - /// /// Returns true if a token is identifiable as a local filesystem path. /// Uses positive identification (anchored prefixes + extension heuristic) @@ -346,236 +161,10 @@ private static bool LooksLikeArgument(string token) /// on URIs, git refs, docker images, sed expressions, and MIME types. /// public static bool LooksLikePath(string token) - { - if (string.IsNullOrWhiteSpace(token)) - return false; - - if (token.StartsWith('-')) - return false; - - // URIs - if (token.Contains("://", StringComparison.Ordinal)) - return false; - - // Anchored paths — definitively filesystem references - if (token.StartsWith('/')) - return true; - if (token.StartsWith("./", StringComparison.Ordinal) || token.StartsWith("../", StringComparison.Ordinal)) - return true; - if (token.StartsWith('~')) - return true; - if (token.StartsWith("$HOME", StringComparison.Ordinal) || token.StartsWith("${HOME}", StringComparison.Ordinal)) - return true; - // Windows drive letter: C:\ or C:/ (case-insensitive) - if (token.Length >= 3 && char.IsAsciiLetter(token[0]) && token[1] == ':' - && (token[2] == '/' || token[2] == '\\')) - return true; - // UNC path: \\server\share - if (token.StartsWith("\\\\", StringComparison.Ordinal)) - return true; - - // Backslash always indicates a Windows-style path - if (token.Contains('\\', StringComparison.Ordinal)) - return true; - - // Unanchored tokens with forward slashes — disambiguate using exclusions - // and the file-extension heuristic - if (token.Contains('/', StringComparison.Ordinal)) - { - // Colon before first slash = docker image, port, or key:value - var firstSlash = token.IndexOf('/', StringComparison.Ordinal); - if (token.IndexOf(':', StringComparison.Ordinal) is var colonIdx && colonIdx >= 0 && colonIdx < firstSlash) - return false; - - // npm scoped package: @scope/name - if (token.StartsWith('@') && token.IndexOf('/', 1) == token.LastIndexOf('/')) - return false; - - // sed/tr expression: s/pattern/replacement/ or y/abc/xyz/ - if ((token.StartsWith("s/", StringComparison.Ordinal) || token.StartsWith("y/", StringComparison.Ordinal)) - && CountChar(token, '/') >= 3) - return false; - - // Path traversal component is always a path signal - if (token.Contains("/../", StringComparison.Ordinal) || token.EndsWith("/..", StringComparison.Ordinal)) - return true; - - // File extension in the last component → treat as relative path - // (src/main.rs, config/app.json). Git refs and docker images don't - // have extensions. - var lastSlash = token.LastIndexOf('/'); - if (lastSlash >= 0 && lastSlash < token.Length - 1) - { - var lastComponent = token.AsSpan(lastSlash + 1); - var dotIdx = lastComponent.LastIndexOf('.'); - if (dotIdx > 0 && dotIdx < lastComponent.Length - 1) - return true; - } - - return false; - } - - return false; - } + => ShellApprovalSemantics.Current.LooksLikePath(token); internal const int MinDirectoryScopeDepth = 2; - private static DirectoryApprovalRoot? TryCreateDirectoryApprovalRoot(string rawPath, string? workingDirectory) - { - var displayRoot = ExtractDisplayDirectory(rawPath, workingDirectory); - if (displayRoot is null) - return null; - - var comparisonRoot = NormalizeShellPathToken(displayRoot, workingDirectory); - if (comparisonRoot is null) - return null; - - if (Directory.Exists(comparisonRoot)) - comparisonRoot = PathUtility.Normalize(new DirectoryInfo(comparisonRoot).ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? comparisonRoot); - - if (CountPathSegments(comparisonRoot) < MinDirectoryScopeDepth) - return null; - - return new DirectoryApprovalRoot(EnsureTrailingSeparator(displayRoot), EnsureTrailingSeparator(comparisonRoot)); - } - - private static string? ExtractDisplayDirectory(string path, string? workingDirectory) - { - if (path.EndsWith('/') || path.EndsWith('\\')) - return path.TrimEnd('/', '\\'); - - if (path.Contains('/', StringComparison.Ordinal) && !path.Contains('\\', StringComparison.Ordinal)) - return ExtractParentDirectory(path); - - var globIdx = path.IndexOfAny(['*', '?', '[']); - if (globIdx >= 0) - { - var lastSep = path.LastIndexOf('/', globIdx); - if (lastSep < 0) - lastSep = path.LastIndexOf('\\', globIdx); - return lastSep > 0 ? path[..lastSep] : null; - } - - var normalizedCandidate = NormalizeShellPathToken(path, workingDirectory); - if (normalizedCandidate is not null && Directory.Exists(normalizedCandidate)) - return path; - - return ExtractParentDirectory(path); - } - - private static string? NormalizeShellPathToken(string path, string? workingDirectory) - { - var expanded = PathUtility.ExpandHome(path); - - // Shell approval extraction is based on the command's path language, not - // the host runtime's filesystem parser. A POSIX shell path like - // `/home/user/...` should stay POSIX-shaped even when the daemon runs on - // Windows, otherwise `Path.GetFullPath()` rewrites it to `D:\home\...` - // and both matching and prompt text drift away from what the shell - // command actually means. - if (IsPosixAbsoluteShellPath(expanded)) - return NormalizePosixShellPath(expanded); - - return PathUtility.TryNormalize(expanded, workingDirectory, out var normalized) - ? normalized - : null; - } - - private static bool IsPosixAbsoluteShellPath(string path) - { - return path.Length > 0 && path[0] == '/' - && !path.StartsWith("//", StringComparison.Ordinal) - && path.IndexOf('\\', StringComparison.Ordinal) < 0 - && !path.Contains("://", StringComparison.Ordinal); - } - - private static string NormalizePosixShellPath(string path) - { - var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); - var normalized = new List(segments.Length); - foreach (var segment in segments) - { - if (segment == ".") - continue; - - if (segment == "..") - { - if (normalized.Count > 0) - normalized.RemoveAt(normalized.Count - 1); - - continue; - } - - normalized.Add(segment); - } - - return normalized.Count == 0 ? "/" : "/" + string.Join('/', normalized); - } - - private static string EnsureTrailingSeparator(string path) - { - if (path.EndsWith('/') || path.EndsWith('\\')) - return path; - - if (path.Contains('/', StringComparison.Ordinal) && !path.Contains('\\', StringComparison.Ordinal)) - return path + '/'; - - if (path.Contains('\\', StringComparison.Ordinal) && !path.Contains('/', StringComparison.Ordinal)) - return path + '\\'; - - return path + Path.DirectorySeparatorChar; - } - - private static string? ExtractParentDirectory(string path) - { - // Already a directory (trailing separator) - if (path.EndsWith('/') || path.EndsWith('\\')) - return path.TrimEnd('/', '\\'); - - // Glob: use directory portion before the glob - var globIdx = path.IndexOfAny(['*', '?', '[']); - if (globIdx >= 0) - { - var lastSep = path.LastIndexOf('/', globIdx); - if (lastSep < 0) - lastSep = path.LastIndexOf('\\', globIdx); - return lastSep > 0 ? path[..lastSep] : null; - } - - // Preserve POSIX-style shell paths instead of routing them through the - // host filesystem parser, which would rewrite `/dir/file` into a drive- - // rooted Windows path when tests run on Windows. - if (path.Contains('/', StringComparison.Ordinal) && !path.Contains('\\', StringComparison.Ordinal)) - { - var lastSlash = path.LastIndexOf('/'); - return lastSlash > 0 ? path[..lastSlash] : null; - } - - // Regular file: parent directory - var dir = Path.GetDirectoryName(path); - return string.IsNullOrEmpty(dir) ? null : dir; - } - - private static int CountPathSegments(string normalizedPath) - { - var trimmed = normalizedPath - .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - - if (trimmed.Length == 0) - return 0; - - // Strip root (e.g., "/" on Linux, "C:\" on Windows) - var root = Path.GetPathRoot(trimmed); - if (root is not null && trimmed.Length > root.Length) - trimmed = trimmed[root.Length..]; - else if (root is not null) - return 0; // path IS the root - - return trimmed.Split( - [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], - StringSplitOptions.RemoveEmptyEntries).Length; - } - /// /// Returns true when the pattern is a single-token shell approval for a /// path-aware verb such as cat or bash. @@ -599,23 +188,4 @@ internal static string TrimShellPunctuation(string token) return token.Trim().TrimStart(';', '|', '&').TrimEnd(';', '|', '&'); } - private static int CountChar(string value, char target) - { - var count = 0; - foreach (var c in value) - { - if (c == target) - count++; - } - - return count; - } - - private static void FlushSegment(StringBuilder current, List segments) - { - var trimmed = current.ToString().Trim(); - if (trimmed.Length > 0) - segments.Add(trimmed); - current.Clear(); - } } diff --git a/src/Netclaw.Security/ToolPathPolicy.cs b/src/Netclaw.Security/ToolPathPolicy.cs index 4d8554ec..58ffc01c 100644 --- a/src/Netclaw.Security/ToolPathPolicy.cs +++ b/src/Netclaw.Security/ToolPathPolicy.cs @@ -122,13 +122,13 @@ public bool CommandReferencesDeniedPath(string command, string? workingDirectory if (!LooksLikePath(token)) continue; - var expanded = PathUtility.ExpandHome(token); - if (PathUtility.TryNormalize(expanded, workingDirectory, out var normalized) - && IsDeniedNormalized(normalized, _shellDeniedPaths)) + var normalized = ShellTokenizer.NormalizePathToken(token, workingDirectory); + if (normalized is not null && IsDeniedNormalized(normalized, _shellDeniedPaths)) { return true; } + var expanded = PathUtility.ExpandHome(token); if (expanded.Contains("secrets.json", StringComparison.OrdinalIgnoreCase) || expanded.Contains(".netclaw/keys", StringComparison.OrdinalIgnoreCase) || expanded.Contains(".netclaw\\keys", StringComparison.OrdinalIgnoreCase)) From b67d3dd1bc310a12ca70dce2cf002fce5cd3e177 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 7 May 2026 19:06:46 +0000 Subject: [PATCH 15/22] fix(security): choose shell approvals by command semantics Use the command's shell language to select POSIX vs Windows approval behavior on Windows hosts so bash-style commands and POSIX paths are still parsed as POSIX while cmd/PowerShell commands keep native Windows semantics. --- .../ShellApprovalSemantics.cs | 65 +++++++++++++++++-- src/Netclaw.Security/ShellTokenizer.cs | 14 ++-- 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/src/Netclaw.Security/ShellApprovalSemantics.cs b/src/Netclaw.Security/ShellApprovalSemantics.cs index ab410df6..10980bdc 100644 --- a/src/Netclaw.Security/ShellApprovalSemantics.cs +++ b/src/Netclaw.Security/ShellApprovalSemantics.cs @@ -26,9 +26,61 @@ internal interface IShellApprovalSemantics internal static class ShellApprovalSemantics { + private static readonly IShellApprovalSemantics Posix = PosixShellApprovalSemantics.Instance; + private static readonly IShellApprovalSemantics Windows = WindowsShellApprovalSemantics.Instance; + public static IShellApprovalSemantics Current { get; } = OperatingSystem.IsWindows() - ? WindowsShellApprovalSemantics.Instance - : PosixShellApprovalSemantics.Instance; + ? Windows + : Posix; + + public static IShellApprovalSemantics ForCommand(string? command) + { + if (!OperatingSystem.IsWindows()) + return Posix; + + if (string.IsNullOrWhiteSpace(command)) + return Windows; + + var tokens = ShellTokenizer.Tokenize(command).ToList(); + if (tokens.Count == 0) + return Windows; + + var first = ShellTokenizer.TrimShellPunctuation(tokens[0]); + if (PosixShellApprovalSemantics.IsPosixShellInvoker(first)) + return Posix; + + if (WindowsShellApprovalSemantics.IsWindowsShellInvoker(first)) + return Windows; + + foreach (var token in tokens) + { + var trimmed = ShellTokenizer.TrimShellPunctuation(token); + if (string.IsNullOrWhiteSpace(trimmed) || trimmed.StartsWith('-')) + continue; + + if (LooksLikePosixCommandPath(trimmed)) + return Posix; + + if (Windows.LooksLikePath(trimmed)) + return Windows; + } + + return Windows; + } + + private static bool LooksLikePosixCommandPath(string token) + { + if (token.Contains("://", StringComparison.Ordinal)) + return false; + + if (token.Equals("/c", StringComparison.OrdinalIgnoreCase) + || token.Equals("/k", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return Posix.LooksLikePath(token); + } } internal abstract class ShellApprovalSemanticsBase : IShellApprovalSemantics @@ -389,7 +441,7 @@ public override IReadOnlyList ExtractInnerCommands(string command) for (var i = 0; i < tokens.Count - 1; i++) { var verb = ShellTokenizer.TrimShellPunctuation(tokens[i]); - if (!IsShellInvoker(verb)) + if (!IsPosixShellInvoker(verb)) continue; if (i + 1 < tokens.Count && IsShellCommandFlag(tokens[i + 1]) && i + 2 < tokens.Count) @@ -441,7 +493,7 @@ protected override bool IsAnchoredPath(string token) || token.StartsWith("${HOME}", StringComparison.Ordinal); } - private static bool IsShellInvoker(string verb) + internal static bool IsPosixShellInvoker(string verb) { return verb is "bash" or "sh" or "/bin/bash" or "/bin/sh" or "/usr/bin/bash" or "/usr/bin/sh" or "zsh" or "/bin/zsh"; @@ -565,6 +617,11 @@ protected override bool IsAnchoredPath(string token) return PathUtility.ExpandAndNormalize(expanded, workingDirectory); } + internal static bool IsWindowsShellInvoker(string verb) + { + return IsCmdInvoker(verb) || IsPowerShellInvoker(verb); + } + private static bool IsCmdInvoker(string verb) => verb.Equals("cmd", StringComparison.OrdinalIgnoreCase) || verb.Equals("cmd.exe", StringComparison.OrdinalIgnoreCase); diff --git a/src/Netclaw.Security/ShellTokenizer.cs b/src/Netclaw.Security/ShellTokenizer.cs index 4f792803..afd8387c 100644 --- a/src/Netclaw.Security/ShellTokenizer.cs +++ b/src/Netclaw.Security/ShellTokenizer.cs @@ -87,7 +87,7 @@ public static IEnumerable Tokenize(string command) /// work. /// public static IReadOnlyList SplitCompoundCommand(string command) - => ShellApprovalSemantics.Current.SplitCompoundCommand(command); + => ShellApprovalSemantics.ForCommand(command).SplitCompoundCommand(command); /// /// Extracts the verb chain (command name + subcommands) from a tokenized @@ -98,28 +98,28 @@ public static IReadOnlyList SplitCompoundCommand(string command) /// argument so the approval pattern captures what the command operates on. /// public static string ExtractVerbChain(string command, int maxDepth = 2) - => ShellApprovalSemantics.Current.ExtractVerbChain(command, maxDepth); + => ShellApprovalSemantics.ForCommand(command).ExtractVerbChain(command, maxDepth); /// /// Produces an exact shell approval unit string with recognizable local paths /// normalized against the working directory. Non-path tokens remain in order. /// public static string NormalizeApprovalUnit(string command, string? workingDirectory = null) - => ShellApprovalSemantics.Current.NormalizeApprovalUnit(command, workingDirectory); + => ShellApprovalSemantics.ForCommand(command).NormalizeApprovalUnit(command, workingDirectory); /// /// Extracts reusable directory approval roots from a shell approval unit. /// Returns an empty list when no reusable roots can be extracted. /// public static IReadOnlyList ExtractDirectoryRoots(string command, string? workingDirectory = null) - => ShellApprovalSemantics.Current.ExtractDirectoryRoots(command, workingDirectory); + => ShellApprovalSemantics.ForCommand(command).ExtractDirectoryRoots(command, workingDirectory); /// /// Normalizes a path token using the active shell family's path semantics. /// Returns null when the token cannot be normalized as a local path. /// public static string? NormalizePathToken(string path, string? workingDirectory = null) - => ShellApprovalSemantics.Current.NormalizePathToken(path, workingDirectory); + => ShellApprovalSemantics.ForCommand(path).NormalizePathToken(path, workingDirectory); /// /// Extracts inner commands from bash -c / sh -c wrappers. Returns the @@ -127,7 +127,7 @@ public static IReadOnlyList ExtractDirectoryRoots(string /// if the command does not use a shell wrapper. /// public static IReadOnlyList ExtractInnerCommands(string command) - => ShellApprovalSemantics.Current.ExtractInnerCommands(command); + => ShellApprovalSemantics.ForCommand(command).ExtractInnerCommands(command); /// /// Returns all command strings that should be evaluated, including the @@ -161,7 +161,7 @@ public static IReadOnlyList GetAllCommandSegments(string command) /// on URIs, git refs, docker images, sed expressions, and MIME types. /// public static bool LooksLikePath(string token) - => ShellApprovalSemantics.Current.LooksLikePath(token); + => ShellApprovalSemantics.ForCommand(token).LooksLikePath(token); internal const int MinDirectoryScopeDepth = 2; From 9a9f787fe80803ccc2375072ff99f0db09bc6bf7 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 7 May 2026 19:25:01 +0000 Subject: [PATCH 16/22] fix(security): keep POSIX approval paths host-agnostic Preserve POSIX absolute path normalization inside the POSIX shell approval strategy on any host so matcher and root extraction logic stop rewriting /etc, /var, and /home paths through Windows drive semantics. --- .../ShellApprovalSemantics.cs | 75 ++++++++++--------- 1 file changed, 41 insertions(+), 34 deletions(-) diff --git a/src/Netclaw.Security/ShellApprovalSemantics.cs b/src/Netclaw.Security/ShellApprovalSemantics.cs index 10980bdc..6a0086a2 100644 --- a/src/Netclaw.Security/ShellApprovalSemantics.cs +++ b/src/Netclaw.Security/ShellApprovalSemantics.cs @@ -481,6 +481,16 @@ public override bool LooksLikePath(string token) return HasTraversalComponent(token) || HasFileExtensionInLastComponent(token); } + public override string? NormalizePathToken(string path, string? workingDirectory) + { + var expanded = PathUtility.ExpandHome(path); + + if (LooksLikePosixAbsoluteShellPath(expanded)) + return NormalizePosixShellPath(expanded); + + return PathUtility.ExpandAndNormalize(expanded, workingDirectory); + } + protected override bool IsShellSeparator(char ch) => ch == '/'; protected override bool IsAnchoredPath(string token) @@ -499,6 +509,37 @@ internal static bool IsPosixShellInvoker(string verb) or "/usr/bin/bash" or "/usr/bin/sh" or "zsh" or "/bin/zsh"; } + private static bool LooksLikePosixAbsoluteShellPath(string path) + { + return path.Length > 0 && path[0] == '/' + && !path.StartsWith("//", StringComparison.Ordinal) + && path.IndexOf('\\', StringComparison.Ordinal) < 0 + && !path.Contains("://", StringComparison.Ordinal); + } + + private static string NormalizePosixShellPath(string path) + { + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + var normalized = new List(segments.Length); + foreach (var segment in segments) + { + if (segment == ".") + continue; + + if (segment == "..") + { + if (normalized.Count > 0) + normalized.RemoveAt(normalized.Count - 1); + + continue; + } + + normalized.Add(segment); + } + + return normalized.Count == 0 ? "/" : "/" + string.Join('/', normalized); + } + private static bool IsShellCommandFlag(string token) { if (token.Length == 0 || token[0] != '-' || token.StartsWith("--", StringComparison.Ordinal)) @@ -611,9 +652,6 @@ protected override bool IsAnchoredPath(string token) { var expanded = PathUtility.ExpandHome(path); - if (LooksLikePosixAbsoluteShellPath(expanded)) - return NormalizePosixShellPath(expanded); - return PathUtility.ExpandAndNormalize(expanded, workingDirectory); } @@ -637,37 +675,6 @@ private static bool IsWindowsRootedPath(string token) && (token[2] == '\\' || token[2] == '/'); } - private static bool LooksLikePosixAbsoluteShellPath(string path) - { - return path.Length > 0 && path[0] == '/' - && !path.StartsWith("//", StringComparison.Ordinal) - && path.IndexOf('\\', StringComparison.Ordinal) < 0 - && !path.Contains("://", StringComparison.Ordinal); - } - - private static string NormalizePosixShellPath(string path) - { - var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); - var normalized = new List(segments.Length); - foreach (var segment in segments) - { - if (segment == ".") - continue; - - if (segment == "..") - { - if (normalized.Count > 0) - normalized.RemoveAt(normalized.Count - 1); - - continue; - } - - normalized.Add(segment); - } - - return normalized.Count == 0 ? "/" : "/" + string.Join('/', normalized); - } - private static bool IsPowerShellInvoker(string verb) { return verb.Equals("powershell", StringComparison.OrdinalIgnoreCase) From 739d453e44f1a951a963126336cea4f6cb52eea7 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 7 May 2026 19:32:42 +0000 Subject: [PATCH 17/22] fix(security): preserve POSIX roots on Windows hosts Keep POSIX root extraction and display path derivation independent of Windows host path APIs, and stop Windows shell semantics from treating /c-style flags as rooted paths. --- .../ShellApprovalSemantics.cs | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/Netclaw.Security/ShellApprovalSemantics.cs b/src/Netclaw.Security/ShellApprovalSemantics.cs index 6a0086a2..0d37a340 100644 --- a/src/Netclaw.Security/ShellApprovalSemantics.cs +++ b/src/Netclaw.Security/ShellApprovalSemantics.cs @@ -491,6 +491,29 @@ public override bool LooksLikePath(string token) return PathUtility.ExpandAndNormalize(expanded, workingDirectory); } + protected override string? ExtractDisplayDirectory(string path, string? workingDirectory) + { + if (string.IsNullOrWhiteSpace(path)) + return null; + + if (path.EndsWith('/') || path.EndsWith('\\')) + return path.TrimEnd('/', '\\'); + + var globIdx = path.IndexOfAny(['*', '?', '[']); + if (globIdx >= 0) + { + var lastSep = path.LastIndexOf('/', globIdx); + return lastSep > 0 ? path[..lastSep] : null; + } + + var normalizedCandidate = NormalizePathToken(path, workingDirectory); + if (normalizedCandidate is not null && Directory.Exists(normalizedCandidate)) + return path; + + var lastSlash = path.LastIndexOf('/'); + return lastSlash > 0 ? path[..lastSlash] : null; + } + protected override bool IsShellSeparator(char ch) => ch == '/'; protected override bool IsAnchoredPath(string token) @@ -629,6 +652,9 @@ public override bool LooksLikePath(string token) return false; } + if (token.Contains('\\', StringComparison.Ordinal)) + return true; + return HasTraversalComponent(token) || HasFileExtensionInLastComponent(token); } @@ -636,8 +662,7 @@ public override bool LooksLikePath(string token) protected override bool IsAnchoredPath(string token) { - return token.Length > 0 && token[0] == '/' - || IsWindowsRootedPath(token) + return IsWindowsRootedPath(token) || token.StartsWith("./", StringComparison.Ordinal) || token.StartsWith("../", StringComparison.Ordinal) || token.StartsWith(@".\", StringComparison.Ordinal) From aba83e64199e117d39bf65922fb56a896c57866b Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 7 May 2026 19:47:36 +0000 Subject: [PATCH 18/22] fix(windows): stabilize shell approvals and Docker-backed tests Preserve POSIX root extraction across Windows hosts, keep Windows flag parsing from treating /c-like tokens as paths, and skip the SearXNG integration test when Docker is unavailable during container build/startup. --- .../SearXngBackendIntegrationTests.cs | 24 ++++++++++--------- .../ShellApprovalSemantics.cs | 5 +++- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/Netclaw.Search.Tests/SearXngBackendIntegrationTests.cs b/src/Netclaw.Search.Tests/SearXngBackendIntegrationTests.cs index 79bfe2b7..99e0b7d8 100644 --- a/src/Netclaw.Search.Tests/SearXngBackendIntegrationTests.cs +++ b/src/Netclaw.Search.Tests/SearXngBackendIntegrationTests.cs @@ -28,24 +28,26 @@ public class SearXngBackendIntegrationTests : IAsyncLifetime public async ValueTask InitializeAsync() { var settingsYml = LoadFixture("searxng-settings.yml"); - - var container = new ContainerBuilder() - .WithImage(SearXngImage) - .WithPortBinding(8080, assignRandomHostPort: true) - .WithResourceMapping( - Encoding.UTF8.GetBytes(settingsYml), - "/etc/searxng/settings.yml") - .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => - r.ForPort(8080).ForPath("/healthz"))) - .Build(); + IContainer? container = null; try { + container = new ContainerBuilder() + .WithImage(SearXngImage) + .WithPortBinding(8080, assignRandomHostPort: true) + .WithResourceMapping( + Encoding.UTF8.GetBytes(settingsYml), + "/etc/searxng/settings.yml") + .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => + r.ForPort(8080).ForPath("/healthz"))) + .Build(); + await container.StartAsync(); } catch (Exception ex) when (IsDockerUnavailable(ex)) { - await container.DisposeAsync(); + if (container is not null) + await container.DisposeAsync(); Assert.Skip($"Docker is not available; integration test skipped. ({ex.GetType().Name}: {ex.Message})"); return; } diff --git a/src/Netclaw.Security/ShellApprovalSemantics.cs b/src/Netclaw.Security/ShellApprovalSemantics.cs index 0d37a340..6d0b3d5e 100644 --- a/src/Netclaw.Security/ShellApprovalSemantics.cs +++ b/src/Netclaw.Security/ShellApprovalSemantics.cs @@ -518,7 +518,7 @@ public override bool LooksLikePath(string token) protected override bool IsAnchoredPath(string token) { - return Path.IsPathRooted(token) + return token.Length > 0 && token[0] == '/' || token.StartsWith("./", StringComparison.Ordinal) || token.StartsWith("../", StringComparison.Ordinal) || token.StartsWith('~') @@ -526,6 +526,9 @@ protected override bool IsAnchoredPath(string token) || token.StartsWith("${HOME}", StringComparison.Ordinal); } + protected override string EnsureTrailingSeparator(string path) + => path.Length > 0 && path[^1] == '/' ? path : path + '/'; + internal static bool IsPosixShellInvoker(string verb) { return verb is "bash" or "sh" or "/bin/bash" or "/bin/sh" From 0a6072ae8e46a2e46278cecbfc6c593ee73e2834 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 7 May 2026 20:11:14 +0000 Subject: [PATCH 19/22] fix(windows): preserve native separators in shell approval comparison roots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PosixShellApprovalSemantics override of EnsureTrailingSeparator unconditionally appended '/', which is correct for relative POSIX-style display roots (e.g., "logs/") but wrong for the host-resolved comparison root that has been through Path.GetFullPath. On Windows that produces a mixed-separator string like "C:\Users\...\logs/" instead of the expected "C:\Users\...\logs\", causing ToolApprovalGateTests.Shell_relative_path_command_keeps_relative_directory_root_for_prompt to fail. Extract the trailing-separator logic into PathUtility.EnsureTrailingSeparatorPreservingStyle which inspects the path's existing separator style: paths containing '\' get a trailing '\', everything else gets '/'. The Posix override now delegates to this helper. Behavior is unchanged on Linux (paths are '/'-only) and is host-independent — i.e. the helper is fully testable on Linux CI even though the bug only manifests on Windows. Adds Theory tests covering Windows-style, POSIX-style, mixed, and already-terminated inputs to PathUtilityTests so future regressions are caught without a Windows runner. --- .../PathUtilityTests.cs | 33 +++++++++++++++++++ src/Netclaw.Security/PathUtility.cs | 26 +++++++++++++++ .../ShellApprovalSemantics.cs | 2 +- 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/Netclaw.Security.Tests/PathUtilityTests.cs b/src/Netclaw.Security.Tests/PathUtilityTests.cs index fe26e93a..b8babc2e 100644 --- a/src/Netclaw.Security.Tests/PathUtilityTests.cs +++ b/src/Netclaw.Security.Tests/PathUtilityTests.cs @@ -193,4 +193,37 @@ public void ExpandAndNormalize_returns_null_for_invalid_path() var result = PathUtility.ExpandAndNormalize("path\0with\0nulls"); Assert.Null(result); } + + // Regression: when POSIX shell semantics handle a Windows host-resolved + // path (which contains backslashes), appending '/' would produce a + // mixed-separator string like "C:\foo\logs/". The trailing-separator + // helper must preserve the path's existing separator style so the + // comparison root recorded for B/C approvals matches what the host + // filesystem produces. This shape is host-independent — the runner OS + // does not affect the result, so it is safe to assert on Linux CI. + [Theory] + [InlineData(@"C:\Users\runner\AppData\Local\Temp\ef44\logs", @"C:\Users\runner\AppData\Local\Temp\ef44\logs\")] + [InlineData(@"C:\foo\bar", @"C:\foo\bar\")] + [InlineData("/home/user/logs", "/home/user/logs/")] + [InlineData("logs", "logs/")] + [InlineData("relative/path", "relative/path/")] + public void EnsureTrailingSeparatorPreservingStyle_preserves_existing_separator_style(string input, string expected) + { + Assert.Equal(expected, PathUtility.EnsureTrailingSeparatorPreservingStyle(input)); + } + + [Theory] + [InlineData("/already/has/slash/")] + [InlineData(@"C:\already\has\backslash\")] + [InlineData("logs/")] + public void EnsureTrailingSeparatorPreservingStyle_is_idempotent(string input) + { + Assert.Equal(input, PathUtility.EnsureTrailingSeparatorPreservingStyle(input)); + } + + [Fact] + public void EnsureTrailingSeparatorPreservingStyle_returns_empty_for_empty_input() + { + Assert.Equal(string.Empty, PathUtility.EnsureTrailingSeparatorPreservingStyle(string.Empty)); + } } diff --git a/src/Netclaw.Security/PathUtility.cs b/src/Netclaw.Security/PathUtility.cs index fc8dc674..10f16663 100644 --- a/src/Netclaw.Security/PathUtility.cs +++ b/src/Netclaw.Security/PathUtility.cs @@ -83,6 +83,32 @@ public static bool IsWithinAnyRoot(string candidate, IReadOnlyList roots return false; } + /// + /// Appends a trailing directory separator to if it does + /// not already end with one, preserving the path's existing separator style. + /// + /// + /// A path that already contains backslashes (typical of Windows host-resolved + /// paths from ) gets a backslash appended, + /// while a path with only forward slashes — or no separators at all — gets a + /// forward slash. This avoids producing mixed-separator strings such as + /// C:\foo\bar/ when POSIX-flavored shell semantics are applied to a + /// host-resolved Windows path. + /// + public static string EnsureTrailingSeparatorPreservingStyle(string path) + { + if (path.Length == 0) + return path; + + var last = path[^1]; + if (last == '/' || last == '\\') + return path; + + return path.Contains('\\', StringComparison.Ordinal) + ? path + '\\' + : path + '/'; + } + /// /// Expands shell path tokens: ~, $HOME, ${HOME}, %USERPROFILE%. /// Does not normalize the path. diff --git a/src/Netclaw.Security/ShellApprovalSemantics.cs b/src/Netclaw.Security/ShellApprovalSemantics.cs index 6d0b3d5e..e23cfee4 100644 --- a/src/Netclaw.Security/ShellApprovalSemantics.cs +++ b/src/Netclaw.Security/ShellApprovalSemantics.cs @@ -527,7 +527,7 @@ protected override bool IsAnchoredPath(string token) } protected override string EnsureTrailingSeparator(string path) - => path.Length > 0 && path[^1] == '/' ? path : path + '/'; + => PathUtility.EnsureTrailingSeparatorPreservingStyle(path); internal static bool IsPosixShellInvoker(string verb) { From ca8c16ff9a8241def02dab01eef1cec16d5465b5 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 7 May 2026 20:58:15 +0000 Subject: [PATCH 20/22] fix(security): match approval entries by host filesystem case rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ApprovalPatternMatching folded case unconditionally (OrdinalIgnoreCase) on every comparison. That's correct on Windows where NTFS and PATH lookup are case-insensitive, but wrong on POSIX where they aren't: - Binary substitution: a user who approves "git push" would also auto-approve "Git push" on Linux, even though `Git` is a separate executable that an attacker can plant earlier in $PATH. - Case-distinct path bypass: a user who approves /home/user/data/ would also auto-approve /home/user/Data/ — a different real directory on ext4/btrfs/etc. Both bypasses sit on the security-sensitive path that the directory-scoped approval feature is supposed to gate. PathUtility.IsWithinRoot already flips comparison by OperatingSystem.IsWindows(); apply the same policy to the exact-equality and verb-prefix comparisons so the matcher is internally consistent. Updates ToolApprovalActorTests.Case_insensitive_match (renamed to Approval_match_follows_host_filesystem_case_rules) to assert per-platform behavior — the original test encoded the bypass. --- .../Tools/ToolApprovalActorTests.cs | 16 ++++++++++++++-- src/Netclaw.Security/ApprovalPatternMatching.cs | 17 +++++++++++++---- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/Netclaw.Actors.Tests/Tools/ToolApprovalActorTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolApprovalActorTests.cs index f3928c75..7b82c62b 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolApprovalActorTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolApprovalActorTests.cs @@ -180,14 +180,26 @@ public async Task Persistent_approval_survives_new_service_instance() } [Fact] - public async Task Case_insensitive_match() + public async Task Approval_match_follows_host_filesystem_case_rules() { + // Approval entries embed both filesystem paths and verb tokens that + // resolve to executables via $PATH lookup, which honors filesystem case + // rules. On POSIX, `Git` and `git` are different executables, and + // `/data/` and `/Data/` are different directories — so a grant issued + // for one MUST NOT cover the other (binary-substitution / case-distinct + // path bypass). On Windows, the filesystem and PATH are + // case-insensitive, so the case-folded match is the correct behavior. var ct = TestContext.Current.CancellationToken; var actor = Sys.ActorOf(ToolApprovalActor.CreateProps()); var service = CreateService(actor); await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["Git Push"], persistent: false, ct); - Assert.Empty(await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], ct)); + var unapproved = await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], ct); + + if (OperatingSystem.IsWindows()) + Assert.Empty(unapproved); + else + Assert.Equal(["git push"], unapproved); } [Fact] diff --git a/src/Netclaw.Security/ApprovalPatternMatching.cs b/src/Netclaw.Security/ApprovalPatternMatching.cs index b5aa4352..60525ee2 100644 --- a/src/Netclaw.Security/ApprovalPatternMatching.cs +++ b/src/Netclaw.Security/ApprovalPatternMatching.cs @@ -13,13 +13,22 @@ namespace Netclaw.Security; /// public static class ApprovalPatternMatching { + // Approval entries embed both filesystem paths (case-sensitive on POSIX, + // case-insensitive on Windows) and verb tokens that resolve to executables + // via the host's $PATH lookup, which honors filesystem case rules. Folding + // case unconditionally on POSIX would let an attacker who plants `Git` + // earlier in $PATH inherit the approval the user issued for `git` — + // similarly for case-distinct directory pairs like `/data/` vs `/Data/`. + private static StringComparison ApprovalEntryComparison => + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + public static bool MatchesShellApprovalEntry(string candidate, IEnumerable approvedEntries) { // Shell approvals never widen by verb prefix here. Reusable entries are // either exact normalized units or normalized directory roots. foreach (var approved in approvedEntries) { - if (string.Equals(candidate, approved, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(candidate, approved, ApprovalEntryComparison)) return true; if (IsDirectoryRootEntry(candidate) && IsDirectoryRootEntry(approved) && MatchesDirectoryRoot(candidate, approved)) @@ -33,7 +42,7 @@ public static bool MatchesAny(string candidate, IEnumerable approvedPatt { foreach (var approved in approvedPatterns) { - if (string.Equals(candidate, approved, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(candidate, approved, ApprovalEntryComparison)) return true; if (!approved.Contains(' ', StringComparison.Ordinal)) @@ -48,7 +57,7 @@ public static bool MatchesAny(string candidate, IEnumerable approvedPatt // "cat" to every path-bearing cat invocation. if (candidate.Length > approved.Length && candidate[approved.Length] == ' ' - && candidate.StartsWith(approved, StringComparison.OrdinalIgnoreCase)) + && candidate.StartsWith(approved, ApprovalEntryComparison)) return true; } @@ -71,7 +80,7 @@ private static bool MatchesDirectoryScope(string candidate, string approvedDirPa var candidateVerb = candidate[..candidateSpaceIdx]; var candidatePath = candidate[(candidateSpaceIdx + 1)..]; - if (!string.Equals(approvedVerb, candidateVerb, StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(approvedVerb, candidateVerb, ApprovalEntryComparison)) return false; try From 8a8243c0dd12bf3352948a724bd81540a3070e1f Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 7 May 2026 21:00:39 +0000 Subject: [PATCH 21/22] fix(security): show absolute path in 'Approve always' label for relative roots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a shell command targets a relative path like `logs/app.log`, the prompt previously offered "Approve shell access in logs/ always". The storage layer correctly persists the absolute comparison root (`/home/user/project1/logs/`), but the label hides that — users think they're granting a portable, repo-style scope when they're actually granting access to the working directory at approval time. Surface the absolute root in the "always" label so the user sees what they are actually persisting: Approve shell access in /home/user/project1/logs/ always The "for this chat" label keeps the relative form because session scope implicitly carries the working-directory context — running another command in the same session against `logs/` is exactly what the user expects to be covered. Detect relative display via `Path.IsPathRooted` after trimming trailing separators. Multi-root commands keep their generic "these directories" label since enumerating roots in the option text would be unreadable. --- .../Tools/ToolApprovalGateTests.cs | 13 ++++++++++- src/Netclaw.Actors/Tools/ToolAccessPolicy.cs | 23 ++++++++++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs index feb48e4b..daa9ea79 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs @@ -824,10 +824,21 @@ public void Shell_relative_path_command_keeps_relative_directory_root_for_prompt Assert.True(decision.NeedsApproval); Assert.Equal(["logs/"], decision.ApprovalContext!.DirectoryRoots); - Assert.Contains(PathUtility.Normalize(logs) + Path.DirectorySeparatorChar, decision.ApprovalContext.ApprovalEntries); + var absoluteRoot = PathUtility.Normalize(logs) + Path.DirectorySeparatorChar; + Assert.Contains(absoluteRoot, decision.ApprovalContext.ApprovalEntries); + // Session label keeps the relative form because session scope + // implicitly carries the working-directory context. Assert.Equal( "Approve shell access in logs/ for this chat", decision.ApprovalContext.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveSession).Label); + // "Approve always" persists the absolute root, so the label MUST + // surface the absolute path the user is actually granting access + // to — otherwise the user thinks they approved `logs/` portably + // when in fact only the current working directory's `logs/` is + // covered. + Assert.Equal( + $"Approve shell access in {absoluteRoot} always", + decision.ApprovalContext.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveAlways).Label); } finally { diff --git a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs index 72e45a79..9921fa7d 100644 --- a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs +++ b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs @@ -314,9 +314,17 @@ private ToolAccessDecision CheckApprovalGate( var alwaysLabel = ApprovalOptionKeys.ApproveAlwaysLabel; if (directoryRoots.Count == 1) { - var dir = directoryRoots[0].DisplayPath; - sessionLabel = $"Approve shell access in {dir} for this chat"; - alwaysLabel = $"Approve shell access in {dir} always"; + var root = directoryRoots[0]; + sessionLabel = $"Approve shell access in {root.DisplayPath} for this chat"; + // "Approve always" persists the absolute comparison root, not the + // display form. When the display is a relative path like `logs/`, + // the persistent grant is still tied to the current working + // directory — the label has to show that absolute path so the user + // understands which directory they are actually granting access to. + // The session label keeps the relative form because session scope + // implicitly carries the working-directory context. + var alwaysScope = IsRelativeDisplayPath(root.DisplayPath) ? root.ComparisonRoot : root.DisplayPath; + alwaysLabel = $"Approve shell access in {alwaysScope} always"; } else if (directoryRoots.Count > 1) { @@ -448,6 +456,15 @@ private bool IsFeatureDisabledTool(string toolName) private static string? GetToolName(AITool tool) => tool is AIFunction function ? function.Name : null; + + private static bool IsRelativeDisplayPath(string displayPath) + { + if (string.IsNullOrWhiteSpace(displayPath)) + return false; + + var trimmed = displayPath.TrimEnd('/', '\\'); + return !Path.IsPathRooted(trimmed); + } } /// From 62d711d9c27e38ac144f3d0b933bcfca808e1a01 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 7 May 2026 21:05:32 +0000 Subject: [PATCH 22/22] fix(security): resolve symlinks in shell command path components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The directory-scoped approval design promises ToolPathPolicy as a defense-in-depth backstop: even after a user grants /home/safe/, any shell command that ultimately touches a protected path must still be blocked. That promise was paper-only against symlink escalation — ToolPathPolicy.CommandReferencesDeniedPath only resolved symlinks at the leaf via TryResolveSymlinkTarget, never along intermediate path components. Concrete bypass: an attacker (or a hallucinating agent) plants /home/safe/leak -> /etc inside the approved root. The approval gate sees /home/safe/leak/passwd as "within" /home/safe/ and waves it through. ToolPathPolicy then runs Path.GetFullPath on the candidate, which collapses .. and . but does not follow symlinks, so /etc never appears in the static analysis. The shell executes the syscall, which DOES follow the symlink, and reads /etc/passwd. Add TryResolveSymlinksInPath that walks the path component by component, calling DirectoryInfo.ResolveLinkTarget(returnFinalTarget: true) on each ancestor. When any segment resolves to a different canonical path, the final canonical path is checked against the denied set as a second pass. The original (unresolved) check remains so non-symlink-bearing paths take the cheap path. Adds CommandReferencesDeniedPath_blocks_symlink_escalation_into_protected_path as a regression. Skipped on Windows because non-elevated symlink creation requires Developer Mode and would make the test flaky there; the underlying gap is platform-agnostic but POSIX coverage is sufficient. --- .../ToolPathPolicyTests.cs | 42 ++++++++++ src/Netclaw.Security/ToolPathPolicy.cs | 77 +++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/src/Netclaw.Security.Tests/ToolPathPolicyTests.cs b/src/Netclaw.Security.Tests/ToolPathPolicyTests.cs index 4088f233..8bbe6c2d 100644 --- a/src/Netclaw.Security.Tests/ToolPathPolicyTests.cs +++ b/src/Netclaw.Security.Tests/ToolPathPolicyTests.cs @@ -252,4 +252,46 @@ public void CommandReferencesDeniedPath_blocks_control_plane_lifecycle_files() Assert.True(policy.CommandReferencesDeniedPath("cat ~/.netclaw/netclaw.lock")); Assert.True(policy.CommandReferencesDeniedPath("cat ~/.netclaw/cache/restart-manifest.json")); } + + // Regression: directory-scoped approvals let a user grant a single root + // (e.g., /home/user/safe/) once, after which all subsequent shell commands + // under that root auto-approve. The design promises that ToolPathPolicy + // remains a backstop and still blocks protected-path access even after a + // root grant. This test verifies that promise specifically against the + // symlink-escalation case: an attacker (or a hallucinating agent) plants a + // symlink inside the approved root that points at a protected path. The + // approval gate sees a path "within" the approved root and waves it + // through, so ToolPathPolicy MUST resolve symlinks during command + // inspection or the layered defense is paper-only. + [Fact] + public void CommandReferencesDeniedPath_blocks_symlink_escalation_into_protected_path() + { + // CreateSymbolicLink without elevation requires Developer Mode on + // Windows; the underlying gap is platform-agnostic but the test + // surface is unreliable there. POSIX is sufficient for regression. + if (OperatingSystem.IsWindows()) + return; + + var safeRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(safeRoot); + var leak = Path.Combine(safeRoot, "leak"); + Directory.CreateSymbolicLink(leak, "/etc"); + + try + { + var policy = new ToolPathPolicy(deniedPaths: ["/etc"]); + var command = $"cat {leak}/passwd"; + + Assert.True( + policy.CommandReferencesDeniedPath(command), + $"ToolPathPolicy must resolve symlinks when inspecting shell commands; otherwise a directory-scoped approval for {safeRoot}/ becomes a read primitive for any protected path reachable via planted symlinks. Command under test: {command}"); + } + finally + { + // Delete the symlink itself, not its target. File.Delete on a + // symlink to a directory removes the link without touching /etc. + File.Delete(leak); + Directory.Delete(safeRoot); + } + } } diff --git a/src/Netclaw.Security/ToolPathPolicy.cs b/src/Netclaw.Security/ToolPathPolicy.cs index 58ffc01c..1ad79fab 100644 --- a/src/Netclaw.Security/ToolPathPolicy.cs +++ b/src/Netclaw.Security/ToolPathPolicy.cs @@ -3,6 +3,8 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using System.Text; + namespace Netclaw.Security; /// @@ -128,6 +130,22 @@ public bool CommandReferencesDeniedPath(string command, string? workingDirectory return true; } + // Defense-in-depth against symlink escalation under a directory- + // scoped approval. Path.GetFullPath (used by NormalizePathToken) + // collapses `.`/`..` but does NOT resolve symlinks in any path + // component. Without this pass, a user who approves /home/safe/ + // can be tricked by a planted /home/safe/leak -> /etc symlink: + // the approval gate sees /home/safe/leak/passwd as "within" the + // approved root and waves it through, and the static path check + // here would never see /etc unless we resolve link targets along + // every component of the path. + if (normalized is not null + && TryResolveSymlinksInPath(normalized, out var canonical) + && IsDeniedNormalized(canonical, _shellDeniedPaths)) + { + return true; + } + var expanded = PathUtility.ExpandHome(token); if (expanded.Contains("secrets.json", StringComparison.OrdinalIgnoreCase) || expanded.Contains(".netclaw/keys", StringComparison.OrdinalIgnoreCase) @@ -185,6 +203,65 @@ private static bool IsSamePathOrChild(string candidate, string denied) return boundary == Path.DirectorySeparatorChar || boundary == Path.AltDirectorySeparatorChar; } + private static bool TryResolveSymlinksInPath(string path, out string canonical) + { + canonical = string.Empty; + if (string.IsNullOrEmpty(path)) + return false; + + try + { + // Walk the path component by component, resolving any directory + // or file symlinks encountered. ResolveLinkTarget(returnFinalTarget: + // true) follows the chain to a non-link, but only operates on the + // entity it's invoked against — it does not see symlinks earlier + // in the path. Hence the explicit segment walk. + var fullPath = Path.GetFullPath(path); + var segments = fullPath.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries); + var sb = new StringBuilder(); + if (Path.IsPathRooted(fullPath)) + sb.Append(Path.DirectorySeparatorChar); + + foreach (var segment in segments) + { + if (sb.Length > 0 && sb[^1] != Path.DirectorySeparatorChar) + sb.Append(Path.DirectorySeparatorChar); + sb.Append(segment); + + var partial = sb.ToString(); + if (Directory.Exists(partial)) + { + var target = new DirectoryInfo(partial).ResolveLinkTarget(returnFinalTarget: true); + if (target is not null) + { + sb.Clear(); + sb.Append(target.FullName); + } + } + else if (File.Exists(partial)) + { + var target = new FileInfo(partial).ResolveLinkTarget(returnFinalTarget: true); + if (target is not null) + { + sb.Clear(); + sb.Append(target.FullName); + } + + break; + } + } + + canonical = PathUtility.Normalize(sb.ToString()); + return !string.IsNullOrEmpty(canonical) && !string.Equals(canonical, PathUtility.Normalize(fullPath), StringComparison.Ordinal); + } + catch + { + return false; + } + } + private static bool TryResolveSymlinkTarget(string path, out string normalizedTarget) { normalizedTarget = string.Empty;