Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
<PackageVersion Include="SlackNet.Extensions.DependencyInjection" Version="$(SlackNetVersion)" />
<PackageVersion Include="Cronos" Version="0.13.0" />
<PackageVersion Include="Netclaw.SkillClient" Version="0.4.1" />
<PackageVersion Include="ShellSyntaxTree" Version="0.3.2" />
<PackageVersion Include="ShellSyntaxTree" Version="0.3.3" />
<PackageVersion Include="Termina" Version="0.16.1" />
</ItemGroup>
<!-- Serialization -->
Expand Down
6 changes: 6 additions & 0 deletions IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,12 @@ Done when:
and path-shape facts introduced in 0.3.1. This store-v3 slice preserves those parser token facts
without executable-private command rules; later parent tasks consume the new
value-domain facts in the coordinator.
- [ ] Netclaw consumes public ShellSyntaxTree `0.3.3` for the parser-owned
authored filesystem domain. Local code accepts only `Exact` and `FiniteSet`.
It checks each value through path policy and keeps unsafe transforms strict.
The Release build and all 7,138 runnable tests pass. The suite reports 15
expected platform or opt-in skips. Adversarial review and CI remain before
completion.
- [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.
Expand Down
13 changes: 5 additions & 8 deletions openspec/changes/structure-shell-approval-policy/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ authority outside code review. At minimum `find`, `awk`, `rg`, and `sort` are
not eligible. Production code has no flag-specific exceptions. The existing
`git ls-tree` special case is deleted.

### 7. Consume ShellSyntaxTree 0.3.1 facts through 0.3.2 explicitly
### 7. Consume ShellSyntaxTree 0.3.1 facts through 0.3.3 explicitly

Netclaw uses effective `AnalyzedArgument.Value` for runtime-sensitive checks.
It may use `AuthoredValue` for approval matching only after the maintainer
Expand All @@ -275,13 +275,10 @@ contract decides whether an effective value is path-relevant. Every finite
effective value for an `IsPath` argument still passes `ToolPathPolicy`; an
unknown path-relevant value stays strict.

ShellSyntaxTree 0.3.2 publishes D14's finite `AuthoredValue`, but its effective
argument has `Argument.IsPath == false`. Netclaw cannot infer file authority
from that contradiction. An authored finite value may enter `ToolPathPolicy`
only after ShellSyntaxTree supplies a separate general parser-owned authored
operand-role fact. Until that additive fact exists and Netclaw adopts it, D14
remains strict. The change does not invent the public member name or infer the
role from an executable's private grammar.
ShellSyntaxTree 0.3.3 publishes D14's finite `AuthoredFileSystemValue`. Netclaw
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.

`AuthoredPathShape` is lexical shape only. It may make review stricter, but it
never establishes that an executable treats an argument as a filesystem
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,8 @@
"observed": "Once",
"classification": "ShellSyntaxTreeFactGap",
"owner": "ShellSyntaxTree",
"sstExpectation": "With PublishAuthoredSourceFacts enabled, effective cat value is Unknown; AuthoredValue is the four pre-field-splitting /work words and AuthoredPathShape is Posix.",
"netclawExpectation": "Allow only if product policy accepts pre-field-splitting authored words, applies path checks conservatively, and covers cat plus echo."
"sstExpectation": "With ShellSyntaxTree 0.3.3 and PublishAuthoredSourceFacts enabled, effective cat Value is Unknown; AuthoredValue is the four pre-field-splitting /work words; AuthoredFileSystemValue is the same normalized finite set.",
"netclawExpectation": "Accept only Exact or FiniteSet AuthoredFileSystemValue alternatives, check every path through product policy, and independently cover cat plus echo."
},
{
"id": "D15",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@
},
"candidates": [
{ "id": 0, "tokens": ["echo"], "realDirectory": "/work", "intentDirectory": null, "expectedCoverage": "ReviewedSafePolicy" },
{ "id": 1, "tokens": ["cat"], "realDirectory": "/work", "intentDirectory": null, "expectedCoverage": "Uncovered" }
{ "id": 1, "tokens": ["cat"], "realDirectory": "/work", "intentDirectory": null, "expectedCoverage": "PersistentGlobal" }
],
"authoredPathFacts": [
{
Expand All @@ -358,16 +358,22 @@
"/work/src/Discovery/Discovery.csproj",
"/work/tests/Hosting.Tests/Hosting.Tests.csproj"
],
"authoredFileSystemValues": [
"/work/src/App/App.csproj",
"/work/src/Hosting/Hosting.csproj",
"/work/src/Discovery/Discovery.csproj",
"/work/tests/Hosting.Tests/Hosting.Tests.csproj"
],
"authoredPathShape": "Posix",
"expectedPathPolicy": "RequiresApproval"
"expectedPathPolicy": "Allow"
}
],
"expectedTrace": [
{ "stage": "CanonicalFacts", "candidateId": 1, "executableBasename": "cat", "outcome": "Uncovered", "reason": "MissingAuthoredOperandRole", "coverage": "Uncovered", "scopeRelation": "None", "grantTimestamp": null },
{ "stage": "StoredGrantMatch", "candidateId": 1, "executableBasename": "cat", "outcome": "Covered", "reason": "GlobalPhraseMatch", "coverage": "PersistentGlobal", "scopeRelation": "Global", "grantTimestamp": null },
{ "stage": "ReviewedSafePolicy", "candidateId": 0, "executableBasename": "echo", "outcome": "Covered", "reason": "ReadOnlyUnderRealRoot", "coverage": "ReviewedSafePolicy", "scopeRelation": "UnderRealRoot", "grantTimestamp": null },
{ "stage": "Completion", "candidateId": null, "executableBasename": null, "outcome": "RequiresApproval", "reason": "UnresolvedPathFacts", "coverage": null, "scopeRelation": null, "grantTimestamp": null }
{ "stage": "Completion", "candidateId": null, "executableBasename": null, "outcome": "Allow", "reason": "AllCandidatesCovered", "coverage": null, "scopeRelation": null, "grantTimestamp": null }
],
"expectedFinal": { "outcome": "RequiresApproval", "reason": "UnresolvedPathFacts" }
"expectedFinal": { "outcome": "Allow", "reason": "AllCandidatesCovered" }
},
{
"evidenceId": "D18",
Expand Down
13 changes: 6 additions & 7 deletions openspec/changes/structure-shell-approval-policy/proposal.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ session and persistent grant snapshots.
decision-gated v2 migration.
- Emit a bounded, redacted decision trace that also supplies near-miss data.
- Adopt the authored/effective fact separation introduced in ShellSyntaxTree
0.3.1 through the corrected public 0.3.2 package.
- Keep D14 strict until ShellSyntaxTree supplies a general parser-owned
authored operand-role fact; lexical path shape alone cannot create file
authority.
0.3.1 and the authored filesystem fact from public 0.3.3.
- 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.
- Pin the exact sanitized D01-D18 catalog and adversarial cases.

No production branch will parse an executable's private options or operands.
Expand All @@ -55,7 +55,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.2 for the
current authored/effective facts. D14 requires a later additive parser fact
before its authored paths can receive policy coverage.
- Dependencies: implementation consumes public ShellSyntaxTree 0.3.3 for the
authored, effective, and authored filesystem facts.
- Documentation/evals: operator guidance and approval behavioral evals change.
Original file line number Diff line number Diff line change
Expand Up @@ -602,10 +602,10 @@ second control-flow tokenizer. Supported static loops SHALL expose candidates.
Unsupported branches and runtime-generated loops SHALL remain strict.

An effective finite argument SHALL enter path policy when the parser-owned
`Argument.IsPath` role is true. An authored finite argument whose effective
role is false SHALL remain strict until ShellSyntaxTree supplies a separate,
general parser-owned authored operand-role fact. `AuthoredPathShape` SHALL NOT
substitute for that fact or create file authority.
`Argument.IsPath` role is true. ShellSyntaxTree 0.3.3 `Exact` and `FiniteSet`
`AuthoredFileSystemValue` facts SHALL also enter path policy. Unknown and all
other alternatives SHALL stay strict. `AuthoredPathShape` SHALL NOT substitute
for the stronger fact or create file authority.

A legacy scanner MAY add a denial when canonical analysis is incomplete. It
SHALL NOT allow, create candidates, create persistent options, or widen scope.
Expand All @@ -618,10 +618,10 @@ SHALL NOT allow, create candidates, create persistent options, or widen scope.
- **THEN** the authored values do not create file authority
- **AND** lexical `AuthoredPathShape` does not cover the candidate

#### Scenario: General authored operand role unlocks finite path checks
#### Scenario: ShellSyntaxTree 0.3.3 unlocks finite D14 path checks

- **GIVEN** a later ShellSyntaxTree version reports D14 finite authored values
- **AND** it supplies a parser-owned authored filesystem-operand role
- **GIVEN** ShellSyntaxTree 0.3.3 reports a finite D14
`AuthoredFileSystemValue`
- **WHEN** the maintainer-approved authored-source policy evaluates it
- **THEN** each finite `cat` path passes `ToolPathPolicy`
- **AND** the presence of `for` alone does not force a prompt
Expand Down Expand Up @@ -686,9 +686,9 @@ ShellSyntaxTree analysis.

PowerShell SHALL use the selected dialect and `PwshInitialStateMode.Unknown`.
Netclaw SHALL use effective values for runtime and deny policy. It MAY use
ShellSyntaxTree 0.3.2 authored values only for the explicitly approved approval
perspective. Unknown policy-relevant values SHALL not create reusable or safe
coverage.
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.

Deny-only defensive scans MAY deny incomplete input but SHALL never authorize
it.
Expand Down
11 changes: 5 additions & 6 deletions openspec/changes/structure-shell-approval-policy/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,18 +100,17 @@
- [ ] 6.5 Keep native PowerShell causal scope strict and record native Windows
expected results.

## 7. ShellSyntaxTree 0.3.1 facts through 0.3.2
## 7. ShellSyntaxTree 0.3.1 facts through 0.3.3

- [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.
- [x] 7.2 Consume effective `Value` for runtime checks and approved
`AuthoredValue` only for the documented approval perspective.
- [x] 7.3 Treat `IntegerRange` and `Concatenation` as bounded scalar data only.
- [ ] 7.4 Check every finite effective value whose `Argument.IsPath` is true
through `ToolPathPolicy`. Keep D14 strict until ShellSyntaxTree supplies a
general parser-owned authored operand-role fact; after adoption, check every
finite authored path through the same policy. Treat `AuthoredPathShape` as
lexical-only and keep unknown path values strict.
- [x] 7.4 Check every finite effective value whose `Argument.IsPath` is true
through `ToolPathPolicy`. Check each ShellSyntaxTree 0.3.3 `Exact` or
`FiniteSet` `AuthoredFileSystemValue` through the same policy. Treat
`AuthoredPathShape` as lexical-only. Keep unknown path values strict.
- [x] 7.5 Delete the broad Bash environment-variable relaxation and its
superseded tests.
- [ ] 7.6 Pin exact D02, D10, and D14 input-to-coverage results.
Expand Down
52 changes: 48 additions & 4 deletions src/Netclaw.Security.Tests/ShellApprovalEvidenceContractTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using Netclaw.Configuration;
using Netclaw.Tools;
using ShellSyntaxTree;
using Xunit;

Expand All @@ -18,7 +20,7 @@ public sealed partial class ShellApprovalEvidenceContractTests
private const string PolicyFixturesFile = "netclaw-policy-fixtures.json";
private const string PostMergeHarvestFile = "post-1890-approval-harvest.json";
private const string ApprovalMatrixSha256 =
"d2a6e64421af337d1c54f1f955934057398176b456e585bfce015ef7ffa24e7d";
"0169105efe87b345d9a82d777ef86909e31fa81a5255cc0cc30f32fbe4d0d6b0";

[Fact]
public void Approval_matrix_matches_the_locked_cross_repository_artifact()
Expand Down Expand Up @@ -139,13 +141,16 @@ public void Exact_symbolic_parser_facts_match_the_policy_fixtures()
Assert.Equal("Unknown", fact.EffectiveValue);
var authored = Assert.IsType<ShellValueDomain.FiniteSet>(argument.AuthoredValue);
Assert.Equal(fact.AuthoredValues, authored.Values);
var authoredFileSystem = Assert.IsType<ShellValueDomain.FiniteSet>(
argument.AuthoredFileSystemValue);
Assert.Equal(fact.AuthoredFileSystemValues, authoredFileSystem.Values);
Assert.Equal(fact.AuthoredPathShape, argument.AuthoredPathShape.ToString());
}
}
}

[Fact]
public void Authored_path_fixture_keeps_missing_operand_role_strict()
public void Authored_path_fixture_uses_the_strong_filesystem_domain()
{
var fixtures = DeserializeFixtures(File.ReadAllBytes(EvidencePath(PolicyFixturesFile)));
var fixture = Assert.Single(fixtures.Cases, item => item.AuthoredPathFacts is { Count: > 0 });
Expand All @@ -159,8 +164,45 @@ public void Authored_path_fixture_keeps_missing_operand_role_strict()

Assert.False(fact.ArgumentIsPath);
Assert.Equal(fact.ArgumentIsPath, argument.Argument.IsPath);
Assert.Equal("RequiresApproval", fact.ExpectedPathPolicy);
Assert.Equal("RequiresApproval", fixture.ExpectedFinal.Outcome);
var authoredFileSystem = Assert.IsType<ShellValueDomain.FiniteSet>(
argument.AuthoredFileSystemValue);
Assert.Equal(fact.AuthoredFileSystemValues, authoredFileSystem.Values);
Assert.Equal("Allow", fact.ExpectedPathPolicy);
Assert.Equal("Allow", fixture.ExpectedFinal.Outcome);
}

[Fact]
public void D14_fixture_is_covered_by_its_typed_grant_and_safe_paths()
{
var fixtures = DeserializeFixtures(File.ReadAllBytes(EvidencePath(PolicyFixturesFile)));
var fixture = Assert.Single(fixtures.Cases, item => item.EvidenceId == "D14");
var environment = CreateEnvironment(fixture.Environment);
var matcher = new ShellApprovalMatcher(environment);
var arguments = new Dictionary<string, object?>
{
["Command"] = fixture.Command,
["WorkingDirectory"] = fixture.InitialWorkingDirectory,
};
var grants = fixture.Available.PersistentGrants.Select(grant =>
ApprovalEntry.CreateTokenPrefix(
Enum.Parse<ApprovalShell>(grant.Shell),
grant.Tokens,
grant.Directory)).ToList();

var invocation = matcher.AnalyzeInvocation(
new ToolName("shell_execute"),
arguments);

Assert.False(invocation.IsMessy);
Assert.True(matcher.IsApproved(
new ToolName("shell_execute"),
arguments,
grants,
fixture.InitialWorkingDirectory));
Assert.False(new ToolPathPolicy(environment, ["/protected"])
.CommandReferencesDeniedPath(
fixture.Command,
fixture.InitialWorkingDirectory));
}

[Fact]
Expand Down Expand Up @@ -515,6 +557,8 @@ internal sealed record PolicyAuthoredPathFact

public required List<string> AuthoredValues { get; init; }

public required List<string> AuthoredFileSystemValues { get; init; }

public required string AuthoredPathShape { get; init; }

public required string ExpectedPathPolicy { get; init; }
Expand Down
73 changes: 73 additions & 0 deletions src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,79 @@ public void Bash_live_read_and_diagnostic_shapes_are_reusable(string command)
});
}

[Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")]
public void Bash_finite_filesystem_loop_uses_bounded_path_scopes()
{
const string command =
"for f in src/A.cs src/B.cs; do cat /work/$f; done";
var arguments = Args(command, "/work");

var analysis = _matcher.AnalyzeInvocation(
new ToolName("shell_execute"),
arguments);

Assert.False(analysis.IsMessy);
var candidate = Assert.Single(analysis.Candidates);
Assert.Equal("cat", candidate.Verb);
Assert.Equal("/work", candidate.Directory);
Assert.True(_matcher.IsApproved(
new ToolName("shell_execute"),
arguments,
[InDir("cat", "/work")],
cwd: "/work"));
}

[Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")]
public void Bash_finite_filesystem_loop_keeps_external_scopes_exact()
{
const string command =
"for f in /work/A.cs /work2/B.cs; do cat \"$f\"; done";

var analysis = _matcher.AnalyzeInvocation(
new ToolName("shell_execute"),
Args(command, "/work"));

Assert.False(analysis.IsMessy);
Assert.Equal(
["/work/A.cs", "/work2/B.cs"],
analysis.Candidates.Select(static candidate => candidate.Directory));
}

[Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")]
public void Bash_finite_filesystem_loop_rejects_a_symlink_scope()
{
var root = Path.Combine(
Path.GetTempPath(),
$"netclaw-authored-loop-{Guid.NewGuid():N}");
Comment on lines +137 to +139
var projectDirectory = Path.Combine(root, "project");
var externalDirectory = Path.Combine(root, "external");
var externalFile = Path.Combine(externalDirectory, "secret.txt");
var link = Path.Combine(projectDirectory, "link.txt");
Directory.CreateDirectory(projectDirectory);
Directory.CreateDirectory(externalDirectory);
File.WriteAllText(externalFile, "secret");
File.CreateSymbolicLink(link, externalFile);

try
{
var arguments = Args(
"for f in link.txt safe.txt; do cat \"$f\"; done",
projectDirectory);

Assert.True(_matcher.IsMessy(
new ToolName("shell_execute"),
arguments));
Assert.Empty(_matcher.ExtractCandidates(
new ToolName("shell_execute"),
arguments));
}
finally
{
File.Delete(link);
Directory.Delete(root, recursive: true);
}
}

[Fact]
public void Power_shell_matcher_uses_the_native_power_shell_grammar()
{
Expand Down
Loading
Loading