Skip to content

Commit 0d3c35e

Browse files
refactor(ShadowCheckpointService): updating template config, and removed env vars (#182)
Co-authored-by: Elliott de Launay <edelauna@gmail.com>
1 parent 7c58206 commit 0d3c35e

3 files changed

Lines changed: 146 additions & 23 deletions

File tree

pnpm-lock.yaml

Lines changed: 19 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/services/checkpoints/ShadowCheckpointService.ts

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,50 @@ import { t } from "../../i18n"
1616
import { CheckpointDiff, CheckpointResult, CheckpointEventMap } from "./types"
1717
import { getExcludePatterns } from "./excludes"
1818

19+
/**
20+
* Environment variables stripped before passing the env to simple-git.
21+
*
22+
* Two categories:
23+
* - Location-override vars (GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE, GIT_OBJECT_DIRECTORY,
24+
* GIT_ALTERNATE_OBJECT_DIRECTORIES, GIT_CEILING_DIRECTORIES): redirect git operations to
25+
* unintended repositories or limit where git searches.
26+
* - Code-execution vectors blocked by simple-git ≥3.36's blockUnsafeOperationsPlugin when
27+
* passed via .env(): GIT_EDITOR, GIT_SSH_COMMAND, GIT_PAGER, PREFIX, etc.
28+
*
29+
* Stripping GIT_CONFIG_COUNT also neutralises the entire GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n
30+
* family — git ignores those per-key entries when the count key is absent.
31+
*/
32+
export const BLOCKED_ENV_KEYS = new Set([
33+
"GIT_DIR",
34+
"GIT_WORK_TREE",
35+
"GIT_INDEX_FILE",
36+
"GIT_OBJECT_DIRECTORY",
37+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
38+
"GIT_CEILING_DIRECTORIES",
39+
"GIT_TEMPLATE_DIR",
40+
"GIT_EDITOR",
41+
"GIT_SEQUENCE_EDITOR",
42+
"GIT_ASKPASS",
43+
"GIT_SSH",
44+
"GIT_SSH_COMMAND",
45+
"GIT_PAGER",
46+
"GIT_PROXY_COMMAND",
47+
"GIT_EXEC_PATH",
48+
"GIT_EXTERNAL_DIFF",
49+
"GIT_CONFIG",
50+
"GIT_CONFIG_GLOBAL",
51+
"GIT_CONFIG_SYSTEM",
52+
"GIT_CONFIG_COUNT",
53+
"PREFIX",
54+
"EDITOR",
55+
"PAGER",
56+
"SSH_ASKPASS",
57+
])
58+
59+
// Lowercase set for case-insensitive lookup — the plugin uses toLowerCase() internally,
60+
// so a var like Git_Editor would bypass an exact-match check on Linux.
61+
const BLOCKED_ENV_KEYS_LOWER = new Set([...BLOCKED_ENV_KEYS].map((k) => k.toLowerCase()))
62+
1963
/**
2064
* Creates a SimpleGit instance with sanitized environment variables to prevent
2165
* interference from inherited git environment variables like GIT_DIR and GIT_WORK_TREE.
@@ -25,43 +69,35 @@ import { getExcludePatterns } from "./excludes"
2569
* @returns A SimpleGit instance with sanitized environment
2670
*/
2771
function createSanitizedGit(baseDir: string): SimpleGit {
28-
// Create a clean environment by explicitly unsetting git-related environment variables
29-
// that could interfere with checkpoint operations
3072
const sanitizedEnv: Record<string, string> = {}
31-
const removedVars: string[] = []
73+
const removedKeys: string[] = []
3274

33-
// Copy all environment variables except git-specific ones
3475
for (const [key, value] of Object.entries(process.env)) {
35-
// Skip git environment variables that would override repository location
36-
if (
37-
key === "GIT_DIR" ||
38-
key === "GIT_WORK_TREE" ||
39-
key === "GIT_INDEX_FILE" ||
40-
key === "GIT_OBJECT_DIRECTORY" ||
41-
key === "GIT_ALTERNATE_OBJECT_DIRECTORIES" ||
42-
key === "GIT_CEILING_DIRECTORIES" ||
43-
key === "GIT_TEMPLATE_DIR"
44-
) {
45-
removedVars.push(`${key}=${value}`)
76+
if (BLOCKED_ENV_KEYS_LOWER.has(key.toLowerCase())) {
77+
removedKeys.push(key)
4678
continue
4779
}
4880

49-
// Only include defined values
5081
if (value !== undefined) {
5182
sanitizedEnv[key] = value
5283
}
5384
}
5485

5586
// Log which git env vars were removed (helps with debugging Dev Container issues)
56-
if (removedVars.length > 0) {
87+
if (removedKeys.length > 0) {
5788
console.log(
58-
`[createSanitizedGit] Removed git environment variables for checkpoint isolation: ${removedVars.join(", ")}`,
89+
`[createSanitizedGit] Removed git environment variables for checkpoint isolation: ${removedKeys.join(", ")}`,
5990
)
6091
}
6192

6293
const options: Partial<SimpleGitOptions> = {
6394
baseDir,
6495
config: [],
96+
// --template="" stops git copying hooks/templates into the shadow repo (axis 1).
97+
// GIT_TEMPLATE_DIR is stripped from the env above to block the env-var path (axis 2).
98+
// allowUnsafeTemplateDir opts out of simple-git ≥3.36's blockUnsafeOperationsPlugin
99+
// so the --template arg is not rejected before reaching git.
100+
unsafe: { allowUnsafeTemplateDir: true },
65101
}
66102

67103
// Create git instance and set the sanitized environment

src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,34 @@ import { fileExistsAtPath } from "../../../utils/fs"
1111
import * as fileSearch from "../../../services/search/file-search"
1212

1313
import { RepoPerTaskCheckpointService } from "../RepoPerTaskCheckpointService"
14+
import { BLOCKED_ENV_KEYS } from "../ShadowCheckpointService"
1415

1516
const tmpDir = path.join(os.tmpdir(), "CheckpointService")
1617

18+
// simple-git ≥3.36 blocks env vars it considers code-execution vectors.
19+
// Strip them for the duration of this test suite so tests pass for developers
20+
// who have GIT_EDITOR, GIT_SSH_COMMAND, etc. configured globally.
21+
// Safe under vitest's default "forks" pool (each worker has its own process.env);
22+
// would be fragile under "threads" pool where workers share the same process.
23+
const savedEnv: Partial<Record<string, string>> = {}
24+
25+
beforeAll(() => {
26+
for (const key of BLOCKED_ENV_KEYS) {
27+
savedEnv[key] = process.env[key]
28+
delete process.env[key]
29+
}
30+
})
31+
32+
afterAll(() => {
33+
for (const key of BLOCKED_ENV_KEYS) {
34+
if (savedEnv[key] !== undefined) {
35+
process.env[key] = savedEnv[key]
36+
} else {
37+
delete process.env[key]
38+
}
39+
}
40+
})
41+
1742
const initWorkspaceRepo = async ({
1843
workspaceDir,
1944
userName = "Roo Code",
@@ -35,6 +60,7 @@ const initWorkspaceRepo = async ({
3560
await git.init()
3661
await git.addConfig("user.name", userName)
3762
await git.addConfig("user.email", userEmail)
63+
await git.addConfig("commit.gpgSign", "false")
3864

3965
// Create test file.
4066
const testFile = path.join(workspaceDir, testFileName)
@@ -390,6 +416,7 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
390416
await mainGit.init()
391417
await mainGit.addConfig("user.name", "Roo Code")
392418
await mainGit.addConfig("user.email", "support@roocode.com")
419+
await mainGit.addConfig("commit.gpgSign", "false")
393420

394421
// Create a nested repo inside the workspace.
395422
const nestedRepoPath = path.join(workspaceDir, "nested-project")
@@ -398,6 +425,7 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
398425
await nestedGit.init()
399426
await nestedGit.addConfig("user.name", "Roo Code")
400427
await nestedGit.addConfig("user.email", "support@roocode.com")
428+
await nestedGit.addConfig("commit.gpgSign", "false")
401429

402430
// Add a file to the nested repo.
403431
const nestedFile = path.join(nestedRepoPath, "nested-file.txt")
@@ -460,6 +488,7 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
460488
await mainGit.init()
461489
await mainGit.addConfig("user.name", "Roo Code")
462490
await mainGit.addConfig("user.email", "support@roocode.com")
491+
await mainGit.addConfig("commit.gpgSign", "false")
463492

464493
// Create a test file in the main workspace.
465494
const mainFile = path.join(workspaceDir, "main-file.txt")
@@ -873,6 +902,47 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
873902
}
874903
})
875904

905+
it("isolates checkpoint operations from simple-git blocked environment variables", async () => {
906+
const testShadowDir = path.join(tmpDir, `shadow-blocked-env-test-${Date.now()}`)
907+
const testWorkspaceDir = path.join(tmpDir, `workspace-blocked-env-test-${Date.now()}`)
908+
await initWorkspaceRepo({ workspaceDir: testWorkspaceDir })
909+
910+
// beforeAll strips PREFIX, so it is always undefined here; set it to exercise isolation.
911+
process.env.PREFIX = path.join(tmpDir, "git-prefix")
912+
913+
try {
914+
const testService = await klass.create({
915+
taskId: `test-blocked-env-${Date.now()}`,
916+
shadowDir: testShadowDir,
917+
workspaceDir: testWorkspaceDir,
918+
log: () => {},
919+
})
920+
await testService.initShadowGit()
921+
922+
const testWorkspaceFile = path.join(testWorkspaceDir, "test.txt")
923+
await fs.writeFile(testWorkspaceFile, "Modified with PREFIX set")
924+
const commit = await testService.saveCheckpoint("Checkpoint with PREFIX set")
925+
expect(commit?.commit).toBeTruthy()
926+
927+
// Verify the checkpoint is accessible and contains the expected change
928+
const diff = await testService.getDiff({ to: commit!.commit })
929+
expect(diff).toHaveLength(1)
930+
expect(diff[0].paths.relative).toBe("test.txt")
931+
expect(diff[0].content.after).toBe("Modified with PREFIX set")
932+
933+
// Verify we can restore the checkpoint
934+
await fs.writeFile(testWorkspaceFile, "Another modification")
935+
await testService.restoreCheckpoint(commit!.commit)
936+
expect(await fs.readFile(testWorkspaceFile, "utf-8")).toBe("Modified with PREFIX set")
937+
} finally {
938+
// beforeAll guarantees PREFIX was undefined at test start, so always delete it.
939+
delete process.env.PREFIX
940+
941+
await fs.rm(testShadowDir, { recursive: true, force: true })
942+
await fs.rm(testWorkspaceDir, { recursive: true, force: true })
943+
}
944+
})
945+
876946
it("isolates checkpoint operations from GIT_DIR environment variable", async () => {
877947
// This test verifies the fix for the issue where GIT_DIR environment variable
878948
// causes checkpoint commits to go to the wrong repository.
@@ -886,6 +956,7 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
886956
await externalGit.init()
887957
await externalGit.addConfig("user.name", "External User")
888958
await externalGit.addConfig("user.email", "external@example.com")
959+
await externalGit.addConfig("commit.gpgSign", "false")
889960

890961
// Create and commit a file in the external repo
891962
const externalFile = path.join(externalGitDir, "external.txt")
@@ -976,6 +1047,7 @@ describe("worktree path comparison", () => {
9761047
await mainGit.init()
9771048
await mainGit.addConfig("user.name", "Roo Code")
9781049
await mainGit.addConfig("user.email", "support@roocode.com")
1050+
await mainGit.addConfig("commit.gpgSign", "false")
9791051

9801052
await fs.writeFile(path.join(workspaceDir, "main.txt"), "main content")
9811053
await mainGit.add("main.txt")
@@ -1011,6 +1083,7 @@ describe("worktree path comparison", () => {
10111083
await mainGit.init()
10121084
await mainGit.addConfig("user.name", "Roo Code")
10131085
await mainGit.addConfig("user.email", "support@roocode.com")
1086+
await mainGit.addConfig("commit.gpgSign", "false")
10141087

10151088
await fs.writeFile(path.join(workspaceDir, "main.txt"), "main content")
10161089
await mainGit.add("main.txt")

0 commit comments

Comments
 (0)