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))