Files
DeepSeek-Reasonix/internal/shellparse/bash_test.go
T
SivanCola e57297959b Harden dynamic Bash authorization without disrupting Auto / 加固动态 Bash 授权并保持 Auto 低干预 (#6985)
* fix(permission): harden dynamic Bash authorization

Problem:
Broad Bash allow rules could silently authorize nested command and process substitutions, while treating every dynamic shell form as human-required would make Auto unnecessarily disruptive.

Root cause:
Bash rule matching did not distinguish reusable static commands, exact-only dynamic syntax, and nested or indirect execution before applying scoped allows and controller bypasses.

Fix:
Classify Bash ASTs into reusable, exact-only, and human-required tiers. Restrict all dynamic approvals to exact Bash=<literal> grants, require a human for nested or indirect execution outside YOLO, preserve Auto fallback for expansions, assignments, redirects, heredocs, and globs, and apply the same contract across interactive, headless, persistence, CLI, and ACP paths.

Verification:
- go test ./internal/shellparse ./internal/permission ./internal/control ./internal/boot ./internal/cli ./internal/acp
- go test -race ./internal/control
- go vet ./...
- env -u DEEPSEEK_API_KEY go test ./...
- gofmt -l .
- git diff --check

Fixes #6963

* fix(control): remove unused approval wrapper

Problem:
The CI lint job rejected PR #6985 because requestApprovalDecision was left unused after approval paths moved to the option-aware helper.

Root cause:
The dynamic Bash approval refactor replaced the wrapper call sites but did not remove the obsolete wrapper itself.

Fix:
Remove the unused wrapper without changing approval behavior.

Verification:
- go run github.com/golangci/golangci-lint/v2/cmd/[email protected].2 run --timeout=5m
- go test ./internal/shellparse ./internal/permission ./internal/control ./internal/boot ./internal/cli ./internal/acp
- go test -race ./internal/control
- gofmt -l .
- git diff --check

* fix(permission): preserve dynamic compound rule precedence

Problem:
Dynamic compound Bash commands could reach fallback before per-segment Deny or Ask rules were evaluated. Raw rule matching also lost the established :* prefix semantics, and brace expansion or extglob arguments could be treated as reusable static commands.

Root cause:
The approval class short-circuited compound decomposition, the raw matcher interpreted Bash prefix markers as ordinary glob text, and the AST feature pass did not normalize brace expansion before testing whether words were static.

Fix:
Run compound segments through the same complete policy recursively, preserve Bash prefix semantics with AST-aware raw matching, prioritize wildcard tokens in glob matching, and classify brace/extglob or other non-static arguments as exact-only expansions using mvdan syntax nodes.

Verification:
- go run github.com/golangci/golangci-lint/v2/cmd/[email protected].2 run --timeout=5m
- go test ./internal/shellparse ./internal/permission ./internal/control ./internal/boot ./internal/cli ./internal/acp
- go test -race ./internal/control
- go vet ./...
- env -u DEEPSEEK_API_KEY go test ./...
- gofmt -l .
- git diff --check

* fix(permission): detect indirect builtin execution

Problem:
Bash builtin eval and builtin source could inherit broad Bash authorization even though their direct forms require human approval.

Root cause:
The indirect-execution classifier recursively handled command, exec, env, and other wrappers, but omitted Bash's builtin wrapper.

Fix:
Treat builtin as a recursive command wrapper and add positive and negative regression cases so eval/source require a human while ordinary builtins stay reusable.

Verification:
- go test ./internal/shellparse ./internal/permission ./internal/control ./internal/boot ./internal/cli ./internal/acp
- go test -race ./internal/control
- go vet ./...
- gofmt -l .
- git diff --check
2026-07-27 22:09:03 +08:00

323 lines
8.3 KiB
Go

package shellparse
import (
"errors"
"reflect"
"strings"
"testing"
)
func TestStaticFields(t *testing.T) {
tests := []struct {
name string
command string
want []string
malformed bool
}{
{
name: "plain command",
command: "git status --short",
want: []string{"git", "status", "--short"},
},
{
name: "quoted static fields",
command: `grep 'a|b' "file name.txt"`,
want: []string{"grep", "a|b", "file name.txt"},
},
{
name: "escaped static field",
command: `find . -name scratch \-delete`,
want: []string{"find", ".", "-name", "scratch", "-delete"},
},
{
name: "redirect is shell syntax",
command: "git log >/dev/null",
malformed: true,
},
{
name: "control operator is shell syntax",
command: "git status && rm -rf /tmp/x",
malformed: true,
},
{
name: "parameter expansion is shell syntax",
command: "git diff $REV",
malformed: true,
},
{
name: "command substitution is shell syntax",
command: "echo $(touch out)",
malformed: true,
},
{
name: "assignment prefix is shell syntax",
command: "GIT_EXTERNAL_DIFF=cat git diff",
malformed: true,
},
{
name: "parse failure is malformed",
command: "echo 'unterminated",
malformed: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, malformed := StaticFields(tt.command)
if tt.malformed {
if malformed == "" {
t.Fatalf("StaticFields(%q) malformed = empty, want error", tt.command)
}
return
}
if malformed != "" {
t.Fatalf("StaticFields(%q) malformed = %q", tt.command, malformed)
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("StaticFields(%q) = %#v, want %#v", tt.command, got, tt.want)
}
})
}
}
func TestContainsUnquotedGlob(t *testing.T) {
tests := []struct {
command string
want bool
}{
{command: "rg TODO *.go", want: true},
{command: "rg TODO file?.go", want: true},
{command: "rg TODO [ab].go", want: true},
{command: `rg TODO "*.go"`},
{command: `rg TODO '*.go'`},
{command: `rg TODO \*.go`},
{command: "git status --short"},
}
for _, tt := range tests {
if got := ContainsUnquotedGlob(tt.command); got != tt.want {
t.Errorf("ContainsUnquotedGlob(%q) = %v, want %v", tt.command, got, tt.want)
}
}
}
func TestAnalyzeApprovalFeaturesMarksNonStaticArgumentsAsExpansion(t *testing.T) {
tests := []struct {
command string
want bool
}{
{command: "printf '%s\\n' {a,b}", want: true},
{command: "printf '%s\\n' @(a|b)", want: true},
{command: `printf '%s\\n' "{a,b}"`},
{command: `printf '%s\\n' \{a,b\}`},
}
for _, tt := range tests {
command := tt.command
features, ok := AnalyzeApprovalFeatures(command)
if !ok {
t.Fatalf("AnalyzeApprovalFeatures(%q) failed", command)
}
if features.Expansion != tt.want {
t.Errorf("AnalyzeApprovalFeatures(%q) expansion = %v, want %v", command, features.Expansion, tt.want)
}
}
}
func TestParseStaticCommandPolicy(t *testing.T) {
got, err := ParseStaticCommand(`FOO=bar MESSAGE='hello world' go test ./...`, StaticCommandPolicy{AllowEnvAssignments: true})
if err != nil {
t.Fatalf("ParseStaticCommand env assignment: %v", err)
}
if !reflect.DeepEqual(got.Env, []string{"FOO=bar", "MESSAGE=hello world"}) {
t.Fatalf("Env = %#v", got.Env)
}
if !reflect.DeepEqual(got.Argv, []string{"go", "test", "./..."}) {
t.Fatalf("Argv = %#v", got.Argv)
}
_, err = ParseStaticCommand(`FOO=bar go test`, StaticCommandPolicy{})
assertStaticRejectReason(t, err, StaticRejectAssignment)
_, err = ParseStaticCommand(`FOO=$(whoami) go test`, StaticCommandPolicy{AllowEnvAssignments: true})
assertStaticRejectReason(t, err, StaticRejectExpansion)
_, err = ParseStaticCommand(`go test ./... >out.txt`, StaticCommandPolicy{AllowEnvAssignments: true})
assertStaticRejectReason(t, err, StaticRejectRedirection)
got, err = ParseStaticCommand(`go test ./... 2>&1`, StaticCommandPolicy{AllowStderrToStdout: true})
if err != nil {
t.Fatalf("ParseStaticCommand stderr merge: %v", err)
}
if !got.MergeStderr {
t.Fatalf("MergeStderr = false, want true")
}
if !reflect.DeepEqual(got.Argv, []string{"go", "test", "./..."}) {
t.Fatalf("Argv with stderr merge = %#v", got.Argv)
}
_, err = ParseStaticCommand(`go test ./... 2>err.txt`, StaticCommandPolicy{AllowStderrToStdout: true})
assertStaticRejectReason(t, err, StaticRejectRedirection)
if _, malformed := StaticFields(`FOO=bar go test`); malformed != "shell control syntax" {
t.Fatalf("StaticFields assignment malformed = %q", malformed)
}
if _, malformed := StaticFields(`go test 2>&1`); malformed != "shell control syntax" {
t.Fatalf("StaticFields redirection malformed = %q", malformed)
}
}
func assertStaticRejectReason(t *testing.T, err error, want StaticRejectReason) {
t.Helper()
var reject *StaticRejectError
if !errors.As(err, &reject) {
t.Fatalf("error = %v (%T), want StaticRejectError", err, err)
}
if reject.Reason != want {
t.Fatalf("reason = %q, want %q (err=%v)", reject.Reason, want, err)
}
}
func TestContainsShellSyntax(t *testing.T) {
for _, command := range []string{
"git status && rm -rf /",
"cat a | tee b",
"git status > out.txt",
"echo $(rm x)",
"echo $HOME",
"echo `whoami`",
"sleep 1 &",
} {
if !ContainsShellSyntax(command) {
t.Fatalf("ContainsShellSyntax(%q) = false, want true", command)
}
}
for _, command := range []string{
"git status",
`grep 'a|b' file`,
`printf "%s\n" "a && b"`,
`find . -name scratch \-delete`,
} {
if ContainsShellSyntax(command) {
t.Fatalf("ContainsShellSyntax(%q) = true, want false", command)
}
}
}
func TestSplitTopLevel(t *testing.T) {
tests := []struct {
name string
command string
want []string
wantSplit bool
wantOK bool
}{
{
name: "atomic command",
command: "git status",
want: []string{"git status"},
wantOK: true,
},
{
name: "and chain",
command: `git add . && git commit -m "wip" && git push`,
want: []string{"git add .", `git commit -m "wip"`, "git push"},
wantSplit: true,
wantOK: true,
},
{
name: "semicolon chain",
command: "cd /tmp; ls -la",
want: []string{"cd /tmp", "ls -la"},
wantSplit: true,
wantOK: true,
},
{
name: "pipe",
command: "git log --oneline | head -20",
want: []string{"git log --oneline", "head -20"},
wantSplit: true,
wantOK: true,
},
{
name: "background",
command: "sleep 1 & echo done",
want: []string{"sleep 1", "echo done"},
wantSplit: true,
wantOK: true,
},
{
name: "operator inside quotes stays in segment",
command: `echo 'a && b' && ls`,
want: []string{`echo 'a && b'`, "ls"},
wantSplit: true,
wantOK: true,
},
{
name: "command substitution is opaque",
command: `echo $(git rev-parse HEAD; date) && ls`,
want: []string{`echo $(git rev-parse HEAD; date)`, "ls"},
wantSplit: true,
wantOK: true,
},
{
name: "process substitution is opaque",
command: "diff <(git log -1 | head) <(git show HEAD | head) && ls",
want: []string{
"diff <(git log -1 | head) <(git show HEAD | head)",
"ls",
},
wantSplit: true,
wantOK: true,
},
{
name: "comments are skipped",
command: "# comment\nshuf -i 1-30 -n 10 | sort -rn",
want: []string{
"shuf -i 1-30 -n 10",
"sort -rn",
},
wantSplit: true,
wantOK: true,
},
{
name: "heredoc fails closed",
command: "cat <<EOF && ls\nline1\nEOF",
wantOK: false,
},
{
name: "compound statement fails closed",
command: "if true; then ls; fi && pwd",
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, split, ok := SplitTopLevel(tt.command)
if ok != tt.wantOK {
t.Fatalf("ok = %v, want %v (segments=%#v)", ok, tt.wantOK, got)
}
if !ok {
return
}
if split != tt.wantSplit {
t.Fatalf("split = %v, want %v", split, tt.wantSplit)
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("segments = %#v, want %#v", got, tt.want)
}
})
}
}
func TestHasHereDoc(t *testing.T) {
file, err := ParseBash(strings.Join([]string{
"cat <<'EOF'",
"nohup sleep 60 >/dev/null 2>&1 &",
"EOF",
}, "\n"))
if err != nil {
t.Fatalf("ParseBash heredoc: %v", err)
}
if !HasHereDoc(file) {
t.Fatal("HasHereDoc = false, want true")
}
}