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
82 changes: 77 additions & 5 deletions internal/sandbox/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,8 +278,77 @@ func packageManagerOffline(words []string) bool {
}

func gitUsesNetwork(words []string) bool {
switch firstSubcommand(words, nil) {
case "clone", "fetch", "pull", "push", "ls-remote", "archive":
switch gitSubcommand(words) {
case "clone", "fetch", "pull", "push", "ls-remote":
return true
case "archive":
// `git archive HEAD` streams a tree out of the local object store and needs
// no egress at all; only `--remote=<repo>` sends the request to another
// host. Classifying every archive as network cost a proactive network
// prompt on a purely local command.
return gitTargetsRemoteArchive(words)
default:
return false
}
}

// gitTargetsRemoteArchive reports whether a git archive invocation carries the
// --remote option, in either its joined (--remote=<repo>) or separated
// (--remote <repo>) spelling. The whole argument list is scanned rather than
// only the part after the subcommand, so option order cannot hide it.
//
// Scanning stops at a standalone `--`: everything after it is a pathspec, so
// `git archive HEAD -- --remote` archives a tree entry NAMED --remote from the
// local object store and never touches the network. Treating that as remote
// would cost a network prompt — and a network grant — on a purely local
// command. Same end-of-options rule hasRecursiveForce applies to `rm -- -rf`.
func gitTargetsRemoteArchive(words []string) bool {
for _, word := range words {
if word == "--" {
return false
}
lowered := strings.ToLower(word)
if lowered == "--remote" || strings.HasPrefix(lowered, "--remote=") {
return true
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
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
Expand Down Expand Up @@ -423,11 +492,14 @@ func effectiveProgram(args []*syntax.Word) (string, []*syntax.Word) {
return "", nil
}

// 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.
// dashCPayload returns the literal text of the word following `-c`/`--command`
// in an AST arg list (the command a shell launcher will run), or "" when there
// is none. Both spellings are recognized, matching shellDashCPayload and the
// unparseable fallback: bash and zsh accept either, so knowing only `-c` would
// leave `bash --command '<payload>'` unclassified on both paths.
func dashCPayload(args []*syntax.Word) string {
for index := 0; index < len(args); index++ {
if wordText(args[index]) == "-c" && index+1 < len(args) {
if isShellCommandFlag(wordText(args[index])) && index+1 < len(args) {
return wordText(args[index+1])
}
}
Expand Down
30 changes: 30 additions & 0 deletions internal/sandbox/analyzer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,37 @@ 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 remote archive separated value", script: "git archive --remote origin HEAD", network: true},
// Only --remote leaves the machine; a local archive streams the object
// store and must not cost a network prompt (issue #703 review).
{name: "git local archive", script: "git archive HEAD", network: false},
{name: "git -C local archive", script: "git -C repo archive HEAD -o out.tar", network: false},
// After `--` every token is a pathspec, so this archives a tree entry
// named "--remote" from the local object store (issue #703 review).
{name: "git archive pathspec named --remote", script: "git archive HEAD -- --remote", network: false},
{name: "git -C archive pathspec named --remote", script: "git -C repo archive HEAD -- --remote=origin", network: false},
// A real --remote before the separator still is one.
{name: "git remote archive with pathspec", script: "git archive --remote=origin HEAD -- src", network: true},
// Both spellings of the shell launcher's command flag are payloads.
{name: "bash --command wraps curl", script: `bash --command 'curl https://x.test'`, 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
26 changes: 26 additions & 0 deletions internal/sandbox/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,32 @@ func TestEvaluatePromptsForNetworkToolsButExemptsThemFromShellNetworkPolicy(t *t
}
}

// TestEvaluatePromptsForUnparseableNetworkBehindWrapper is the engine-level
// regression for jatmn's #726 P2 finding. Classification is what decides egress
// here: when the fallback stopped recognizing a network program hidden behind a
// wrapper, an already-permission-granted shell command went from ActionPrompt /
// ReasonNetworkBlocked to a plain ActionAllow — silently granting network to a
// command too obfuscated to parse.
func TestEvaluatePromptsForUnparseableNetworkBehindWrapper(t *testing.T) {
engine := NewEngine(EngineOptions{Policy: Policy{Mode: ModeEnforce, Network: NetworkDeny}})
for _, command := range []string{
`sudo curl https://evil.test && "unterminated`,
`env git push origin main && "unterminated`,
`curl.exe https://evil.test && "unterminated`,
"true\ncurl https://evil.test && \"unterminated",
} {
t.Run(command, func(t *testing.T) {
decision := engine.Evaluate(context.Background(), Request{
ToolName: "bash", SideEffect: SideEffectShell, PermissionGranted: true,
Args: map[string]any{"command": command},
})
if decision.Action != ActionPrompt || decision.Reason != ReasonNetworkBlocked {
t.Fatalf("Evaluate(%q) = action %q reason %q, want a network prompt", command, decision.Action, decision.Reason)
}
})
}
}

func TestEngineBashAllowGrantDoesNotBypassNetworkPrompt(t *testing.T) {
store, err := NewGrantStore(StoreOptions{
FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json"),
Expand Down
Loading
Loading