Skip to content
Open
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
5 changes: 3 additions & 2 deletions internal/agent/command_prefix.go
Original file line number Diff line number Diff line change
Expand Up @@ -414,15 +414,16 @@ func gitSubcommand(command []string) (int, string, bool) {

func gitOptionConsumesValue(arg string) bool {
switch arg {
case "-C", "-c", "--config-env", "--exec-path", "--git-dir", "--namespace", "--super-prefix", "--work-tree":
case "-C", "-c", "--attr-source", "--config-env", "--exec-path", "--git-dir", "--namespace", "--super-prefix", "--work-tree":
return true
default:
return false
}
}

func gitOptionHasInlineValue(arg string) bool {
return strings.HasPrefix(arg, "--config-env=") ||
return strings.HasPrefix(arg, "--attr-source=") ||
strings.HasPrefix(arg, "--config-env=") ||
strings.HasPrefix(arg, "--exec-path=") ||
strings.HasPrefix(arg, "--git-dir=") ||
strings.HasPrefix(arg, "--namespace=") ||
Expand Down
11 changes: 11 additions & 0 deletions internal/agent/command_prefix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ func TestProposedCommandPrefixHonorsValidatedRequestedPrefix(t *testing.T) {
}
}

func TestSafeGitCommandConsumesAttrSourceOptionValue(t *testing.T) {
for _, command := range [][]string{
{"git", "--attr-source", "HEAD", "status"},
{"git", "--attr-source=HEAD", "status"},
} {
if !safeGitCommand(command) {
t.Errorf("safeGitCommand(%q) = false; want true", command)
}
}
}

func TestProposedCommandPrefixSupportsSegmentedCommands(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"})
if runtime.GOOS == "windows" {
Expand Down
20 changes: 10 additions & 10 deletions internal/agent/loop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2428,22 +2428,22 @@ func TestRunPersistentCommandPrefixStillPromptsForNetwork(t *testing.T) {
}
}

func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
func TestRunApprovedGitPushPromptAppliesTurnNetworkGrant(t *testing.T) {
root := t.TempDir()
command := "PATH=.:$PATH curl https://example.com"
command := "PATH=.:$PATH git push gitlawb://example.com/repo.git main"
if runtime.GOOS == "windows" {
if windowsTestUsesPowerShell() {
command = `$env:PATH = '.;' + $env:PATH; curl.cmd https://example.com`
command = `$env:PATH = '.;' + $env:PATH; git.cmd push gitlawb://example.com/repo.git main`
} else {
command = "set PATH=.;%PATH% && curl https://example.com"
command = "set PATH=.;%PATH% && git push gitlawb://example.com/repo.git main"
}
fakeCurl := filepath.Join(root, "curl.cmd")
if err := os.WriteFile(fakeCurl, []byte("@echo fake curl %*\r\n"), 0o755); err != nil {
fakeGit := filepath.Join(root, "git.cmd")
if err := os.WriteFile(fakeGit, []byte("@echo fake git %*\r\n"), 0o755); err != nil {
t.Fatal(err)
}
} else {
fakeCurl := filepath.Join(root, "curl")
if err := os.WriteFile(fakeCurl, []byte("#!/bin/sh\necho fake curl \"$@\"\n"), 0o755); err != nil {
fakeGit := filepath.Join(root, "git")
if err := os.WriteFile(fakeGit, []byte("#!/bin/sh\necho fake git \"$@\"\n"), 0o755); err != nil {
t.Fatal(err)
}
}
Expand All @@ -2465,7 +2465,7 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
}
var requests []PermissionRequest
var events []PermissionEvent
result, err := Run(context.Background(), "curl", provider, Options{
result, err := Run(context.Background(), "push changes", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
Autonomy: "medium",
Expand Down Expand Up @@ -2507,7 +2507,7 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
t.Fatalf("expected tool result to be sent back to provider, got %d requests", len(provider.requests))
}
lastMessage := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
if !strings.Contains(lastMessage.Content, "fake curl https://example.com") {
if !strings.Contains(lastMessage.Content, "fake git push gitlawb://example.com/repo.git main") {
t.Fatalf("expected approved network command output after degraded execution, got %q", lastMessage.Content)
}
}
Expand Down
42 changes: 41 additions & 1 deletion internal/sandbox/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,14 +278,54 @@ func packageManagerOffline(words []string) bool {
}

func gitUsesNetwork(words []string) bool {
switch firstSubcommand(words, nil) {
switch gitSubcommand(words) {
case "clone", "fetch", "pull", "push", "ls-remote", "archive":
return true
default:
return false
}
}

// gitSubcommand resolves the subcommand past git's GLOBAL options. The generic
// firstSubcommand cannot: git's value-taking globals put their value in the next
// token, so scanning for the first non-dash token returns that value instead —
// `git -C repo push origin main` looked like the subcommand "repo" and so
// classified as no-network, dropping the proactive network prompt for the most
// common form of the command. internal/agent/command_prefix.go resolves the same
// option set for its own prefix matching.
func gitSubcommand(words []string) string {
for index := 0; index < len(words); index++ {
word := words[index]
if word == "" {
continue
}
if strings.HasPrefix(word, "-") {
// A joined value (--git-dir=/x, -C/x) is one token and needs no skip;
// a separated one puts its value in the next token.
if gitGlobalOptionConsumesValue(word) {
index++
}
continue
}
if isNumericToken(word) {
continue
}
return word
}
return ""
}

// gitGlobalOptionConsumesValue lists git's global options whose value is a
// SEPARATE token (mirrors gitOptionConsumesValue in internal/agent).
func gitGlobalOptionConsumesValue(option string) bool {
switch option {
case "-C", "-c", "--attr-source", "--config-env", "--exec-path", "--git-dir", "--namespace", "--super-prefix", "--work-tree":
return true
default:
return false
}
}

func npxUsesNetwork(_ []string) bool {
return true
}
Expand Down
17 changes: 17 additions & 0 deletions internal/sandbox/analyzer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,24 @@ func TestAnalyzeCommand(t *testing.T) {
{name: "next dev", script: "next dev", network: true},
{name: "git clone", script: "git clone https://example.com/repo.git", network: true},
{name: "git fetch", script: "git fetch origin", network: true},
{name: "git pull", script: "git pull origin main", network: true},
{name: "git push custom transport", script: "git push gitlawb://example.com/repo.git main", network: true},
{name: "git ls-remote", script: "git ls-remote origin", network: true},
{name: "git remote archive", script: "git archive --remote=origin HEAD", network: true},
{name: "git status is offline", script: "git status", network: false},
{name: "git local commit", script: `git commit -m "local change"`, network: false},
// git's value-taking global options put their value in the NEXT token, so a
// generic "first non-dash token" scan reads the value as the subcommand and
// misses the network verb entirely (issue #703 review).
{name: "git -C push", script: "git -C repo push origin main", network: true},
{name: "git -c config push", script: "git -c http.sslVerify=false push origin main", network: true},
{name: "git --git-dir fetch", script: "git --git-dir /repo/.git fetch origin", network: true},
{name: "git --work-tree pull", script: "git --work-tree /repo pull origin main", network: true},
{name: "git --attr-source push", script: "git --attr-source HEAD push origin main", network: true},
{name: "git -C local commit", script: `git -C repo commit -m "local change"`, network: false},
{name: "git.exe push", script: "git.exe push origin main", network: true},
{name: "git.cmd push", script: "git.cmd push origin main", network: true},
{name: "git.exe local commit", script: `git.exe commit -m "local change"`, network: false},
{name: "gh release download", script: "gh release download v1.0.0", network: true},
{name: "no network", script: "ls -la && echo done", network: false},
{name: "process pattern is not network", script: `pkill -f "python3 -m http.server 8000"`, network: false},
Expand Down
105 changes: 103 additions & 2 deletions internal/sandbox/risk.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@
// unparseableNetworkPattern is used only after the shell parser fails. At
// that point the command is already marked too complex, so this intentionally
// favors catching obvious network programs over proving exact shell syntax.
unparseableNetworkPattern = regexp.MustCompile(`(?i)\b(curl|wget|fetch|aria2c|ssh|scp|sftp|rsync|nc|ncat|netcat|telnet|ftp|npx|http-server|vite|next|nuxt|astro)\b|\b(npm|pnpm|yarn|bun|pip|pip2|pip3)\s+(install|add|publish|login|start|serve|dev|preview|run\s+(start|serve|dev|preview)|exec|x|dlx)\b|\bgo\s+get\b|\bgit\s+clone\b|\bpython(2|3)?\s+-m\s+(http\.server|pip\s+install)\b|\bgh\s+(api|repo\s+clone|release\s+download)\b`)
// Git needs token-aware handling below: a regex cannot reliably distinguish
// option values and executable path components from subcommands.
unparseableNetworkPattern = regexp.MustCompile(`(?i)^(?:(curl|wget|fetch|aria2c|ssh|scp|sftp|rsync|nc|ncat|netcat|telnet|ftp|npx|http-server|vite|next|nuxt|astro)(\s|$)|(npm|pnpm|yarn|bun|pip|pip2|pip3)\s+(install|add|publish|login|start|serve|dev|preview|run\s+(start|serve|dev|preview)|exec|x|dlx)\b|go\s+get\b|python(2|3)?\s+-m\s+(http\.server|pip\s+install)\b|gh\s+(api|repo\s+clone|release\s+download)\b)`)
// destructiveExtraPatterns hold high-severity patterns that the legacy
// destructiveCommandPattern does not already cover. Folded in from the
// blueprint safe_bash.go without duplicating existing matches.
Expand Down Expand Up @@ -67,7 +69,106 @@
}

func matchesUnparseableNetwork(command string) bool {
return unparseableNetworkPattern.MatchString(command)
commands := fallbackCommandTokens(command)
for _, tokens := range commands {
if len(tokens) == 0 {
continue
}
if isGitExecutableToken(tokens[0]) && matchesUnparseableGitNetwork(tokens) {
return true
}
executable := executableTokenBase(tokens[0])
if unparseableNetworkPattern.MatchString(strings.Join(append([]string{executable}, tokens[1:]...), " ")) {
return true
}
}
return false
}

func matchesUnparseableGitNetwork(tokens []string) bool {
for j := 1; j < len(tokens); j++ {
word := strings.ToLower(tokens[j])
if word == "--help" || word == "--version" {
break
}
if strings.HasPrefix(word, "-") {
if gitGlobalOptionConsumesValue(word) {
j++
}
continue
}
switch word {
case "clone", "fetch", "pull", "push", "ls-remote", "archive":
return true
}
break
}
return false
}

func isGitExecutableToken(token string) bool {
switch executableTokenBase(token) {
case "git", "git.exe", "git.cmd", "git.bat", "git.com":
return true
default:
return false
}
}

func executableTokenBase(token string) string {
token = strings.Trim(token, `\"'`)
if slash := strings.LastIndexAny(token, `/\\`); slash >= 0 {
token = token[slash+1:]
}
return strings.ToLower(token)
}

// fallbackCommandTokens performs deliberately small shell/cmd tokenization.
// It preserves quoted spaces even when the command's trailing quote is
// unmatched (the condition that sends classification down this fallback).
func fallbackCommandTokens(command string) [][]string {
// Command strings commonly preserve cmd.exe's escaped quote spelling.
command = strings.ReplaceAll(command, `\"`, `"`)
var commands [][]string
var tokens []string
var word strings.Builder
var quote rune
flush := func() {
if word.Len() > 0 {
tokens = append(tokens, word.String())
word.Reset()
}
}
flushCommand := func() {
flush()
if len(tokens) > 0 {
commands = append(commands, tokens)
tokens = nil
}
}
for _, r := range command {
if r == '\'' || r == '"' {
if quote == 0 {

Check failure on line 151 in internal/sandbox/risk.go

View workflow job for this annotation

GitHub Actions / Security & code health

QF1003: could use tagged switch on quote (staticcheck)

Check failure on line 151 in internal/sandbox/risk.go

View workflow job for this annotation

GitHub Actions / Smoke (windows-latest)

QF1003: could use tagged switch on quote (staticcheck)
quote = r
} else if quote == r {
quote = 0
} else {
word.WriteRune(r)
}
continue
}
if quote == 0 && (r == ';' || r == '&' || r == '|') {
flushCommand()
continue
}
if quote == 0 && (r == ' ' || r == '\t' || r == '\r' || r == '\n') {
flush()
continue
}
word.WriteRune(r)
}
flushCommand()
return commands
}

func Classify(request Request) Risk {
Expand Down
Loading
Loading