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: 2 additions & 0 deletions IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ Done when:
root from one-command `WorkingDirectory` scope, prevent redundant project
switches, and preserve `cd` when directory mutation is the requested shell
behavior.
- [x] Unknown non-path argument data does not make a structurally complete
shell command complex. Dynamic identities, paths, and redirects stay strict.
- [x] Sanitized behavioral eval cases cover early project declaration,
one-command typed scope, failed-path recovery, and deliberate inline `cd`.
- [ ] Run the new behavioral eval cases against a configured model provider.
Expand Down
8 changes: 8 additions & 0 deletions src/Netclaw.Actors.Tests/Tools/ShellApprovalCaseCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,14 @@ public static class ShellApprovalCases
Approvals.None,
ExpectedApproval.Allow(ToolAllowReason.SafeVerbInTrustedScope)),

Case(
"safe-gh-run-diagnostic-exit-status-allows",
Bash(
"gh run view 123456 --repo example/project --log-failed --verbose 2>&1 "
+ "| head -200; echo \"---EXIT $?---\""),
Approvals.None,
ExpectedApproval.Allow(ToolAllowReason.ApprovalExemptShellCandidates)),

Case(
"native-project-path-operand-allows-safe-verb",
Bash("git diff install-skills.sh"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
| mixed-safe-unsafe-compound-prompts | Bash | Personal | Project | Interactive | git status && git push | none | RequiresApproval | approval required | git push | No |
| safe-pipe-unsafe-tail-prompts | Bash | Personal | Project | Interactive | git status \| git push | none | RequiresApproval | approval required | git push | No |
| safe-pipeline-allows | Bash | Personal | Project | Interactive | git log \| head -20 | none | Allowed | SafeVerbInTrustedScope | none | Not applicable |
| safe-gh-run-diagnostic-exit-status-allows | Bash | Personal | Project | Interactive | gh run view 123456 --repo example/project --log-failed --verbose 2>&1 \| head -200; echo "---EXIT $?---" | none | Allowed | ApprovalExemptShellCandidates | none | Not applicable |
| native-project-path-operand-allows-safe-verb | Bash | Personal | Project | Interactive | git diff install-skills.sh | none | Allowed | SafeVerbInTrustedScope | none | Not applicable |
| native-external-path-operand-prompts | Bash | Personal | Project | Interactive | git diff /etc/passwd | none | RequiresApproval | approval required | git diff | No |
| native-project-path-operand-reuses-grant | Bash | Personal | Project | Interactive | kubectl apply deployment.yaml | persistent[project]:kubectl apply | Allowed | StoredApproval | none | Not applicable |
Expand Down
17 changes: 17 additions & 0 deletions src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,23 @@ public void Bash_matcher_keeps_power_shell_as_one_external_approval_unit(
Assert.Equal(expectedVerb, Assert.Single(analysis.Candidates).Verb);
}

[Fact]
public void Bash_github_diagnostic_with_exit_status_is_reusable()
{
const string command =
"gh run view 123456 --repo example/project --log-failed --verbose 2>&1 "
+ "| head -200; echo \"---EXIT $?---\"";

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

Assert.False(analysis.IsMessy);
Assert.Equal(
["gh run view", "head", "echo"],
analysis.Candidates.Select(static candidate => candidate.Verb));
}

[Fact]
public void Power_shell_matcher_uses_the_native_power_shell_grammar()
{
Expand Down
29 changes: 29 additions & 0 deletions src/Netclaw.Security.Tests/ShellCommandAnalysisTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,35 @@ public void Power_shell_unknown_command_argument_region_stays_dynamic()
Assert.True(analysis.HasDynamicSyntax, Describe(analysis));
}

[Theory]
[InlineData("echo \"---EXIT $?---\"")]
[InlineData("printf '%s' \"$?\"")]
[InlineData("status-report \"$?\"")]
[InlineData("status-report \"$@\"")]
public void Bash_unknown_non_path_data_keeps_static_structure(string command)
{
var analyzer = new ShellCommandAnalyzer(BashEnvironment);
var analysis = analyzer.Analyze(command, "/work");

Assert.Equal(ShellAnalysisFailure.None, analysis.Failure);
Assert.False(analysis.HasDynamicSyntax, Describe(analysis));
}

[Theory]
[InlineData("rm \"$1\"")]
[InlineData("echo ok > \"$1\"")]
[InlineData("\"$1\" --version")]
[InlineData("sh -c \"$1\"")]
public void Bash_unknown_authority_or_identity_stays_dynamic(string command)
{
var analyzer = new ShellCommandAnalyzer(BashEnvironment);
var analysis = analyzer.Analyze(command, "/work");

Assert.True(
analysis.Failure != ShellAnalysisFailure.None || analysis.HasDynamicSyntax,
Describe(analysis));
}

[Fact]
public void Power_shell_empty_command_argument_region_stays_dynamic()
{
Expand Down
16 changes: 10 additions & 6 deletions src/Netclaw.Security/ShellCommandAnalysis.cs
Original file line number Diff line number Diff line change
Expand Up @@ -338,10 +338,6 @@ private bool CommandHasDynamicSyntax(
command,
arg,
accountedRegionArguments))
|| command.Clause.Args.Any(static arg =>
arg.Kind == ArgKind.EnvVar
&& !arg.IsCwdAttribution
&& string.IsNullOrWhiteSpace(arg.Resolved))
|| command.Clause.Args.Any(static arg =>
arg.IsPath
&& arg.Kind != ArgKind.Glob
Expand Down Expand Up @@ -431,12 +427,20 @@ private static bool HasUnsupportedWorkingDirectory(ShellValueDomain workingDirec
_ => true
};

private static bool HasUnsupportedArgumentDomain(AnalyzedArgument argument)
private bool HasUnsupportedArgumentDomain(AnalyzedArgument argument)
=> argument.Value switch
{
// A raw authored glob has no one runtime value. Netclaw applies
// its fixed covering-scope checks to the source Arg below.
ShellValueDomain.Unknown => argument.Argument.Kind != ArgKind.Glob,
// Bash parameter expansion happens after parsing. In a non-path
// argument it can change argv values or cardinality, but it cannot
// introduce shell operators, redirects, or command occurrences.
// Path slots, dynamic identities, and execution regions are
// classified separately and remain strict.
ShellValueDomain.Unknown =>
argument.Argument.Kind != ArgKind.Glob
&& (Environment.Grammar != ShellGrammar.Bash
|| argument.Argument.Kind != ArgKind.EnvVar),
ShellValueDomain.Exact => false,
ShellValueDomain.FiniteSet finite => finite.Values.Count is < 2 or > 32
|| finite.Values.Any(static value => value is null)
Expand Down
Loading