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..15bed8b511 --- /dev/null +++ b/src/services/destructive-command-guard/__tests__/manager.spec.ts @@ -0,0 +1,387 @@ +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("https://github.com.evil.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 URL is not a trusted HTTPS host", + ) + }) + + 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://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", + ) + expect(() => resolveTrustedRedirect("https://github.com/release", "/asset", 0)).toThrow( + "Too many DCG download redirects", + ) + expect(() => resolveTrustedRedirect("https://github.com/release", undefined, 5)).toThrow( + "DCG download redirect is missing a Location header", + ) + }) + + 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(`got ${checksum}`) + }) + + 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", + "-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, { + 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) + 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 () => { + 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("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() + 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 }) + } + }) + + 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, + }) + 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 new file mode 100644 index 0000000000..3dcfd805e2 --- /dev/null +++ b/src/services/destructive-command-guard/__tests__/runner.spec.ts @@ -0,0 +1,168 @@ +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", () => { + let warnSpy: ReturnType + + beforeEach(() => { + vi.useRealTimers() + mockSpawn.mockReset() + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + }) + + afterEach(() => { + vi.useRealTimers() + warnSpy.mockRestore() + }) + + 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) + expect(mockSpawn).toHaveBeenCalledWith( + "/dcg", + 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 + const originalTmpdir = process.env.TMPDIR + process.env.GITHUB_TOKEN = "secret" + process.env.TMPDIR = "/sandbox/tmp" + + 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", 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 + } + }) + + 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) + expect(warnSpy).toHaveBeenCalledWith("[DCG]", 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") + expect(warnSpy).toHaveBeenCalledWith("[DCG]", "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") + expect(warnSpy).toHaveBeenCalledWith("[DCG]", "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..84338713b4 --- /dev/null +++ b/src/services/destructive-command-guard/manager.ts @@ -0,0 +1,105 @@ +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 } 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" +const MAX_ARCHIVE_BYTES = 50 * 1024 * 1024 + +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, + maxBytes: MAX_ARCHIVE_BYTES, + }) +} + +export async function verifyChecksum(filePath: string, expected: string): Promise { + await verifySha256Checksum( + filePath, + expected, + (actual) => new Error(`DCG archive checksum verification failed (got ${actual})`), + ) +} + +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) { + console.warn(`[DCG] Unsupported platform: ${process.platform}-${process.arch}`) + 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..3e3b3cab45 --- /dev/null +++ b/src/services/destructive-command-guard/runner.ts @@ -0,0 +1,101 @@ +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 +} + +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" } + 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: getDcgEnvironment(), + }) + let stdout: Buffer = Buffer.alloc(0) + let stderr: Buffer = Buffer.alloc(0) + let settled = false + const fail = (error: Error, killChild = true) => { + if (settled) return + settled = true + clearTimeout(timer) + if (killChild) child.kill("SIGKILL") + console.warn("[DCG]", error.message) + reject(error) + } + 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 + } + 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 = 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 + if (signal || (code !== 0 && code !== 1)) { + fail(new Error(`DCG evaluation failed${stderr.length ? `: ${stderr.toString().trim()}` : ""}`), false) + return + } + + let payload: DcgJsonOutput + try { + payload = JSON.parse(stdout.toString("utf8")) as DcgJsonOutput + } catch { + fail(new Error("DCG returned invalid JSON"), false) + return + } + + const schemaVersion = Number(payload.schema_version) + if (![1, 2].includes(schemaVersion)) { + 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, + ruleId: + payload.rule_id ?? + (payload.pack_id && payload.pattern_name + ? `${payload.pack_id}:${payload.pattern_name}` + : undefined), + }) + } else { + fail(new Error("DCG decision did not match its exit status"), false) + } + }) + }) +}