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
30 changes: 30 additions & 0 deletions src/Netclaw.Actors.Tests/Tools/ShellApprovalCaseCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,36 @@ public static class ShellApprovalCases
Bash("git status > \"$OUTPUT\""),
Approvals.None,
ExpectedApproval.Require([], isMessy: true, approvalChecks: 0)),
Case(
"fd-dup-redirect-safe-verb-allows",
Bash("git status 2>&1"),
Approvals.None,
ExpectedApproval.Allow(ToolAllowReason.SafeVerbInTrustedScope)),
Case(
"fd-dup-redirect-safe-pipeline-allows",
Bash("git log --oneline -5 2>&1 | tail -20"),
Approvals.None,
ExpectedApproval.Allow(ToolAllowReason.SafeVerbInTrustedScope)),
Case(
"fd-close-redirect-safe-verb-allows",
Bash("git status 2>&-"),
Approvals.None,
ExpectedApproval.Allow(ToolAllowReason.SafeVerbInTrustedScope)),
Case(
"fd-move-redirect-safe-verb-allows",
Bash("git status 2>&1-"),
Approvals.None,
ExpectedApproval.Allow(ToolAllowReason.SafeVerbInTrustedScope)),
Case(
"fd-dup-redirect-mutating-no-grant-prompts-not-messy",
Bash("git push origin dev 2>&1 | tail -2"),
Approvals.None,
ExpectedApproval.Require(["git push origin dev", "tail"], isMessy: false)),
Case(
"dynamic-fd-redirect-fails-closed",
Bash("git status 2>&$FD"),
Approvals.None,
ExpectedApproval.Require([], isMessy: true, approvalChecks: 0)),
Case(
"background-list-prompts-for-mutating-tail",
Bash("git status & git push"),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Fresh Personal approval matrix
# Fresh Personal approval matrix

`Tools.ShellMode`: `HostAllowed`

Expand Down Expand Up @@ -62,6 +62,12 @@
| command-substitution-fails-closed | Personal | Project | Interactive | echo $(git push) | none | RequiresApproval | approval required | none | Yes |
| dynamic-path-fails-closed | Personal | Project | Interactive | cat "$FILE" | none | RequiresApproval | approval required | none | Yes |
| dynamic-redirect-fails-closed | Personal | Project | Interactive | git status > "$OUTPUT" | none | RequiresApproval | approval required | none | Yes |
| fd-dup-redirect-safe-verb-allows | Personal | Project | Interactive | git status 2>&1 | none | Allowed | SafeVerbInTrustedScope | none | Not applicable |
| fd-dup-redirect-safe-pipeline-allows | Personal | Project | Interactive | git log --oneline -5 2>&1 \| tail -20 | none | Allowed | SafeVerbInTrustedScope | none | Not applicable |
| fd-close-redirect-safe-verb-allows | Personal | Project | Interactive | git status 2>&- | none | Allowed | SafeVerbInTrustedScope | none | Not applicable |
| fd-move-redirect-safe-verb-allows | Personal | Project | Interactive | git status 2>&1- | none | Allowed | SafeVerbInTrustedScope | none | Not applicable |
| fd-dup-redirect-mutating-no-grant-prompts-not-messy | Personal | Project | Interactive | git push origin dev 2>&1 \| tail -2 | none | RequiresApproval | approval required | git push origin dev, tail | No |
| dynamic-fd-redirect-fails-closed | Personal | Project | Interactive | git status 2>&$FD | none | RequiresApproval | approval required | none | Yes |
| background-list-prompts-for-mutating-tail | Personal | Project | Interactive | git status & git push | none | RequiresApproval | approval required | none | Yes |
| unbalanced-quote-fails-closed | Personal | Project | Interactive | git push "unterminated | none | RequiresApproval | approval required | none | Yes |
| multiline-argument-prompts | Personal | Project | Interactive | gh issue comment 123 --body "first line\nsecond line" | none | RequiresApproval | approval required | gh issue comment | No |
Expand Down
25 changes: 25 additions & 0 deletions src/Netclaw.Security.Tests/ShellCommandAnalysisTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,31 @@ public void Dynamic_command_syntax_is_explicit(string command)
Assert.True(analysis.HasDynamicSyntax);
}

[Theory]
[InlineData("git status 2>&1")]
[InlineData("git status 2>&123")]
[InlineData("git status 2>&1-")]
[InlineData("git status 2>&-")]
public void Static_file_descriptor_redirect_is_not_dynamic(string command)
{
var analysis = _analyzer.Analyze(command);

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

[Theory]
[InlineData("git status 2>&$FD")]
[InlineData("git status >&$FD")]
[InlineData("git status 2>&${FD}")]
public void Dynamic_file_descriptor_redirect_stays_dynamic(string command)
{
var analysis = _analyzer.Analyze(command);

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

[Fact]
public void Background_list_fails_closed_when_parser_omits_its_tail()
{
Expand Down
33 changes: 32 additions & 1 deletion src/Netclaw.Security/ShellCommandAnalysis.cs
Original file line number Diff line number Diff line change
Expand Up @@ -183,5 +183,36 @@ internal sealed record ShellCommandAnalysis(
// A glob in a directory segment can hide traversal or a symlink.
// Only a leaf glob has a fixed directory scope.
|| clause.Args.Any(ShellGlobPath.HasUnresolvedDescendantScope)
|| clause.Redirects.Any(static redirect => redirect.IsDynamicSkip));
// An fd-dup target (&1, &2, &-) is a static file-descriptor number,
// not a dynamic token: ShellSyntaxTree marks it IsDynamicSkip to mean
// "do not path-resolve", but it carries no unresolved syntax and no
// filesystem scope (ResolveRedirectDirectory skips &-prefixed targets).
// Treating it as dynamic fails the whole command closed to an approval
// prompt for every `2>&1`-shaped command, even fully safe ones.
|| clause.Redirects.Any(static redirect =>
redirect.IsDynamicSkip
&& !IsStaticFileDescriptor(redirect.Target)));

/// <summary>
/// Returns true only for the static file-descriptor targets that
/// ShellSyntaxTree 0.2 recognizes: &amp;N, &amp;N-, and &amp;-.
/// </summary>
private static bool IsStaticFileDescriptor(string target)
{
if (target == "&-")
return true;

if (target.Length < 2 || target[0] != '&')
return false;

var index = 1;
while (index < target.Length && char.IsAsciiDigit(target[index]))
index++;

if (index == 1)
return false;

return index == target.Length
|| index == target.Length - 1 && target[index] == '-';
}
}
Loading