From baade5feaae71782a1724d76f1b8a5f2db56e6fa Mon Sep 17 00:00:00 2001 From: Naved Date: Wed, 29 Jul 2026 21:15:39 -0700 Subject: [PATCH] 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")) + } + }) + }) +}