Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/simple-git-4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@openspec-ui/core": patch
"@openspec-ui/cli": patch
"@openspec-ui/server": patch
"openspec-ui-vscode": patch
---

simple-git 4.0.2, which fixes two critical advisories in earlier versions (GHSA-v5rq-49vh-5v5c, GHSA-x6jw-m9v5-85vh) (simple-git-4). simple-git 4 removes `GIT_` and other guarded variables from the environment of the git it starts; the product now opens git through one function that lets through only what a person set up to reach a remote - `GIT_ASKPASS`, `SSH_ASKPASS`, `GIT_SSH`, `GIT_SSH_COMMAND`, `GIT_SSH_VARIANT`, `GIT_TERMINAL_PROMPT` - so pushes keep authenticating, and keeps every other guarded variable away from git.
2 changes: 2 additions & 0 deletions openspec/changes/simple-git-4/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-06
41 changes: 41 additions & 0 deletions openspec/changes/simple-git-4/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
## Context

simple-git 4 (`allowEnvironment`, `@simple-git/argv-parser`'s
`isGitEnvKey`) strips, from the environment of every git it runs, each key
starting with `GIT_` and `editor`, `pager`, `prefix`, `ssh_askpass`,
`visual`. A key passed through `.env()` that is guarded and not allowed
rejects the task. Of the allowed ones, those that run a program
(`GIT_ASKPASS`, `SSH_ASKPASS`, `GIT_SSH`, `GIT_SSH_COMMAND`) also need the
matching `unsafe` flag, or the task is refused.

This product runs git through simple-git for status, diff, log, blame,
commit, push, fetch, pull and rebase, in the extension host, the server
and the CLI, and never passes an environment of its own.

## Decisions

1. **One function opens git.** `openGit(cwd)` in `git-client.ts` replaces
every `simpleGit(cwd)` outside tests, so what reaches git is decided in
one place.
2. **The connection variables pass, the rest do not.** `GIT_ASKPASS`,
`SSH_ASKPASS`, `GIT_SSH`, `GIT_SSH_COMMAND`, `GIT_SSH_VARIANT` and
`GIT_TERMINAL_PROMPT`, with `allowUnsafeAskPass` and
`allowUnsafeSshCommand`. They come from the person's own environment
(their shell, VS Code); nothing here builds them from a change, a task
or an agent's output, which is what the advisories are about. Editors,
pagers, config paths, `GIT_DIR` and the like stay removed: nothing here
opens an editor or a pager, and a stray `GIT_DIR` in a parent's
environment is a way to act on the wrong repository.
- *Alternative: allow every `GIT_` variable.* Rejected: it gives back
what 4 took away for a reason, for no case this product has.
- *Alternative: allow none.* Rejected: measured in `git-client.test.ts`,
an SSH remote then waits on the real `ssh` until it is killed.
3. **Fixtures name what they set.** `gitIsolationOptions()` adds the six
author and committer variables fixture commits pass through `.env()`.

## Risks / Trade-offs

- **A setup that relied on another stripped variable** (a proxy command,
`GIT_CONFIG_GLOBAL`) no longer reaches git. Proxy settings in the
environment (`HTTPS_PROXY`) and in git's own configuration are not
guarded and still apply.
40 changes: 40 additions & 0 deletions openspec/changes/simple-git-4/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
## Why

On 2026-10-06 the dependency audit failed every pull request: two
critical advisories in `simple-git` <= 4.0.1 (GHSA-v5rq-49vh-5v5c, the
`VISUAL` editor variable missing from its unsafe-editor detection, and
GHSA-x6jw-m9v5-85vh, trailer command configuration not blocked). The fix
is only in 4.0.2; this repository was on 3.x.

simple-git 4 also changes what the `git` it starts can see: every
`GIT_`-prefixed variable and a few others (`EDITOR`, `PAGER`,
`SSH_ASKPASS`, `VISUAL`, `PREFIX`) are removed from its environment. That
includes what a person set up to reach a remote - the askpass helper VS
Code provides, an SSH command - and without them a push either fails to
authenticate or, as measured here with an SSH remote, waits until it is
killed.

## What Changes

- `simple-git` ^4.0.2 in `core` and `cli`; the default import, which 4
removed, is replaced.
- Every `git` this product starts through simple-git is opened by one
function, `openGit(cwd)`, which lets through only the variables that
carry how to connect and authenticate: `GIT_ASKPASS`, `SSH_ASKPASS`,
`GIT_SSH`, `GIT_SSH_COMMAND`, `GIT_SSH_VARIANT`, `GIT_TERMINAL_PROMPT`.
Every other guarded variable stays removed.
- Test fixtures that commit with a set author and date name those
variables, which simple-git 4 otherwise refuses.

## Capabilities

### Modified Capabilities

- `execution-core`: the git this product starts keeps the person's
connection settings and nothing else of the guarded environment.

## Impact

- `packages/core`: `git-client.ts` (new), every module that opened
simple-git, `test-support/git-isolation.ts`.
- `packages/core`, `packages/cli`: `package.json`; `package-lock.json`.
22 changes: 22 additions & 0 deletions openspec/changes/simple-git-4/specs/execution-core/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
## ADDED Requirements

### Requirement: The git this product starts keeps the person's connection settings, and nothing else guarded

Every `git` the product starts through simple-git SHALL receive, from the
environment it runs in, `GIT_ASKPASS`, `SSH_ASKPASS`, `GIT_SSH`,
`GIT_SSH_COMMAND`, `GIT_SSH_VARIANT` and `GIT_TERMINAL_PROMPT` where they
are set, and SHALL NOT receive any other variable simple-git guards
(every other `GIT_`-prefixed key, `EDITOR`, `PAGER`, `PREFIX`, `VISUAL`).
The product SHALL NOT build any of the allowed variables from a change, a
task or an agent's output.

#### Scenario: An SSH command set by the person

- **WHEN** `GIT_SSH_COMMAND` is set in the environment and the product runs
git against an SSH remote
- **THEN** git runs that command

#### Scenario: An editor in the environment

- **WHEN** `GIT_EDITOR` is set in the environment and the product runs git
- **THEN** git does not see it
44 changes: 44 additions & 0 deletions openspec/changes/simple-git-4/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
The dependency audit failed every pull request on 2026-10-06: two
critical advisories in simple-git <= 4.0.1, fixed only in 4.0.2.

## 1. Dependency

- [x] 1.1 `simple-git` ^4.0.2 in `packages/core` and `packages/cli`;
`npm audit --omit=dev --audit-level=high` reports nothing.
simple-git 4.0.2 in both; `npm audit --omit=dev --audit-level=high`:
found 0 vulnerabilities.
- [x] 1.2 `git-client.ts`: `openGit(cwd)` (design.md decisions 1, 2); every
`simpleGit(...)` outside tests replaced; the default import, which 4
removed, replaced in tests. Test in `git-client.test.ts`, in the
single-fork project: a `GIT_SSH_COMMAND` reaches git, a `GIT_EDITOR` does
not; checked to fail without the allowance.
`git-client.test.ts`: 2 passed; with `allowEnvironment: []` the SSH
case failed, git waiting on the real ssh until the 45 s budget.
- [x] 1.3 `test-support/git-isolation.ts`: fixture commits name the author
and committer variables (decision 3); `git.test.ts` asserts the options
`openGit` passes.
`git.test.ts` 5 passed; every fixture file passes in the full run.

## 2. Documents

- [x] 2.1 A changeset: core, cli, server, extension, patch.
`.changeset/simple-git-4.md`.

## 3. Checks

- [x] 3.1 `npm run typecheck && npm run lint`, and every test project, each
on its own where the root run would exceed a background limit.
Typecheck clean; lint 0 errors (3 warnings in lines this change did
not touch); seven script tests pass. Core 2096, core-git-subprocess 69,
cli 201, webui 712, server 122, extension 502 passed.
- [x] 3.2 `openspec validate simple-git-4 --strict`, and the merge gate
with the worktree's absolute path as `--cwd`.
Valid; the gate with the worktree's absolute path reports ok.
- [x] 3.3 A real push through the product's git wrapper to a remote that
needs the person's credentials, recorded.
Record, 2026-10-06: this branch pushed to GitHub over HTTPS by
`createGitWrapper(...).push("origin", "simple-git-4")` from a script, in
4.1 s, authenticated by the Git Credential Manager named in git's
configuration; `ls-remote` then showed the branch at the local HEAD. The
askpass and SSH routes, which this machine does not use for this
remote, are the ones `git-client.test.ts` checks.
28 changes: 14 additions & 14 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
},
"dependencies": {
"cross-spawn": "^7.0.6",
"simple-git": "^3.27.0",
"simple-git": "^4.0.2",
"yaml": "^2.9.1"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"dependencies": {
"@agentclientprotocol/sdk": "^1.5.1",
"cross-spawn": "^7.0.6",
"simple-git": "^3.27.0",
"simple-git": "^4.0.2",
"undici": "^7.30.0",
"yaml": "^2.9.1",
"zod": "^4.6.5"
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/change-diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import { readFile } from "node:fs/promises";
import path from "node:path";
import simpleGit from "simple-git";
import { openGit } from "./git-client.js";
import { discoverOpenSpecWorkspace } from "./workbench.js";

/** How much diff text one answer carries. A change whose `tasks.md` was
Expand Down Expand Up @@ -48,7 +48,7 @@ export async function readChangeDiff(
return { kind: "not-active", message: `"${changeName}" is not an active change of this workspace.` };
}

const git = simpleGit(workspaceRoot);
const git = openGit(workspaceRoot);
const isRepository = await git.checkIsRepo().catch(() => false);
if (!isRepository) {
return {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/change-spans.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import simpleGit from "simple-git";
import { simpleGit } from "simple-git";
import { afterAll, describe, expect, it, vi } from "vitest";
import { readChangeSpans, type ChangeSpan } from "./change-spans.js";
import { getFileCreatedDate } from "./change-timeline.js";
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/change-spans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
// `readProposalAddedDates` below.

import { readFile } from "node:fs/promises";
import simpleGit, { type SimpleGit } from "simple-git";
import type { SimpleGit } from "simple-git";
import { openGit } from "./git-client.js";
import { mapBounded } from "./bounded-map.js";
import { buildChangeDates, withoutArchivePrefix, type DatedFact } from "./change-dates.js";
import { countTaskCheckboxes, type TaskCounts } from "./change-standing-facts.js";
Expand Down Expand Up @@ -126,7 +127,7 @@ async function readTaskCounts(change: WorkbenchChange): Promise<TaskCounts | nul
async function readProposalAddedDates(root: string): Promise<Map<string, string>> {
const dates = new Map<string, string>();
try {
const git = simpleGit(root);
const git = openGit(root);
const prefix = await pathPrefixInRepository(git);
const output = await git.raw([
// A path with anything unusual in it is otherwise rendered as a
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/change-timeline.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import simpleGit, { type SimpleGit } from "simple-git";
import { simpleGit, type SimpleGit } from "simple-git";
import { afterAll, describe, expect, it, vi } from "vitest";
import {
blameLineDates,
Expand Down
21 changes: 11 additions & 10 deletions packages/core/src/change-timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
// a change read should ever depend on succeeding.

import { readFile } from "node:fs/promises";
import simpleGit, { type SimpleGit } from "simple-git";
import type { SimpleGit } from "simple-git";
import { openGit } from "./git-client.js";
import {
buildChangeDates,
normalizedInstant,
Expand Down Expand Up @@ -105,7 +106,7 @@ export async function blameLineDates(
): Promise<Map<number, string> | undefined> {
let output: string;
try {
output = await simpleGit(cwd).raw(["blame", "--line-porcelain", "--", filePath]);
output = await openGit(cwd).raw(["blame", "--line-porcelain", "--", filePath]);
} catch {
return undefined;
}
Expand Down Expand Up @@ -164,7 +165,7 @@ export async function getFileCreatedDate(cwd: string, filePath: string): Promise
// as "undeterminable". Verified against git 2.54.0 on 2026-09-09.
// The oldest commit is the last line instead. See
// change-dates-from-evidence.
const output = await simpleGit(cwd).raw([
const output = await openGit(cwd).raw([
"log",
"--follow",
"--diff-filter=A",
Expand All @@ -191,7 +192,7 @@ export async function getAddedFileDates(cwd: string, directoryPath: string): Pro
const added = new Map<string, string>();
let output: string;
try {
output = await simpleGit(cwd).raw([
output = await openGit(cwd).raw([
"log", "--diff-filter=A", "--name-only", "--format=@%aI", "--", directoryPath, `:(exclude)${directoryPath}/archive`,
]);
} catch {
Expand All @@ -216,7 +217,7 @@ export async function getAddedFileDates(cwd: string, directoryPath: string): Pro
* change made before its proposal (ADR 0037, amended 2026-09-25). */
export async function getDirectoryCreatedDate(cwd: string, directoryPath: string): Promise<string | null> {
try {
const output = await simpleGit(cwd).raw(["log", "--diff-filter=A", "--format=%aI", "--", directoryPath]);
const output = await openGit(cwd).raw(["log", "--diff-filter=A", "--format=%aI", "--", directoryPath]);
return oldestDateIn(output);
} catch {
return null;
Expand All @@ -241,7 +242,7 @@ function oldestDateIn(output: string): string | null {
* change-dates-from-evidence. */
export async function getPathAddedDate(cwd: string, filePath: string): Promise<string | null> {
try {
const output = await simpleGit(cwd).raw([
const output = await openGit(cwd).raw([
"log",
"--diff-filter=A",
"--format=%aI",
Expand Down Expand Up @@ -284,7 +285,7 @@ export async function readArchiveCommitDates(cwd: string): Promise<ArchiveCommit
const dates = new Map<string, string>();
let unreadableLines = 0;
try {
const git = simpleGit(cwd);
const git = openGit(cwd);
const archivePrefix = `${await pathPrefixInRepository(git)}${ARCHIVE_LOG_PATH}/`;
const output = await git.raw([
// git renders a path with anything unusual in it as a C-style
Expand Down Expand Up @@ -402,7 +403,7 @@ const AUTHOR_LOG_FIELD_SEP = "\x1f";
export async function getChangeAuthorship(cwd: string, changeDirPath: string): Promise<ChangeAuthorship> {
const empty: ChangeAuthorship = { primaryAuthor: null, contributors: [] };
try {
const output = await simpleGit(cwd).raw([
const output = await openGit(cwd).raw([
"log",
`--format=%an${AUTHOR_LOG_FIELD_SEP}%ae${AUTHOR_LOG_FIELD_SEP}%aI`,
"--",
Expand Down Expand Up @@ -449,7 +450,7 @@ export async function readProposalCreatedDates(cwd: string): Promise<ProposalCre
const carriageReturn = String.fromCharCode(13);
const tab = String.fromCharCode(9);
try {
const git = simpleGit(cwd);
const git = openGit(cwd);
const prefix = await pathPrefixInRepository(git);
const output = await git.raw([
"-c",
Expand Down Expand Up @@ -549,7 +550,7 @@ export async function readChangeAuthorships(cwd: string): Promise<ChangeAuthorsh
const newline = String.fromCharCode(10);
const carriageReturn = String.fromCharCode(13);
try {
const git = simpleGit(cwd);
const git = openGit(cwd);
const changesPrefix = `${await pathPrefixInRepository(git)}${CHANGES_LOG_PATH}/`;
const output = await git.raw([
"-c",
Expand Down
Loading
Loading