diff --git a/Directory.Packages.props b/Directory.Packages.props index 4c4a3611c..6b2572177 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -75,7 +75,7 @@ - + diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index ff543762c..953a5b7ec 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -218,6 +218,12 @@ Done when: each effective directory, across live, sub-agent, and redrive paths. - [x] External paths, mismatched grants, dynamic syntax, and hard-deny rules keep their strict behavior. +- [ ] Bash causal approval intent composes exact stored grants for an initial + exact ShellSyntaxTree 0.3.4 directory change with reviewed diagnostic tails. + Execution facts, folder grants, protected paths, headless authority, and + PowerShell remain strict. Each possible fallback passes the same symlink + check. POSIX `/tmp` aliases map through host path facts. The exact D03 fixture + must pass on Linux, macOS, and native Windows CI. - [x] Directory operands preserve dotted directory names without weakening the external-path or symlink checks. - [x] Reviewed-safe policy matches canonical ShellSyntaxTree token prefixes. @@ -280,6 +286,9 @@ Done when: The Release build and all 7,138 runnable tests pass. The suite reports 15 expected platform or opt-in skips. Adversarial review and all required CI checks passed before merge. +- [ ] Netclaw consumes public ShellSyntaxTree `0.3.4` for parser-owned + working-directory effects. The causal policy does not recognize command + names or private executable grammar. Native platform CI remains required. - [x] The expanded 247-test matrix covers command-substitution and PowerShell execution-region behavior. Known command-owned regions reuse independently matched host and body grants after Netclaw accounts for the parsed body. diff --git a/docs/runbooks/tool-approval-gates.md b/docs/runbooks/tool-approval-gates.md index 438b6c509..94bcaddbd 100644 --- a/docs/runbooks/tool-approval-gates.md +++ b/docs/runbooks/tool-approval-gates.md @@ -296,6 +296,50 @@ names a directory that the session can declare, Netclaw can first return a candidates require approval. The bare `echo` side effect does not become a reusable prompt choice. +#### Example: Bash causal directory intent + +Input: + +```bash +cd /tmp && gh api repos/example/project/actions/jobs/123456/logs \ + > slopwatch.log 2>&1; wc -c slopwatch.log; head -100 slopwatch.log +``` + +Assume global grants cover `cd` and `gh api`. The reviewed catalog contains +`wc` and `head`. + +| Candidate | Real scope | Approval scope | Coverage | +|-----------|------------|----------------|----------| +| `cd` | `/tmp` target | Real scope | Persistent global grant | +| `gh api` | `/tmp` | Real scope | Persistent global grant | +| `wc` | Unknown after the sequence boundary | `/tmp` intent | Reviewed-safe policy | +| `head` | Unknown after the sequence boundary | `/tmp` intent | Reviewed-safe policy | + +Netclaw allows the call when all four rows have coverage. It does not change +the command, its arguments, its execution directory, or model history. + +ShellSyntaxTree starts intent only after an exact working-directory change on +success. The next action must be success-gated with `&&`. Both prerequisites +need one-time, session, or stored authority. + +Netclaw does not inspect names such as `cd`, `command cd`, or `builtin cd`. +It consumes ShellSyntaxTree's closed working-directory effect. An unchanged +effect preserves intent. An unknown effect invalidates it. + +The intent stops after an unknown directory change, an alternate branch, a +scope join, a group, a subshell, dynamic flow, or unsupported syntax. +Netclaw also validates every possible fallback directory. A prior symlink +target cannot become a later fallback. On POSIX, the policy resolves the +runtime temp root and the conventional `/tmp` alias independently. Safe alias +descendants map to their canonical host path. + +Session grants can cover prerequisites. Folder grants use each prerequisite's +real scope. Intent scope cannot convert a folder near miss into coverage. + +Only a reviewed diagnostic without a file-output redirect can use the intent. +Protected paths and folder grants always use real execution facts. Headless +runs and native PowerShell do not receive this reviewed-safe authority. + #### Example: a finite Bash loop over known files Input: diff --git a/openspec/changes/structure-shell-approval-policy/design.md b/openspec/changes/structure-shell-approval-policy/design.md index 374e13553..3d6e73d07 100644 --- a/openspec/changes/structure-shell-approval-policy/design.md +++ b/openspec/changes/structure-shell-approval-policy/design.md @@ -170,33 +170,37 @@ convert deny or prompt to allow, and its output cannot enter a stored grant. directory. Hard deny, protected paths, folder grants, noninteractive authority, and process execution use it. -For Bash only, `Intent` can carry the exact target of a leading authored -directory transition. The transition begins on the success edge of `cd TARGET -&& ...`. It may remain the user's approval scope for later top-level diagnostic -occurrences until invalidated, even when a semicolon means runtime failure -would continue in the original directory. This is an approval-intent fact, not -a runtime cwd claim. +For Bash only, `Intent` can carry an exact target from ShellSyntaxTree 0.3.4. +The leading occurrence must publish `ChangesOnSuccess(Exact(target))`. Its next +top-level action must be success-gated with `&&`. Intent may remain the user's +approval scope for later top-level diagnostics. A semicolon can still execute a +tail in the original directory after failure. Intent is an approval fact, not a +runtime cwd claim. Intent is invalidated by: -- a later directory mutation whose exact target is unavailable; +- a later `Unknown` working-directory effect; +- a later `ChangesOnSuccess` effect whose target is not exact; - `||`, alternate branches, or a join whose incoming intent scopes differ; - entry to or exit from a subshell/group boundary unless both sides retain the same proved intent; - dynamic identity, command substitution controlling flow, or unsupported control flow. -An exact later success-gated `cd` replaces intent on its success edge. Relative -authored paths under intent are rebased only for safe-policy scope; protected -path evaluation also checks their real execution projection. Folder grants -never use intent. - -An intent target is eligible only when it is exact, absolute, normalized, -symlink-free, and allowed by protected-path policy; the `cd` candidate and the -first non-navigation action on its success edge must already have one-time, -session, or stored-grant coverage. This existing user authority is what lets a -later reviewed diagnostic consume intent even when the target is not a normal -session/project safe root. Safe policy alone cannot manufacture causal intent. +An exact later success-gated `ChangesOnSuccess` effect replaces intent. An +`Unchanged` effect preserves it. Netclaw does not identify transition verbs. +Relative authored paths under intent are rebased only for safe-policy scope. +Protected-path evaluation also checks their real execution projection. Folder +grants never use intent. + +An intent target is eligible only when it is exact, absolute, normalized, and +allowed by protected-path policy. It must contain no symlink segment. The +captured platform temporary alias can map to its canonical root. No other +symlink target is eligible. The `cd` candidate and the first non-navigation +action on its success edge must already have one-time, session, or stored-grant +coverage. This user authority lets a later reviewed diagnostic consume intent +outside a normal session or project safe root. Safe policy alone cannot create +causal intent. Only a reviewed diagnostic entry and an occurrence without a file-writing redirect can consume eligible intent. Native @@ -325,7 +329,7 @@ filesystem resolution. This rule applies to both execution and the eligibility probe. An invalid path returns a bounded error and cannot enter model history, child scope, or project-instruction lookup as a successful declaration. -### 7. Consume ShellSyntaxTree 0.3.1 facts through 0.3.3 explicitly +### 7. Consume ShellSyntaxTree 0.3.1 facts through 0.3.4 explicitly Netclaw uses effective `AnalyzedArgument.Value` for runtime-sensitive checks. It may use `AuthoredValue` for approval matching only after the maintainer @@ -340,6 +344,10 @@ accepts only `Exact` and `FiniteSet`. Each value enters `ToolPathPolicy` and the approval scope check. Unknown and all other alternatives stay strict. Netclaw does not infer the role from an executable's private grammar. +ShellSyntaxTree 0.3.4 publishes each occurrence's working-directory effect. +The causal projection consumes this closed fact directly. It never parses a +directory command name, alias, option, or operand. + `AuthoredPathShape` is lexical shape only. It may make review stricter, but it never establishes that an executable treats an argument as a filesystem operand and never creates filesystem authority. Repository slugs, container @@ -408,13 +416,22 @@ Complete D03 example: ```text Input: cd /tmp && gh api ... > slopwatch.log 2>&1; wc -c slopwatch.log; head -100 slopwatch.log -Preflight: unresolved approval scope; no reusable candidate rows -Trace: Completion/RequiresApproval/UncoveredCandidates -Final: RequiresApproval(IsMessy=true) +Execution: real scopes remain unchanged +Prerequisites: cd and gh api use persistent global grants +Intent: wc and head use the exact protected-path-safe /tmp target +Trace: two StoredGrantMatch rows, two ReviewedSafePolicy rows, then Completion/Allow +Final: Allow(AllCandidatesCovered) ``` -This current result remains explicit evidence debt. The fixture does not claim -that a future session-scratch correction or stronger parser fact already exists. +The fixture keeps each causal role and prerequisite ID explicit. It does not +replace execution scope with approval intent. + +The policy validates each runtime fallback before reviewed-safe coverage. A +prior target cannot become a later fallback through a symlink. POSIX policy +captures the conventional `/tmp` alias independently of the runtime temp root. +It maps that alias and its safe descendants to the host-resolved canonical +root. Parser-published working-directory effects identify each transition. +Netclaw does not inspect transition command names. ## Risks / Trade-offs diff --git a/openspec/changes/structure-shell-approval-policy/evidence/netclaw-policy-fixtures.json b/openspec/changes/structure-shell-approval-policy/evidence/netclaw-policy-fixtures.json index 88b69eb6b..1b31643ea 100644 --- a/openspec/changes/structure-shell-approval-policy/evidence/netclaw-policy-fixtures.json +++ b/openspec/changes/structure-shell-approval-policy/evidence/netclaw-policy-fixtures.json @@ -78,16 +78,31 @@ { "tokens": ["head"], "proof": "ReviewedDiagnostic" } ] }, - "candidates": [], + "candidates": [ + { "id": 0, "tokens": ["cd"], "realDirectory": "/tmp", "intentDirectory": null, "role": "CausalPrerequisite", "prerequisiteIds": [], "expectedCoverage": "PersistentGlobal" }, + { "id": 1, "tokens": ["gh", "api"], "realDirectory": "/tmp", "intentDirectory": null, "role": "CausalPrerequisite", "prerequisiteIds": [], "expectedCoverage": "PersistentGlobal" }, + { "id": 2, "tokens": ["wc"], "realDirectory": null, "intentDirectory": "/tmp", "role": "CausalIntentConsumer", "prerequisiteIds": [0, 1], "expectedCoverage": "ReviewedSafePolicy" }, + { "id": 3, "tokens": ["head"], "realDirectory": null, "intentDirectory": "/tmp", "role": "CausalIntentConsumer", "prerequisiteIds": [0, 1], "expectedCoverage": "ReviewedSafePolicy" } + ], "shellEffects": { "redirects": [ { "commandIndex": 1, "target": "/tmp/slopwatch.log", "mode": "Output", "expectedPathPolicy": "Allow" } + ], + "workingDirectoryEffects": [ + { "commandIndex": 0, "kind": "ChangesOnSuccess", "targets": ["/tmp"] }, + { "commandIndex": 1, "kind": "Unchanged", "targets": [] }, + { "commandIndex": 2, "kind": "Unchanged", "targets": [] }, + { "commandIndex": 3, "kind": "Unchanged", "targets": [] } ] }, "expectedTrace": [ - { "stage": "Completion", "candidateId": null, "executableBasename": null, "outcome": "RequiresApproval", "reason": "UncoveredCandidates", "coverage": null, "scopeRelation": "None", "grantTimestamp": null } + { "stage": "StoredGrantMatch", "candidateId": 0, "executableBasename": "cd", "outcome": "Covered", "reason": "PersistentGlobalGrant", "coverage": "PersistentGlobal", "scopeRelation": "Global", "grantTimestamp": "2026-08-13T00:00:00.0000000+00:00" }, + { "stage": "StoredGrantMatch", "candidateId": 1, "executableBasename": "gh", "outcome": "Covered", "reason": "PersistentGlobalGrant", "coverage": "PersistentGlobal", "scopeRelation": "Global", "grantTimestamp": "2026-08-13T00:00:00.0000000+00:00" }, + { "stage": "ReviewedSafePolicy", "candidateId": 2, "executableBasename": "wc", "outcome": "Covered", "reason": "ReviewedSafePhrase", "coverage": "ReviewedSafePolicy", "scopeRelation": "UnderIntentRoot", "grantTimestamp": null }, + { "stage": "ReviewedSafePolicy", "candidateId": 3, "executableBasename": "head", "outcome": "Covered", "reason": "ReviewedSafePhrase", "coverage": "ReviewedSafePolicy", "scopeRelation": "UnderIntentRoot", "grantTimestamp": null }, + { "stage": "Completion", "candidateId": null, "executableBasename": null, "outcome": "Allow", "reason": "AllCandidatesCovered", "coverage": null, "scopeRelation": "None", "grantTimestamp": null } ], - "expectedFinal": { "outcome": "RequiresApproval", "reason": "UncoveredCandidates", "approvalCandidates": [], "isMessy": true, "agentCorrection": null } + "expectedFinal": { "outcome": "Allow", "reason": "AllCandidatesCovered" } }, { "evidenceId": "D07", diff --git a/openspec/changes/structure-shell-approval-policy/proposal.md b/openspec/changes/structure-shell-approval-policy/proposal.md index 188143fb3..d9baaa158 100644 --- a/openspec/changes/structure-shell-approval-policy/proposal.md +++ b/openspec/changes/structure-shell-approval-policy/proposal.md @@ -27,6 +27,7 @@ session and persistent grant snapshots. - Emit a bounded, redacted decision trace that also supplies near-miss data. - Adopt the authored/effective fact separation introduced in ShellSyntaxTree 0.3.1 and the authored filesystem fact from public 0.3.3. +- Consume ShellSyntaxTree 0.3.4 working-directory effects for causal intent. - Use the 0.3.3 authored filesystem fact for D14. Accept only exact or finite values. Check each path through product policy. Lexical path shape alone cannot create file authority. @@ -55,6 +56,6 @@ No production branch will parse an executable's private options or operands. per-candidate coverage from one atomic snapshot. - UX: covered diagnostic chains stop prompting; unresolved syntax remains one-time-only. -- Dependencies: implementation consumes public ShellSyntaxTree 0.3.3 for the - authored, effective, and authored filesystem facts. +- Dependencies: implementation consumes public ShellSyntaxTree 0.3.4 for the + authored, effective, filesystem, and working-directory effect facts. - Documentation/evals: operator guidance and approval behavioral evals change. diff --git a/openspec/changes/structure-shell-approval-policy/specs/tool-approval-gates/spec.md b/openspec/changes/structure-shell-approval-policy/specs/tool-approval-gates/spec.md index 4327102ec..bea8b380f 100644 --- a/openspec/changes/structure-shell-approval-policy/specs/tool-approval-gates/spec.md +++ b/openspec/changes/structure-shell-approval-policy/specs/tool-approval-gates/spec.md @@ -159,20 +159,26 @@ mismatched actor result, or impossible transition SHALL produce terminal deny. ### Requirement: Causal approval intent is separate from execution scope The system SHALL keep canonical execution facts unchanged. For Bash only, it -MAY derive an approval-intent directory from an exact leading authored -directory transition on the success edge of `cd TARGET && ...`. +MAY derive approval intent from a leading ShellSyntaxTree 0.3.4 occurrence. +That occurrence SHALL publish `ChangesOnSuccess(Exact(target))`. Its next +top-level action SHALL be success-gated with `&&`. Intent MAY continue through later top-level diagnostic statements until a later directory mutation, differing control-flow join, alternate branch, subshell/group boundary, dynamic flow, or unsupported region invalidates it. An -exact later success-gated directory transition SHALL replace intent on its -success edge. - -An intent target SHALL be eligible only when exact, absolute, normalized, -symlink-free, and allowed by protected-path policy. The directory-transition -candidate and first non-navigation action on its success edge SHALL already -have one-time, session, or stored-grant coverage. Safe policy alone SHALL NOT -manufacture causal intent. +exact later success-gated `ChangesOnSuccess` effect SHALL replace intent. +`Unchanged` SHALL preserve intent. `Unknown` or a non-exact change target SHALL +invalidate intent. Causal and temporary-scope policy SHALL NOT identify +directory-transition verbs. + +An intent target SHALL be eligible only when exact, absolute, normalized, and +allowed by protected-path policy. It SHALL contain no symlink segment. Every +possible fallback directory SHALL meet the same rule. A captured platform +temporary alias and its descendants MAY map to its canonical root. POSIX hosts +MAY also capture the conventional `/tmp` alias. No other symlink target SHALL +be eligible. The directory-transition candidate and first non-navigation +action on its success edge SHALL already have one-time, session, or +stored-grant coverage. Safe policy alone SHALL NOT create causal intent. Only a reviewed diagnostic candidate without a file-writing redirect MAY consume eligible intent. Hard deny, protected paths, folder @@ -200,6 +206,62 @@ scope from `Set-Location`. - **THEN** the tail has unknown intent - **AND** safe policy cannot use the earlier `/tmp` intent +#### Scenario: Parser-owned wrappers establish and replace intent + +- **WHEN** Bash reports `ChangesOnSuccess(Exact("/tmp"))` for `command cd /tmp` +- **THEN** the effect can establish causal intent +- **AND** no Netclaw command-name rule is consulted + +#### Scenario: Directory-stack effect invalidates intent + +- **GIVEN** intent is `/tmp` +- **WHEN** a later `pushd` or `popd` occurrence reports `Unknown` +- **THEN** no later diagnostic receives the earlier intent + +#### Scenario: Failure-only transition shape does not create intent + +- **WHEN** Bash reports `Unchanged` for `cd /tmp extra` +- **THEN** no causal intent is created +- **AND** Netclaw does not reinterpret the command's private arguments + +#### Scenario: Arbitrary symlink target cannot create intent + +- **GIVEN** `/work/alias` is a symlink to another directory +- **WHEN** source starts with `cd /work/alias && inspect` +- **THEN** no causal approval intent is eligible +- **AND** the captured platform temporary alias remains a separate bounded + exception + +#### Scenario: Earlier symlink target cannot become a fallback + +- **GIVEN** an earlier exact intent target crosses a symlink +- **WHEN** a later eligible transition replaces intent +- **THEN** the earlier target fails fallback eligibility +- **AND** no later diagnostic receives reviewed-safe intent coverage + +#### Scenario: Protected fallback denial stays terminal + +- **GIVEN** an earlier fallback alias resolves into a protected directory +- **WHEN** a later intent candidate also fails symlink eligibility +- **THEN** protected-path policy denies before the eligibility check +- **AND** the system does not offer a one-time approval prompt + +#### Scenario: Conventional macOS tmp alias remains eligible + +- **GIVEN** the host runtime temp root differs from `/tmp` +- **AND** the POSIX `/tmp` alias resolves to `/private/tmp` +- **WHEN** intent targets `/tmp` or one of its safe descendants +- **THEN** causal policy validates the canonical `/private/tmp` path +- **AND** arbitrary POSIX symlink aliases remain strict + +#### Scenario: Session and folder grants use real prerequisite scope + +- **GIVEN** session or persistent-folder authority covers each prerequisite +- **WHEN** causal policy checks a diagnostic tail +- **THEN** prerequisite coverage can establish intent +- **AND** a folder grant matches only the prerequisite's real scope +- **AND** intent scope cannot convert a folder near miss into coverage + #### Scenario: Alternate branch does not leak intent - **WHEN** source is `cd /tmp && inspect || recover; head result.log` @@ -814,6 +876,10 @@ authored values only for the approved approval perspective. It SHALL route ShellSyntaxTree 0.3.3 authored filesystem values through path policy. Unknown policy-relevant values SHALL not create reusable or safe coverage. +Netclaw SHALL consume ShellSyntaxTree 0.3.4 working-directory effects for the +bounded Bash causal projection. It SHALL NOT derive equivalent effects from +command names or executable-private grammar. + Deny-only defensive scans MAY deny incomplete input but SHALL never authorize it. diff --git a/openspec/changes/structure-shell-approval-policy/tasks.md b/openspec/changes/structure-shell-approval-policy/tasks.md index 9a4524561..fa6621c2b 100644 --- a/openspec/changes/structure-shell-approval-policy/tasks.md +++ b/openspec/changes/structure-shell-approval-policy/tasks.md @@ -103,17 +103,21 @@ ## 6. Bash causal approval intent -- [ ] 6.1 Derive intent from canonical Bash directory-transition and control-flow - facts without changing execution analysis. -- [ ] 6.2 Implement replacement and invalidation across later directory changes, +- [x] 6.1 Derive intent from ShellSyntaxTree 0.3.4 working-directory effects and + canonical Bash control flow without changing execution analysis. +- [x] 6.2 Implement replacement and invalidation across later directory changes, `||`, joins, groups/subshells, dynamic flow, and unsupported regions. -- [ ] 6.3 Apply intent only to reviewed diagnostic candidates without writing +- [x] 6.3 Apply intent only to reviewed diagnostic candidates without writing redirects; keep folder grants and every deny check on real facts. -- [ ] 6.4 Pin D03's `/tmp` trace and later-directory-mutation counterexamples. +- [x] 6.4 Pin D03's `/tmp` trace and later-directory-mutation counterexamples. +- [x] 6.4a Validate each fallback directory, POSIX `/tmp` alias descendants, + session prerequisites, and real-scope folder grants. Use parser effects for + temporary-scope transitions. Keep protected fallback denial terminal before + symlink eligibility. - [ ] 6.5 Keep native PowerShell causal scope strict and record native Windows expected results. -## 7. ShellSyntaxTree 0.3.1 facts through 0.3.3 +## 7. ShellSyntaxTree 0.3.1 facts through 0.3.4 - [x] 7.1 Upgrade the central package to public 0.3.2, which includes the 0.3.1 authored-source facts and keeps same-language child shells strict. @@ -127,6 +131,8 @@ - [x] 7.5 Delete the broad Bash environment-variable relaxation and its superseded tests. - [x] 7.6 Pin exact D02, D10, and D14 input-to-coverage results. +- [x] 7.7 Upgrade to public ShellSyntaxTree 0.3.4. Consume its closed + working-directory effect without command-name parsing. ## 8. Trace, guides, and behavioral evals diff --git a/src/Netclaw.Actors.Tests/Tools/BashCausalApprovalIntentTests.cs b/src/Netclaw.Actors.Tests/Tools/BashCausalApprovalIntentTests.cs new file mode 100644 index 000000000..ad100aea2 --- /dev/null +++ b/src/Netclaw.Actors.Tests/Tools/BashCausalApprovalIntentTests.cs @@ -0,0 +1,195 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Actors.Tools; +using Netclaw.Security; +using ShellSyntaxTree; +using Xunit; + +namespace Netclaw.Actors.Tests.Tools; + +public sealed class BashCausalApprovalIntentTests +{ + private static readonly ShellExecutionEnvironment BashEnvironment = + ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux); + + [Fact] + public void Exact_diagnostic_chain_projects_prerequisites_and_intent_consumers() + { + var projected = Project( + "cd /tmp && gh api repos/example/project/actions/jobs/123456/logs " + + "> slopwatch.log 2>&1; wc -c slopwatch.log; head -100 slopwatch.log"); + + Assert.Collection( + projected, + candidate => AssertPrerequisite(candidate, "cd"), + candidate => AssertPrerequisite(candidate, "gh api"), + candidate => AssertConsumer(candidate, "wc", "/tmp", ["/work"], [0, 1]), + candidate => AssertConsumer(candidate, "head", "/tmp", ["/work"], [0, 1])); + } + + [Fact] + public void Later_success_gated_transition_replaces_intent_and_prerequisites() + { + var projected = Project( + "cd /tmp && inspect; head first.log; " + + "cd /var/tmp && collect; wc second.log"); + + Assert.Equal(6, projected.Count); + AssertConsumer(projected[2], "head", "/tmp", ["/work"], [0, 1]); + AssertConsumer(projected[5], "wc", "/var/tmp", ["/work", "/tmp"], [3, 4]); + } + + [Theory] + [InlineData("command cd /tmp && inspect; head result.log")] + [InlineData("builtin cd /tmp && inspect; head result.log")] + public void Parser_owned_directory_effect_establishes_intent(string command) + { + var projected = Project(command); + + Assert.Equal(3, projected.Count); + Assert.IsType( + projected[0].SourceOccurrence.WorkingDirectoryEffect); + AssertConsumer(projected[2], "head", "/tmp", ["/work"], [0, 1]); + } + + [Theory] + [InlineData("cd /tmp && inspect; cd \"$1\"; head result.log")] + [InlineData("cd /tmp && inspect || recover; head result.log")] + [InlineData("(cd /tmp && inspect); head result.log")] + [InlineData("cd /tmp && inspect; head result.log > copy.log")] + [InlineData("cd /tmp && inspect; head /etc/passwd")] + [InlineData("cd /tmp && inspect; \"$tool\" result.log")] + [InlineData("cd /tmp && inspect; status-report \"$OPTS\"")] + [InlineData("chdir /tmp && inspect; head result.log")] + [InlineData("pushd /tmp && inspect; head result.log")] + [InlineData("cd /tmp && inspect; pushd /other; head result.log")] + [InlineData("cd /tmp && inspect; popd; head result.log")] + [InlineData("cd /tmp extra && inspect; head result.log")] + [InlineData("cd -z /tmp && inspect; head result.log")] + [InlineData("pwd; cd /tmp && inspect; head result.log")] + [InlineData("cd /tmp && inspect; cd /var/tmp; head result.log")] + [InlineData("cd /tmp && inspect; head result.log | wc -c")] + [InlineData("cd /tmp && inspect")] + public void Unsupported_or_ambiguous_flow_does_not_publish_intent(string command) + { + Assert.False(TryProject(BashEnvironment, command, out _)); + } + + [Fact] + public void Native_power_shell_does_not_publish_bash_causal_intent() + { + var environment = ShellExecutionEnvironment.CreatePowerShell( + "C:\\Program Files\\PowerShell\\7\\pwsh.exe", + PwshDialect.PowerShell7); + + Assert.False(TryProject( + environment, + "Set-Location C:\\Temp; Get-Content result.log", + out _)); + } + + [Fact] + public void Captured_temporary_alias_allows_redirect_projection_without_allowing_other_aliases() + { + if (OperatingSystem.IsWindows()) + return; + + var testRoot = Path.Combine( + Path.GetTempPath(), + $"netclaw-causal-alias-{Guid.NewGuid():N}"); + var canonicalTemp = Path.Combine(testRoot, "canonical-temp"); + var authoredTemp = Path.Combine(testRoot, "authored-temp"); + var otherTarget = Path.Combine(testRoot, "other-target"); + var otherAlias = Path.Combine(testRoot, "other-alias"); + Directory.CreateDirectory(canonicalTemp); + Directory.CreateDirectory(otherTarget); + Directory.CreateSymbolicLink(authoredTemp, canonicalTemp); + Directory.CreateSymbolicLink(otherAlias, otherTarget); + + try + { + var policy = new PlatformTemporaryScopePolicy( + BashEnvironment, + authoredTemp, + HostPlatformTemporaryPathInspector.Instance); + var allowedCommand = + $"cd {authoredTemp} && inspect > result.log 2>&1; head result.log"; + var otherCommand = + $"cd {otherAlias} && inspect > result.log 2>&1; head result.log"; + + Assert.True(TryProject( + BashEnvironment, + allowedCommand, + policy.IsSafePlatformTemporaryPath, + out _)); + Assert.False(TryProject( + BashEnvironment, + otherCommand, + policy.IsSafePlatformTemporaryPath, + out _)); + } + finally + { + Directory.Delete(testRoot, recursive: true); + } + } + + private static IReadOnlyList Project(string command) + { + Assert.True(TryProject(BashEnvironment, command, out var projected)); + return projected; + } + + private static bool TryProject( + ShellExecutionEnvironment environment, + string command, + out IReadOnlyList projected) + => TryProject( + environment, + command, + PlatformTemporaryScopePolicy.Create(environment).IsSafePlatformTemporaryPath, + out projected); + + private static bool TryProject( + ShellExecutionEnvironment environment, + string command, + Func isAllowedHostPath, + out IReadOnlyList projected) + { + var analysis = new ShellCommandAnalyzer(environment).Analyze(command, "/work"); + return BashCausalApprovalIntent.TryProject( + environment, + analysis, + new ShellApprovalMatcher(environment), + isAllowedHostPath, + out projected); + } + + private static void AssertPrerequisite( + BashCausalApprovalCandidate candidate, + string verb) + { + Assert.Equal(ShellPolicyCandidateRole.CausalPrerequisite, candidate.Role); + Assert.Equal(verb, candidate.Candidate.Verb); + Assert.Null(candidate.IntentDirectory); + Assert.Empty(candidate.PrerequisiteIndexes); + } + + private static void AssertConsumer( + BashCausalApprovalCandidate candidate, + string verb, + string intentDirectory, + IReadOnlyList fallbackDirectories, + IReadOnlyList prerequisiteIndexes) + { + Assert.Equal(ShellPolicyCandidateRole.CausalIntentConsumer, candidate.Role); + Assert.Equal(verb, candidate.Candidate.Verb); + Assert.Null(candidate.Candidate.Directory); + Assert.Equal(intentDirectory, candidate.IntentDirectory); + Assert.Equal(fallbackDirectories, candidate.FallbackDirectories); + Assert.Equal(prerequisiteIndexes, candidate.PrerequisiteIndexes); + } +} diff --git a/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs b/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs index 11516e278..a064b29ab 100644 --- a/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs @@ -14,6 +14,7 @@ using Netclaw.Security; using Netclaw.Tests.Utilities; using Netclaw.Tools; +using ShellSyntaxTree; using Xunit; namespace Netclaw.Actors.Tests.Tools; @@ -766,6 +767,638 @@ public async Task Authorization_evaluation_composes_session_and_reviewed_safe_co } } + [SlopwatchSuppress("SW001", "This test pins Bash causal approval intent on POSIX hosts.")] + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only shell directory semantics")] + public async Task Authorization_evaluation_composes_grants_with_causal_intent_diagnostics() + { + var approvalService = new FixedShellApprovalService(request => + { + var matches = request.Candidates.Select(candidate => + { + var shell = Assert.IsType(candidate.Candidate.Shell); + var tokens = Assert.IsAssignableFrom>( + candidate.Candidate.VerbTokens); + var entry = ApprovalEntry.CreateTokenPrefix( + shell, + tokens, + directory: null, + createdAt: null); + return new ShellGrantCandidateMatch( + candidate.CandidateId, + new ToolApprovalMatch( + candidate.Candidate.Verb, + "persistent", + entry.FormatScope()), + ShellCoverageKind.PersistentGlobal, + NearMisses: []); + }).ToArray(); + return new ShellApprovalMatchResult( + new PersistentGrantStoreStatus.Ready(), + Array.AsReadOnly(matches)); + }); + var executor = CreateApprovalGatedShellExecutor( + approvalService, + safeVerbs: SafeVerbList.FromVerbs( + ApprovalShell.Bash, + ["wc", "head"])); + var command = "cd /tmp && gh api repos/example/project/actions/jobs/123456/logs " + + "> slopwatch.log 2>&1; wc -c slopwatch.log; head -100 slopwatch.log"; + var call = new FunctionCallContent( + "call-causal-intent", + "shell_execute", + ToolInput.Create( + "Command", + command, + "WorkingDirectory", + "/work")); + + var decision = await executor.EvaluateAuthorizationAsync( + call, + CreateInteractivePersonalContext("signalr/causal-intent"), + TestContext.Current.CancellationToken); + + Assert.Equal(ToolAuthorizationOutcome.Allowed, decision.Outcome); + Assert.Equal(ToolAllowReason.StoredApproval, decision.AllowReason); + var request = Assert.IsType(approvalService.LastRequest); + Assert.All(request.Candidates, candidate => + Assert.Contains(candidate.Candidate.Verb, new[] { "cd", "gh api" })); + Assert.Contains(request.Candidates, candidate => candidate.Candidate.Verb == "cd"); + Assert.Contains(request.Candidates, candidate => candidate.Candidate.Verb == "gh api"); + var intentRows = decision.ShellPolicyTrace.Rows + .Where(row => row.ScopeRelation == ShellScopeRelation.UnderIntentRoot) + .ToArray(); + Assert.Equal(2, intentRows.Length); + Assert.All(intentRows, row => + { + Assert.Equal(ShellPolicyTraceStage.ReviewedSafePolicy, row.Stage); + Assert.Equal(ShellPolicyTraceReason.ReviewedSafePhrase, row.Reason); + }); + Assert.Equal( + new[] { "head", "wc" }, + intentRows + .Select(row => Assert.IsType(row.ExecutableBasename)) + .Order(StringComparer.Ordinal) + .ToArray()); + } + + [SlopwatchSuppress("SW001", "This test pins Bash causal approval intent on POSIX hosts.")] + [Theory(SkipUnless = nameof(IsPosix), Skip = "POSIX-only shell directory semantics")] + [InlineData("Session")] + [InlineData("PersistentFolder")] + public async Task Causal_intent_accepts_session_or_real_folder_prerequisite_coverage( + string prerequisiteCoverageName) + { + var prerequisiteCoverage = Enum.Parse(prerequisiteCoverageName); + var approvalService = new FixedShellApprovalService(request => + { + var matches = request.Candidates.Select(candidate => + { + var match = prerequisiteCoverage == ShellCoverageKind.Session + ? new ToolApprovalMatch(candidate.Candidate.Verb, "session", "this chat") + : new ToolApprovalMatch( + candidate.Candidate.Verb, + "persistent", + ApprovalEntry.CreateTokenPrefix( + ApprovalShell.Bash, + Assert.IsAssignableFrom>( + candidate.Candidate.VerbTokens), + "/tmp", + createdAt: null).FormatScope()); + return new ShellGrantCandidateMatch( + candidate.CandidateId, + match, + prerequisiteCoverage, + NearMisses: []); + }).ToArray(); + return new ShellApprovalMatchResult( + new PersistentGrantStoreStatus.Ready(), + Array.AsReadOnly(matches)); + }); + var executor = CreateApprovalGatedShellExecutor( + approvalService, + safeVerbs: SafeVerbList.FromVerbs(ApprovalShell.Bash, ["head"])); + var call = new FunctionCallContent( + "call-causal-intent-bounded-grant", + "shell_execute", + ToolInput.Create( + "Command", + "cd /tmp && inspect; head result.log", + "WorkingDirectory", + "/work")); + + var decision = await executor.EvaluateAuthorizationAsync( + call, + CreateInteractivePersonalContext("signalr/causal-intent-bounded-grant"), + TestContext.Current.CancellationToken); + + Assert.Equal(ToolAuthorizationOutcome.Allowed, decision.Outcome); + var request = Assert.IsType(approvalService.LastRequest); + Assert.Equal(["cd", "inspect"], request.Candidates + .Select(candidate => candidate.Candidate.Verb).ToArray()); + Assert.All(request.Candidates, candidate => + Assert.Equal("/tmp", candidate.Candidate.Directory)); + Assert.Equal( + 2, + decision.ShellPolicyTrace.Rows.Count(row => + row.Stage == ShellPolicyTraceStage.StoredGrantMatch + && row.Coverage == prerequisiteCoverage)); + Assert.Contains( + decision.ShellPolicyTrace.Rows, + row => row is + { + Stage: ShellPolicyTraceStage.ReviewedSafePolicy, + ScopeRelation: ShellScopeRelation.UnderIntentRoot, + ExecutableBasename: "head" + }); + } + + [SlopwatchSuppress("SW001", "This test pins Bash causal approval intent on POSIX hosts.")] + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only shell directory semantics")] + public async Task Causal_intent_does_not_rebase_a_folder_grant_to_the_intent_scope() + { + var approvalService = new FixedShellApprovalService(request => + new ShellApprovalMatchResult( + new PersistentGrantStoreStatus.Ready(), + Array.AsReadOnly(request.Candidates.Select(candidate => + { + var grant = ApprovalEntry.CreateTokenPrefix( + ApprovalShell.Bash, + Assert.IsAssignableFrom>( + candidate.Candidate.VerbTokens), + "/work", + createdAt: null); + return new ShellGrantCandidateMatch( + candidate.CandidateId, + Match: null, + GrantCoverage: null, + NearMisses: + [ + new ShellApprovalNearMiss( + grant, + ShellApprovalNearMissReason.OutsideDirectory) + ]); + }).ToArray()))); + var executor = CreateApprovalGatedShellExecutor( + approvalService, + safeVerbs: SafeVerbList.FromVerbs(ApprovalShell.Bash, ["head"])); + var call = new FunctionCallContent( + "call-causal-intent-folder-near-miss", + "shell_execute", + ToolInput.Create( + "Command", + "cd /tmp && inspect; head result.log", + "WorkingDirectory", + "/work")); + + var decision = await executor.EvaluateAuthorizationAsync( + call, + CreateInteractivePersonalContext("signalr/causal-intent-folder-near-miss"), + TestContext.Current.CancellationToken); + + Assert.Equal(ToolAuthorizationOutcome.RequiresApproval, decision.Outcome); + var request = Assert.IsType(approvalService.LastRequest); + Assert.All(request.Candidates, candidate => + Assert.Equal("/tmp", candidate.Candidate.Directory)); + Assert.DoesNotContain( + decision.ShellPolicyTrace.Rows, + row => row.ScopeRelation == ShellScopeRelation.UnderIntentRoot); + } + + [SlopwatchSuppress("SW001", "This test pins Bash causal approval intent on POSIX hosts.")] + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only shell directory semantics")] + public async Task Causal_intent_requires_authority_for_each_prerequisite() + { + var approvalService = new FixedShellApprovalService(request => + new ShellApprovalMatchResult( + new PersistentGrantStoreStatus.Ready(), + Array.AsReadOnly(request.Candidates.Select(candidate => + { + if (candidate.Candidate.Verb != "cd") + { + return new ShellGrantCandidateMatch( + candidate.CandidateId, + Match: null, + GrantCoverage: null, + NearMisses: []); + } + + var entry = ApprovalEntry.CreateTokenPrefix( + ApprovalShell.Bash, + ["cd"], + directory: null, + createdAt: null); + return new ShellGrantCandidateMatch( + candidate.CandidateId, + new ToolApprovalMatch("cd", "persistent", entry.FormatScope()), + ShellCoverageKind.PersistentGlobal, + NearMisses: []); + }).ToArray()))); + var executor = CreateApprovalGatedShellExecutor( + approvalService, + safeVerbs: SafeVerbList.FromVerbs( + ApprovalShell.Bash, + ["gh api", "wc", "head"])); + var call = new FunctionCallContent( + "call-causal-intent-missing-prerequisite", + "shell_execute", + ToolInput.Create( + "Command", + "cd /tmp && gh api repos/example/project > result.log 2>&1; " + + "wc -c result.log; head result.log", + "WorkingDirectory", + "/work")); + + var decision = await executor.EvaluateAuthorizationAsync( + call, + CreateInteractivePersonalContext("signalr/causal-intent-missing-prerequisite"), + TestContext.Current.CancellationToken); + + Assert.Equal(ToolAuthorizationOutcome.RequiresApproval, decision.Outcome); + var approval = Assert.IsType(decision.ApprovalContext); + Assert.True(approval.IsMessy); + Assert.Empty(approval.Candidates!); + Assert.Equal( + [ + Netclaw.Actors.Protocol.ApprovalOptionKeys.ApproveOnce, + Netclaw.Actors.Protocol.ApprovalOptionKeys.Deny + ], + approval.Options.Select(option => option.Key.Value).ToArray()); + Assert.Equal( + ["cd", "gh api"], + Assert.IsType(approvalService.LastRequest) + .Candidates.Select(candidate => candidate.Candidate.Verb).ToArray()); + } + + [SlopwatchSuppress("SW001", "This test pins Bash causal approval intent on POSIX hosts.")] + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only shell directory semantics")] + public async Task Exact_one_time_retry_covers_the_original_causal_call() + { + var approvalService = new FixedShellApprovalService(request => + new ShellApprovalMatchResult( + new PersistentGrantStoreStatus.Ready(), + Array.AsReadOnly(request.Candidates.Select(candidate => + new ShellGrantCandidateMatch( + candidate.CandidateId, + Match: null, + GrantCoverage: null, + NearMisses: [])).ToArray()))); + var executor = CreateApprovalGatedShellExecutor( + approvalService, + safeVerbs: SafeVerbList.FromVerbs( + ApprovalShell.Bash, + ["head"])); + var call = new FunctionCallContent( + "call-causal-intent-once", + "shell_execute", + ToolInput.Create( + "Command", + "cd /tmp && inspect; head result.log", + "WorkingDirectory", + "/work")); + var context = CreateInteractivePersonalContext("signalr/causal-intent-once"); + + var initial = await executor.EvaluateAuthorizationAsync( + call, + context, + TestContext.Current.CancellationToken); + var approval = Assert.IsType(initial.ApprovalContext); + context.OneTimeApprovedToolName = call.Name; + context.SetOneTimeApprovedPatterns(OneTimeApprovalKeys.Create(approval)); + + var retry = await executor.EvaluateAuthorizationAsync( + call, + context, + TestContext.Current.CancellationToken); + + Assert.Equal(ToolAuthorizationOutcome.Allowed, retry.Outcome); + Assert.Equal(ToolAllowReason.OneTimeApproval, retry.AllowReason); + Assert.DoesNotContain( + retry.ShellPolicyTrace.Rows, + row => row.ScopeRelation == ShellScopeRelation.UnderIntentRoot); + } + + [SlopwatchSuppress("SW001", "This test pins Bash causal approval intent on POSIX hosts.")] + [Theory(SkipUnless = nameof(IsPosix), Skip = "POSIX-only shell directory semantics")] + [InlineData("command cd /tmp && inspect; head result.log")] + [InlineData("builtin cd /tmp && inspect; head result.log")] + public async Task Parser_owned_directory_effect_allows_wrapped_transition( + string command) + { + var approvalService = GrantEveryShellCandidate(); + var executor = CreateApprovalGatedShellExecutor( + approvalService, + safeVerbs: SafeVerbList.FromVerbs( + ApprovalShell.Bash, + ["head"])); + var call = new FunctionCallContent( + "call-causal-intent-wrapped-transition", + "shell_execute", + ToolInput.Create( + "Command", + command, + "WorkingDirectory", + "/work")); + + var decision = await executor.EvaluateAuthorizationAsync( + call, + CreateInteractivePersonalContext("signalr/causal-intent-wrapper"), + TestContext.Current.CancellationToken); + + Assert.Equal(ToolAuthorizationOutcome.Allowed, decision.Outcome); + Assert.Contains( + decision.ShellPolicyTrace.Rows, + row => row is + { + Stage: ShellPolicyTraceStage.ReviewedSafePolicy, + ScopeRelation: ShellScopeRelation.UnderIntentRoot, + ExecutableBasename: "head" + }); + } + + [SlopwatchSuppress("SW001", "This test pins Bash causal approval intent on POSIX hosts.")] + [Theory(SkipUnless = nameof(IsPosix), Skip = "POSIX-only shell directory semantics")] + [InlineData("cd /tmp && inspect; pushd /other; head result.log")] + [InlineData("cd /tmp && inspect; popd; head result.log")] + [InlineData("cd /tmp && inspect; cd \"$1\"; head result.log")] + [InlineData("cd /tmp extra && inspect; head result.log")] + [InlineData("cd -z /tmp && inspect; head result.log")] + public async Task Unproved_directory_effect_keeps_causal_chain_strict( + string command) + { + var approvalService = GrantEveryShellCandidate(); + var executor = CreateApprovalGatedShellExecutor( + approvalService, + safeVerbs: SafeVerbList.FromVerbs( + ApprovalShell.Bash, + ["head"])); + var call = new FunctionCallContent( + "call-causal-intent-strict-effect", + "shell_execute", + ToolInput.Create( + "Command", + command, + "WorkingDirectory", + "/work")); + + var decision = await executor.EvaluateAuthorizationAsync( + call, + CreateInteractivePersonalContext("signalr/causal-intent-strict-effect"), + TestContext.Current.CancellationToken); + + Assert.Equal(ToolAuthorizationOutcome.RequiresApproval, decision.Outcome); + Assert.True(Assert.IsType(decision.ApprovalContext).IsMessy); + Assert.Null(approvalService.LastRequest); + Assert.DoesNotContain( + decision.ShellPolicyTrace.Rows, + row => row.ScopeRelation == ShellScopeRelation.UnderIntentRoot); + } + + [SlopwatchSuppress("SW001", "This test pins Bash causal approval intent on POSIX hosts.")] + [Theory(SkipUnless = nameof(IsPosix), Skip = "POSIX-only shell directory semantics")] + [InlineData("cd /tmp && inspect; head private.log", "/tmp/private.log", "head")] + [InlineData("cd /tmp && inspect; head private.log", "/work/private.log", "head")] + [InlineData("cd /tmp && inspect; grep -f /protected/patterns local.txt", "/protected/patterns", "grep")] + [InlineData("cd /tmp && inspect; wc -c < private.log", "/tmp/private.log", "wc")] + public async Task Causal_intent_cannot_bypass_protected_path_policy( + string command, + string deniedPath, + string safeVerb) + { + var approvalService = GrantEveryShellCandidate(); + var executor = CreateApprovalGatedShellExecutor( + approvalService, + safeVerbs: SafeVerbList.FromVerbs( + ApprovalShell.Bash, + [safeVerb]), + deniedPaths: [deniedPath]); + var call = new FunctionCallContent( + "call-causal-intent-protected-path", + "shell_execute", + ToolInput.Create( + "Command", + command, + "WorkingDirectory", + "/work")); + + var decision = await executor.EvaluateAuthorizationAsync( + call, + CreateInteractivePersonalContext("signalr/causal-intent-protected-path"), + TestContext.Current.CancellationToken); + + Assert.Equal(ToolAuthorizationOutcome.Denied, decision.Outcome); + Assert.Equal("shell_references_protected_path", decision.DenyReason); + Assert.DoesNotContain( + decision.ShellPolicyTrace.Rows, + row => row.ScopeRelation == ShellScopeRelation.UnderIntentRoot); + } + + [SlopwatchSuppress("SW001", "This test requires native POSIX symbolic-link behavior.")] + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only symbolic-link semantics")] + public async Task Causal_intent_rejects_a_symbolic_link_transition_target() + { + var root = Directory.CreateTempSubdirectory("netclaw-causal-intent-"); + try + { + var target = Path.Combine(root.FullName, "target"); + var alias = Path.Combine(root.FullName, "alias"); + Directory.CreateDirectory(target); + Directory.CreateSymbolicLink(alias, target); + var approvalService = GrantEveryShellCandidate(); + var executor = CreateApprovalGatedShellExecutor( + approvalService, + safeVerbs: SafeVerbList.FromVerbs( + ApprovalShell.Bash, + ["head"])); + var call = new FunctionCallContent( + "call-causal-intent-symlink-target", + "shell_execute", + ToolInput.Create( + "Command", + $"cd {alias} && inspect; head result.log", + "WorkingDirectory", + "/work")); + + var decision = await executor.EvaluateAuthorizationAsync( + call, + CreateInteractivePersonalContext("signalr/causal-intent-symlink-target"), + TestContext.Current.CancellationToken); + + Assert.Equal(ToolAuthorizationOutcome.RequiresApproval, decision.Outcome); + Assert.True(Assert.IsType(decision.ApprovalContext).IsMessy); + Assert.Null(approvalService.LastRequest); + Assert.DoesNotContain( + decision.ShellPolicyTrace.Rows, + row => row.ScopeRelation == ShellScopeRelation.UnderIntentRoot); + } + finally + { + root.Delete(recursive: true); + } + } + + [SlopwatchSuppress("SW001", "This test requires native POSIX symbolic-link behavior.")] + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only symbolic-link semantics")] + public async Task Causal_intent_rejects_a_symbolic_link_fallback_directory() + { + var root = Directory.CreateTempSubdirectory("netclaw-causal-fallback-"); + try + { + var target = Path.Combine(root.FullName, "target"); + var alias = Path.Combine(root.FullName, "alias"); + Directory.CreateDirectory(target); + Directory.CreateSymbolicLink(alias, target); + var approvalService = GrantEveryShellCandidate(); + var executor = CreateApprovalGatedShellExecutor( + approvalService, + safeVerbs: SafeVerbList.FromVerbs( + ApprovalShell.Bash, + ["head"])); + var call = new FunctionCallContent( + "call-causal-intent-symlink-fallback", + "shell_execute", + ToolInput.Create( + "Command", + $"cd {alias} && inspect; cd /tmp && collect; head result.log", + "WorkingDirectory", + "/work")); + + var decision = await executor.EvaluateAuthorizationAsync( + call, + CreateInteractivePersonalContext("signalr/causal-intent-symlink-fallback"), + TestContext.Current.CancellationToken); + + Assert.Equal(ToolAuthorizationOutcome.RequiresApproval, decision.Outcome); + Assert.True(Assert.IsType(decision.ApprovalContext).IsMessy); + Assert.Null(approvalService.LastRequest); + Assert.DoesNotContain( + decision.ShellPolicyTrace.Rows, + row => row.ScopeRelation == ShellScopeRelation.UnderIntentRoot); + } + finally + { + root.Delete(recursive: true); + } + } + + [SlopwatchSuppress("SW001", "This test requires native POSIX symbolic-link behavior.")] + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only symbolic-link semantics")] + public async Task Protected_path_denial_precedes_symlink_fallback_rejection() + { + var root = Directory.CreateTempSubdirectory("netclaw-causal-denied-fallback-"); + try + { + var denied = Path.Combine(root.FullName, "denied"); + var alias = Path.Combine(root.FullName, "alias"); + Directory.CreateDirectory(denied); + Directory.CreateSymbolicLink(alias, denied); + var approvalService = GrantEveryShellCandidate(); + var executor = CreateApprovalGatedShellExecutor( + approvalService, + safeVerbs: SafeVerbList.FromVerbs( + ApprovalShell.Bash, + ["head"]), + deniedPaths: [denied]); + var call = new FunctionCallContent( + "call-causal-intent-denied-fallback", + "shell_execute", + ToolInput.Create( + "Command", + "cd /tmp && inspect; head result.log", + "WorkingDirectory", + alias)); + + var decision = await executor.EvaluateAuthorizationAsync( + call, + CreateInteractivePersonalContext("signalr/causal-intent-denied-fallback"), + TestContext.Current.CancellationToken); + + Assert.Equal(ToolAuthorizationOutcome.Denied, decision.Outcome); + Assert.Equal("shell_references_protected_path", decision.DenyReason); + Assert.Null(approvalService.LastRequest); + Assert.Null(decision.ApprovalContext); + } + finally + { + root.Delete(recursive: true); + } + } + + [SlopwatchSuppress("SW001", "This test pins Bash causal approval intent on POSIX hosts.")] + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only shell directory semantics")] + public async Task Causal_intent_does_not_grant_reviewed_safe_authority_to_headless_runs() + { + var approvalService = GrantEveryShellCandidate(); + var executor = CreateApprovalGatedShellExecutor( + approvalService, + safeVerbs: SafeVerbList.FromVerbs( + ApprovalShell.Bash, + ["head"]), + shellTrustZonePolicy: new AllowAllShellTrustZonePolicy()); + var call = new FunctionCallContent( + "call-causal-intent-headless", + "shell_execute", + ToolInput.Create( + "Command", + "cd /tmp && inspect; head result.log", + "WorkingDirectory", + "/work")); + var context = TestToolExecutionContext.CreateBound( + "webhook/causal-intent-headless", + null, + new TestToolExecutionContextOptions + { + Audience = TrustAudience.Personal, + InteractiveApproval = TestToolExecutionContext.InteractiveApproval(false) + }); + + var decision = await executor.EvaluateAuthorizationAsync( + call, + context, + TestContext.Current.CancellationToken); + + Assert.Equal(ToolAuthorizationOutcome.Denied, decision.Outcome); + Assert.Equal("shell_unresolved_trust_zone_input", decision.DenyReason); + Assert.Null(approvalService.LastRequest); + Assert.DoesNotContain( + decision.ShellPolicyTrace.Rows, + row => row.ScopeRelation == ShellScopeRelation.UnderIntentRoot); + } + + [Fact] + public async Task Native_power_shell_directory_change_does_not_create_causal_approval_scope() + { + var environment = ShellExecutionEnvironment.CreatePowerShell( + "C:\\Program Files\\PowerShell\\7\\pwsh.exe", + PwshDialect.PowerShell7); + var approvalService = GrantEveryShellCandidate(); + var executor = CreateApprovalGatedShellExecutor( + environment, + approvalService, + safeVerbs: SafeVerbList.FromVerbs( + ApprovalShell.PowerShell, + ["Get-Content"])); + var call = new FunctionCallContent( + "call-native-power-shell-causal-scope", + "shell_execute", + ToolInput.Create( + "Command", + "Set-Location C:\\Temp; Get-Content result.log", + "WorkingDirectory", + "C:\\work")); + + var decision = await executor.EvaluateAuthorizationAsync( + call, + CreateInteractivePersonalContext("signalr/native-power-shell-causal-scope"), + TestContext.Current.CancellationToken); + + Assert.Equal(ToolAuthorizationOutcome.RequiresApproval, decision.Outcome); + Assert.True(Assert.IsType(decision.ApprovalContext).IsMessy); + Assert.Null(approvalService.LastRequest); + Assert.DoesNotContain( + decision.ShellPolicyTrace.Rows, + row => row.ScopeRelation == ShellScopeRelation.UnderIntentRoot); + } + [Fact] public async Task Authorization_trace_carries_persistent_grant_timestamp() { @@ -2326,7 +2959,25 @@ public async Task Background_job_control_does_not_contact_approval_service(bool private static DispatchingToolExecutor CreateApprovalGatedShellExecutor( IToolApprovalService? approvalService = null, - ILogger? logger = null) + ILogger? logger = null, + SafeVerbList? safeVerbs = null, + IEnumerable? deniedPaths = null, + IShellTrustZonePolicy? shellTrustZonePolicy = null) + => CreateApprovalGatedShellExecutor( + ShellExecutionEnvironmentDefaults.Bash, + approvalService, + logger, + safeVerbs, + deniedPaths, + shellTrustZonePolicy); + + private static DispatchingToolExecutor CreateApprovalGatedShellExecutor( + ShellExecutionEnvironment environment, + IToolApprovalService? approvalService = null, + ILogger? logger = null, + SafeVerbList? safeVerbs = null, + IEnumerable? deniedPaths = null, + IShellTrustZonePolicy? shellTrustZonePolicy = null) { var config = new ToolConfig { ShellMode = ShellExecutionMode.HostAllowed }; config.AudienceProfiles.Personal.ApprovalPolicy = new ToolApprovalConfig @@ -2336,12 +2987,14 @@ private static DispatchingToolExecutor CreateApprovalGatedShellExecutor( ["shell_execute"] = ToolApprovalMode.Approval } }; + var pathPolicy = new ToolPathPolicy(environment, deniedPaths ?? []); + var commandPolicy = new ShellCommandPolicy(environment); var registry = new ToolRegistry(); registry.WithFirstPartyTools( config, new NetclawPaths(), - new ToolPathPolicy([]), - new ShellCommandPolicy()); + pathPolicy, + commandPolicy); return new DispatchingToolExecutor( registry, new ToolAccessPolicy( @@ -2351,12 +3004,41 @@ private static DispatchingToolExecutor CreateApprovalGatedShellExecutor( TrustAudience.Personal, ShellExecutionMode.HostAllowed, UsedStrictFallback: false), - new ShellCommandPolicy(), - new ToolPathPolicy([])), + commandPolicy, + pathPolicy, + shellTrustZonePolicy: shellTrustZonePolicy, + safeVerbs: safeVerbs), approvalService ?? new UnexpectedApprovalService(), logger: logger); } + private static FixedShellApprovalService GrantEveryShellCandidate() + => new(request => + { + var matches = request.Candidates.Select(candidate => + { + var shell = Assert.IsType(candidate.Candidate.Shell); + var tokens = Assert.IsAssignableFrom>( + candidate.Candidate.VerbTokens); + var entry = ApprovalEntry.CreateTokenPrefix( + shell, + tokens, + directory: null, + createdAt: null); + return new ShellGrantCandidateMatch( + candidate.CandidateId, + new ToolApprovalMatch( + candidate.Candidate.Verb, + "persistent", + entry.FormatScope()), + ShellCoverageKind.PersistentGlobal, + NearMisses: []); + }).ToArray(); + return new ShellApprovalMatchResult( + new PersistentGrantStoreStatus.Ready(), + Array.AsReadOnly(matches)); + }); + private static ToolExecutionContext CreateInteractivePersonalContext(string sessionId) => TestToolExecutionContext.CreateBound( sessionId, @@ -2367,6 +3049,12 @@ private static ToolExecutionContext CreateInteractivePersonalContext(string sess InteractiveApproval = TestToolExecutionContext.InteractiveApproval(true) }); + private sealed class AllowAllShellTrustZonePolicy : IShellTrustZonePolicy + { + public bool IsShellWritePathAuthorized(string fullPath, ToolInvocationContext context) + => true; + } + public static bool IsPosix => !OperatingSystem.IsWindows(); private static ApprovalCandidate BashCandidate(string verb, string? directory = null) => diff --git a/src/Netclaw.Actors.Tests/Tools/PlatformTemporaryScopePolicyTests.cs b/src/Netclaw.Actors.Tests/Tools/PlatformTemporaryScopePolicyTests.cs index 5d6e150be..aa5f7d3e0 100644 --- a/src/Netclaw.Actors.Tests/Tools/PlatformTemporaryScopePolicyTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/PlatformTemporaryScopePolicyTests.cs @@ -54,13 +54,17 @@ public void Platform_temp_alias_maps_to_canonical_target() Assert.Equal("/private/tmp", correction.TemporaryRoot); } - [Fact] - public void Static_bash_temp_transition_returns_private_scratch_correction() + [Theory] + [InlineData("cd /tmp && gh api repos/example/project > /tmp/result.log")] + [InlineData("command cd /tmp && gh api repos/example/project > /tmp/result.log")] + [InlineData("builtin cd /tmp && gh api repos/example/project > /tmp/result.log")] + public void Parser_owned_bash_temp_transition_returns_private_scratch_correction( + string command) { var decision = Evaluate( BashEnvironment(), PosixTemp, - "cd /tmp && gh api repos/example/project > /tmp/result.log", + command, PosixSession); var context = Assert.IsType(decision.ApprovalContext); @@ -68,6 +72,62 @@ public void Static_bash_temp_transition_returns_private_scratch_correction() Assert.Null(context.SuggestedProjectDirectory); } + [Fact] + public void Additional_posix_temp_alias_maps_to_its_own_canonical_root() + { + const string runtimeTemp = "/var/folders/example/T"; + var decision = Evaluate( + BashEnvironment(), + runtimeTemp, + "cat /tmp/result.log", + PosixSession, + explicitWorkingDirectory: PosixTemp, + inspector: new MappedPathInspector( + new Dictionary(StringComparer.Ordinal) + { + [runtimeTemp] = runtimeTemp, + [PosixTemp] = "/private/tmp" + }), + additionalTemporaryRoots: [PosixTemp]); + + var context = Assert.IsType(decision.ApprovalContext); + var correction = Assert.IsType( + context.AgentCorrection); + Assert.Equal("/private/tmp", correction.TemporaryRoot); + } + + [Fact] + public void Additional_posix_temp_alias_maps_descendants_to_its_canonical_root() + { + const string runtimeTemp = "/var/folders/example/T"; + var inspector = new MappedPathInspector( + new Dictionary(StringComparer.Ordinal) + { + [runtimeTemp] = runtimeTemp, + [PosixTemp] = "/private/tmp" + }); + var policy = new PlatformTemporaryScopePolicy( + BashEnvironment(), + runtimeTemp, + inspector, + [PosixTemp]); + + Assert.True(policy.IsSafePlatformTemporaryPath("/tmp/work/result.log")); + Assert.True(policy.IsSafePlatformTemporaryPath("/private/tmp/work/result.log")); + Assert.False(policy.IsSafePlatformTemporaryPath("/var/external/result.log")); + } + + [Fact] + public void MacOS_factory_recognizes_the_conventional_posix_temp_alias() + { + if (!OperatingSystem.IsMacOS()) + return; + + var policy = PlatformTemporaryScopePolicy.Create(BashEnvironment()); + + Assert.True(policy.IsPlatformTemporaryRoot(PosixTemp)); + } + [Fact] public void Native_windows_explicit_temp_cwd_returns_private_scratch_correction() { @@ -366,7 +426,8 @@ private static ToolAccessDecision Evaluate( bool interactive = true, TrustAudience audience = TrustAudience.Personal, IPlatformTemporaryPathInspector? inspector = null, - IReadOnlyList? deniedPaths = null) + IReadOnlyList? deniedPaths = null, + IReadOnlyList? additionalTemporaryRoots = null) { var config = new ToolConfig { ShellMode = ShellExecutionMode.HostAllowed }; var profile = audience switch @@ -385,10 +446,14 @@ private static ToolAccessDecision Evaluate( }; var commandPolicy = new ShellCommandPolicy(environment); var pathPolicy = new ToolPathPolicy(environment, deniedPaths ?? []); - var tempPolicy = new PlatformTemporaryScopePolicy( - environment, - tempRoot, - inspector ?? new TestPathInspector()); + var pathInspector = inspector ?? new TestPathInspector(); + var tempPolicy = additionalTemporaryRoots is null + ? new PlatformTemporaryScopePolicy(environment, tempRoot, pathInspector) + : new PlatformTemporaryScopePolicy( + environment, + tempRoot, + pathInspector, + additionalTemporaryRoots); var policy = new ToolAccessPolicy( config, new EffectivePolicyDefaults( @@ -459,4 +524,24 @@ public bool IsSafeDescendant(string root, string path, ShellPathStyle pathStyle) public bool ContainsInvalidPathState(string path, ShellPathStyle pathStyle) => false; } + + private sealed class MappedPathInspector( + IReadOnlyDictionary roots) : IPlatformTemporaryPathInspector + { + public bool TryResolveRoot( + string path, + ShellPathStyle pathStyle, + out string resolvedRoot) + { + resolvedRoot = string.Empty; + return roots.TryGetValue(path, out var mapped) + && ShellPathRules.TryNormalize(mapped, pathStyle, out resolvedRoot); + } + + public bool IsSafeDescendant(string root, string path, ShellPathStyle pathStyle) + => ShellPathRules.IsWithinRoot(path, root, pathStyle); + + public bool ContainsInvalidPathState(string path, ShellPathStyle pathStyle) + => false; + } } diff --git a/src/Netclaw.Actors.Tests/Tools/ShellPolicyEvidenceFixtureTests.cs b/src/Netclaw.Actors.Tests/Tools/ShellPolicyEvidenceFixtureTests.cs index 0f811b75b..7b43368f5 100644 --- a/src/Netclaw.Actors.Tests/Tools/ShellPolicyEvidenceFixtureTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ShellPolicyEvidenceFixtureTests.cs @@ -90,6 +90,36 @@ private static void AssertProjectedCandidates( .AnalyzeInvocation(new ToolName(ShellTool.ToolName), arguments) .Candidates; + var hasCausalMetadata = policyCase.Candidates.Any(candidate => candidate.Role is not null); + if (hasCausalMetadata) + { + Assert.All(policyCase.Candidates, candidate => Assert.NotNull(candidate.Role)); + var analysis = new ShellCommandAnalyzer(environment).Analyze( + policyCase.Command, + policyCase.InitialWorkingDirectory); + AssertWorkingDirectoryEffects(policyCase, analysis); + Assert.True(BashCausalApprovalIntent.TryProject( + environment, + analysis, + new ShellApprovalMatcher(environment), + PlatformTemporaryScopePolicy.Create(environment).IsSafePlatformTemporaryPath, + out var causalCandidates)); + Assert.Equal(policyCase.Candidates.Count, causalCandidates.Count); + for (var index = 0; index < policyCase.Candidates.Count; index++) + { + var expected = policyCase.Candidates[index]; + var candidate = causalCandidates[index]; + Assert.Equal(index, expected.Id); + Assert.Equal(expected.Tokens, candidate.Candidate.VerbTokens); + Assert.Equal(expected.RealDirectory, candidate.Candidate.Directory); + Assert.Equal(expected.IntentDirectory, candidate.IntentDirectory); + Assert.Equal(expected.Role, candidate.Role.ToString()); + Assert.Equal(expected.PrerequisiteIds ?? [], candidate.PrerequisiteIndexes); + } + + return; + } + Assert.Equal(policyCase.Candidates.Count, actual.Count); for (var index = 0; index < policyCase.Candidates.Count; index++) { @@ -103,6 +133,36 @@ private static void AssertProjectedCandidates( } } + private static void AssertWorkingDirectoryEffects( + PolicyFixtureCase policyCase, + ShellCommandAnalysis analysis) + { + var expectedEffects = policyCase.ShellEffects?.WorkingDirectoryEffects ?? []; + Assert.NotEmpty(expectedEffects); + foreach (var expected in expectedEffects) + { + var effect = analysis.Commands[expected.CommandIndex].WorkingDirectoryEffect; + switch (expected.Kind) + { + case "Unchanged": + Assert.IsType(effect); + Assert.Empty(expected.Targets); + break; + case "ChangesOnSuccess": + var change = Assert.IsType< + ShellSyntaxTree.ShellWorkingDirectoryEffect.ChangesOnSuccess>(effect); + Assert.Equal( + Assert.Single(expected.Targets), + Assert.IsType(change.Target) + .Value); + break; + default: + throw new InvalidDataException( + $"Unsupported working-directory effect: {expected.Kind}."); + } + } + } + private static ShellApprovalInvocation CreateInvocation( PolicyFixtureDefaults defaults, PolicyFixtureCase policyCase) diff --git a/src/Netclaw.Actors/Tools/BashCausalApprovalIntent.cs b/src/Netclaw.Actors/Tools/BashCausalApprovalIntent.cs new file mode 100644 index 000000000..296a8e5b6 --- /dev/null +++ b/src/Netclaw.Actors/Tools/BashCausalApprovalIntent.cs @@ -0,0 +1,271 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Security; +using ShellSyntaxTree; + +namespace Netclaw.Actors.Tools; + +internal sealed record BashCausalApprovalCandidate( + ApprovalCandidate Candidate, + CommandOccurrence SourceOccurrence, + ShellPolicyCandidateRole Role, + string? IntentDirectory, + IReadOnlyList FallbackDirectories, + IReadOnlyList PrerequisiteIndexes); + +internal static class BashCausalApprovalIntent +{ + internal static bool TryProject( + ShellExecutionEnvironment environment, + ShellCommandAnalysis execution, + ShellApprovalMatcher matcher, + Func isAllowedHostPath, + out IReadOnlyList candidates) + { + ArgumentNullException.ThrowIfNull(isAllowedHostPath); + candidates = []; + if (environment.Grammar != ShellGrammar.Bash + || !execution.IsResolved + || execution.Commands.Count < 3 + || !TryGetTopLevelList(execution.Commands, out var list)) + { + return false; + } + + var projected = new List(); + var prerequisites = new List(); + if (!ShellPathRules.TryNormalize( + execution.WorkingDirectory, + ShellPathStyle.Posix, + out var initialDirectory)) + { + return false; + } + + var fallbackDirectories = new List { initialDirectory }; + string? intentDirectory = null; + var hasConsumer = false; + + for (var index = 0; index < execution.Commands.Count; index++) + { + var occurrence = execution.Commands[index]; + var item = list.Items[index]; + if (TryGetExactAbsoluteTarget(occurrence, out var target)) + { + var expectedOperator = index == 0 + ? CompoundOperator.None + : CompoundOperator.Sequence; + if (item.Operator != expectedOperator + || index + 1 >= execution.Commands.Count + || list.Items[index + 1].Operator != CompoundOperator.AndIf + || !TryGetPrerequisiteCandidates( + matcher, + occurrence, + execution.WorkingDirectory, + isAllowedHostPath, + out var transitionCandidates)) + { + return false; + } + + var firstAction = execution.Commands[++index]; + if (firstAction.WorkingDirectoryEffect is not + ShellWorkingDirectoryEffect.Unchanged + || !TryGetPrerequisiteCandidates( + matcher, + firstAction, + execution.WorkingDirectory, + isAllowedHostPath, + out var actionCandidates)) + { + return false; + } + + prerequisites.Clear(); + AppendPrerequisites(transitionCandidates, occurrence, projected, prerequisites); + AppendPrerequisites(actionCandidates, firstAction, projected, prerequisites); + if (intentDirectory is not null + && !fallbackDirectories.Contains(intentDirectory, StringComparer.Ordinal)) + { + fallbackDirectories.Add(intentDirectory); + } + + intentDirectory = target; + continue; + } + + if (intentDirectory is null + || item.Operator != CompoundOperator.Sequence + || occurrence.WorkingDirectoryEffect is not + ShellWorkingDirectoryEffect.Unchanged + || HasUnknownArgumentValue(occurrence) + || ShellRedirectPolicyFacts.HasFileWritingRedirect(occurrence)) + { + return false; + } + + var intentCandidates = matcher.ExtractCandidatesForOccurrence( + occurrence, + intentDirectory, + resolveUnknownPathsFromEffectiveValues: true, + isAllowedHostPath); + if (intentCandidates is not { Count: > 0 } + || intentCandidates.Any(candidate => + candidate.Directory is { } directory + && !IsWithinIntent(directory, intentDirectory))) + { + return false; + } + + foreach (var candidate in intentCandidates) + { + projected.Add(new BashCausalApprovalCandidate( + candidate with { Directory = null, SourceOccurrence = null }, + occurrence, + ShellPolicyCandidateRole.CausalIntentConsumer, + intentDirectory, + Array.AsReadOnly(fallbackDirectories.ToArray()), + Array.AsReadOnly(prerequisites.ToArray()))); + } + + hasConsumer = true; + } + + if (!hasConsumer) + return false; + + candidates = Array.AsReadOnly(projected.ToArray()); + return true; + } + + private static bool TryGetTopLevelList( + IReadOnlyList occurrences, + out CommandListSyntax list) + { + list = null!; + for (var index = 0; index < occurrences.Count; index++) + { + var occurrence = occurrences[index]; + if (!occurrence.IsComplete + || occurrence.ImmediateRole != CommandOccurrenceRole.Ordinary + || occurrence.Ancestry.Count != 2 + || occurrence.Ancestry[0] is not + { + Ancestor: ShellBlockSyntax, + Region: CommandAncestryRegion.Root, + ChildIndex: 0 + } + || occurrence.Ancestry[1] is not + { + Ancestor: CommandListSyntax currentList, + Region: CommandAncestryRegion.Statement, + ChildIndex: var childIndex + } + || childIndex != index + || index > 0 && !ReferenceEquals(list, currentList)) + { + return false; + } + + list = currentList; + } + + if (list.Items.Count != occurrences.Count) + return false; + + for (var index = 0; index < list.Items.Count; index++) + { + if (list.Items[index].Command is not SimpleCommandSyntax simple + || !ReferenceEquals(simple.Clause, occurrences[index].Clause) + || !Enum.IsDefined(list.Items[index].Operator)) + { + return false; + } + } + + return true; + } + + private static bool TryGetPrerequisiteCandidates( + ShellApprovalMatcher matcher, + CommandOccurrence occurrence, + string? executionWorkingDirectory, + Func isAllowedHostPath, + out IReadOnlyList candidates) + { + candidates = matcher.ExtractCandidatesForOccurrence( + occurrence, + executionWorkingDirectory, + resolveUnknownPathsFromEffectiveValues: false, + isAllowedHostPath) ?? []; + return candidates.Count > 0 + && !candidates.Any(ApprovalPatternMatching.IsPureSideEffect); + } + + private static void AppendPrerequisites( + IReadOnlyList candidates, + CommandOccurrence occurrence, + List projected, + List prerequisites) + { + foreach (var candidate in candidates) + { + prerequisites.Add(projected.Count); + projected.Add(new BashCausalApprovalCandidate( + candidate with { SourceOccurrence = null }, + occurrence, + ShellPolicyCandidateRole.CausalPrerequisite, + IntentDirectory: null, + FallbackDirectories: [], + PrerequisiteIndexes: [])); + } + + } + + private static bool TryGetExactAbsoluteTarget( + CommandOccurrence occurrence, + out string target) + { + target = string.Empty; + if (occurrence.WorkingDirectoryEffect is not + ShellWorkingDirectoryEffect.ChangesOnSuccess + { + Target: ShellValueDomain.Exact exact + } + || string.IsNullOrWhiteSpace(exact.Value) + || exact.Value[0] != '/') + { + return false; + } + + return ShellPathRules.TryNormalize( + exact.Value, + ShellPathStyle.Posix, + out target); + } + + private static bool HasUnknownArgumentValue(CommandOccurrence occurrence) => + occurrence.Arguments.Any(static argument => + !argument.Argument.IsPath + && argument.Value is ShellValueDomain.Unknown); + + private static bool IsWithinIntent(string path, string intentDirectory) + { + try + { + return PathUtility.IsWithinRoot(path, intentDirectory) + && !PathUtility.ContainsSymlinkSegment(intentDirectory, path); + } + catch (Exception ex) when (ex is ArgumentException + or IOException + or NotSupportedException + or UnauthorizedAccessException + or System.Security.SecurityException) + { + return false; + } + } +} diff --git a/src/Netclaw.Actors/Tools/PlatformTemporaryScopePolicy.cs b/src/Netclaw.Actors/Tools/PlatformTemporaryScopePolicy.cs index 7751f4613..5ffcd21df 100644 --- a/src/Netclaw.Actors/Tools/PlatformTemporaryScopePolicy.cs +++ b/src/Netclaw.Actors/Tools/PlatformTemporaryScopePolicy.cs @@ -43,38 +43,46 @@ internal sealed class PlatformTemporaryScopePolicy { private readonly ShellExecutionEnvironment _environment; private readonly IPlatformTemporaryPathInspector _pathInspector; - private readonly string? _authoredTemporaryRoot; + private readonly IReadOnlyList _temporaryRoots; internal PlatformTemporaryScopePolicy( ShellExecutionEnvironment environment, string platformTemporaryRoot, IPlatformTemporaryPathInspector pathInspector) + : this(environment, platformTemporaryRoot, pathInspector, []) + { + } + + internal PlatformTemporaryScopePolicy( + ShellExecutionEnvironment environment, + string platformTemporaryRoot, + IPlatformTemporaryPathInspector pathInspector, + IReadOnlyList additionalTemporaryRoots) { ArgumentNullException.ThrowIfNull(environment); ArgumentException.ThrowIfNullOrWhiteSpace(platformTemporaryRoot); ArgumentNullException.ThrowIfNull(pathInspector); + ArgumentNullException.ThrowIfNull(additionalTemporaryRoots); _environment = environment; _pathInspector = pathInspector; - _authoredTemporaryRoot = ShellPathRules.TryNormalize( - platformTemporaryRoot, - environment.PathStyle, - out var authoredRoot) - ? authoredRoot - : null; - TemporaryRoot = _authoredTemporaryRoot is not null - && pathInspector.TryResolveRoot( - platformTemporaryRoot, - environment.PathStyle, - out var resolvedRoot) - ? resolvedRoot - : null; + var roots = new List(); + AddTemporaryRoot(platformTemporaryRoot, roots); + foreach (var additionalRoot in additionalTemporaryRoots) + AddTemporaryRoot(additionalRoot, roots); + + _temporaryRoots = Array.AsReadOnly(roots.ToArray()); + TemporaryRoot = roots.Count > 0 ? roots[0].Canonical : null; } internal string? TemporaryRoot { get; } internal static PlatformTemporaryScopePolicy Create(ShellExecutionEnvironment environment) - => new(environment, Path.GetTempPath(), HostPlatformTemporaryPathInspector.Instance); + => new( + environment, + Path.GetTempPath(), + HostPlatformTemporaryPathInspector.Instance, + environment.PathStyle == ShellPathStyle.Posix ? ["/tmp"] : []); internal ToolAgentCorrection.SessionScratchSuggested? Evaluate( ShellCommandAnalysis analysis, @@ -82,14 +90,13 @@ internal static PlatformTemporaryScopePolicy Create(ShellExecutionEnvironment en IDictionary? arguments, ToolInvocationContext context) { - if (TemporaryRoot is null - || !analysis.IsResolved + if (!analysis.IsResolved || analysis.HasDynamicSyntax || context.RunScope.InteractiveApproval is not InteractiveApprovalCapability.Available || context.Audience != TrustAudience.Personal || !TryNormalizeSessionDirectory(context.SessionDirectory, out var sessionDirectory) - || !HasExplicitTemporaryIntent(analysis, arguments) - || !AllScopesStayWithinTemporaryRoot(analysis, candidates)) + || !TryGetExplicitTemporaryRoot(analysis, arguments, out var temporaryRoot) + || !AllScopesStayWithinTemporaryRoot(analysis, candidates, temporaryRoot)) { return null; } @@ -99,54 +106,82 @@ internal static PlatformTemporaryScopePolicy Create(ShellExecutionEnvironment en : ApprovalShell.PowerShell; return new ToolAgentCorrection.SessionScratchSuggested( sessionDirectory, - TemporaryRoot, + temporaryRoot.Canonical, shell); } internal bool IsPlatformTemporaryRoot(string? path) - => TemporaryRoot is not null - && TryNormalizePath(path, out var normalized) - && (PathEquals(normalized, TemporaryRoot) - || _authoredTemporaryRoot is not null - && PathEquals(normalized, _authoredTemporaryRoot)); + => TryGetTemporaryRoot(path, out _); - private bool HasExplicitTemporaryIntent( + internal bool IsSafePlatformTemporaryPath(string? path) + { + if (!TryNormalizePath(path, out var normalized)) + return false; + + foreach (var root in _temporaryRoots) + { + if ((IsWithinRoot(normalized, root.Authored) + || IsWithinRoot(normalized, root.Canonical)) + && IsSafeTemporaryPath(normalized, root)) + { + return true; + } + } + + return false; + } + + private bool TryGetExplicitTemporaryRoot( ShellCommandAnalysis analysis, - IDictionary? arguments) + IDictionary? arguments, + out PlatformTemporaryRoot temporaryRoot) { var explicitDirectory = ToolArgumentHelper.GetString(arguments, "WorkingDirectory"); if (!string.IsNullOrWhiteSpace(explicitDirectory)) - return IsPlatformTemporaryRoot(explicitDirectory); + return TryGetTemporaryRoot(explicitDirectory, out temporaryRoot); if (_environment.Grammar != ShellGrammar.Bash) + { + temporaryRoot = default; return false; + } + + foreach (var command in analysis.Commands) + { + if (command.WorkingDirectoryEffect is + ShellWorkingDirectoryEffect.ChangesOnSuccess + { + Target: ShellValueDomain.Exact exact + } + && TryGetTemporaryRoot(exact.Value, out temporaryRoot)) + { + return true; + } + } - return analysis.Commands.Any(command => - command.Clause.Args.Any(argument => - argument.IsCwdAttribution - && IsPlatformTemporaryRoot(argument.Resolved)) - && command.WorkingDirectory is ShellValueDomain.Exact exact - && IsPlatformTemporaryRoot(exact.Value)); + temporaryRoot = default; + return false; } private bool AllScopesStayWithinTemporaryRoot( ShellCommandAnalysis analysis, - IReadOnlyList candidates) + IReadOnlyList candidates, + PlatformTemporaryRoot temporaryRoot) { foreach (var candidate in candidates) { if (candidate.Directory is null) continue; - if (!IsSafeTemporaryPath(candidate.Directory)) + if (!IsSafeTemporaryPath(candidate.Directory, temporaryRoot)) return false; } foreach (var command in analysis.Commands) { - if (!IsBashDirectoryTransition(command) + if (!HasSafeDirectoryTransitionEffect(command, temporaryRoot) && (command.WorkingDirectory is not ShellValueDomain.Exact workingDirectory - || !IsSafeTemporaryPath(workingDirectory.Value))) + || !IsSafeTemporaryPath(workingDirectory.Value, temporaryRoot))) { return false; } @@ -157,7 +192,7 @@ private bool AllScopesStayWithinTemporaryRoot( continue; if (string.IsNullOrWhiteSpace(argument.Resolved) - || !IsSafeTemporaryPath(argument.Resolved)) + || !IsSafeTemporaryPath(argument.Resolved, temporaryRoot)) { return false; } @@ -167,7 +202,8 @@ private bool AllScopesStayWithinTemporaryRoot( { var safe = redirect switch { - FileRedirectAnalysis file => HasSafeRedirectTarget(file.Target), + FileRedirectAnalysis file => + HasSafeRedirectTarget(file.Target, temporaryRoot), DescriptorDuplicateRedirectAnalysis => true, DescriptorMoveRedirectAnalysis => true, DescriptorCloseRedirectAnalysis => true, @@ -185,65 +221,116 @@ private bool AllScopesStayWithinTemporaryRoot( return true; } - private bool IsBashDirectoryTransition(CommandOccurrence command) + private bool HasSafeDirectoryTransitionEffect( + CommandOccurrence command, + PlatformTemporaryRoot temporaryRoot) => _environment.Grammar == ShellGrammar.Bash - && command.Clause.Verb.Tokens.Count > 0 - && command.Clause.Verb.Tokens[0] is "cd" or "chdir"; - - private bool HasSafeRedirectTarget(ShellValueDomain target) + && command.WorkingDirectoryEffect is + ShellWorkingDirectoryEffect.ChangesOnSuccess + { + Target: ShellValueDomain.Exact exact + } + && IsSafeTemporaryPath(exact.Value, temporaryRoot); + + private bool HasSafeRedirectTarget( + ShellValueDomain target, + PlatformTemporaryRoot temporaryRoot) => target switch { - ShellValueDomain.Exact exact => IsSafeTemporaryPath(exact.Value), + ShellValueDomain.Exact exact => + IsSafeTemporaryPath(exact.Value, temporaryRoot), ShellValueDomain.FiniteSet finite => - finite.Values.Count > 0 && finite.Values.All(IsSafeTemporaryPath), + finite.Values.Count > 0 + && finite.Values.All(path => IsSafeTemporaryPath(path, temporaryRoot)), ShellValueDomain.PathPattern pattern => - IsSafeTemporaryPath(pattern.CoveringDirectory), + IsSafeTemporaryPath(pattern.CoveringDirectory, temporaryRoot), _ => false }; - private bool IsSafeTemporaryPath(string path) + private bool IsSafeTemporaryPath( + string path, + PlatformTemporaryRoot temporaryRoot) { - if (TemporaryRoot is null - || !TryNormalizePath(path, out var normalized) - || !TryMapToCanonicalTemporaryPath(normalized, out var canonicalPath)) + if (!TryNormalizePath(path, out var normalized) + || !TryMapToCanonicalTemporaryPath( + normalized, + temporaryRoot, + out var canonicalPath)) { return false; } return _pathInspector.IsSafeDescendant( - TemporaryRoot, + temporaryRoot.Canonical, canonicalPath, _environment.PathStyle); } - private bool TryMapToCanonicalTemporaryPath(string path, out string canonicalPath) + private bool TryMapToCanonicalTemporaryPath( + string path, + PlatformTemporaryRoot temporaryRoot, + out string canonicalPath) { canonicalPath = string.Empty; - if (TemporaryRoot is null) - return false; - - if (IsWithinRoot(path, TemporaryRoot)) + if (IsWithinRoot(path, temporaryRoot.Canonical)) { canonicalPath = path; return true; } - if (_authoredTemporaryRoot is null - || !IsWithinRoot(path, _authoredTemporaryRoot)) + if (!IsWithinRoot(path, temporaryRoot.Authored)) { return false; } - var relative = path[_authoredTemporaryRoot.Length..] + var relative = path[temporaryRoot.Authored.Length..] .TrimStart('/', '\\'); canonicalPath = relative.Length == 0 - ? TemporaryRoot - : TemporaryRoot.TrimEnd('/', '\\') + + ? temporaryRoot.Canonical + : temporaryRoot.Canonical.TrimEnd('/', '\\') + (_environment.PathStyle == ShellPathStyle.Windows ? '\\' : '/') + relative; return true; } + private void AddTemporaryRoot( + string path, + ICollection roots) + { + if (!ShellPathRules.TryNormalize(path, _environment.PathStyle, out var authored) + || !_pathInspector.TryResolveRoot( + path, + _environment.PathStyle, + out var canonical) + || roots.Any(root => PathEquals(root.Authored, authored))) + { + return; + } + + roots.Add(new PlatformTemporaryRoot(authored, canonical)); + } + + private bool TryGetTemporaryRoot( + string? path, + out PlatformTemporaryRoot temporaryRoot) + { + temporaryRoot = default; + if (!TryNormalizePath(path, out var normalized)) + return false; + + foreach (var root in _temporaryRoots) + { + if (PathEquals(normalized, root.Authored) + || PathEquals(normalized, root.Canonical)) + { + temporaryRoot = root; + return true; + } + } + + return false; + } + private bool TryNormalizeSessionDirectory(string? path, out string normalized) { if (!TryNormalizePath(path, out normalized)) @@ -260,6 +347,10 @@ private bool IsWithinRoot(string candidate, string root) private bool PathEquals(string left, string right) => ShellPathRules.Equals(left, right, _environment.PathStyle); + + private readonly record struct PlatformTemporaryRoot( + string Authored, + string Canonical); } internal interface IPlatformTemporaryPathInspector diff --git a/src/Netclaw.Actors/Tools/ScopedShellSafeVerbPolicy.cs b/src/Netclaw.Actors/Tools/ScopedShellSafeVerbPolicy.cs index a999b73fe..3ce126db2 100644 --- a/src/Netclaw.Actors/Tools/ScopedShellSafeVerbPolicy.cs +++ b/src/Netclaw.Actors/Tools/ScopedShellSafeVerbPolicy.cs @@ -127,7 +127,8 @@ public bool CanShortCircuitAfterProjectDeclaration( private bool IsReviewedDiagnostic( ApprovalCandidate candidate, CommandOccurrence? sourceOccurrence, - IReadOnlyList safeRoots) + IReadOnlyList safeRoots, + string? workingDirectoryOverride = null) { if (candidate is not { @@ -152,7 +153,38 @@ private bool IsReviewedDiagnostic( return AllPossibleAuthoredPathsStayWithinRoots( sourceOccurrence, shell, - safeRoots); + safeRoots, + workingDirectoryOverride); + } + + internal bool ShortCircuitsCausalIntent( + ApprovalCandidate candidate, + CommandOccurrence? sourceOccurrence, + string intentDirectory, + ToolInvocationContext context) + { + if (context.Audience != TrustAudience.Personal + || candidate is not + { + Shell: ApprovalShell.Bash, + VerbTokens: { } + } + || sourceOccurrence is null + || string.IsNullOrWhiteSpace(intentDirectory) + || ShellRedirectPolicyFacts.HasFileWritingRedirect(sourceOccurrence) + || !IsSafePath(intentDirectory, intentDirectory) + || !IsReviewedDiagnostic( + candidate, + sourceOccurrence, + [intentDirectory], + intentDirectory)) + { + return false; + } + + return AllEffectivePathsStayWithinIntent( + sourceOccurrence, + intentDirectory); } internal bool ShortCircuits( @@ -188,11 +220,13 @@ or NotSupportedException private static bool AllPossibleAuthoredPathsStayWithinRoots( CommandOccurrence occurrence, ApprovalShell shell, - IReadOnlyList safeRoots) + IReadOnlyList safeRoots, + string? workingDirectoryOverride) { - var workingDirectory = occurrence.WorkingDirectory is ShellValueDomain.Exact exact - ? exact.Value - : null; + var workingDirectory = workingDirectoryOverride + ?? (occurrence.WorkingDirectory is ShellValueDomain.Exact exact + ? exact.Value + : null); var pathStyle = shell == ApprovalShell.Bash ? ShellPathStyle.Posix : ShellPathStyle.Windows; @@ -235,6 +269,58 @@ private static bool AllPossibleAuthoredPathsStayWithinRoots( return true; } + private static bool AllEffectivePathsStayWithinIntent( + CommandOccurrence occurrence, + string intentDirectory) + { + foreach (var argument in occurrence.Arguments.Where(static argument => + argument.Argument.IsPath)) + { + IReadOnlyList values = argument.Value switch + { + ShellValueDomain.Exact exact => [exact.Value], + ShellValueDomain.FiniteSet finite => finite.Values, + _ => [] + }; + if (values.Count == 0) + return false; + + foreach (var value in values) + { + var resolved = ShellTokenizer.NormalizePathToken( + value, + intentDirectory, + ShellPathStyle.Posix); + if (string.IsNullOrWhiteSpace(resolved) + || !IsSafePath(resolved, intentDirectory)) + { + return false; + } + } + } + + foreach (var redirect in occurrence.Redirects.OfType()) + { + if (redirect.Mode != FileRedirectMode.Input + || redirect.Target is not ShellValueDomain.Exact exact) + { + return false; + } + + var resolved = ShellTokenizer.NormalizePathToken( + exact.Value, + intentDirectory, + ShellPathStyle.Posix); + if (string.IsNullOrWhiteSpace(resolved) + || !IsSafePath(resolved, intentDirectory)) + { + return false; + } + } + + return true; + } + private static bool IsSafePath(string path, string root) { try diff --git a/src/Netclaw.Actors/Tools/ShellPolicyCoordinator.cs b/src/Netclaw.Actors/Tools/ShellPolicyCoordinator.cs index ea81eab96..4526c9ac4 100644 --- a/src/Netclaw.Actors/Tools/ShellPolicyCoordinator.cs +++ b/src/Netclaw.Actors/Tools/ShellPolicyCoordinator.cs @@ -56,9 +56,11 @@ private async Task EvaluateCoreAsync( if (approvalContext is null || !ShellPolicyProjection.TryCreate( policy.ShellEnvironment, + policy.ShellApprovalMatcher, execution, approvalContext, context, + policy.IsSafePlatformTemporaryPath, out var projection) || projection is null) { @@ -84,7 +86,7 @@ private async Task CompleteAsync( ShellPolicyDecisionTraceBuilder trace, CancellationToken cancellationToken) { - if (projection.ApprovalContext.IsMessy) + if (projection.ApprovalContext.IsMessy && !projection.HasCausalIntent) return CompleteOneTimeOrPrompt(toolCall.Name, projection, projection.ApprovalContext, [], trace); if (projection.Candidates.Count == 0) @@ -110,9 +112,39 @@ private async Task CompleteAsync( trace); } + if (projection.Candidates.Any(candidate => + candidate.Role == ShellPolicyCandidateRole.CausalIntentConsumer + && candidate.IntentDirectory is { } intentDirectory + && candidate.SourceOccurrence is { } sourceOccurrence + && policy.CausalIntentReferencesProtectedPath( + sourceOccurrence, + intentDirectory, + candidate.IntentFallbackDirectories))) + { + return CompleteWithTrace( + ToolAuthorizationDecision.Deny("shell_references_protected_path"), + trace); + } + + if (projection.Candidates.Any(candidate => + candidate.Role == ShellPolicyCandidateRole.CausalIntentConsumer + && candidate.IntentDirectory is { } intentDirectory + && !policy.AreCausalIntentDirectoriesEligible( + intentDirectory, + candidate.IntentFallbackDirectories))) + { + return CompleteOneTimeOrPrompt( + toolCall.Name, + projection, + projection.ApprovalContext, + [], + trace); + } + var coverage = new ShellCoverageSet(projection.Candidates); foreach (var candidate in projection.Candidates.Where(item => approvalService is not null + && item.Role == ShellPolicyCandidateRole.Ordinary && ApprovalPatternMatching.IsPureSideEffect(item.Candidate))) { coverage.Cover( @@ -148,6 +180,7 @@ approvalService is not null foreach (var candidate in projection.Candidates.Where(item => approvalService is not null + && item.Role == ShellPolicyCandidateRole.Ordinary && ApprovalPatternMatching.IsPureSideEffect(item.Candidate))) { trace.AddCoverage( @@ -162,6 +195,7 @@ approvalService is not null projection.RunScope.InteractiveApproval is InteractiveApprovalCapability.Available; foreach (var candidate in grantCandidates.Where(candidate => canUseReviewedSafePolicy + && candidate.CanUseRealReviewedSafePolicy && coverage.UncoveredIds.Contains(candidate.Id))) { if (policy.IsReviewedSafeCandidate( @@ -183,14 +217,46 @@ approvalService is not null } } + foreach (var candidate in projection.Candidates.Where(candidate => + canUseReviewedSafePolicy + && candidate.Role == ShellPolicyCandidateRole.CausalIntentConsumer + && coverage.UncoveredIds.Contains(candidate.Id))) + { + if (candidate.IntentDirectory is null + || candidate.IntentPrerequisites.Count == 0 + || candidate.IntentPrerequisites.Any(prerequisite => + !coverage.IsCovered(prerequisite)) + || !policy.IsReviewedSafeIntentCandidate( + candidate.Candidate, + candidate.SourceOccurrence, + candidate.IntentDirectory, + context.Invocation)) + { + continue; + } + + coverage.Cover( + candidate.Id, + ShellCoverageKind.ReviewedSafePolicy, + ShellPolicyReason.ReviewedSafePhrase); + trace.AddCoverage( + ShellPolicyTraceStage.ReviewedSafePolicy, + candidate, + ShellCoverageKind.ReviewedSafePolicy, + ShellPolicyReason.ReviewedSafePhrase, + ShellScopeRelation.UnderIntentRoot); + } + var uncovered = GetUncoveredCandidates(projection, coverage); var oneTimeApplied = false; if (uncovered.Count > 0) { - var remainingContext = ToolAccessPolicy.NarrowShellApprovalContext( - projection.ApprovalContext, - uncovered.Select(static candidate => candidate.Candidate).ToArray(), - context.SessionDirectory); + var remainingContext = projection.HasCausalIntent + ? projection.ApprovalContext + : ToolAccessPolicy.NarrowShellApprovalContext( + projection.ApprovalContext, + uncovered.Select(static candidate => candidate.Candidate).ToArray(), + context.SessionDirectory); if (projection.HasExactOneTimeApproval(toolCall.Name, remainingContext)) { foreach (var candidate in uncovered) @@ -222,10 +288,12 @@ approvalService is not null if (uncovered.Count > 0) { - var promptContext = ToolAccessPolicy.NarrowShellApprovalContext( - projection.ApprovalContext, - uncovered.Select(static candidate => candidate.Candidate).ToArray(), - context.SessionDirectory); + var promptContext = projection.HasCausalIntent + ? projection.ApprovalContext + : ToolAccessPolicy.NarrowShellApprovalContext( + projection.ApprovalContext, + uncovered.Select(static candidate => candidate.Candidate).ToArray(), + context.SessionDirectory); return CompleteWithTrace( ToolAuthorizationDecision.RequiresApproval(promptContext, approvalMatches), trace); diff --git a/src/Netclaw.Actors/Tools/ShellPolicyDecisionTrace.cs b/src/Netclaw.Actors/Tools/ShellPolicyDecisionTrace.cs index 398d9f4a9..d5fce103b 100644 --- a/src/Netclaw.Actors/Tools/ShellPolicyDecisionTrace.cs +++ b/src/Netclaw.Actors/Tools/ShellPolicyDecisionTrace.cs @@ -64,6 +64,7 @@ internal enum ShellScopeRelation UnderRealRoot = 4, OutsideGrantRoot = 5, SymlinkBoundary = 6, + UnderIntentRoot = 7, } internal sealed record ShellPolicyTraceRow( diff --git a/src/Netclaw.Actors/Tools/ShellPolicyProjection.cs b/src/Netclaw.Actors/Tools/ShellPolicyProjection.cs index 9ac104040..1a78baf08 100644 --- a/src/Netclaw.Actors/Tools/ShellPolicyProjection.cs +++ b/src/Netclaw.Actors/Tools/ShellPolicyProjection.cs @@ -43,10 +43,30 @@ internal ShellPolicyCandidateId(int value) internal int Value { get; } } +internal enum ShellPolicyCandidateRole +{ + Ordinary = 0, + CausalPrerequisite = 1, + CausalIntentConsumer = 2, +} + internal sealed record ShellPolicyCandidate( ShellPolicyCandidateId Id, ApprovalCandidate Candidate, - ShellSyntaxTree.CommandOccurrence? SourceOccurrence); + ShellSyntaxTree.CommandOccurrence? SourceOccurrence) +{ + internal ShellPolicyCandidateRole Role { get; init; } + + internal string? IntentDirectory { get; init; } + + internal IReadOnlyList IntentFallbackDirectories { get; init; } = []; + + internal IReadOnlyList IntentPrerequisites { get; init; } = []; + + internal bool CanMatchStoredGrant => Role != ShellPolicyCandidateRole.CausalIntentConsumer; + + internal bool CanUseRealReviewedSafePolicy => Role == ShellPolicyCandidateRole.Ordinary; +} internal sealed record ShellCandidateCoverage( ShellPolicyCandidateId CandidateId, @@ -93,9 +113,14 @@ private ShellPolicyProjection( internal IReadOnlyList GrantCandidates => Candidates .Where(static candidate => + candidate.CanMatchStoredGrant + && !ApprovalPatternMatching.IsPureSideEffect(candidate.Candidate)) .ToArray(); + internal bool HasCausalIntent => Candidates.Any(static candidate => + candidate.Role != ShellPolicyCandidateRole.Ordinary); + internal bool HasExactOneTimeApproval( string toolName, ToolApprovalContext approvalContext) @@ -111,19 +136,42 @@ internal bool HasExactOneTimeApproval( internal static bool TryCreate( ShellExecutionEnvironment environment, + ShellApprovalMatcher matcher, ShellCommandAnalysis? execution, ToolApprovalContext approvalContext, ToolExecutionContext context, + Func isAllowedHostPath, out ShellPolicyProjection? projection) { ArgumentNullException.ThrowIfNull(environment); + ArgumentNullException.ThrowIfNull(matcher); ArgumentNullException.ThrowIfNull(approvalContext); ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(isAllowedHostPath); projection = null; if (approvalContext.Candidates is null) return false; + if (approvalContext.IsMessy + && approvalContext.Candidates.Count == 0 + && execution is not null + && BashCausalApprovalIntent.TryProject( + environment, + execution, + matcher, + isAllowedHostPath, + out var causalCandidates)) + { + return TryCreateCausal( + environment, + execution, + approvalContext, + context, + causalCandidates, + out projection); + } + var candidates = new ShellPolicyCandidate[approvalContext.Candidates.Count]; var candidateCopies = new ApprovalCandidate[approvalContext.Candidates.Count]; for (var index = 0; index < approvalContext.Candidates.Count; index++) @@ -167,6 +215,68 @@ internal static bool TryCreate( context.Approval.OneTimeApprovedToolName); return true; } + + private static bool TryCreateCausal( + ShellExecutionEnvironment environment, + ShellCommandAnalysis execution, + ToolApprovalContext approvalContext, + ToolExecutionContext context, + IReadOnlyList causalCandidates, + out ShellPolicyProjection? projection) + { + projection = null; + var candidates = new ShellPolicyCandidate[causalCandidates.Count]; + for (var index = 0; index < causalCandidates.Count; index++) + { + var source = causalCandidates[index]; + if (source.PrerequisiteIndexes.Any(prerequisite => + prerequisite < 0 || prerequisite >= causalCandidates.Count)) + { + return false; + } + + var candidateCopy = source.Candidate with + { + VerbTokens = source.Candidate.VerbTokens is null + ? null + : Array.AsReadOnly(source.Candidate.VerbTokens.ToArray()), + SourceOccurrence = null + }; + candidates[index] = new ShellPolicyCandidate( + new ShellPolicyCandidateId(index), + candidateCopy, + source.SourceOccurrence) + { + Role = source.Role, + IntentDirectory = source.IntentDirectory, + IntentFallbackDirectories = Array.AsReadOnly(source.FallbackDirectories.ToArray()), + IntentPrerequisites = Array.AsReadOnly(source.PrerequisiteIndexes + .Select(static prerequisite => new ShellPolicyCandidateId(prerequisite)) + .ToArray()) + }; + } + + var contextCopy = approvalContext with + { + Patterns = Array.AsReadOnly(approvalContext.Patterns.ToArray()), + CandidateVerbs = Array.AsReadOnly(approvalContext.CandidateVerbs.ToArray()), + Options = Array.AsReadOnly(approvalContext.Options.ToArray()), + Candidates = Array.AsReadOnly(approvalContext.Candidates!.ToArray()) + }; + var runScopeCopy = context.RunScope with + { + RecentFiles = Array.AsReadOnly(context.RunScope.RecentFiles.ToArray()) + }; + projection = new ShellPolicyProjection( + environment, + execution, + runScopeCopy, + contextCopy, + Array.AsReadOnly(candidates), + context.Approval.OneTimeApprovedPatterns.ToFrozenSet(StringComparer.OrdinalIgnoreCase), + context.Approval.OneTimeApprovedToolName); + return true; + } } internal sealed class ShellCoverageSet @@ -198,6 +308,10 @@ internal ShellCoverageSet(IReadOnlyList candidates) internal bool AllCovered => _coverage.Values.All(static item => item.Kind is not ShellCoverageKind.Uncovered and not ShellCoverageKind.Denied); + internal bool IsCovered(ShellPolicyCandidateId candidateId) => + _coverage.TryGetValue(candidateId, out var coverage) + && coverage.Kind is not ShellCoverageKind.Uncovered and not ShellCoverageKind.Denied; + internal void Cover( ShellPolicyCandidateId candidateId, ShellCoverageKind kind, diff --git a/src/Netclaw.Actors/Tools/ShellRedirectPolicyFacts.cs b/src/Netclaw.Actors/Tools/ShellRedirectPolicyFacts.cs new file mode 100644 index 000000000..3217d8cbe --- /dev/null +++ b/src/Netclaw.Actors/Tools/ShellRedirectPolicyFacts.cs @@ -0,0 +1,20 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using ShellSyntaxTree; + +namespace Netclaw.Actors.Tools; + +internal static class ShellRedirectPolicyFacts +{ + internal static bool HasFileWritingRedirect(CommandOccurrence occurrence) => + occurrence.Redirects.Any(static redirect => redirect is FileRedirectAnalysis + { + Mode: FileRedirectMode.Output + or FileRedirectMode.Append + or FileRedirectMode.CombinedOutput + or FileRedirectMode.CombinedOutputAppend + }); +} diff --git a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs index fc880468e..226536254 100644 --- a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs +++ b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs @@ -45,6 +45,9 @@ private readonly ConditionalWeakTable _shellCommandPolicy.Environment; + internal bool IsSafePlatformTemporaryPath(string path) + => _platformTemporaryScopePolicy.IsSafePlatformTemporaryPath(path); + public ToolAccessPolicy( ToolConfig toolConfig, EffectivePolicyDefaults defaults, @@ -311,6 +314,68 @@ internal bool IsReviewedSafeCandidate( cwd, context); + internal bool IsReviewedSafeIntentCandidate( + ApprovalCandidate candidate, + ShellSyntaxTree.CommandOccurrence? sourceOccurrence, + string intentDirectory, + ToolInvocationContext context) + => _safeVerbPolicy is not null + && _safeVerbPolicy.ShortCircuitsCausalIntent( + candidate, + sourceOccurrence, + intentDirectory, + context); + + internal bool CausalIntentReferencesProtectedPath( + ShellSyntaxTree.CommandOccurrence sourceOccurrence, + string intentDirectory, + IReadOnlyList fallbackDirectories) + => _toolPathPolicy.CausalIntentReferencesDeniedPath( + sourceOccurrence, + intentDirectory, + fallbackDirectories); + + internal bool IsCausalIntentDirectoryEligible(string intentDirectory) + { + if (ShellEnvironment.Grammar != ShellGrammar.Bash + || !ShellPathRules.TryNormalize( + intentDirectory, + ShellEnvironment.PathStyle, + out var normalized) + || !ShellPathRules.Equals( + normalized, + intentDirectory, + ShellEnvironment.PathStyle)) + { + return false; + } + + if (_platformTemporaryScopePolicy.IsSafePlatformTemporaryPath(normalized)) + return true; + + try + { + return !PathUtility.ContainsSymlinkSegment("/", normalized); + } + catch (Exception ex) when (ex is ArgumentException + or IOException + or NotSupportedException + or UnauthorizedAccessException + or System.Security.SecurityException) + { + return false; + } + } + + internal bool AreCausalIntentDirectoriesEligible( + string intentDirectory, + IReadOnlyList fallbackDirectories) + => fallbackDirectories.Count > 0 + && IsCausalIntentDirectoryEligible(intentDirectory) + && fallbackDirectories.All(IsCausalIntentDirectoryEligible); + + internal ShellApprovalMatcher ShellApprovalMatcher => _shellApprovalMatcher; + /// /// For non-interactive channels, validates that the working directory and all /// path-like arguments in a shell command are write-authorized for the channel's diff --git a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs index fb5ad2c6d..a635250fb 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs @@ -1591,6 +1591,31 @@ public void ExtractCandidates_tracks_latest_cd_through_multiple_hops() Assert.Contains(candidates, c => c.Verb == "pwd" && c.Directory == "/b"); } + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] + public void Occurrence_extraction_rebases_unknown_path_only_for_explicit_intent_scope() + { + var environment = ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux); + var analysis = new ShellCommandAnalyzer(environment).Analyze( + "cd /tmp && inspect; head result.log", + "/work"); + var occurrence = Assert.Single( + analysis.Commands, + command => command.Clause.Verb.Tokens is ["head"]); + var matcher = new ShellApprovalMatcher(environment); + + Assert.Null(matcher.ExtractCandidatesForOccurrence( + occurrence, + "/tmp", + resolveUnknownPathsFromEffectiveValues: false)); + + var candidate = Assert.Single(matcher.ExtractCandidatesForOccurrence( + occurrence, + "/tmp", + resolveUnknownPathsFromEffectiveValues: true)!); + Assert.Equal("head", candidate.Verb); + Assert.Equal("/tmp", candidate.Directory); + } + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")] public void ExtractCandidates_recurses_into_bash_dash_c_with_cd_attribution_intact() { diff --git a/src/Netclaw.Security.Tests/ShellPolicyEvidenceModels.cs b/src/Netclaw.Security.Tests/ShellPolicyEvidenceModels.cs index b10112954..780fd601f 100644 --- a/src/Netclaw.Security.Tests/ShellPolicyEvidenceModels.cs +++ b/src/Netclaw.Security.Tests/ShellPolicyEvidenceModels.cs @@ -125,6 +125,10 @@ internal sealed record PolicyFixtureCandidate public string? IntentDirectory { get; init; } + public string? Role { get; init; } + + public List? PrerequisiteIds { get; init; } + public required string ExpectedCoverage { get; init; } } @@ -170,6 +174,17 @@ internal sealed record PolicyAuthoredPathFact internal sealed record PolicyShellEffects { public required List Redirects { get; init; } + + public List? WorkingDirectoryEffects { get; init; } +} + +internal sealed record PolicyWorkingDirectoryEffect +{ + public required int CommandIndex { get; init; } + + public required string Kind { get; init; } + + public required List Targets { get; init; } } internal sealed record PolicyRedirect diff --git a/src/Netclaw.Security/IToolApprovalMatcher.cs b/src/Netclaw.Security/IToolApprovalMatcher.cs index 5372365a4..7cd4cbb66 100644 --- a/src/Netclaw.Security/IToolApprovalMatcher.cs +++ b/src/Netclaw.Security/IToolApprovalMatcher.cs @@ -242,53 +242,68 @@ private IReadOnlyList ExtractCandidatesViaAnalysis( foreach (var occurrence in result.Commands) { - var clause = occurrence.Clause; - // ShellSyntaxTree's greedy verb walk (SPEC §6.1) folds - // lowercase-leading value tokens into the verb chain (`git tag - // v0.4.2`, `git show aa211dcb`, `git checkout feature2`), while - // digit-leading ones (`0.4.2`) stop the walk and land in Args - // (verb stays `git tag`). Both are call-specific values, not - // approvable intent, so strip them off the chain before gating — - // otherwise `git tag v0.4.2` would miss a `git tag` grant that - // `git tag 0.4.2` matches. Mirrors the value-termination in - // ReconstructClauseText so the gate candidate and the persisted - // pattern normalize identically. - if (clause.Verb.IsDynamic) - continue; - - var parsedVerb = clause.Verb.CanonicalVerb - ?? string.Join(" ", TrimTrailingValueTokens(clause.Verb.Tokens)); - var verb = ShellTokenizer.ApplyVerbShortCircuit(parsedVerb); - if (string.IsNullOrEmpty(verb)) - continue; - - var isSideEffectVerb = ShellTokenizer.SingleTokenSideEffectVerbs.Contains(verb); - var directories = ResolveCommandDirectories( + var occurrenceCandidates = ExtractCandidatesForOccurrence( occurrence, - verb, - isSideEffectVerb, workingDirectory, - Environment.PathStyle); - if (directories is null) + resolveUnknownPathsFromEffectiveValues: false); + if (occurrenceCandidates is null) return []; - foreach (var directory in directories) - { - var shell = Environment.Grammar == ShellGrammar.Bash - ? ApprovalShell.Bash - : ApprovalShell.PowerShell; - candidates.Add(new ApprovalCandidate(verb, directory) - { - VerbTokens = GetCanonicalVerbTokens(clause), - Shell = shell, - SourceOccurrence = occurrence, - }); - } + candidates.AddRange(occurrenceCandidates); } return candidates; } + internal IReadOnlyList? ExtractCandidatesForOccurrence( + CommandOccurrence occurrence, + string? workingDirectory, + bool resolveUnknownPathsFromEffectiveValues, + Func? isAllowedHostPath = null) + { + ArgumentNullException.ThrowIfNull(occurrence); + + var clause = occurrence.Clause; + // ShellSyntaxTree's greedy verb walk (SPEC §6.1) folds + // lowercase-leading value tokens into the verb chain (`git tag + // v0.4.2`, `git show aa211dcb`, `git checkout feature2`), while + // digit-leading ones (`0.4.2`) stop the walk and land in Args + // (verb stays `git tag`). Both are call-specific values, not + // approvable intent, so strip them off the chain before gating. + if (clause.Verb.IsDynamic) + return null; + + var parsedVerb = clause.Verb.CanonicalVerb + ?? string.Join(" ", TrimTrailingValueTokens(clause.Verb.Tokens)); + var verb = ShellTokenizer.ApplyVerbShortCircuit(parsedVerb); + if (string.IsNullOrEmpty(verb)) + return null; + + var isSideEffectVerb = ShellTokenizer.SingleTokenSideEffectVerbs.Contains(verb); + var directories = ResolveCommandDirectories( + occurrence, + verb, + isSideEffectVerb, + workingDirectory, + Environment.PathStyle, + resolveUnknownPathsFromEffectiveValues, + isAllowedHostPath); + if (directories is null) + return null; + + var shell = Environment.Grammar == ShellGrammar.Bash + ? ApprovalShell.Bash + : ApprovalShell.PowerShell; + return directories + .Select(directory => new ApprovalCandidate(verb, directory) + { + VerbTokens = GetCanonicalVerbTokens(clause), + Shell = shell, + SourceOccurrence = occurrence, + }) + .ToArray(); + } + private static IReadOnlyList? GetCanonicalVerbTokens( ShellSyntaxTree.Clause clause) { @@ -311,14 +326,18 @@ private IReadOnlyList ExtractCandidatesViaAnalysis( string verb, bool isSideEffectVerb, string? workingDirectory, - ShellPathStyle pathStyle) + ShellPathStyle pathStyle, + bool resolveUnknownPathsFromEffectiveValues, + Func? isAllowedHostPath) { var clause = occurrence.Clause; var directories = new List(); var cwdAttribution = clause.Args.FirstOrDefault(static arg => arg.IsCwdAttribution); - var clauseWorkingDirectory = ExactValue(occurrence.WorkingDirectory) - ?? cwdAttribution?.Resolved - ?? (cwdAttribution is null ? workingDirectory : null); + var clauseWorkingDirectory = resolveUnknownPathsFromEffectiveValues + ? workingDirectory + : ExactValue(occurrence.WorkingDirectory) + ?? cwdAttribution?.Resolved + ?? (cwdAttribution is null ? workingDirectory : null); // Each parser path is an authorization scope. A grant must cover all // scopes, or a later external path could hide behind an earlier local @@ -346,13 +365,17 @@ private IReadOnlyList ExtractCandidatesViaAnalysis( continue; } - // A parser path without a canonical value cannot use the broader - // cwd grant. Return no candidates so the command fails closed. - var resolved = arg.Resolved; - if (string.IsNullOrWhiteSpace(resolved)) + var resolvedPaths = ResolveArgumentPaths( + occurrence, + arg, + clauseWorkingDirectory, + pathStyle, + resolveUnknownPathsFromEffectiveValues); + if (resolvedPaths is null) return null; - directories.Add(ResolveAuthorizationScope(verb, arg, resolved, pathStyle)); + directories.AddRange(resolvedPaths.Select(resolved => + ResolveAuthorizationScope(verb, arg, resolved, pathStyle))); } foreach (var argument in occurrence.Arguments) @@ -363,7 +386,8 @@ private IReadOnlyList ExtractCandidatesViaAnalysis( var authoredDirectories = ResolveAuthoredFileSystemDirectories( argument.AuthoredFileSystemValue, clauseWorkingDirectory, - pathStyle); + pathStyle, + isAllowedHostPath); if (authoredDirectories is null) return null; @@ -373,7 +397,10 @@ private IReadOnlyList ExtractCandidatesViaAnalysis( foreach (var redirect in occurrence.Redirects) { - var redirectDirectories = ResolveRedirectDirectories(redirect, pathStyle); + var redirectDirectories = ResolveRedirectDirectories( + redirect, + pathStyle, + isAllowedHostPath); if (redirectDirectories is null) return null; @@ -413,10 +440,50 @@ private IReadOnlyList ExtractCandidatesViaAnalysis( return directories.Distinct(StringComparer.Ordinal).ToList(); } + private static IReadOnlyList? ResolveArgumentPaths( + CommandOccurrence occurrence, + Arg argument, + string? workingDirectory, + ShellPathStyle pathStyle, + bool resolveUnknownPathsFromEffectiveValues) + { + if (!string.IsNullOrWhiteSpace(argument.Resolved)) + return [argument.Resolved]; + if (!resolveUnknownPathsFromEffectiveValues) + return null; + + var analyzed = occurrence.Arguments.FirstOrDefault(candidate => + ReferenceEquals(candidate.Argument, argument)); + IReadOnlyList values = analyzed?.Value switch + { + ShellValueDomain.Exact exact => [exact.Value], + ShellValueDomain.FiniteSet finite => finite.Values, + _ => [] + }; + if (values.Count == 0) + return null; + + var resolved = new List(values.Count); + foreach (var value in values) + { + var path = ShellTokenizer.NormalizePathToken( + value, + workingDirectory, + pathStyle); + if (string.IsNullOrWhiteSpace(path)) + return null; + + resolved.Add(path); + } + + return resolved; + } + private static IReadOnlyList? ResolveAuthoredFileSystemDirectories( ShellValueDomain domain, string? workingDirectory, - ShellPathStyle pathStyle) + ShellPathStyle pathStyle, + Func? isAllowedHostPath) { if (domain is ShellValueDomain.Unknown) return []; @@ -439,7 +506,9 @@ private IReadOnlyList ExtractCandidatesViaAnalysis( { if (string.IsNullOrWhiteSpace(path) || !IsRootedForPathStyle(path, pathStyle) - || UsesHostPathStyle(pathStyle) && HasUnsafeHostPath(path)) + || UsesHostPathStyle(pathStyle) + && HasUnsafeHostPath(path) + && isAllowedHostPath?.Invoke(path) != true) { return null; } @@ -712,7 +781,8 @@ or UnauthorizedAccessException private static IReadOnlyList? ResolveRedirectDirectories( ShellSyntaxTree.RedirectAnalysis redirect, - ShellPathStyle pathStyle) + ShellPathStyle pathStyle, + Func? isAllowedHostPath) { if (!redirect.IsComplete) return null; @@ -757,7 +827,9 @@ or ShellSyntaxTree.HereStringRedirectAnalysis if (string.IsNullOrWhiteSpace(target)) return null; - if (UsesHostPathStyle(pathStyle) && HasUnsafeHostPath(target)) + if (UsesHostPathStyle(pathStyle) + && HasUnsafeHostPath(target) + && isAllowedHostPath?.Invoke(target) != true) return null; // The resolved POSIX null device creates no reusable filesystem @@ -1168,7 +1240,9 @@ private bool IsMessy(ShellCommandAnalysis analysis) NormalizedVerb(command), IsSideEffectCommand(command), workingDirectory, - Environment.PathStyle) is null)) + Environment.PathStyle, + resolveUnknownPathsFromEffectiveValues: false, + isAllowedHostPath: null) is null)) { return true; } diff --git a/src/Netclaw.Security/ToolPathPolicy.cs b/src/Netclaw.Security/ToolPathPolicy.cs index b80507d4f..fe14a76f9 100644 --- a/src/Netclaw.Security/ToolPathPolicy.cs +++ b/src/Netclaw.Security/ToolPathPolicy.cs @@ -157,6 +157,72 @@ public bool IsDenied(string path) public bool IsReadDenied(string path) => IsDeniedAgainst(path, _readDeniedPaths) || IsDeniedAgainst(path, _shellDeniedPaths); + internal bool CausalIntentReferencesDeniedPath( + CommandOccurrence occurrence, + string intentDirectory, + IReadOnlyList fallbackDirectories) + { + ArgumentNullException.ThrowIfNull(occurrence); + ArgumentNullException.ThrowIfNull(fallbackDirectories); + if (string.IsNullOrWhiteSpace(intentDirectory) + || IsShellDenied(intentDirectory) + || fallbackDirectories.Count == 0 + || fallbackDirectories.Any(IsShellDenied)) + { + return true; + } + + var possibleDirectories = fallbackDirectories + .Append(intentDirectory) + .Distinct(StringComparer.Ordinal) + .ToArray(); + foreach (var argument in occurrence.Arguments) + { + if (argument.Argument.IsPath + && possibleDirectories.Any(directory => + DomainReferencesDeniedPath(argument.Value, directory))) + { + return true; + } + + if (argument.AuthoredPathShape != ShellPathShape.Unknown + && possibleDirectories.Any(directory => + DomainReferencesDeniedPath(argument.AuthoredValue, directory))) + { + return true; + } + } + + return occurrence.Redirects + .OfType() + .Any(redirect => possibleDirectories.Any(directory => + DomainReferencesDeniedPath(redirect.Target, directory))); + } + + private bool DomainReferencesDeniedPath( + ShellValueDomain domain, + string workingDirectory) + { + IReadOnlyList values = domain switch + { + ShellValueDomain.Exact exact => [exact.Value], + ShellValueDomain.FiniteSet finite => finite.Values, + _ => [] + }; + + return values.Any(value => + { + var resolved = ShellTokenizer.NormalizePathToken( + value, + workingDirectory, + Environment.PathStyle); + return string.IsNullOrWhiteSpace(resolved) || IsShellDenied(resolved); + }); + } + + private bool IsShellDenied(string path) + => IsDeniedAgainst(path, _shellDeniedPaths); + private static bool IsDeniedAgainst(string path, HashSet deniedSet) { if (string.IsNullOrWhiteSpace(path))