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
11 changes: 11 additions & 0 deletions git/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ specific application or forge workflow.
settings in the imported worktree. This is a narrow Git execution boundary,
not an OS sandbox for hostile configuration, remotes, lifecycle scripts,
same-user replacement races, or resource exhaustion.
- Replacement merge drivers for untrusted merge-request imports must run the
resolved Git executable without looking it up through the worktree `PATH`.
They must clear inherited repository bindings and counted configuration,
classify binary inputs without repository attributes or external
diff/textconv helpers, and pin `core.bigFileThreshold=1023m` to match
`merge-file`'s maximum text size. They must write clean text merges and diff3
markers for text conflicts, and treat classified binary content as an
ordinary per-file conflict. Classifier failures, text-merge I/O failures, a
missing Git executable, or a merge-process crash must fail the whole
operation. This contract requires Git 2.42.0+ on non-Windows and Git for
Windows 2.53.0.windows.3+. Keep both platform behaviors explicit.
- Reject isolation-sensitive command-scope configuration during import because
worktree configuration cannot outrank it. Explicit command-scope overrides
on later Git commands are caller policy, not a sandbox boundary Kit can
Expand Down
7 changes: 2 additions & 5 deletions git/cmd/gitcmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"time"

gitenv "go.kenn.io/kit/git/env"
"go.kenn.io/kit/git/internal/shellquote"
)

// Config is one temporary git config entry injected through GIT_CONFIG_* env.
Expand Down Expand Up @@ -187,15 +188,11 @@ func credentialHelper(path string) string {
return `!f() { ` +
`while IFS= read -r line && [ -n "$line" ]; do :; done; ` +
`if [ "$1" = get ]; then ` +
`while IFS= read -r line; do printf '%s\n' "$line"; done < ` + shellSingleQuote(path) + `; ` +
`while IFS= read -r line; do printf '%s\n' "$line"; done < ` + shellquote.Single(path) + `; ` +
`fi; ` +
`}; f`
}

func shellSingleQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
}

var (
emptyGlobalConfigOnce sync.Once
emptyGlobalConfigPath string
Expand Down
3 changes: 2 additions & 1 deletion git/cmd/gitcmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
Require "github.com/stretchr/testify/require"

gitenv "go.kenn.io/kit/git/env"
"go.kenn.io/kit/git/internal/shellquote"
)

func TestRunnerCommandUsesDefensiveEnvironment(t *testing.T) {
Expand Down Expand Up @@ -566,7 +567,7 @@ func captureGitEnv(t *testing.T, runner Runner) string {
binDir := t.TempDir()
envPath := filepath.Join(t.TempDir(), "env")
gitPath := filepath.Join(binDir, "git")
script := "#!/bin/sh\nenv > " + shellSingleQuote(envPath) + "\n"
script := "#!/bin/sh\nenv > " + shellquote.Single(envPath) + "\n"
if os.PathSeparator == '\\' {
gitPath += ".bat"
script = "@echo off\r\nset > " + shellDoubleQuote(envPath) + "\r\n"
Expand Down
9 changes: 9 additions & 0 deletions git/internal/shellquote/shellquote.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Package shellquote quotes arguments embedded in Git shell command strings.
package shellquote

import "strings"

// Single returns value enclosed in POSIX shell single quotes.
func Single(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
}
23 changes: 23 additions & 0 deletions git/internal/shellquote/shellquote_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package shellquote

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestSingle(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name, input, want string
}{
{name: "empty", want: "''"},
{name: "spaces", input: "/opt/Git Tools/git", want: "'/opt/Git Tools/git'"},
{name: "single quote", input: "/opt/Git's/git", want: "'/opt/Git'\\''s/git'"},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, test.want, Single(test.input))
})
}
}
7 changes: 7 additions & 0 deletions git/managed/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ type HookError struct {
}

// GitRunner runs one Git command under an application's process policy.
//
// It governs how Kit's own Git commands are executed, not which Git
// installation Kit targets. Merge-request import pins the git found on the
// process PATH into the replacement merge driver, because Git runs that driver
// itself and cannot route it back through this callback. A runner that
// executes some other Git therefore does not redirect the merge driver, and
// import fails outright when no git is on PATH.
type GitRunner func(
ctx context.Context, runner gitcmd.Runner, dir string, args ...string,
) ([]byte, error)
Expand Down
3 changes: 3 additions & 0 deletions git/managed/lifecycle_mr.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ type mergeRequestRemoteTarget struct {
// submodule recursion. The existing repository, its configuration, provider
// metadata, remotes, and explicitly configured setup hook remain trusted.
//
// Untrusted-tree isolation requires Git 2.42.0 or newer on non-Windows
// platforms and Git for Windows 2.53.0.windows.3 or newer.
//
// The function also configures upstream tracking when possible, non-fatally
// skipping it when the fork cannot be fetched. Failures after the worktree
// exists roll it back.
Expand Down
77 changes: 61 additions & 16 deletions git/managed/lifecycle_mr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@ func worktreeOnlyConfig(t *testing.T, dir, key string) string {
return strings.TrimSpace(string(out))
}

func expectedSafeMergeDriverCommand(t *testing.T, worktree string) string {
t.Helper()
path, err := resolveMergeDriverGitPath()
Require.NoError(t, err)
hooksPath := worktreeConfig(t, worktree, "core.hooksPath")
Require.NotEmpty(t, hooksPath)
return safeMergeDriverCommand(path, hooksPath)
}

// TestCreateWorktreeFromMergeRequestSameRepo covers the same-repo scenario:
// the head branch is fetched from origin, the new local branch starts at
// it, and upstream tracking points at origin's head branch.
Expand Down Expand Up @@ -519,7 +528,7 @@ func TestCreateWorktreeFromMergeRequestIsolatesUntrustedTreeGitPrograms(t *testi
worktreeConfig(t, dest, "diff.owned.command"))
assert.Equal(safeTextconvCommand,
worktreeConfig(t, dest, "diff.owned.textconv"))
assert.Equal(safeMergeDriverCommand,
assert.Equal(expectedSafeMergeDriverCommand(t, dest),
worktreeConfig(t, dest, "merge.owned.driver"))

if err := os.Remove(fsmonitorMarker); err != nil {
Expand Down Expand Up @@ -669,7 +678,7 @@ func TestCreateWorktreeFromMergeRequestNeutralizesCaseDistinctAttributeDrivers(
worktreeOnlyConfig(t, dest, "filter."+driver+".required"))
assert.Equal(safeExternalDiffCommand,
worktreeOnlyConfig(t, dest, "diff."+driver+".command"))
assert.Equal(safeMergeDriverCommand,
assert.Equal(expectedSafeMergeDriverCommand(t, dest),
worktreeOnlyConfig(t, dest, "merge."+driver+".driver"))
}
}
Expand All @@ -684,23 +693,38 @@ func TestCreateWorktreeFromMergeRequestDoesNotPATHSearchDriverHelpers(
assert := assert.New(t)
origin, clone := initOriginAndClone(t)
marker := filepath.Join(t.TempDir(), "attacker-sh-ran")
lifecycleGit(t, origin, "checkout", "-q", "-b", "path-driver")
require.NoError(os.WriteFile(
filepath.Join(origin, ".gitattributes"),
[]byte("payload diff=owned\n"), 0o644,
[]byte("payload diff=owned merge=owned\n"), 0o644,
))
require.NoError(os.WriteFile(
filepath.Join(origin, "payload"), []byte("external\n"), 0o644,
filepath.Join(origin, "payload"), []byte("base\n"), 0o644,
))
for _, helper := range []string{"git", "sh"} {
require.NoError(os.WriteFile(
filepath.Join(origin, helper),
[]byte("#!/bin/sh\n: > \""+marker+"\"\nexit 0\n"), 0o755,
))
}
lifecycleGit(t, origin, "add", ".gitattributes", "payload", "git", "sh")
lifecycleGit(t, origin, "commit", "-qm", "PATH driver base")
lifecycleGit(t, origin, "checkout", "-q", "-b", "path-driver")
require.NoError(os.WriteFile(
filepath.Join(origin, "sh"),
[]byte("#!/bin/sh\n: > \""+marker+"\"\nexit 0\n"), 0o755,
filepath.Join(origin, "payload"), []byte("current\n"), 0o644,
))
lifecycleGit(t, origin, "add", ".gitattributes", "payload", "sh")
lifecycleGit(t, origin, "commit", "-qm", "PATH driver fixture")
lifecycleGit(t, origin, "commit", "-qam", "PATH driver current")
headSHA := lifecycleGit(t, origin, "rev-parse", "HEAD")
lifecycleGit(t, origin, "checkout", "-q", "main")
lifecycleGit(t, origin, "checkout", "-q", "-b", "path-driver-other")
require.NoError(os.WriteFile(
filepath.Join(origin, "payload"), []byte("other\n"), 0o644,
))
lifecycleGit(t, origin, "commit", "-qam", "PATH driver other")
lifecycleGit(t, origin, "checkout", "-q", "main")
lifecycleGit(t, clone, "fetch", "-q", "origin",
"refs/heads/path-driver-other:refs/remotes/origin/path-driver-other")
lifecycleGit(t, clone, "config", "diff.owned.command", "false")
lifecycleGit(t, clone, "config", "merge.owned.driver", "false")

dest := filepath.Join(t.TempDir(), "wt")
_, err := CreateWorktreeFromMergeRequest(
Expand All @@ -712,15 +736,36 @@ func TestCreateWorktreeFromMergeRequestDoesNotPATHSearchDriverHelpers(
ExpectedHeadSHA: headSHA,
})
require.NoError(err)

require.NoError(os.WriteFile(
filepath.Join(dest, "payload"), []byte("changed\n"), 0o644,
))
diffCmd := lifecycleGitCommand(t, dest, "diff", "--", "payload")
diffCmd.Env = append(diffCmd.Env, "PATH="+dest+":"+os.Getenv("PATH"))
diff, err := diffCmd.CombinedOutput()
require.NoError(err, string(diff))
assert.Contains(string(diff), "-current")
assert.Contains(string(diff), "+changed")
assert.NoFileExists(marker)
require.NoError(os.WriteFile(
filepath.Join(dest, "payload"), []byte("current\n"), 0o644,
))

cmd := lifecycleGitCommand(t, dest, "diff", "--", "payload")
cmd.Env = append(cmd.Env, "PATH="+dest+":"+os.Getenv("PATH"))
out, err := cmd.CombinedOutput()
mergeCmd := lifecycleGitCommand(
t, dest, "merge", "refs/remotes/origin/path-driver-other",
)
mergeCmd.Env = append(mergeCmd.Env, "PATH="+dest+":"+os.Getenv("PATH"))
out, err := mergeCmd.CombinedOutput()

require.NoError(err, string(out))
require.Error(err, string(out))
assert.Equal("UU payload",
lifecycleGit(t, dest, "status", "--short", "--", "payload"))
payload, readErr := os.ReadFile(filepath.Join(dest, "payload"))
require.NoError(readErr)
assert.Equal(
"<<<<<<< current\ncurrent\n||||||| base\nbase\n=======\nother\n>>>>>>> other\n",
string(payload),
)
assert.NoFileExists(marker)
}

Expand Down Expand Up @@ -867,7 +912,7 @@ func TestCreateWorktreeFromMergeRequestInspectsSelectedConfigFiles(t *testing.T)
worktreeOnlyConfig(t, dest, "filter.selected.required"))
assert.Equal(safeExternalDiffCommand,
worktreeOnlyConfig(t, dest, "diff.selected.command"))
assert.Equal(safeMergeDriverCommand,
assert.Equal(expectedSafeMergeDriverCommand(t, dest),
worktreeOnlyConfig(t, dest, "merge.selected.driver"))
}

Expand Down Expand Up @@ -1054,11 +1099,11 @@ func TestSectionLevelConfigKeysDoNotNameSubsections(t *testing.T) {
_, ok := configuredSubmoduleName("submodule.path")
assert.False(t, ok)

assert.Empty(t, neutralizeAttributeDrivers([]string{
assert.False(t, configuredAttributeDrivers([]string{
"filter.clean",
"diff.command",
"merge.driver",
}))
}).configured())
}

func TestSafeTextconvPreservesUnterminatedLines(t *testing.T) {
Expand Down
Loading