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..7c389149 --- /dev/null +++ b/openspec/changes/directory-scoped-approval-patterns/design.md @@ -0,0 +1,122 @@ +## Context + +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) +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 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 +- 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 + +### 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. + +This is intentionally verb-agnostic. The safety boundary moves from shell verb +classification to filesystem containment plus the existing backstops. + +### Extraction: recognized local filesystem paths across the whole unit + +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. + +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. + +### Matching: all recognized local paths must stay under approved roots + +Auto-approval succeeds only when every recognized local filesystem path in the +candidate approval unit resolves under an already approved root. + +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 + +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 + +Internally, each extracted directory root should carry a tiny pair: + +- 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] 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 new file mode 100644 index 00000000..8eed215a --- /dev/null +++ b/openspec/changes/directory-scoped-approval-patterns/proposal.md @@ -0,0 +1,57 @@ +## Why + +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 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 + +### New Capabilities + +(none) + +### Modified Capabilities + +- `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 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 new file mode 100644 index 00000000..ebf48d40 --- /dev/null +++ b/openspec/changes/directory-scoped-approval-patterns/specs/tool-approval-gates/spec.md @@ -0,0 +1,290 @@ +## ADDED Requirements + +### Requirement: Directory-root approvals for shell_execute + +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. + +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. + +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. + +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. + +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 the directory root `/home/.netclaw/logs/` +- **AND** a later `grep "error" /home/.netclaw/logs/daemon.log` in the same session + does not prompt + +#### 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** `/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 + +#### Scenario: All recognized local paths in a unit must be covered + +- **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 + +#### Scenario: No reusable local roots falls back to exact approval behavior + +- **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 root falls back to exact approval behavior + +- **GIVEN** a shell command `cat /etc/passwd` requires approval +- **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 matching prevents prefix collisions + +- **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 root extraction via IToolApprovalMatcher + +`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 a root from a later argument + +- **GIVEN** the command `grep -l "timeout" /home/.netclaw/logs/daemon.log` +- **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: Control operators split approval units + +- **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 root + +- **GIVEN** the command `ls /home/.netclaw/logs/crash-*.log` +- **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 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 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` + +#### 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 +- **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 `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 + +- **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 roots + +- **GIVEN** a shell command targets a file under `/home/.netclaw/logs/` +- **WHEN** the approval request is generated +- **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 + +- **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 exact approvals and directory roots as applicable. +Approval lookup and recording SHALL be mediated by `IToolApprovalService`. + +#### 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** `/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", "/home/.netclaw/logs/"]}}` +- **WHEN** the daemon starts +- **THEN** `git push` is pre-approved for Personal audience shell commands +- **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 + +- **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 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 root 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 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. + +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 + +- **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: Control operators create separate approval units + +- **GIVEN** the command `git add . && git commit -m "fix" && git push` +- **WHEN** approval is checked +- **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 + +- **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: Pipeline stays in one approval unit for root matching + +- **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 new file mode 100644 index 00000000..13325893 --- /dev/null +++ b/openspec/changes/directory-scoped-approval-patterns/tasks.md @@ -0,0 +1,38 @@ +## 1. Pattern Extraction + +- [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 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 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 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 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 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/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/Sessions/SessionToolExecutionPipelineTests.cs b/src/Netclaw.Actors.Tests/Sessions/SessionToolExecutionPipelineTests.cs index f365577f..dd62b006 100644 --- a/src/Netclaw.Actors.Tests/Sessions/SessionToolExecutionPipelineTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/SessionToolExecutionPipelineTests.cs @@ -147,14 +147,16 @@ 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), new ToolApprovalOption(ApprovalOptionKeys.ApproveSession, ApprovalOptionKeys.ApproveSessionLabel), new ToolApprovalOption(ApprovalOptionKeys.ApproveAlways, ApprovalOptionKeys.ApproveAlwaysLabel), new ToolApprovalOption(ApprovalOptionKeys.Deny, ApprovalOptionKeys.DenyLabel) - ])); + ], + DirectoryRoots: [])); } ct.ThrowIfCancellationRequested(); diff --git a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs index 9620499c..11ddc7e9 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] @@ -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/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..7b82c62b 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) { } @@ -88,7 +102,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 +110,48 @@ 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); + } + + [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"), [approvedRoot], persistent: false, ct); + + Assert.Empty(await service.GetUnapprovedPatternsAsync( + "session-a", + TrustAudience.Personal, + new ToolName("shell_execute"), + [approvedRoot], + ct)); + } + + [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"), [approvedRoot], persistent: false, ct); + + var unapproved = await service.GetUnapprovedPatternsAsync( + "session-a", + TrustAudience.Personal, + new ToolName("shell_execute"), + [approvedRoot, otherRoot], + ct); + + Assert.Equal([expectedUnapproved], unapproved); } [Fact] @@ -127,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.Actors.Tests/Tools/ToolApprovalGateTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs index 33ffa67b..daa9ea79 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; @@ -55,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] @@ -117,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] @@ -129,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] @@ -195,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)); } @@ -217,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)); } @@ -243,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)); } @@ -755,4 +756,110 @@ private sealed class FakeShellTrustZonePolicy : IShellTrustZonePolicy public IReadOnlyList GetTrustZoneRoots(ToolExecutionContext context) => _roots; } + + // ── Directory-root shell approvals ── + + [Fact] + 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"); + + var decision = policy.AuthorizeInvocation(ShellTool(), PersonalContext(), args); + + Assert.True(decision.NeedsApproval); + Assert.NotNull(decision.ApprovalContext); + 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] + 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 shell access in ", sessionOption.Label); + Assert.Contains("for this chat", sessionOption.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 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); + 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 + { + Directory.Delete(root, recursive: true); + } + } + + [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); + 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); + Assert.Equal(ApprovalOptionKeys.ApproveAlwaysLabel, alwaysOption.Label); + } } diff --git a/src/Netclaw.Actors/Protocol/SessionOutput.cs b/src/Netclaw.Actors/Protocol/SessionOutput.cs index 08eb9a44..663c44cc 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutput.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutput.cs @@ -357,9 +357,25 @@ 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; } = []; + /// + /// 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; } = []; + + /// + /// 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; } = []; + /// 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 5eea5f29..25898886 100644 --- a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs +++ b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs @@ -746,6 +746,8 @@ private void Processing() _pendingToolInteractions[msg.CallId] = new PendingToolInteraction( msg.ToolName, msg.Patterns, + msg.ApprovalEntries, + msg.DirectoryRoots, CurrentTurnAudience(), msg.RequesterSenderId, msg.RequesterPrincipal, @@ -799,7 +801,7 @@ await _approvalService.RecordApprovalAsync( _sessionId.Value, pending.Audience, new ToolName(pending.ToolName), - pending.Patterns, + pending.ApprovalEntries, persistent: decision == ApprovalDecision.ApprovedAlways, CancellationToken.None); } @@ -3042,6 +3044,8 @@ private void EmitOutput(SessionOutput output, OutputFilter requiredFlag = Output private sealed record PendingToolInteraction( string ToolName, IReadOnlyList Patterns, + IReadOnlyList ApprovalEntries, + IReadOnlyList DirectoryRoots, TrustAudience Audience, string? RequesterSenderId, PrincipalClassification? RequesterPrincipal, 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 9605c7ce..58a7dc4d 100644 --- a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs +++ b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs @@ -283,7 +283,9 @@ public static async Task ExecuteSingleToolAsync( HasAdoptedContext = source?.HasAdoptedContext ?? false, AdoptedSpeakerIds = source?.AdoptedSpeakerIds ?? [], PersistedAdoptedContext = source?.HasAdoptedContext ?? false, - Patterns = ctx.UnapprovedPatterns, + Patterns = ctx.Patterns, + ApprovalEntries = ctx.ApprovalEntries, + DirectoryRoots = ctx.DirectoryRoots, Options = ctx.Options .Select(o => new ToolInteractionOption(o.Key, o.Label)) .ToList() @@ -301,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(); @@ -317,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, @@ -338,7 +340,10 @@ 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); + // 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 { Allowed = false, diff --git a/src/Netclaw.Actors/SubAgents/SubAgentActor.cs b/src/Netclaw.Actors/SubAgents/SubAgentActor.cs index 0d62e2b0..3f0b44a4 100644 --- a/src/Netclaw.Actors/SubAgents/SubAgentActor.cs +++ b/src/Netclaw.Actors/SubAgents/SubAgentActor.cs @@ -419,7 +419,9 @@ private static async Task ExecuteToolsAsync( new ToolCallId(tc.CallId), ctx.ToolName, ctx.DisplayText, - ctx.UnapprovedPatterns, + ctx.Patterns, + ctx.ApprovalEntries, + ctx.DirectoryRoots, ct); if (decision is ParentApprovalDecision.ApprovedOnce @@ -432,7 +434,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 89981f13..0497c40f 100644 --- a/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs +++ b/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs @@ -112,16 +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)); + : ToolAccessDecision.RequiresApproval(approvalContext); } if (accessDecision.NeedsApproval @@ -161,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 1e8f6cd2..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,6 +76,9 @@ public string FormatForDisplay(ToolName toolName, IDictionary? return toolName.Value; } + public IReadOnlyList ExtractDirectoryRoots(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..9921fa7d 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; @@ -297,18 +297,56 @@ private ToolAccessDecision CheckApprovalGate( return ToolAccessDecision.Allow(); } - var allPatterns = matcher.ExtractPatterns(toolName, arguments); + // 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); var displayText = matcher.FormatForDisplay(toolName, arguments); + + var sessionLabel = ApprovalOptionKeys.ApproveSessionLabel; + var alwaysLabel = ApprovalOptionKeys.ApproveAlwaysLabel; + if (directoryRoots.Count == 1) + { + 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) + { + // 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"; + } + var approvalContext = new ToolApprovalContext( toolName.Value, displayText, - allPatterns, + patterns, + approvalEntries, [ 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) - ]); + ], + [.. directoryRoots.Select(static x => x.DisplayPath)]); return ToolAccessDecision.RequiresApproval(approvalContext); } @@ -418,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); + } } /// @@ -454,8 +501,12 @@ public sealed record ToolAccessDecision(bool Allowed, string? DenyReason = null, public sealed record ToolApprovalContext( string ToolName, string DisplayText, - IReadOnlyList UnapprovedPatterns, - IReadOnlyList Options); + 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.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 7f84769e..90142e5d 100644 --- a/src/Netclaw.Cli/Tui/ChatPage.cs +++ b/src/Netclaw.Cli/Tui/ChatPage.cs @@ -361,6 +361,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.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.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.Tests/ShellApprovalMatcherTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs index fa23b69b..142549eb 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs @@ -12,14 +12,35 @@ 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) + => 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 +49,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 +59,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 +70,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 +80,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 +93,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,67 +101,59 @@ 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)] - // 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/", "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 }; 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() { - 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] @@ -149,6 +162,60 @@ 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); } + + // ── ExtractDirectoryRoots / ExtractApprovalEntries ── + + [Theory] + [MemberData(nameof(PlatformDirectoryMatchCases))] + public void ExtractDirectoryRoots_simple_path_command(string expectedRoot, string command, bool _) + { + var roots = _matcher.ExtractDirectoryRoots( + new ToolName("shell_execute"), + Args(command)); + Assert.Single(roots); + Assert.Equal(expectedRoot, roots[0].ComparisonRoot); + } + + [Fact] + public void ExtractDirectoryRoots_pipeline_and_multiple_verbs_share_same_root() + { + var roots = _matcher.ExtractDirectoryRoots( + new ToolName("shell_execute"), + 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 ExtractDirectoryRoots_returns_empty_when_no_reusable_roots_exist() + { + var roots = _matcher.ExtractDirectoryRoots( + new ToolName("shell_execute"), + Args("git push origin main")); + Assert.Empty(roots); + } + + [Fact] + public void ExtractApprovalEntries_use_roots_when_available() + { + 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 ExtractApprovalEntries_fall_back_to_exact_unit_when_no_roots_exist() + { + var entries = _matcher.ExtractApprovalEntries( + new ToolName("shell_execute"), + Args("cat /home/user/.netclaw/logs/crash.log && git push origin main")); + Assert.Equal(2, entries.Count); + Assert.Contains("/home/user/.netclaw/logs/", entries.Select(p => p.Replace('\\', '/'))); + Assert.Contains("git push origin main", entries); + } } public sealed class DefaultApprovalMatcherTests diff --git a/src/Netclaw.Security.Tests/ShellTokenizerTests.cs b/src/Netclaw.Security.Tests/ShellTokenizerTests.cs index c0f70f27..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] @@ -70,10 +140,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] @@ -206,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")] @@ -264,10 +336,100 @@ 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 ── + + [Theory] + [MemberData(nameof(AbsoluteRootCases))] + public void ExtractDirectoryRoots_returns_normalized_root_for_file_path(string command, string expectedRoot) + { + var roots = ShellTokenizer.ExtractDirectoryRoots(command); + + Assert.Single(roots); + Assert.Equal(expectedRoot, roots[0].ComparisonRoot); + Assert.Equal(expectedRoot, roots[0].DisplayPath); + } + + [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('\\', '/')); + } + + [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"); + Directory.CreateDirectory(logs); + + try + { + var roots = ShellTokenizer.ExtractDirectoryRoots(command, root); + + Assert.Single(roots); + Assert.Equal(expectedDisplayRoot, roots[0].DisplayPath); + Assert.Equal(PathUtility.Normalize(logs) + Path.DirectorySeparatorChar, roots[0].ComparisonRoot); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void ExtractDirectoryRoots_handles_glob_paths() + { + var roots = ShellTokenizer.ExtractDirectoryRoots("ls /home/user/.netclaw/logs/crash-*.log"); + + Assert.Single(roots); + 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() + { + 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")] + [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.Empty(ShellTokenizer.ExtractDirectoryRoots(command)); } } 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/ApprovalPatternMatching.cs b/src/Netclaw.Security/ApprovalPatternMatching.cs index 3f162e69..60525ee2 100644 --- a/src/Netclaw.Security/ApprovalPatternMatching.cs +++ b/src/Netclaw.Security/ApprovalPatternMatching.cs @@ -6,33 +6,116 @@ 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". +/// 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 { + // 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, ApprovalEntryComparison)) + 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) { - if (string.Equals(candidate, approved, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(candidate, approved, ApprovalEntryComparison)) 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, ApprovalEntryComparison)) 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, ApprovalEntryComparison)) + return false; + + try + { + return PathUtility.IsWithinRoot(candidatePath, approvedDir); + } + catch (Exception ex) when (ex is ArgumentException or IOException) + { + 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..2403ddcb --- /dev/null +++ b/src/Netclaw.Security/DirectoryApprovalRoot.cs @@ -0,0 +1,16 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- + +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 706bbd6b..4d061541 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. /// @@ -48,11 +53,16 @@ public interface IToolApprovalMatcher /// Formats the tool call for display in the approval prompt. /// string FormatForDisplay(ToolName toolName, IDictionary? arguments); + + /// + /// Extracts reusable directory approval roots for the invocation. + /// + 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 { @@ -71,21 +81,71 @@ 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 []; + + // 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 => + { + 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; } @@ -97,6 +157,26 @@ public string FormatForDisplay(ToolName toolName, IDictionary? return GetCommand(arguments) ?? "(empty command)"; } + public IReadOnlyList ExtractDirectoryRoots(ToolName toolName, IDictionary? arguments) + { + var command = GetCommand(arguments); + if (string.IsNullOrWhiteSpace(command)) + return []; + + 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) { if (arguments is null) @@ -108,25 +188,34 @@ public string FormatForDisplay(ToolName toolName, IDictionary? 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; + + if (arguments.TryGetValue("WorkingDirectory", out var val) || arguments.TryGetValue("workingDirectory", out val)) + return val?.ToString(); - private static void CollectPatterns(string command, ISet patterns) + return null; + } + + 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); if (innerCommands.Count > 0) { foreach (var inner in innerCommands) - CollectPatterns(inner, patterns); + TraverseApprovalUnits(inner, visitUnit); continue; } - var verbChain = ShellTokenizer.ExtractVerbChain(segment); - if (!string.IsNullOrEmpty(verbChain)) - patterns.Add(verbChain); + visitUnit(segment); } } } @@ -150,6 +239,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) @@ -165,4 +257,7 @@ public string FormatForDisplay(ToolName toolName, IDictionary? { return toolName.Value; } + + public IReadOnlyList ExtractDirectoryRoots(ToolName toolName, IDictionary? arguments) + => []; } 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 new file mode 100644 index 00000000..e23cfee4 --- /dev/null +++ b/src/Netclaw.Security/ShellApprovalSemantics.cs @@ -0,0 +1,737 @@ +// ----------------------------------------------------------------------- +// +// 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 +{ + private static readonly IShellApprovalSemantics Posix = PosixShellApprovalSemantics.Instance; + private static readonly IShellApprovalSemantics Windows = WindowsShellApprovalSemantics.Instance; + + public static IShellApprovalSemantics Current { get; } = OperatingSystem.IsWindows() + ? 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 +{ + 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 (!IsPosixShellInvoker(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); + } + + 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 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) + { + return token.Length > 0 && token[0] == '/' + || token.StartsWith("./", StringComparison.Ordinal) + || token.StartsWith("../", StringComparison.Ordinal) + || token.StartsWith('~') + || token.StartsWith("$HOME", StringComparison.Ordinal) + || token.StartsWith("${HOME}", StringComparison.Ordinal); + } + + protected override string EnsureTrailingSeparator(string path) + => PathUtility.EnsureTrailingSeparatorPreservingStyle(path); + + 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"; + } + + 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)) + 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; + } + + if (token.Contains('\\', StringComparison.Ordinal)) + return true; + + return HasTraversalComponent(token) || HasFileExtensionInLastComponent(token); + } + + protected override bool IsShellSeparator(char ch) => ch is '/' or '\\'; + + protected override bool IsAnchoredPath(string token) + { + return 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); + + 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); + + 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 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 4fbea4e6..afd8387c 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" }; /// @@ -80,71 +81,13 @@ 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) - { - 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 operators: ; and | - if (ch is ';' or '|') - { - FlushSegment(current, segments); - continue; - } - - current.Append(ch); - } - - FlushSegment(current, segments); - return segments; - } + => ShellApprovalSemantics.ForCommand(command).SplitCompoundCommand(command); /// /// Extracts the verb chain (command name + subcommands) from a tokenized @@ -155,48 +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; + => ShellApprovalSemantics.ForCommand(command).ExtractVerbChain(command, maxDepth); - 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; + /// + /// 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.ForCommand(command).NormalizeApprovalUnit(command, workingDirectory); - verbParts.Add(trimmed); - break; - } - } + /// + /// 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.ForCommand(command).ExtractDirectoryRoots(command, workingDirectory); - return string.Join(' ', verbParts); - } + /// + /// 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.ForCommand(path).NormalizePathToken(path, workingDirectory); /// /// Extracts inner commands from bash -c / sh -c wrappers. Returns the @@ -204,25 +127,7 @@ public static string ExtractVerbChain(string command, int maxDepth = 2) /// 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.ForCommand(command).ExtractInnerCommands(command); /// /// Returns all command strings that should be evaluated, including the @@ -249,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) @@ -285,77 +161,9 @@ 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; + => ShellApprovalSemantics.ForCommand(token).LooksLikePath(token); - 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; - } + internal const int MinDirectoryScopeDepth = 2; /// /// Returns true when the pattern is a single-token shell approval for a @@ -380,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..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; /// @@ -122,13 +124,29 @@ 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; + } + + // 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) || 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; 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); }