From 346074876251364bb9cb761af1d4453b9617fb89 Mon Sep 17 00:00:00 2001 From: Naved Date: Wed, 29 Jul 2026 21:15:39 -0700 Subject: [PATCH 1/4] feat: add destructive command guard binary service Refs #1056 --- .../__tests__/manager.spec.ts | 249 ++++++++++++++++++ .../__tests__/runner.spec.ts | 126 +++++++++ .../destructive-command-guard/constants.ts | 41 +++ .../destructive-command-guard/manager.ts | 98 +++++++ .../destructive-command-guard/runner.ts | 84 ++++++ 5 files changed, 598 insertions(+) create mode 100644 src/services/destructive-command-guard/__tests__/manager.spec.ts create mode 100644 src/services/destructive-command-guard/__tests__/runner.spec.ts create mode 100644 src/services/destructive-command-guard/constants.ts create mode 100644 src/services/destructive-command-guard/manager.ts create mode 100644 src/services/destructive-command-guard/runner.ts diff --git a/src/services/destructive-command-guard/__tests__/manager.spec.ts b/src/services/destructive-command-guard/__tests__/manager.spec.ts new file mode 100644 index 0000000000..32831e749f --- /dev/null +++ b/src/services/destructive-command-guard/__tests__/manager.spec.ts @@ -0,0 +1,249 @@ +import { createHash } from "crypto" +import { EventEmitter } from "events" +import { access, chmod, mkdtemp, mkdir, readFile, rm, stat, writeFile } from "fs/promises" +import { tmpdir } from "os" +import path from "path" +import { PassThrough } from "stream" + +import { spawn } from "child_process" +import { get } from "https" +import type { IncomingMessage, RequestOptions } from "http" + +import { DCG_ARCHIVES, DCG_VERSION } from "../constants" +import { + downloadFile, + extractSingleBinary, + getDcgArchiveInfo, + getDcgBinaryPath, + isDcgSupportedPlatform, + isTrustedDownloadUrl, + resolveTrustedRedirect, + ensureDcgInstalled, + verifyChecksum, +} from "../manager" + +vi.mock("child_process", () => ({ spawn: vi.fn() })) +vi.mock("https", () => ({ get: vi.fn() })) + +const mockSpawn = vi.mocked(spawn) +const mockGet = vi.mocked(get) + +describe("Destructive Command Guard manager", () => { + let tempDir: string + + beforeEach(async () => { + tempDir = await mkdtemp(path.join(tmpdir(), "dcg-manager-")) + mockSpawn.mockReset() + mockGet.mockReset() + }) + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }) + }) + + it("maps all supported platform and architecture combinations", () => { + expect(Object.keys(DCG_ARCHIVES).sort()).toEqual(["darwin-arm64", "linux-arm64", "linux-x64", "win32-x64"]) + expect(getDcgArchiveInfo("darwin", "arm64")?.archive).toBe("dcg-aarch64-apple-darwin.tar.xz") + expect(getDcgArchiveInfo("win32", "x64")?.binary).toBe("dcg.exe") + }) + + it("rejects unsupported platforms", () => { + expect(isDcgSupportedPlatform("freebsd", "x64")).toBe(false) + expect(getDcgBinaryPath("/storage", "freebsd", "x64")).toBeUndefined() + }) + + it("returns the managed binary path", () => { + expect(getDcgBinaryPath("/storage", "linux", "x64")).toBe( + path.join("/storage", "destructive-command-guard", "dcg"), + ) + }) + + it("accepts only HTTPS URLs on trusted host boundaries", () => { + expect(isTrustedDownloadUrl("https://github.com/release")).toBe(true) + expect(isTrustedDownloadUrl("https://cdn.objects.githubusercontent.com/release")).toBe(true) + expect(isTrustedDownloadUrl("http://github.com/release")).toBe(false) + expect(isTrustedDownloadUrl("https://evilgithub.com/release")).toBe(false) + expect(isTrustedDownloadUrl("not a URL")).toBe(false) + }) + + it("rejects untrusted download URLs before opening a destination", async () => { + await expect(downloadFile("https://example.com/dcg", path.join(tempDir, "archive"))).rejects.toThrow( + "DCG download redirected to an untrusted host", + ) + }) + + it("allows trusted relative redirects and rejects unsafe or exhausted redirects", () => { + expect(resolveTrustedRedirect("https://github.com/release", "/asset", 5)).toBe("https://github.com/asset") + expect(() => resolveTrustedRedirect("https://github.com/release", "https://example.com/asset", 5)).toThrow( + "DCG download redirected to an untrusted host", + ) + expect(() => resolveTrustedRedirect("https://github.com/release", "/asset", 0)).toThrow( + "Too many DCG download redirects", + ) + expect(() => resolveTrustedRedirect("https://github.com/release", undefined, 5)).toThrow( + "Too many DCG download redirects", + ) + }) + + it("verifies matching checksums and rejects mismatches", async () => { + const filePath = path.join(tempDir, "archive") + const contents = Buffer.from("verified archive") + await writeFile(filePath, contents) + const checksum = createHash("sha256").update(contents).digest("hex") + + await expect(verifyChecksum(filePath, checksum)).resolves.toBeUndefined() + await expect(verifyChecksum(filePath, "0".repeat(64))).rejects.toThrow( + "DCG archive checksum verification failed", + ) + }) + + it("uses the platform ZIP extractor", async () => { + const child = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(), + }) + // The production code uses only the event and stream subset supplied by this test double. + mockSpawn.mockReturnValue(child as unknown as ReturnType) + + const extraction = extractSingleBinary("C:\\dcg.zip", "C:\\staging", DCG_ARCHIVES["win32-x64"]) + child.emit("close", 0) + await extraction + + const expectedExecutable = process.platform === "win32" ? "powershell" : "unzip" + const expectedArgs = + process.platform === "win32" + ? ["-NoProfile", "-Command", "Expand-Archive -Path 'C:\\dcg.zip' -DestinationPath 'C:\\staging' -Force"] + : ["-o", "C:\\dcg.zip", "-d", "C:\\staging"] + + expect(mockSpawn).toHaveBeenCalledWith(expectedExecutable, expectedArgs, { + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }) + }) + + it("extracts tar archives without imposing a single-file layout", async () => { + const child = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(), + }) + // The production code uses only the event and stream subset supplied by this test double. + mockSpawn.mockReturnValue(child as unknown as ReturnType) + + const extraction = extractSingleBinary("/tmp/dcg.tar.xz", tempDir, DCG_ARCHIVES["linux-x64"]) + child.emit("close", 0) + + await expect(extraction).resolves.toBeUndefined() + expect(mockSpawn).toHaveBeenCalledTimes(1) + expect(mockSpawn).toHaveBeenCalledWith( + "tar", + expect.arrayContaining(["-xJf", "/tmp/dcg.tar.xz", "-C", tempDir, "--no-same-owner"]), + expect.objectContaining({ shell: false }), + ) + }) + + it("surfaces process failures during extraction", async () => { + const child = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(), + }) + mockSpawn.mockReturnValue(child as unknown as ReturnType) + + const extraction = extractSingleBinary("/tmp/dcg.tar.xz", tempDir, DCG_ARCHIVES["linux-x64"]) + child.stderr.write("invalid archive") + child.emit("close", 2) + + await expect(extraction).rejects.toThrow("invalid archive") + }) + + it("reuses an existing managed binary and restores its executable permissions", async () => { + const binaryPath = getDcgBinaryPath(tempDir) + expect(binaryPath).toBeDefined() + await mkdir(path.dirname(binaryPath!), { recursive: true }) + await writeFile(binaryPath!, "existing binary") + await writeFile(path.join(path.dirname(binaryPath!), ".dcg-version"), DCG_VERSION) + if (process.platform !== "win32") { + await chmod(binaryPath!, 0o600) + } + + await expect(ensureDcgInstalled(tempDir)).resolves.toBe(binaryPath) + expect(mockSpawn).not.toHaveBeenCalled() + if (process.platform !== "win32") { + expect((await stat(binaryPath!)).mode & 0o111).toBe(0o111) + } + }) + + it("downloads, verifies, extracts, and deduplicates a new installation", async () => { + const info = getDcgArchiveInfo() + expect(info).toBeDefined() + if (!info || info.archive.endsWith(".zip")) return + + const archive = Buffer.from("test archive") + const originalChecksum = info.sha256 + Object.defineProperty(info, "sha256", { + value: createHash("sha256").update(archive).digest("hex"), + configurable: true, + }) + const response = Object.assign(new PassThrough(), { + statusCode: 200, + headers: { "content-length": String(archive.length) }, + }) + const request = Object.assign(new EventEmitter(), { + setTimeout: vi.fn(), + destroy: vi.fn(), + }) + mockGet.mockImplementation( + ( + _url: string | URL, + optionsOrCallback: RequestOptions | ((response: IncomingMessage) => void), + optionalCallback?: (response: IncomingMessage) => void, + ) => { + const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : optionalCallback + setImmediate(() => { + // The downloader uses only the response stream/status subset supplied here. + callback?.(response as unknown as IncomingMessage) + response.end(archive) + }) + // The downloader uses only timeout/error handling from ClientRequest. + return request as unknown as ReturnType + }, + ) + + mockSpawn.mockImplementation((executable, args) => { + const child = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(), + }) + setImmediate(async () => { + if (executable === "tar") { + const stagingDir = args[args.indexOf("-C") + 1] + await writeFile(path.join(stagingDir, info.binary), "executable") + } + child.emit("close", 0) + }) + // The process runner uses only the event and stream subset supplied here. + return child as unknown as ReturnType + }) + + try { + const firstInstallation = ensureDcgInstalled(tempDir) + const concurrentInstallation = ensureDcgInstalled(tempDir) + expect(concurrentInstallation).toBe(firstInstallation) + + const binaryPath = await firstInstallation + if (!binaryPath) throw new Error("Expected DCG to be supported in this test") + expect(await readFile(binaryPath, "utf8")).toBe("executable") + expect(mockGet).toHaveBeenCalledTimes(1) + expect(mockSpawn).toHaveBeenCalledTimes(1) + await expect(access(path.join(tempDir, `${DCG_VERSION}-${info.archive}`))).rejects.toThrow() + expect(await readFile(path.join(tempDir, "destructive-command-guard", ".dcg-version"), "utf8")).toBe( + DCG_VERSION, + ) + } finally { + Object.defineProperty(info, "sha256", { value: originalChecksum, configurable: true }) + } + }) +}) diff --git a/src/services/destructive-command-guard/__tests__/runner.spec.ts b/src/services/destructive-command-guard/__tests__/runner.spec.ts new file mode 100644 index 0000000000..47eb355dc8 --- /dev/null +++ b/src/services/destructive-command-guard/__tests__/runner.spec.ts @@ -0,0 +1,126 @@ +import { EventEmitter } from "events" +import { PassThrough } from "stream" + +import { spawn } from "child_process" + +import { DCG_MAX_OUTPUT_BYTES } from "../constants" +import { runDcg } from "../runner" + +vi.mock("child_process", () => ({ spawn: vi.fn() })) + +type MockChild = EventEmitter & { + stdout: PassThrough + stderr: PassThrough + kill: ReturnType +} + +const mockSpawn = vi.mocked(spawn) + +const useChild = (child: MockChild): void => { + // runDcg uses only the event, stream, and kill subset supplied by this test double. + mockSpawn.mockReturnValue(child as unknown as ReturnType) +} + +function createChild(): MockChild { + return Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(), + }) +} + +function emitResult(child: MockChild, payload: unknown, code: number): void { + child.stdout.write(JSON.stringify(payload)) + child.emit("close", code, null) +} + +describe("runDcg", () => { + beforeEach(() => { + vi.useRealTimers() + mockSpawn.mockReset() + }) + + afterEach(() => vi.useRealTimers()) + + it.each([ + [{ schema_version: 1, decision: "allow" }, 0, { decision: "allow" }], + [ + { schema_version: 2, decision: "deny", reason: "unsafe", rule_id: "delete" }, + 1, + { decision: "deny", reason: "unsafe", ruleId: "delete" }, + ], + [ + { schema_version: 2, decision: "deny", pack_id: "core", pattern_name: "delete" }, + 1, + { decision: "deny", ruleId: "core:delete" }, + ], + ])("accepts valid DCG result %#", async (payload, code, expected) => { + const child = createChild() + useChild(child) + + const result = runDcg("/dcg", "echo test", "/workspace") + emitResult(child, payload, code) + + await expect(result).resolves.toEqual(expected) + }) + + it.each([ + ["not json", 0, "DCG returned invalid JSON"], + [JSON.stringify({ schema_version: 3, decision: "allow" }), 0, "DCG returned an unsupported response schema"], + [JSON.stringify({ schema_version: 1, decision: "deny" }), 0, "DCG decision did not match its exit status"], + ])("rejects invalid output %#", async (output, code, message) => { + const child = createChild() + useChild(child) + + const result = runDcg("/dcg", "echo test", "/workspace") + child.stdout.write(output) + child.emit("close", code, null) + + await expect(result).rejects.toThrow(message) + }) + + it("rejects non-DCG exit statuses with stderr", async () => { + const child = createChild() + useChild(child) + + const result = runDcg("/dcg", "echo test", "/workspace") + child.stderr.write("failure details") + child.emit("close", 2, null) + + await expect(result).rejects.toThrow("DCG evaluation failed: failure details") + }) + + it("rejects process startup errors", async () => { + const child = createChild() + useChild(child) + + const result = runDcg("/dcg", "echo test", "/workspace") + child.emit("error", new Error("ENOENT")) + + await expect(result).rejects.toThrow("Unable to start DCG: ENOENT") + }) + + it("rejects excessive output and kills the process", async () => { + const child = createChild() + useChild(child) + + const result = runDcg("/dcg", "echo test", "/workspace") + child.stdout.write(Buffer.alloc(DCG_MAX_OUTPUT_BYTES + 1)) + + await expect(result).rejects.toThrow("DCG produced too much output") + expect(child.kill).toHaveBeenCalledWith("SIGKILL") + }) + + it("times out and kills the process", async () => { + vi.useFakeTimers() + const child = createChild() + useChild(child) + + const result = runDcg("/dcg", "echo test", "/workspace") + const rejection = expect(result).rejects.toThrow("DCG evaluation timed out") + await vi.runAllTimersAsync() + + await rejection + expect(child.kill).toHaveBeenCalledWith("SIGKILL") + }) +}) diff --git a/src/services/destructive-command-guard/constants.ts b/src/services/destructive-command-guard/constants.ts new file mode 100644 index 0000000000..74ce76ada4 --- /dev/null +++ b/src/services/destructive-command-guard/constants.ts @@ -0,0 +1,41 @@ +export const DCG_VERSION = "v0.7.7" + +export type DcgArchiveInfo = Readonly<{ + archive: string + binary: "dcg" | "dcg.exe" + sha256: string +}> + +export const DCG_ARCHIVES: Readonly> = { + "darwin-arm64": { + archive: "dcg-aarch64-apple-darwin.tar.xz", + binary: "dcg", + sha256: "a63cf82bd3584055112d5ec7a4ab3d7e0619a9f806a53930c27aa0e6297484de", + }, + "linux-arm64": { + archive: "dcg-aarch64-unknown-linux-gnu.tar.xz", + binary: "dcg", + sha256: "abb0d94f23ab50f9edc16f8ca6939ff8eec23e1831d3ad7a28d9f03252c3306d", + }, + "linux-x64": { + archive: "dcg-x86_64-unknown-linux-musl.tar.xz", + binary: "dcg", + sha256: "472b130a9b235edc57e6cb7566641da5fef905e9dbefd3a46f9ad1e33205fa04", + }, + "win32-x64": { + archive: "dcg-x86_64-pc-windows-msvc.zip", + binary: "dcg.exe", + sha256: "435127410eabc53e772be4f5c668a875b45fbaf806654b577c2d975bd0e38964", + }, +} as const + +export const DCG_DOWNLOAD_BASE_URL = `https://github.com/Dicklesworthstone/destructive_command_guard/releases/download/${DCG_VERSION}` + +export const DCG_RUN_TIMEOUT_MS = 3_000 +export const DCG_MAX_OUTPUT_BYTES = 256 * 1024 + +export const DCG_TRUSTED_DOWNLOAD_DOMAINS = [ + "github.com", + "objects.githubusercontent.com", + "release-assets.githubusercontent.com", +] as const diff --git a/src/services/destructive-command-guard/manager.ts b/src/services/destructive-command-guard/manager.ts new file mode 100644 index 0000000000..61325954bb --- /dev/null +++ b/src/services/destructive-command-guard/manager.ts @@ -0,0 +1,98 @@ +import * as path from "path" + +import { extractTarXzArchive, extractZipArchive } from "../managed-binary/archive" +import { + downloadBinaryFile, + isTrustedHttpsUrl, + resolveTrustedRedirect as resolveManagedBinaryRedirect, + verifySha256Checksum, +} from "../managed-binary/download" +import { ensureManagedBinaryInstalled, getManagedBinaryPaths } from "../managed-binary/install" + +import { + DCG_ARCHIVES, + DCG_DOWNLOAD_BASE_URL, + DCG_TRUSTED_DOWNLOAD_DOMAINS, + DCG_VERSION, + type DcgArchiveInfo, +} from "./constants" + +const VERSION_FILE = ".dcg-version" + +export function getDcgArchiveInfo(platform = process.platform, arch = process.arch): DcgArchiveInfo | undefined { + return DCG_ARCHIVES[`${platform}-${arch}`] +} + +export function isDcgSupportedPlatform(platform = process.platform, arch = process.arch): boolean { + return getDcgArchiveInfo(platform, arch) !== undefined +} + +export function getDcgBinaryPath( + storageDir: string, + platform = process.platform, + arch = process.arch, +): string | undefined { + const info = getDcgArchiveInfo(platform, arch) + return info ? path.join(storageDir, "destructive-command-guard", info.binary) : undefined +} + +export function isTrustedDownloadUrl(url: string): boolean { + return isTrustedHttpsUrl(url, DCG_TRUSTED_DOWNLOAD_DOMAINS) +} + +export function resolveTrustedRedirect(url: string, location: string | undefined, redirectsRemaining: number): string { + return resolveManagedBinaryRedirect(url, location, redirectsRemaining, { + name: "DCG", + trustedDomains: DCG_TRUSTED_DOWNLOAD_DOMAINS, + }) +} + +export function downloadFile(url: string, destination: string, maxRedirects = 5): Promise { + return downloadBinaryFile(url, destination, { + name: "DCG", + trustedDomains: DCG_TRUSTED_DOWNLOAD_DOMAINS, + timeoutMs: 120_000, + maxRedirects, + }) +} + +export async function verifyChecksum(filePath: string, expected: string): Promise { + await verifySha256Checksum(filePath, expected, () => new Error("DCG archive checksum verification failed")) +} + +export async function extractSingleBinary( + archivePath: string, + stagingDir: string, + info: DcgArchiveInfo, +): Promise { + if (info.archive.endsWith(".zip")) { + await extractZipArchive(archivePath, stagingDir) + return + } + + await extractTarXzArchive(archivePath, stagingDir) +} + +function installDcg(storageDir: string): Promise { + const info = getDcgArchiveInfo() + if (!info) { + return Promise.resolve(undefined) + } + + return ensureManagedBinaryInstalled({ + storageDir, + id: "destructive-command-guard", + version: DCG_VERSION, + versionFile: VERSION_FILE, + archiveName: info.archive, + binaryName: info.binary, + errorPrefix: "Failed to download DCG", + download: (archivePath) => downloadFile(`${DCG_DOWNLOAD_BASE_URL}/${info.archive}`, archivePath), + verifyArchive: (archivePath) => verifyChecksum(archivePath, info.sha256), + extractArchive: (archivePath, stagingDir) => extractSingleBinary(archivePath, stagingDir, info), + }) +} + +export function ensureDcgInstalled(storageDir: string): Promise { + return installDcg(storageDir) +} diff --git a/src/services/destructive-command-guard/runner.ts b/src/services/destructive-command-guard/runner.ts new file mode 100644 index 0000000000..22c0ac58e2 --- /dev/null +++ b/src/services/destructive-command-guard/runner.ts @@ -0,0 +1,84 @@ +import { spawn } from "child_process" + +import { DCG_MAX_OUTPUT_BYTES, DCG_RUN_TIMEOUT_MS } from "./constants" + +export type DcgDecision = { decision: "allow" } | { decision: "deny"; reason?: string; ruleId?: string } + +type DcgJsonOutput = { + schema_version?: number | string + decision?: string + reason?: string + rule_id?: string + pattern_name?: string + pack_id?: string +} + +export function runDcg(binaryPath: string, command: string, cwd: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(binaryPath, ["test", "--format", "json", "--no-color", command], { + cwd, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, NO_COLOR: "1" }, + }) + let stdout: Buffer = Buffer.alloc(0) + let stderr: Buffer = Buffer.alloc(0) + let settled = false + const fail = (error: Error) => { + if (settled) return + settled = true + clearTimeout(timer) + child.kill("SIGKILL") + reject(error) + } + const append = (current: Buffer, chunk: Buffer): Buffer => { + if (current.length + chunk.length > DCG_MAX_OUTPUT_BYTES) { + fail(new Error("DCG produced too much output")) + return current + } + return Buffer.concat([current, chunk]) + } + const timer = setTimeout(() => fail(new Error("DCG evaluation timed out")), DCG_RUN_TIMEOUT_MS) + child.stdout?.on("data", (chunk: Buffer) => (stdout = append(stdout, chunk))) + child.stderr?.on("data", (chunk: Buffer) => (stderr = append(stderr, chunk))) + child.on("error", (error) => fail(new Error(`Unable to start DCG: ${error.message}`))) + child.on("close", (code, signal) => { + if (settled) return + settled = true + clearTimeout(timer) + if (signal || (code !== 0 && code !== 1)) { + reject(new Error(`DCG evaluation failed${stderr.length ? `: ${stderr.toString().trim()}` : ""}`)) + return + } + + let payload: DcgJsonOutput + try { + payload = JSON.parse(stdout.toString("utf8")) as DcgJsonOutput + } catch { + reject(new Error("DCG returned invalid JSON")) + return + } + + const schemaVersion = Number(payload.schema_version) + if (![1, 2].includes(schemaVersion)) { + reject(new Error("DCG returned an unsupported response schema")) + return + } + if (payload.decision === "allow" && code === 0) { + resolve({ decision: "allow" }) + } else if (payload.decision === "deny" && code === 1) { + resolve({ + decision: "deny", + reason: payload.reason, + ruleId: + payload.rule_id ?? + (payload.pack_id && payload.pattern_name + ? `${payload.pack_id}:${payload.pattern_name}` + : undefined), + }) + } else { + reject(new Error("DCG decision did not match its exit status")) + } + }) + }) +} From e1a0c3943af6ba575a38d8fc3765e47672630554 Mon Sep 17 00:00:00 2001 From: Naved Date: Sat, 1 Aug 2026 10:16:56 -0700 Subject: [PATCH 2/4] test: strengthen DCG binary service coverage --- .../__tests__/manager.spec.ts | 21 ++++++++++++------- .../__tests__/runner.spec.ts | 5 +++++ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/services/destructive-command-guard/__tests__/manager.spec.ts b/src/services/destructive-command-guard/__tests__/manager.spec.ts index 32831e749f..45543a8129 100644 --- a/src/services/destructive-command-guard/__tests__/manager.spec.ts +++ b/src/services/destructive-command-guard/__tests__/manager.spec.ts @@ -68,7 +68,7 @@ describe("Destructive Command Guard manager", () => { it("rejects untrusted download URLs before opening a destination", async () => { await expect(downloadFile("https://example.com/dcg", path.join(tempDir, "archive"))).rejects.toThrow( - "DCG download redirected to an untrusted host", + "DCG download URL is not a trusted HTTPS host", ) }) @@ -81,7 +81,7 @@ describe("Destructive Command Guard manager", () => { "Too many DCG download redirects", ) expect(() => resolveTrustedRedirect("https://github.com/release", undefined, 5)).toThrow( - "Too many DCG download redirects", + "DCG download redirect is missing a Location header", ) }) @@ -113,7 +113,14 @@ describe("Destructive Command Guard manager", () => { const expectedExecutable = process.platform === "win32" ? "powershell" : "unzip" const expectedArgs = process.platform === "win32" - ? ["-NoProfile", "-Command", "Expand-Archive -Path 'C:\\dcg.zip' -DestinationPath 'C:\\staging' -Force"] + ? [ + "-NoProfile", + "-NonInteractive", + "-Command", + "$archivePath = $args[0]; $destination = $args[1]; Expand-Archive -LiteralPath $archivePath -DestinationPath $destination -Force", + "C:\\dcg.zip", + "C:\\staging", + ] : ["-o", "C:\\dcg.zip", "-d", "C:\\staging"] expect(mockSpawn).toHaveBeenCalledWith(expectedExecutable, expectedArgs, { @@ -136,11 +143,9 @@ describe("Destructive Command Guard manager", () => { await expect(extraction).resolves.toBeUndefined() expect(mockSpawn).toHaveBeenCalledTimes(1) - expect(mockSpawn).toHaveBeenCalledWith( - "tar", - expect.arrayContaining(["-xJf", "/tmp/dcg.tar.xz", "-C", tempDir, "--no-same-owner"]), - expect.objectContaining({ shell: false }), - ) + const expectedArgs = ["-xJf", "/tmp/dcg.tar.xz", "-C", tempDir, "--no-same-owner"] + if (process.platform === "linux") expectedArgs.push("--no-overwrite-dir") + expect(mockSpawn).toHaveBeenCalledWith("tar", expectedArgs, { shell: false, stdio: ["ignore", "pipe", "pipe"] }) }) it("surfaces process failures during extraction", async () => { diff --git a/src/services/destructive-command-guard/__tests__/runner.spec.ts b/src/services/destructive-command-guard/__tests__/runner.spec.ts index 47eb355dc8..7be215f2cc 100644 --- a/src/services/destructive-command-guard/__tests__/runner.spec.ts +++ b/src/services/destructive-command-guard/__tests__/runner.spec.ts @@ -62,6 +62,11 @@ describe("runDcg", () => { emitResult(child, payload, code) await expect(result).resolves.toEqual(expected) + expect(mockSpawn).toHaveBeenCalledWith( + "/dcg", + expect.any(Array), + expect.objectContaining({ cwd: "/workspace" }), + ) }) it.each([ From bae4ff7fb170df438999145e1916e0d7fa7eff5f Mon Sep 17 00:00:00 2001 From: Naved Date: Sat, 1 Aug 2026 17:54:52 -0700 Subject: [PATCH 3/4] fix: address DCG service review feedback --- .../__tests__/manager.spec.ts | 129 +++++++++++++++++- .../__tests__/runner.spec.ts | 35 ++++- .../destructive-command-guard/manager.ts | 11 +- .../destructive-command-guard/runner.ts | 41 ++++-- 4 files changed, 198 insertions(+), 18 deletions(-) diff --git a/src/services/destructive-command-guard/__tests__/manager.spec.ts b/src/services/destructive-command-guard/__tests__/manager.spec.ts index 45543a8129..b49ef8aebe 100644 --- a/src/services/destructive-command-guard/__tests__/manager.spec.ts +++ b/src/services/destructive-command-guard/__tests__/manager.spec.ts @@ -63,6 +63,7 @@ describe("Destructive Command Guard manager", () => { expect(isTrustedDownloadUrl("https://cdn.objects.githubusercontent.com/release")).toBe(true) expect(isTrustedDownloadUrl("http://github.com/release")).toBe(false) expect(isTrustedDownloadUrl("https://evilgithub.com/release")).toBe(false) + expect(isTrustedDownloadUrl("https://github.com.evil.com/release")).toBe(false) expect(isTrustedDownloadUrl("not a URL")).toBe(false) }) @@ -72,6 +73,61 @@ describe("Destructive Command Guard manager", () => { ) }) + it("rejects non-successful HTTP responses", async () => { + const response = Object.assign(new PassThrough(), { statusCode: 503, headers: {}, destroy: vi.fn() }) + const request = Object.assign(new EventEmitter(), { setTimeout: vi.fn(), destroy: vi.fn() }) + mockGet.mockImplementation((_url, optionsOrCallback, optionalCallback) => { + const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : optionalCallback + setImmediate(() => callback?.(response as unknown as IncomingMessage)) + return request as unknown as ReturnType + }) + + await expect(downloadFile("https://github.com/release", path.join(tempDir, "archive"))).rejects.toThrow( + "DCG download failed with HTTP 503", + ) + }) + + it("rejects request errors", async () => { + const request = Object.assign(new EventEmitter(), { setTimeout: vi.fn(), destroy: vi.fn() }) + mockGet.mockReturnValue(request as unknown as ReturnType) + + const download = downloadFile("https://github.com/release", path.join(tempDir, "archive")) + request.emit("error", new Error("socket failed")) + + await expect(download).rejects.toThrow("socket failed") + }) + + it("times out stalled requests", async () => { + const request = Object.assign(new EventEmitter(), { + setTimeout: vi.fn((_timeout: number, callback: () => void) => setImmediate(callback)), + destroy: vi.fn((error: Error) => request.emit("error", error)), + }) + mockGet.mockReturnValue(request as unknown as ReturnType) + + await expect(downloadFile("https://github.com/release", path.join(tempDir, "archive"))).rejects.toThrow( + "DCG download timed out", + ) + expect(request.setTimeout).toHaveBeenCalledWith(120_000, expect.any(Function)) + }) + + it("rejects archives larger than 50 MiB", async () => { + const response = Object.assign(new PassThrough(), { + statusCode: 200, + headers: { "content-length": String(50 * 1024 * 1024 + 1) }, + destroy: vi.fn(), + }) + const request = Object.assign(new EventEmitter(), { setTimeout: vi.fn(), destroy: vi.fn() }) + mockGet.mockImplementation((_url, optionsOrCallback, optionalCallback) => { + const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : optionalCallback + setImmediate(() => callback?.(response as unknown as IncomingMessage)) + return request as unknown as ReturnType + }) + + await expect(downloadFile("https://github.com/release", path.join(tempDir, "archive"))).rejects.toThrow( + "DCG archive exceeds the download size limit", + ) + }) + it("allows trusted relative redirects and rejects unsafe or exhausted redirects", () => { expect(resolveTrustedRedirect("https://github.com/release", "/asset", 5)).toBe("https://github.com/asset") expect(() => resolveTrustedRedirect("https://github.com/release", "https://example.com/asset", 5)).toThrow( @@ -92,9 +148,7 @@ describe("Destructive Command Guard manager", () => { const checksum = createHash("sha256").update(contents).digest("hex") await expect(verifyChecksum(filePath, checksum)).resolves.toBeUndefined() - await expect(verifyChecksum(filePath, "0".repeat(64))).rejects.toThrow( - "DCG archive checksum verification failed", - ) + await expect(verifyChecksum(filePath, "0".repeat(64))).rejects.toThrow(`got ${checksum}`) }) it("uses the platform ZIP extractor", async () => { @@ -180,6 +234,22 @@ describe("Destructive Command Guard manager", () => { } }) + it("warns when the current platform is unsupported", async () => { + const platformKey = `${process.platform}-${process.arch}` + const info = DCG_ARCHIVES[platformKey] + if (!info) return + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + Reflect.deleteProperty(DCG_ARCHIVES, platformKey) + + try { + await expect(ensureDcgInstalled(tempDir)).resolves.toBeUndefined() + expect(warnSpy).toHaveBeenCalledWith(`[DCG] Unsupported platform: ${platformKey}`) + } finally { + Reflect.set(DCG_ARCHIVES, platformKey, info) + warnSpy.mockRestore() + } + }) + it("downloads, verifies, extracts, and deduplicates a new installation", async () => { const info = getDcgArchiveInfo() expect(info).toBeDefined() @@ -251,4 +321,57 @@ describe("Destructive Command Guard manager", () => { Object.defineProperty(info, "sha256", { value: originalChecksum, configurable: true }) } }) + + it("downloads, verifies, extracts, and installs a ZIP archive", async () => { + const info = getDcgArchiveInfo() + if (!info?.archive.endsWith(".zip")) return + + const archive = Buffer.from("test ZIP archive") + const originalChecksum = info.sha256 + Object.defineProperty(info, "sha256", { + value: createHash("sha256").update(archive).digest("hex"), + configurable: true, + }) + const response = Object.assign(new PassThrough(), { + statusCode: 200, + headers: { "content-length": String(archive.length) }, + }) + const request = Object.assign(new EventEmitter(), { setTimeout: vi.fn(), destroy: vi.fn() }) + mockGet.mockImplementation((_url, optionsOrCallback, optionalCallback) => { + const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : optionalCallback + setImmediate(() => { + callback?.(response as unknown as IncomingMessage) + response.end(archive) + }) + return request as unknown as ReturnType + }) + mockSpawn.mockImplementation((_executable, args) => { + const child = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(), + }) + setImmediate(async () => { + const destinationIndex = args.indexOf( + "$archivePath = $args[0]; $destination = $args[1]; Expand-Archive -LiteralPath $archivePath -DestinationPath $destination -Force", + ) + const stagingDir = args[destinationIndex + 2] + await writeFile(path.join(stagingDir, info.binary), "ZIP executable") + child.emit("close", 0) + }) + return child as unknown as ReturnType + }) + + try { + const binaryPath = await ensureDcgInstalled(tempDir) + if (!binaryPath) throw new Error("Expected DCG to be supported in this test") + expect(await readFile(binaryPath, "utf8")).toBe("ZIP executable") + expect(await readFile(path.join(tempDir, "destructive-command-guard", ".dcg-version"), "utf8")).toBe( + DCG_VERSION, + ) + await expect(access(path.join(tempDir, `${DCG_VERSION}-${info.archive}`))).rejects.toThrow() + } finally { + Object.defineProperty(info, "sha256", { value: originalChecksum, configurable: true }) + } + }) }) diff --git a/src/services/destructive-command-guard/__tests__/runner.spec.ts b/src/services/destructive-command-guard/__tests__/runner.spec.ts index 7be215f2cc..d738de9b7a 100644 --- a/src/services/destructive-command-guard/__tests__/runner.spec.ts +++ b/src/services/destructive-command-guard/__tests__/runner.spec.ts @@ -35,12 +35,18 @@ function emitResult(child: MockChild, payload: unknown, code: number): void { } describe("runDcg", () => { + let warnSpy: ReturnType + beforeEach(() => { vi.useRealTimers() mockSpawn.mockReset() + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) }) - afterEach(() => vi.useRealTimers()) + afterEach(() => { + vi.useRealTimers() + warnSpy.mockRestore() + }) it.each([ [{ schema_version: 1, decision: "allow" }, 0, { decision: "allow" }], @@ -67,6 +73,30 @@ describe("runDcg", () => { expect.any(Array), expect.objectContaining({ cwd: "/workspace" }), ) + if (expected.decision === "deny") { + const reason = "reason" in expected ? expected.reason : undefined + expect(warnSpy).toHaveBeenCalledWith("[DCG] Command denied", reason ?? "No reason provided") + } + }) + + it("passes only the environment variables DCG requires", async () => { + const child = createChild() + useChild(child) + const originalToken = process.env.GITHUB_TOKEN + process.env.GITHUB_TOKEN = "secret" + + try { + const result = runDcg("/dcg", "echo test", "/workspace") + emitResult(child, { schema_version: 1, decision: "allow" }, 0) + await result + + const options = mockSpawn.mock.calls[0][2] + expect(options?.env).toMatchObject({ NO_COLOR: "1" }) + expect(options?.env).not.toHaveProperty("GITHUB_TOKEN") + } finally { + if (originalToken === undefined) delete process.env.GITHUB_TOKEN + else process.env.GITHUB_TOKEN = originalToken + } }) it.each([ @@ -82,6 +112,7 @@ describe("runDcg", () => { child.emit("close", code, null) await expect(result).rejects.toThrow(message) + expect(warnSpy).toHaveBeenCalledWith("[DCG]", message) }) it("rejects non-DCG exit statuses with stderr", async () => { @@ -93,6 +124,7 @@ describe("runDcg", () => { child.emit("close", 2, null) await expect(result).rejects.toThrow("DCG evaluation failed: failure details") + expect(warnSpy).toHaveBeenCalledWith("[DCG]", "DCG evaluation failed: failure details") }) it("rejects process startup errors", async () => { @@ -103,6 +135,7 @@ describe("runDcg", () => { child.emit("error", new Error("ENOENT")) await expect(result).rejects.toThrow("Unable to start DCG: ENOENT") + expect(warnSpy).toHaveBeenCalledWith("[DCG]", "Unable to start DCG: ENOENT") }) it("rejects excessive output and kills the process", async () => { diff --git a/src/services/destructive-command-guard/manager.ts b/src/services/destructive-command-guard/manager.ts index 61325954bb..84338713b4 100644 --- a/src/services/destructive-command-guard/manager.ts +++ b/src/services/destructive-command-guard/manager.ts @@ -7,7 +7,7 @@ import { resolveTrustedRedirect as resolveManagedBinaryRedirect, verifySha256Checksum, } from "../managed-binary/download" -import { ensureManagedBinaryInstalled, getManagedBinaryPaths } from "../managed-binary/install" +import { ensureManagedBinaryInstalled } from "../managed-binary/install" import { DCG_ARCHIVES, @@ -18,6 +18,7 @@ import { } from "./constants" const VERSION_FILE = ".dcg-version" +const MAX_ARCHIVE_BYTES = 50 * 1024 * 1024 export function getDcgArchiveInfo(platform = process.platform, arch = process.arch): DcgArchiveInfo | undefined { return DCG_ARCHIVES[`${platform}-${arch}`] @@ -53,11 +54,16 @@ export function downloadFile(url: string, destination: string, maxRedirects = 5) trustedDomains: DCG_TRUSTED_DOWNLOAD_DOMAINS, timeoutMs: 120_000, maxRedirects, + maxBytes: MAX_ARCHIVE_BYTES, }) } export async function verifyChecksum(filePath: string, expected: string): Promise { - await verifySha256Checksum(filePath, expected, () => new Error("DCG archive checksum verification failed")) + await verifySha256Checksum( + filePath, + expected, + (actual) => new Error(`DCG archive checksum verification failed (got ${actual})`), + ) } export async function extractSingleBinary( @@ -76,6 +82,7 @@ export async function extractSingleBinary( function installDcg(storageDir: string): Promise { const info = getDcgArchiveInfo() if (!info) { + console.warn(`[DCG] Unsupported platform: ${process.platform}-${process.arch}`) return Promise.resolve(undefined) } diff --git a/src/services/destructive-command-guard/runner.ts b/src/services/destructive-command-guard/runner.ts index 22c0ac58e2..83d5b95c7b 100644 --- a/src/services/destructive-command-guard/runner.ts +++ b/src/services/destructive-command-guard/runner.ts @@ -13,25 +13,39 @@ type DcgJsonOutput = { pack_id?: string } +const DCG_ENV_KEYS = ["HOME", "PATH", "TEMP", "TMP", "USERPROFILE", "SystemRoot", "WINDIR"] as const + +function getDcgEnvironment(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { NO_COLOR: "1" } + for (const key of DCG_ENV_KEYS) { + if (process.env[key] !== undefined) env[key] = process.env[key] + } + return env +} + export function runDcg(binaryPath: string, command: string, cwd: string): Promise { return new Promise((resolve, reject) => { const child = spawn(binaryPath, ["test", "--format", "json", "--no-color", command], { cwd, shell: false, stdio: ["ignore", "pipe", "pipe"], - env: { ...process.env, NO_COLOR: "1" }, + env: getDcgEnvironment(), }) let stdout: Buffer = Buffer.alloc(0) let stderr: Buffer = Buffer.alloc(0) let settled = false - const fail = (error: Error) => { + const fail = (error: Error, killChild = true) => { if (settled) return settled = true clearTimeout(timer) - child.kill("SIGKILL") + if (killChild) child.kill("SIGKILL") + console.warn("[DCG]", error.message) reject(error) } - const append = (current: Buffer, chunk: Buffer): Buffer => { + const appendOutputOrFail = ( + current: Buffer, + chunk: Buffer, + ): Buffer => { if (current.length + chunk.length > DCG_MAX_OUTPUT_BYTES) { fail(new Error("DCG produced too much output")) return current @@ -39,15 +53,13 @@ export function runDcg(binaryPath: string, command: string, cwd: string): Promis return Buffer.concat([current, chunk]) } const timer = setTimeout(() => fail(new Error("DCG evaluation timed out")), DCG_RUN_TIMEOUT_MS) - child.stdout?.on("data", (chunk: Buffer) => (stdout = append(stdout, chunk))) - child.stderr?.on("data", (chunk: Buffer) => (stderr = append(stderr, chunk))) + child.stdout?.on("data", (chunk: Buffer) => (stdout = appendOutputOrFail(stdout, chunk))) + child.stderr?.on("data", (chunk: Buffer) => (stderr = appendOutputOrFail(stderr, chunk))) child.on("error", (error) => fail(new Error(`Unable to start DCG: ${error.message}`))) child.on("close", (code, signal) => { if (settled) return - settled = true - clearTimeout(timer) if (signal || (code !== 0 && code !== 1)) { - reject(new Error(`DCG evaluation failed${stderr.length ? `: ${stderr.toString().trim()}` : ""}`)) + fail(new Error(`DCG evaluation failed${stderr.length ? `: ${stderr.toString().trim()}` : ""}`), false) return } @@ -55,18 +67,23 @@ export function runDcg(binaryPath: string, command: string, cwd: string): Promis try { payload = JSON.parse(stdout.toString("utf8")) as DcgJsonOutput } catch { - reject(new Error("DCG returned invalid JSON")) + fail(new Error("DCG returned invalid JSON"), false) return } const schemaVersion = Number(payload.schema_version) if (![1, 2].includes(schemaVersion)) { - reject(new Error("DCG returned an unsupported response schema")) + fail(new Error("DCG returned an unsupported response schema"), false) return } if (payload.decision === "allow" && code === 0) { + settled = true + clearTimeout(timer) resolve({ decision: "allow" }) } else if (payload.decision === "deny" && code === 1) { + settled = true + clearTimeout(timer) + console.warn("[DCG] Command denied", payload.reason ?? "No reason provided") resolve({ decision: "deny", reason: payload.reason, @@ -77,7 +94,7 @@ export function runDcg(binaryPath: string, command: string, cwd: string): Promis : undefined), }) } else { - reject(new Error("DCG decision did not match its exit status")) + fail(new Error("DCG decision did not match its exit status"), false) } }) }) From 5f3854a568a7e10d6f3bb06dc4fa1d679bd8246e Mon Sep 17 00:00:00 2001 From: Naved Merchant <14171946+navedmerchant@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:46:19 +0000 Subject: [PATCH 4/4] fix: address DCG binary service feedback --- .../__tests__/manager.spec.ts | 106 ++++++++++-------- .../__tests__/runner.spec.ts | 6 +- .../destructive-command-guard/runner.ts | 2 +- 3 files changed, 64 insertions(+), 50 deletions(-) diff --git a/src/services/destructive-command-guard/__tests__/manager.spec.ts b/src/services/destructive-command-guard/__tests__/manager.spec.ts index b49ef8aebe..15bed8b511 100644 --- a/src/services/destructive-command-guard/__tests__/manager.spec.ts +++ b/src/services/destructive-command-guard/__tests__/manager.spec.ts @@ -130,6 +130,13 @@ describe("Destructive Command Guard manager", () => { it("allows trusted relative redirects and rejects unsafe or exhausted redirects", () => { expect(resolveTrustedRedirect("https://github.com/release", "/asset", 5)).toBe("https://github.com/asset") + expect( + resolveTrustedRedirect( + "https://github.com/release", + "https://release-assets.githubusercontent.com/asset", + 5, + ), + ).toBe("https://release-assets.githubusercontent.com/asset") expect(() => resolveTrustedRedirect("https://github.com/release", "https://example.com/asset", 5)).toThrow( "DCG download redirected to an untrusted host", ) @@ -322,56 +329,59 @@ describe("Destructive Command Guard manager", () => { } }) - it("downloads, verifies, extracts, and installs a ZIP archive", async () => { - const info = getDcgArchiveInfo() - if (!info?.archive.endsWith(".zip")) return - - const archive = Buffer.from("test ZIP archive") - const originalChecksum = info.sha256 - Object.defineProperty(info, "sha256", { - value: createHash("sha256").update(archive).digest("hex"), - configurable: true, - }) - const response = Object.assign(new PassThrough(), { - statusCode: 200, - headers: { "content-length": String(archive.length) }, - }) - const request = Object.assign(new EventEmitter(), { setTimeout: vi.fn(), destroy: vi.fn() }) - mockGet.mockImplementation((_url, optionsOrCallback, optionalCallback) => { - const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : optionalCallback - setImmediate(() => { - callback?.(response as unknown as IncomingMessage) - response.end(archive) + it.skipIf(!getDcgArchiveInfo()?.archive.endsWith(".zip"))( + "downloads, verifies, extracts, and installs a ZIP archive", + async () => { + const info = getDcgArchiveInfo() + if (!info) throw new Error("Expected a ZIP archive in this test") + + const archive = Buffer.from("test ZIP archive") + const originalChecksum = info.sha256 + Object.defineProperty(info, "sha256", { + value: createHash("sha256").update(archive).digest("hex"), + configurable: true, }) - return request as unknown as ReturnType - }) - mockSpawn.mockImplementation((_executable, args) => { - const child = Object.assign(new EventEmitter(), { - stdout: new PassThrough(), - stderr: new PassThrough(), - kill: vi.fn(), + const response = Object.assign(new PassThrough(), { + statusCode: 200, + headers: { "content-length": String(archive.length) }, }) - setImmediate(async () => { - const destinationIndex = args.indexOf( - "$archivePath = $args[0]; $destination = $args[1]; Expand-Archive -LiteralPath $archivePath -DestinationPath $destination -Force", - ) - const stagingDir = args[destinationIndex + 2] - await writeFile(path.join(stagingDir, info.binary), "ZIP executable") - child.emit("close", 0) + const request = Object.assign(new EventEmitter(), { setTimeout: vi.fn(), destroy: vi.fn() }) + mockGet.mockImplementation((_url, optionsOrCallback, optionalCallback) => { + const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : optionalCallback + setImmediate(() => { + callback?.(response as unknown as IncomingMessage) + response.end(archive) + }) + return request as unknown as ReturnType + }) + mockSpawn.mockImplementation((_executable, args) => { + const child = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(), + }) + setImmediate(async () => { + const destinationIndex = args.indexOf( + "$archivePath = $args[0]; $destination = $args[1]; Expand-Archive -LiteralPath $archivePath -DestinationPath $destination -Force", + ) + const stagingDir = args[destinationIndex + 2] + await writeFile(path.join(stagingDir, info.binary), "ZIP executable") + child.emit("close", 0) + }) + return child as unknown as ReturnType }) - return child as unknown as ReturnType - }) - try { - const binaryPath = await ensureDcgInstalled(tempDir) - if (!binaryPath) throw new Error("Expected DCG to be supported in this test") - expect(await readFile(binaryPath, "utf8")).toBe("ZIP executable") - expect(await readFile(path.join(tempDir, "destructive-command-guard", ".dcg-version"), "utf8")).toBe( - DCG_VERSION, - ) - await expect(access(path.join(tempDir, `${DCG_VERSION}-${info.archive}`))).rejects.toThrow() - } finally { - Object.defineProperty(info, "sha256", { value: originalChecksum, configurable: true }) - } - }) + try { + const binaryPath = await ensureDcgInstalled(tempDir) + if (!binaryPath) throw new Error("Expected DCG to be supported in this test") + expect(await readFile(binaryPath, "utf8")).toBe("ZIP executable") + expect(await readFile(path.join(tempDir, "destructive-command-guard", ".dcg-version"), "utf8")).toBe( + DCG_VERSION, + ) + await expect(access(path.join(tempDir, `${DCG_VERSION}-${info.archive}`))).rejects.toThrow() + } finally { + Object.defineProperty(info, "sha256", { value: originalChecksum, configurable: true }) + } + }, + ) }) diff --git a/src/services/destructive-command-guard/__tests__/runner.spec.ts b/src/services/destructive-command-guard/__tests__/runner.spec.ts index d738de9b7a..3dcfd805e2 100644 --- a/src/services/destructive-command-guard/__tests__/runner.spec.ts +++ b/src/services/destructive-command-guard/__tests__/runner.spec.ts @@ -83,7 +83,9 @@ describe("runDcg", () => { const child = createChild() useChild(child) const originalToken = process.env.GITHUB_TOKEN + const originalTmpdir = process.env.TMPDIR process.env.GITHUB_TOKEN = "secret" + process.env.TMPDIR = "/sandbox/tmp" try { const result = runDcg("/dcg", "echo test", "/workspace") @@ -91,11 +93,13 @@ describe("runDcg", () => { await result const options = mockSpawn.mock.calls[0][2] - expect(options?.env).toMatchObject({ NO_COLOR: "1" }) + expect(options?.env).toMatchObject({ NO_COLOR: "1", TMPDIR: "/sandbox/tmp" }) expect(options?.env).not.toHaveProperty("GITHUB_TOKEN") } finally { if (originalToken === undefined) delete process.env.GITHUB_TOKEN else process.env.GITHUB_TOKEN = originalToken + if (originalTmpdir === undefined) delete process.env.TMPDIR + else process.env.TMPDIR = originalTmpdir } }) diff --git a/src/services/destructive-command-guard/runner.ts b/src/services/destructive-command-guard/runner.ts index 83d5b95c7b..3e3b3cab45 100644 --- a/src/services/destructive-command-guard/runner.ts +++ b/src/services/destructive-command-guard/runner.ts @@ -13,7 +13,7 @@ type DcgJsonOutput = { pack_id?: string } -const DCG_ENV_KEYS = ["HOME", "PATH", "TEMP", "TMP", "USERPROFILE", "SystemRoot", "WINDIR"] as const +const DCG_ENV_KEYS = ["HOME", "PATH", "TEMP", "TMPDIR", "TMP", "USERPROFILE", "SystemRoot", "WINDIR"] as const function getDcgEnvironment(): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { NO_COLOR: "1" }