From 7a2294582939ecba4097a0660986d7d916703f40 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Sat, 18 Jul 2026 15:52:31 +0200 Subject: [PATCH 1/8] fix(sandbox): classify git push as network access --- internal/sandbox/analyzer.go | 7 ++++++- internal/sandbox/analyzer_test.go | 2 ++ internal/sandbox/risk_hardening_test.go | 1 + 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/sandbox/analyzer.go b/internal/sandbox/analyzer.go index 9509c5a81..400d0607f 100644 --- a/internal/sandbox/analyzer.go +++ b/internal/sandbox/analyzer.go @@ -150,7 +150,12 @@ func commandUsesNetwork(prog string, args []*syntax.Word) bool { case "go": return firstSubcommand(words, nil) == "get" case "git": - return firstSubcommand(words, nil) == "clone" + switch firstSubcommand(words, nil) { + case "clone", "push": + return true + default: + return false + } case "gh": return ghUsesNetwork(words) default: diff --git a/internal/sandbox/analyzer_test.go b/internal/sandbox/analyzer_test.go index 1154922b4..070cadc3e 100644 --- a/internal/sandbox/analyzer_test.go +++ b/internal/sandbox/analyzer_test.go @@ -56,6 +56,8 @@ func TestAnalyzeCommand(t *testing.T) { {name: "direct vite", script: "vite --host 127.0.0.1", network: true}, {name: "next dev", script: "next dev", network: true}, {name: "git clone", script: "git clone https://example.com/repo.git", network: true}, + {name: "git push custom remote", script: "git push gitlawb main", network: true}, + {name: "git local commit", script: `git 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}, diff --git a/internal/sandbox/risk_hardening_test.go b/internal/sandbox/risk_hardening_test.go index ce2f89994..b105d4b98 100644 --- a/internal/sandbox/risk_hardening_test.go +++ b/internal/sandbox/risk_hardening_test.go @@ -273,6 +273,7 @@ func TestClassifyASTCatchesNetworkProgramsRegexMisses(t *testing.T) { "ftp ftp.example.com", "sftp user@host", "sudo telnet example.com 23", + "git push gitlawb main", } { risk := classifyCommand(command) if risk.Level != RiskCritical || !HasRiskCategory(risk, "network") { From d72a19229d21ed94336f59c90880c14d05278195 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Sat, 18 Jul 2026 22:14:33 +0200 Subject: [PATCH 2/8] fix(sandbox): harden git network classification --- internal/agent/loop_test.go | 18 +++++++++--------- internal/sandbox/analyzer.go | 2 +- internal/sandbox/analyzer_test.go | 4 +++- internal/sandbox/risk.go | 2 +- internal/sandbox/risk_hardening_test.go | 23 ++++++++++++++++------- 5 files changed, 30 insertions(+), 19 deletions(-) diff --git a/internal/agent/loop_test.go b/internal/agent/loop_test.go index 485918f56..5031158d3 100644 --- a/internal/agent/loop_test.go +++ b/internal/agent/loop_test.go @@ -2308,18 +2308,18 @@ 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" { - command = "set PATH=.;%PATH% && curl https://example.com" - fakeCurl := filepath.Join(root, "curl.cmd") - if err := os.WriteFile(fakeCurl, []byte("@echo fake curl %*\r\n"), 0o755); err != nil { + command = "set PATH=.;%PATH% && git push gitlawb://example.com/repo.git main" + 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) } } @@ -2341,7 +2341,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", @@ -2383,7 +2383,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) } } diff --git a/internal/sandbox/analyzer.go b/internal/sandbox/analyzer.go index 400d0607f..aeb93f058 100644 --- a/internal/sandbox/analyzer.go +++ b/internal/sandbox/analyzer.go @@ -151,7 +151,7 @@ func commandUsesNetwork(prog string, args []*syntax.Word) bool { return firstSubcommand(words, nil) == "get" case "git": switch firstSubcommand(words, nil) { - case "clone", "push": + case "clone", "fetch", "pull", "push": return true default: return false diff --git a/internal/sandbox/analyzer_test.go b/internal/sandbox/analyzer_test.go index 070cadc3e..2ce0f6adc 100644 --- a/internal/sandbox/analyzer_test.go +++ b/internal/sandbox/analyzer_test.go @@ -56,7 +56,9 @@ func TestAnalyzeCommand(t *testing.T) { {name: "direct vite", script: "vite --host 127.0.0.1", network: true}, {name: "next dev", script: "next dev", network: true}, {name: "git clone", script: "git clone https://example.com/repo.git", network: true}, - {name: "git push custom remote", script: "git push gitlawb main", 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 local commit", script: `git 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}, diff --git a/internal/sandbox/risk.go b/internal/sandbox/risk.go index a0e784f67..a383d3760 100644 --- a/internal/sandbox/risk.go +++ b/internal/sandbox/risk.go @@ -33,7 +33,7 @@ var ( // 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`) + 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|fetch|pull|push)\b|\bpython(2|3)?\s+-m\s+(http\.server|pip\s+install)\b|\bgh\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. diff --git a/internal/sandbox/risk_hardening_test.go b/internal/sandbox/risk_hardening_test.go index b105d4b98..c407773ee 100644 --- a/internal/sandbox/risk_hardening_test.go +++ b/internal/sandbox/risk_hardening_test.go @@ -273,7 +273,9 @@ func TestClassifyASTCatchesNetworkProgramsRegexMisses(t *testing.T) { "ftp ftp.example.com", "sftp user@host", "sudo telnet example.com 23", - "git push gitlawb main", + "git fetch origin", + "git pull origin main", + "git push gitlawb://example.com/repo.git main", } { risk := classifyCommand(command) if risk.Level != RiskCritical || !HasRiskCategory(risk, "network") { @@ -292,12 +294,19 @@ func TestClassifyFlagsUnparseableCommand(t *testing.T) { } func TestClassifyUnparseableNetworkCommandFailsClosed(t *testing.T) { - risk := classifyCommand(`curl https://example.com && "unterminated`) - if !HasRiskCategory(risk, "unparseable_command") { - t.Fatalf("Classify of unparseable network command = categories %v; want unparseable_command", risk.Categories) - } - if risk.Level != RiskCritical || !HasRiskCategory(risk, "network") { - t.Fatalf("Classify of unparseable network command = level %s, categories %v; want critical network", risk.Level, risk.Categories) + for _, command := range []string{ + `curl https://example.com && "unterminated`, + `git fetch origin && "unterminated`, + `git pull origin main && "unterminated`, + `git push gitlawb://example.com/repo.git main && "unterminated`, + } { + risk := classifyCommand(command) + if !HasRiskCategory(risk, "unparseable_command") { + t.Fatalf("Classify(%q) = categories %v; want unparseable_command", command, risk.Categories) + } + if risk.Level != RiskCritical || !HasRiskCategory(risk, "network") { + t.Fatalf("Classify(%q) = level %s, categories %v; want critical network", command, risk.Level, risk.Categories) + } } } From 5a7c67265e7262a1829a92fb396b950b01fe936f Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Sun, 19 Jul 2026 16:12:33 +0200 Subject: [PATCH 3/8] fix(sandbox): handle git options in network fallback --- internal/sandbox/risk.go | 2 +- internal/sandbox/risk_hardening_test.go | 17 ++++++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/internal/sandbox/risk.go b/internal/sandbox/risk.go index a383d3760..256171706 100644 --- a/internal/sandbox/risk.go +++ b/internal/sandbox/risk.go @@ -33,7 +33,7 @@ var ( // 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|fetch|pull|push)\b|\bpython(2|3)?\s+-m\s+(http\.server|pip\s+install)\b|\bgh\s+(api|repo\s+clone|release\s+download)\b`) + 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+[^\s;&|]+){0,8}\s+(clone|fetch|pull|push)\b|\bpython(2|3)?\s+-m\s+(http\.server|pip\s+install)\b|\bgh\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. diff --git a/internal/sandbox/risk_hardening_test.go b/internal/sandbox/risk_hardening_test.go index c407773ee..916785579 100644 --- a/internal/sandbox/risk_hardening_test.go +++ b/internal/sandbox/risk_hardening_test.go @@ -299,14 +299,17 @@ func TestClassifyUnparseableNetworkCommandFailsClosed(t *testing.T) { `git fetch origin && "unterminated`, `git pull origin main && "unterminated`, `git push gitlawb://example.com/repo.git main && "unterminated`, + `git -C repo push gitlawb://example.com/repo.git main && "unterminated`, } { - risk := classifyCommand(command) - if !HasRiskCategory(risk, "unparseable_command") { - t.Fatalf("Classify(%q) = categories %v; want unparseable_command", command, risk.Categories) - } - if risk.Level != RiskCritical || !HasRiskCategory(risk, "network") { - t.Fatalf("Classify(%q) = level %s, categories %v; want critical network", command, risk.Level, risk.Categories) - } + t.Run(command, func(t *testing.T) { + risk := classifyCommand(command) + if !HasRiskCategory(risk, "unparseable_command") { + t.Errorf("Classify(%q) = categories %v; want unparseable_command", command, risk.Categories) + } + if risk.Level != RiskCritical || !HasRiskCategory(risk, "network") { + t.Errorf("Classify(%q) = level %s, categories %v; want critical network", command, risk.Level, risk.Categories) + } + }) } } From d54e97130374d2d3cf5fce108ffbc7bd57c701ab Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Sat, 25 Jul 2026 23:31:22 +0200 Subject: [PATCH 4/8] fix(sandbox): recognize --attr-source and widen the unparseable git fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gitSubcommand (and its mirror in internal/agent/command_prefix.go) was missing --attr-source from git's value-taking global options, so `git --attr-source HEAD push origin main` resolved to the wrong subcommand and never got classified as network access. The unparseable-command regex fallback used when the shell parser fails now also matches an optional .exe suffix, so a Windows form like `git.exe push origin main & rem '` — valid under cmd.exe but rejected by the POSIX parser AnalyzeCommand uses — still classifies as network. The generic-token scan before the subcommand no longer caps at 8 tokens; Go's regexp package is RE2-backed (linear time, no backtracking blowup), so the cap only served to silently drop coverage once a git invocation had more than a handful of value-taking global options. Addresses review feedback from jatmn on PR #726. Co-Authored-By: Claude Sonnet 5 --- internal/agent/command_prefix.go | 5 +++-- internal/sandbox/analyzer.go | 2 +- internal/sandbox/analyzer_test.go | 1 + internal/sandbox/risk.go | 10 +++++++++- internal/sandbox/risk_hardening_test.go | 8 ++++++++ 5 files changed, 22 insertions(+), 4 deletions(-) diff --git a/internal/agent/command_prefix.go b/internal/agent/command_prefix.go index 2d0f5a606..dffc472a7 100644 --- a/internal/agent/command_prefix.go +++ b/internal/agent/command_prefix.go @@ -414,7 +414,7 @@ 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 @@ -422,7 +422,8 @@ func gitOptionConsumesValue(arg string) bool { } 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=") || diff --git a/internal/sandbox/analyzer.go b/internal/sandbox/analyzer.go index 4f24b3fd1..cb328d134 100644 --- a/internal/sandbox/analyzer.go +++ b/internal/sandbox/analyzer.go @@ -305,7 +305,7 @@ func gitSubcommand(words []string) string { // SEPARATE token (mirrors gitOptionConsumesValue in internal/agent). func gitGlobalOptionConsumesValue(option string) bool { switch option { - 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 diff --git a/internal/sandbox/analyzer_test.go b/internal/sandbox/analyzer_test.go index 78d13ed8a..49bf4242b 100644 --- a/internal/sandbox/analyzer_test.go +++ b/internal/sandbox/analyzer_test.go @@ -74,6 +74,7 @@ func TestAnalyzeCommand(t *testing.T) { {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.exe local commit", script: `git.exe commit -m "local change"`, network: false}, diff --git a/internal/sandbox/risk.go b/internal/sandbox/risk.go index 256171706..7e684ec29 100644 --- a/internal/sandbox/risk.go +++ b/internal/sandbox/risk.go @@ -33,7 +33,15 @@ var ( // 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+[^\s;&|]+){0,8}\s+(clone|fetch|pull|push)\b|\bpython(2|3)?\s+-m\s+(http\.server|pip\s+install)\b|\bgh\s+(api|repo\s+clone|release\s+download)\b`) + // The git branch matches an optional .exe suffix (a parser-failing Windows + // command like `git.exe push origin main & rem '` runs under cmd.exe and + // must still classify as network) and scans an unbounded run of + // non-separator tokens before the subcommand rather than capping at a + // fixed count — Go's regexp is RE2-backed (linear time, no backtracking + // blowup), so nothing is gained by capping, and a git invocation with more + // than a handful of value-taking global options would otherwise silently + // fall through uncapped. + 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(?:\.exe)?(?:\s+[^\s;&|]+)*\s+(clone|fetch|pull|push)\b|\bpython(2|3)?\s+-m\s+(http\.server|pip\s+install)\b|\bgh\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. diff --git a/internal/sandbox/risk_hardening_test.go b/internal/sandbox/risk_hardening_test.go index 0eff6eb18..340cec7fd 100644 --- a/internal/sandbox/risk_hardening_test.go +++ b/internal/sandbox/risk_hardening_test.go @@ -340,6 +340,14 @@ func TestClassifyUnparseableNetworkCommandFailsClosed(t *testing.T) { `git pull origin main && "unterminated`, `git push gitlawb://example.com/repo.git main && "unterminated`, `git -C repo push gitlawb://example.com/repo.git main && "unterminated`, + // git.exe runs under cmd.exe, which has no notion of a trailing single + // quote — this parses fine there but fails the POSIX shell parser used + // by AnalyzeCommand, so it must still be caught by the regex fallback. + `git.exe push origin main & rem '`, + // More value-taking global options than the fallback regex used to cap + // its generic-token scan at (formerly {0,8}) — every option here still + // precedes the actual subcommand. + `git -c a=1 -c b=2 -c c=3 -c d=4 -c e=5 push gitlawb://example.com/repo.git main && "unterminated`, } { t.Run(command, func(t *testing.T) { risk := classifyCommand(command) From 708c2c241921e2814f41c367b335b9979d481dd9 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Sun, 26 Jul 2026 16:13:10 +0200 Subject: [PATCH 5/8] fix(sandbox): restrict git fallback regex to recognized global options The unparseable-command regex fallback skipped any run of non-separator tokens before the git subcommand verb, so an arbitrary bare token (e.g. "status" in `git status push`, where push is only a pathspec) was mistaken for a git global option. This misclassified local-only commands as critical network on the unparseable fallback path. Restrict the skipped span to git's actual global options, mirroring gitGlobalOptionConsumesValue in analyzer.go: recognized value-taking options (-C, -c, --attr-source, --config-env, --exec-path, --git-dir, --namespace, --super-prefix, --work-tree) consume one following token, and any other dash-prefixed token (including joined --opt=value forms) is skipped on its own. A bare non-option token still halts the scan. Co-Authored-By: Claude Sonnet 5 --- internal/sandbox/risk.go | 20 +++++++++++++------- internal/sandbox/risk_hardening_test.go | 22 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/internal/sandbox/risk.go b/internal/sandbox/risk.go index 7e684ec29..e0ddf734a 100644 --- a/internal/sandbox/risk.go +++ b/internal/sandbox/risk.go @@ -35,13 +35,19 @@ var ( // favors catching obvious network programs over proving exact shell syntax. // The git branch matches an optional .exe suffix (a parser-failing Windows // command like `git.exe push origin main & rem '` runs under cmd.exe and - // must still classify as network) and scans an unbounded run of - // non-separator tokens before the subcommand rather than capping at a - // fixed count — Go's regexp is RE2-backed (linear time, no backtracking - // blowup), so nothing is gained by capping, and a git invocation with more - // than a handful of value-taking global options would otherwise silently - // fall through uncapped. - 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(?:\.exe)?(?:\s+[^\s;&|]+)*\s+(clone|fetch|pull|push)\b|\bpython(2|3)?\s+-m\s+(http\.server|pip\s+install)\b|\bgh\s+(api|repo\s+clone|release\s+download)\b`) + // must still classify as network) and, before the subcommand, skips only + // git's own global options — mirroring gitGlobalOptionConsumesValue in + // analyzer.go: a recognized value-taking option (-C, -c, --attr-source, + // --config-env, --exec-path, --git-dir, --namespace, --super-prefix, + // --work-tree) consumes one following token as its value, and any other + // dash-prefixed token (including a joined `--git-dir=/x` form) is skipped + // on its own. This span is unbounded — Go's regexp is RE2-backed (linear + // time, no backtracking blowup) — so a git invocation with any number of + // global options still reaches its subcommand. Critically, a bare + // non-option token (e.g. the "status" in `git status push`, where "push" + // is only a pathspec) does NOT match either alternative, so the scan stops + // and the command is correctly left unclassified as network. + 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(?:\.exe)?(?:\s+(?:-C|-c|--attr-source|--config-env|--exec-path|--git-dir|--namespace|--super-prefix|--work-tree)\s+[^\s;&|]+|\s+-[^\s;&|]+)*\s+(clone|fetch|pull|push)\b|\bpython(2|3)?\s+-m\s+(http\.server|pip\s+install)\b|\bgh\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. diff --git a/internal/sandbox/risk_hardening_test.go b/internal/sandbox/risk_hardening_test.go index 340cec7fd..69c84cf6f 100644 --- a/internal/sandbox/risk_hardening_test.go +++ b/internal/sandbox/risk_hardening_test.go @@ -361,6 +361,28 @@ func TestClassifyUnparseableNetworkCommandFailsClosed(t *testing.T) { } } +// TestClassifyUnparseableNonGitOptionTokenStaysNonNetwork guards against the +// fallback regex treating an arbitrary bare token before a network verb as if +// it were a git global option. `status` in `git status push` is a pathspec +// argument to `git status`, not a value-taking global option, so `push` here +// is not the git subcommand and must not be classified as network — even +// though the trailing unmatched quote (a cmd.exe REM comment, invalid under +// the POSIX parser AnalyzeCommand uses) still forces the unparseable-command +// fallback path. +func TestClassifyUnparseableNonGitOptionTokenStaysNonNetwork(t *testing.T) { + command := `git status push & rem '` + risk := classifyCommand(command) + if !HasRiskCategory(risk, "unparseable_command") { + t.Fatalf("Classify(%q) = categories %v; want unparseable_command", command, risk.Categories) + } + if HasRiskCategory(risk, "network") { + t.Fatalf("Classify(%q) = level %s, categories %v; want no network category", command, risk.Level, risk.Categories) + } + if risk.Level != RiskHigh { + t.Fatalf("Classify(%q) = level %s; want high (unparseable_command only)", command, risk.Level) + } +} + func TestClassifyASTDoesNotFlagQuotedProgramName(t *testing.T) { // A program name inside a quoted argument is not a command, so the AST // analyzer must not flag it (documenting a destructive command in an echo). From f5ad5dee37a2ee6276e8f444dc09e9674d099325 Mon Sep 17 00:00:00 2001 From: Amp Date: Sun, 26 Jul 2026 20:42:00 +0000 Subject: [PATCH 6/8] fix(sandbox): preserve quoted git fallback tokens Co-authored-by: Pierre Bruno --- internal/sandbox/risk.go | 20 +++++++++++--------- internal/sandbox/risk_hardening_test.go | 4 ++++ 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/internal/sandbox/risk.go b/internal/sandbox/risk.go index e0ddf734a..28ac9ff54 100644 --- a/internal/sandbox/risk.go +++ b/internal/sandbox/risk.go @@ -39,15 +39,17 @@ var ( // git's own global options — mirroring gitGlobalOptionConsumesValue in // analyzer.go: a recognized value-taking option (-C, -c, --attr-source, // --config-env, --exec-path, --git-dir, --namespace, --super-prefix, - // --work-tree) consumes one following token as its value, and any other - // dash-prefixed token (including a joined `--git-dir=/x` form) is skipped - // on its own. This span is unbounded — Go's regexp is RE2-backed (linear - // time, no backtracking blowup) — so a git invocation with any number of - // global options still reaches its subcommand. Critically, a bare - // non-option token (e.g. the "status" in `git status push`, where "push" - // is only a pathspec) does NOT match either alternative, so the scan stops - // and the command is correctly left unclassified as network. - 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(?:\.exe)?(?:\s+(?:-C|-c|--attr-source|--config-env|--exec-path|--git-dir|--namespace|--super-prefix|--work-tree)\s+[^\s;&|]+|\s+-[^\s;&|]+)*\s+(clone|fetch|pull|push)\b|\bpython(2|3)?\s+-m\s+(http\.server|pip\s+install)\b|\bgh\s+(api|repo\s+clone|release\s+download)\b`) + // --work-tree) consumes one following token as its value, including a + // single- or double-quoted value, and any other dash-prefixed token + // (including a joined `--git-dir=/x` form) is skipped on its own. The + // subcommand may also be quoted, as cmd.exe permits `git "push" ...`. + // This span is unbounded — Go's regexp is RE2-backed (linear time, no + // backtracking blowup) — so a git invocation with any number of global + // options still reaches its subcommand. Critically, a bare non-option token + // (e.g. the "status" in `git status push`, where "push" is only a pathspec) + // does NOT match either alternative, so the scan stops and the command is + // correctly left unclassified as network. + 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(?:\.exe)?(?:\s+(?:-C|-c|--attr-source|--config-env|--exec-path|--git-dir|--namespace|--super-prefix|--work-tree)\s+(?:"[^"]*"|'[^']*'|[^\s;&|]+)|\s+-[^\s;&|]+)*\s+(?:"(?:clone|fetch|pull|push)"|'(?:clone|fetch|pull|push)'|(clone|fetch|pull|push)\b)|\bpython(2|3)?\s+-m\s+(http\.server|pip\s+install)\b|\bgh\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. diff --git a/internal/sandbox/risk_hardening_test.go b/internal/sandbox/risk_hardening_test.go index 69c84cf6f..f62fbe0f5 100644 --- a/internal/sandbox/risk_hardening_test.go +++ b/internal/sandbox/risk_hardening_test.go @@ -344,6 +344,10 @@ func TestClassifyUnparseableNetworkCommandFailsClosed(t *testing.T) { // quote — this parses fine there but fails the POSIX shell parser used // by AnalyzeCommand, so it must still be caught by the regex fallback. `git.exe push origin main & rem '`, + // cmd.exe accepts quoted option values and verbs. Preserve those token + // boundaries when the trailing REM quote forces the fallback path. + `git.exe -C "C:\Program Files\repo" push origin main & rem '`, + `git.exe -C "C:\Program Files\repo" "push" origin main & rem '`, // More value-taking global options than the fallback regex used to cap // its generic-token scan at (formerly {0,8}) — every option here still // precedes the actual subcommand. From 89c8e38cbbafa82969f323993e05792c7a439fb5 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 27 Jul 2026 21:22:50 +0000 Subject: [PATCH 7/8] fix(sandbox): complete Windows git fallback coverage Amp-Thread-ID: https://ampcode.com/threads/T-019fa55a-70fb-71ad-816d-7bf849ceb6c4 Co-authored-by: Pierre Bruno --- internal/agent/command_prefix_test.go | 11 +++++++ internal/sandbox/analyzer.go | 14 +-------- internal/sandbox/analyzer_test.go | 3 ++ internal/sandbox/risk.go | 18 ++++++----- internal/sandbox/risk_hardening_test.go | 42 ++++++++++++++++++------- 5 files changed, 55 insertions(+), 33 deletions(-) diff --git a/internal/agent/command_prefix_test.go b/internal/agent/command_prefix_test.go index 69508a67c..6a4ca30f7 100644 --- a/internal/agent/command_prefix_test.go +++ b/internal/agent/command_prefix_test.go @@ -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" { diff --git a/internal/sandbox/analyzer.go b/internal/sandbox/analyzer.go index 082b79d76..6368eb1a3 100644 --- a/internal/sandbox/analyzer.go +++ b/internal/sandbox/analyzer.go @@ -453,7 +453,7 @@ func effectiveProgram(args []*syntax.Word) (string, []*syntax.Word) { if isNumericToken(text) { continue } - token := trimWindowsExecutableExtension(normalizeProgramToken(text)) + token := normalizeProgramToken(text) if wrapperPrograms[token] { wrapper = token continue @@ -463,18 +463,6 @@ func effectiveProgram(args []*syntax.Word) (string, []*syntax.Word) { return "", nil } -// trimWindowsExecutableExtension maps git.exe to git, curl.exe to curl, and so -// on, so a Windows spelling reaches the same classification as the bare name. -// Only ".exe" is trimmed: a .bat/.cmd of the same stem is a separate script, not -// the program it is named after, so classifying it as that program would be a -// guess. normalizeProgramToken has already lowercased the token. -func trimWindowsExecutableExtension(token string) string { - if trimmed := strings.TrimSuffix(token, ".exe"); trimmed != "" { - return trimmed - } - return token -} - // dashCPayload returns the literal text of the word following `-c` in an AST arg // list (the command a shell launcher will run), or "" when there is none. func dashCPayload(args []*syntax.Word) string { diff --git a/internal/sandbox/analyzer_test.go b/internal/sandbox/analyzer_test.go index c6185e613..daa9e11ad 100644 --- a/internal/sandbox/analyzer_test.go +++ b/internal/sandbox/analyzer_test.go @@ -86,6 +86,8 @@ func TestAnalyzeCommand(t *testing.T) { {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 @@ -98,6 +100,7 @@ func TestAnalyzeCommand(t *testing.T) { {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}, diff --git a/internal/sandbox/risk.go b/internal/sandbox/risk.go index 28ac9ff54..aa879bcf4 100644 --- a/internal/sandbox/risk.go +++ b/internal/sandbox/risk.go @@ -33,23 +33,25 @@ var ( // 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. - // The git branch matches an optional .exe suffix (a parser-failing Windows - // command like `git.exe push origin main & rem '` runs under cmd.exe and - // must still classify as network) and, before the subcommand, skips only + // The git branch accepts Windows executable suffixes and a closing quote + // from a fully qualified path (a parser-failing command like + // `"C:\Program Files\Git\cmd\git.exe" push ... & rem '` runs under cmd.exe + // and must still classify as network). Before the subcommand, it skips only // git's own global options — mirroring gitGlobalOptionConsumesValue in // analyzer.go: a recognized value-taking option (-C, -c, --attr-source, // --config-env, --exec-path, --git-dir, --namespace, --super-prefix, - // --work-tree) consumes one following token as its value, including a - // single- or double-quoted value, and any other dash-prefixed token - // (including a joined `--git-dir=/x` form) is skipped on its own. The - // subcommand may also be quoted, as cmd.exe permits `git "push" ...`. + // --work-tree) consumes one following token as its value, and any other + // dash-prefixed token is skipped on its own. Both branches preserve quoted + // fragments, including spaces in separate or joined option values such as + // `-C "C:\Program Files\repo"` and `--git-dir="C:\Program Files\repo\.git"`. + // The subcommand may also be quoted, as cmd.exe permits `git "push" ...`. // This span is unbounded — Go's regexp is RE2-backed (linear time, no // backtracking blowup) — so a git invocation with any number of global // options still reaches its subcommand. Critically, a bare non-option token // (e.g. the "status" in `git status push`, where "push" is only a pathspec) // does NOT match either alternative, so the scan stops and the command is // correctly left unclassified as network. - 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(?:\.exe)?(?:\s+(?:-C|-c|--attr-source|--config-env|--exec-path|--git-dir|--namespace|--super-prefix|--work-tree)\s+(?:"[^"]*"|'[^']*'|[^\s;&|]+)|\s+-[^\s;&|]+)*\s+(?:"(?:clone|fetch|pull|push)"|'(?:clone|fetch|pull|push)'|(clone|fetch|pull|push)\b)|\bpython(2|3)?\s+-m\s+(http\.server|pip\s+install)\b|\bgh\s+(api|repo\s+clone|release\s+download)\b`) + 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(?:\.(?:exe|cmd|bat|com))?["']?(?:\s+(?:(?:(?:-C|-c|--attr-source|--config-env|--exec-path|--git-dir|--namespace|--super-prefix|--work-tree)|"(?:-C|-c|--attr-source|--config-env|--exec-path|--git-dir|--namespace|--super-prefix|--work-tree)"|'(?:-C|-c|--attr-source|--config-env|--exec-path|--git-dir|--namespace|--super-prefix|--work-tree)')\s+(?:[^\s;&|"']|"[^"]*"|'[^']*')+|(?:-(?:[^\s;&|"']|"[^"]*"|'[^']*')+|"-[^"]*"|'-[^']*')))*\s+(?:"(?:clone|fetch|pull|push|ls-remote|archive)"|'(?:clone|fetch|pull|push|ls-remote|archive)'|(clone|fetch|pull|push|ls-remote|archive)\b)|\bpython(2|3)?\s+-m\s+(http\.server|pip\s+install)\b|\bgh\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. diff --git a/internal/sandbox/risk_hardening_test.go b/internal/sandbox/risk_hardening_test.go index f62fbe0f5..bb2829d4a 100644 --- a/internal/sandbox/risk_hardening_test.go +++ b/internal/sandbox/risk_hardening_test.go @@ -339,15 +339,23 @@ func TestClassifyUnparseableNetworkCommandFailsClosed(t *testing.T) { `git fetch origin && "unterminated`, `git pull origin main && "unterminated`, `git push gitlawb://example.com/repo.git main && "unterminated`, + `git ls-remote gitlawb://example.com/repo.git & rem '`, + `git archive --remote=gitlawb://example.com/repo.git HEAD & rem '`, `git -C repo push gitlawb://example.com/repo.git main && "unterminated`, // git.exe runs under cmd.exe, which has no notion of a trailing single // quote — this parses fine there but fails the POSIX shell parser used // by AnalyzeCommand, so it must still be caught by the regex fallback. `git.exe push origin main & rem '`, - // cmd.exe accepts quoted option values and verbs. Preserve those token - // boundaries when the trailing REM quote forces the fallback path. + `git.cmd push origin main & rem '`, + // cmd.exe accepts quoted executable paths, option values, and verbs. + // Preserve those token boundaries when the trailing REM quote forces the + // fallback path, including joined short and long option-value forms. + `"C:\Program Files\Git\cmd\git.exe" "push" origin main & rem '`, `git.exe -C "C:\Program Files\repo" push origin main & rem '`, `git.exe -C "C:\Program Files\repo" "push" origin main & rem '`, + `git.exe -C"C:\Program Files\repo" "push" origin main & rem '`, + `git.exe --git-dir="C:\Program Files\repo\.git" push origin main & rem '`, + `git.exe "--git-dir=C:\Program Files\repo\.git" push origin main & rem '`, // More value-taking global options than the fallback regex used to cap // its generic-token scan at (formerly {0,8}) — every option here still // precedes the actual subcommand. @@ -374,16 +382,26 @@ func TestClassifyUnparseableNetworkCommandFailsClosed(t *testing.T) { // the POSIX parser AnalyzeCommand uses) still forces the unparseable-command // fallback path. func TestClassifyUnparseableNonGitOptionTokenStaysNonNetwork(t *testing.T) { - command := `git status push & rem '` - risk := classifyCommand(command) - if !HasRiskCategory(risk, "unparseable_command") { - t.Fatalf("Classify(%q) = categories %v; want unparseable_command", command, risk.Categories) - } - if HasRiskCategory(risk, "network") { - t.Fatalf("Classify(%q) = level %s, categories %v; want no network category", command, risk.Level, risk.Categories) - } - if risk.Level != RiskHigh { - t.Fatalf("Classify(%q) = level %s; want high (unparseable_command only)", command, risk.Level) + for _, command := range []string{ + `git status push & rem '`, + `git "status" push & rem '`, + `git 'status' push & rem "`, + `git.exe -C "C:\Program Files\push" status & rem '`, + `git.exe --git-dir="C:\Program Files\push\.git" status & rem '`, + `git.exe "--git-dir=C:\Program Files\push\.git" status & rem '`, + } { + t.Run(command, func(t *testing.T) { + risk := classifyCommand(command) + if !HasRiskCategory(risk, "unparseable_command") { + t.Errorf("Classify(%q) = categories %v; want unparseable_command", command, risk.Categories) + } + if HasRiskCategory(risk, "network") { + t.Errorf("Classify(%q) = level %s, categories %v; want no network category", command, risk.Level, risk.Categories) + } + if risk.Level != RiskHigh { + t.Errorf("Classify(%q) = level %s; want high (unparseable_command only)", command, risk.Level) + } + }) } } From 8a7ffe69cb1d7b7202e648eed14f6d2692072b76 Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 28 Jul 2026 10:40:38 +0000 Subject: [PATCH 8/8] fix(sandbox): parse unclosed git commands safely Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8 Co-authored-by: Pierre Bruno --- internal/sandbox/risk.go | 123 ++++++++++++++++++++---- internal/sandbox/risk_hardening_test.go | 13 +++ 2 files changed, 116 insertions(+), 20 deletions(-) diff --git a/internal/sandbox/risk.go b/internal/sandbox/risk.go index aa879bcf4..24225cbde 100644 --- a/internal/sandbox/risk.go +++ b/internal/sandbox/risk.go @@ -33,25 +33,9 @@ var ( // 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. - // The git branch accepts Windows executable suffixes and a closing quote - // from a fully qualified path (a parser-failing command like - // `"C:\Program Files\Git\cmd\git.exe" push ... & rem '` runs under cmd.exe - // and must still classify as network). Before the subcommand, it skips only - // git's own global options — mirroring gitGlobalOptionConsumesValue in - // analyzer.go: a recognized value-taking option (-C, -c, --attr-source, - // --config-env, --exec-path, --git-dir, --namespace, --super-prefix, - // --work-tree) consumes one following token as its value, and any other - // dash-prefixed token is skipped on its own. Both branches preserve quoted - // fragments, including spaces in separate or joined option values such as - // `-C "C:\Program Files\repo"` and `--git-dir="C:\Program Files\repo\.git"`. - // The subcommand may also be quoted, as cmd.exe permits `git "push" ...`. - // This span is unbounded — Go's regexp is RE2-backed (linear time, no - // backtracking blowup) — so a git invocation with any number of global - // options still reaches its subcommand. Critically, a bare non-option token - // (e.g. the "status" in `git status push`, where "push" is only a pathspec) - // does NOT match either alternative, so the scan stops and the command is - // correctly left unclassified as network. - 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(?:\.(?:exe|cmd|bat|com))?["']?(?:\s+(?:(?:(?:-C|-c|--attr-source|--config-env|--exec-path|--git-dir|--namespace|--super-prefix|--work-tree)|"(?:-C|-c|--attr-source|--config-env|--exec-path|--git-dir|--namespace|--super-prefix|--work-tree)"|'(?:-C|-c|--attr-source|--config-env|--exec-path|--git-dir|--namespace|--super-prefix|--work-tree)')\s+(?:[^\s;&|"']|"[^"]*"|'[^']*')+|(?:-(?:[^\s;&|"']|"[^"]*"|'[^']*')+|"-[^"]*"|'-[^']*')))*\s+(?:"(?:clone|fetch|pull|push|ls-remote|archive)"|'(?:clone|fetch|pull|push|ls-remote|archive)'|(clone|fetch|pull|push|ls-remote|archive)\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. @@ -85,7 +69,106 @@ func matchesDestructive(command string) bool { } 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 { + 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 { diff --git a/internal/sandbox/risk_hardening_test.go b/internal/sandbox/risk_hardening_test.go index bb2829d4a..2a6ac2db1 100644 --- a/internal/sandbox/risk_hardening_test.go +++ b/internal/sandbox/risk_hardening_test.go @@ -356,6 +356,9 @@ func TestClassifyUnparseableNetworkCommandFailsClosed(t *testing.T) { `git.exe -C"C:\Program Files\repo" "push" origin main & rem '`, `git.exe --git-dir="C:\Program Files\repo\.git" push origin main & rem '`, `git.exe "--git-dir=C:\Program Files\repo\.git" push origin main & rem '`, + `git -C repo push origin main & rem '`, + `git -c user.name=test fetch origin & rem '`, + `git -C "C:\Program Files\repo" push origin main & rem '`, // More value-taking global options than the fallback regex used to cap // its generic-token scan at (formerly {0,8}) — every option here still // precedes the actual subcommand. @@ -389,6 +392,16 @@ func TestClassifyUnparseableNonGitOptionTokenStaysNonNetwork(t *testing.T) { `git.exe -C "C:\Program Files\push" status & rem '`, `git.exe --git-dir="C:\Program Files\push\.git" status & rem '`, `git.exe "--git-dir=C:\Program Files\push\.git" status & rem '`, + `git -C push status & rem '`, + `git -c push status & rem '`, + `git -C "push" status & rem '`, + `git -c "push" status & rem '`, + `git --help push & rem '`, + `git --version push & rem '`, + `echo https://example.com/repo.git push & rem '`, + `echo ssh://git@example.com/repo.git push & rem '`, + `echo C:\repos\repo.git push & rem '`, + `echo git.example.com push & rem '`, } { t.Run(command, func(t *testing.T) { risk := classifyCommand(command)