Skip to content

Commit baade5f

Browse files
committed
feat: add destructive command guard binary service
Refs #1056
1 parent e93b1cc commit baade5f

5 files changed

Lines changed: 598 additions & 0 deletions

File tree

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import { createHash } from "crypto"
2+
import { EventEmitter } from "events"
3+
import { access, chmod, mkdtemp, mkdir, readFile, rm, stat, writeFile } from "fs/promises"
4+
import { tmpdir } from "os"
5+
import path from "path"
6+
import { PassThrough } from "stream"
7+
8+
import { spawn } from "child_process"
9+
import { get } from "https"
10+
import type { IncomingMessage, RequestOptions } from "http"
11+
12+
import { DCG_ARCHIVES, DCG_VERSION } from "../constants"
13+
import {
14+
downloadFile,
15+
extractSingleBinary,
16+
getDcgArchiveInfo,
17+
getDcgBinaryPath,
18+
isDcgSupportedPlatform,
19+
isTrustedDownloadUrl,
20+
resolveTrustedRedirect,
21+
ensureDcgInstalled,
22+
verifyChecksum,
23+
} from "../manager"
24+
25+
vi.mock("child_process", () => ({ spawn: vi.fn() }))
26+
vi.mock("https", () => ({ get: vi.fn() }))
27+
28+
const mockSpawn = vi.mocked(spawn)
29+
const mockGet = vi.mocked(get)
30+
31+
describe("Destructive Command Guard manager", () => {
32+
let tempDir: string
33+
34+
beforeEach(async () => {
35+
tempDir = await mkdtemp(path.join(tmpdir(), "dcg-manager-"))
36+
mockSpawn.mockReset()
37+
mockGet.mockReset()
38+
})
39+
40+
afterEach(async () => {
41+
await rm(tempDir, { recursive: true, force: true })
42+
})
43+
44+
it("maps all supported platform and architecture combinations", () => {
45+
expect(Object.keys(DCG_ARCHIVES).sort()).toEqual(["darwin-arm64", "linux-arm64", "linux-x64", "win32-x64"])
46+
expect(getDcgArchiveInfo("darwin", "arm64")?.archive).toBe("dcg-aarch64-apple-darwin.tar.xz")
47+
expect(getDcgArchiveInfo("win32", "x64")?.binary).toBe("dcg.exe")
48+
})
49+
50+
it("rejects unsupported platforms", () => {
51+
expect(isDcgSupportedPlatform("freebsd", "x64")).toBe(false)
52+
expect(getDcgBinaryPath("/storage", "freebsd", "x64")).toBeUndefined()
53+
})
54+
55+
it("returns the managed binary path", () => {
56+
expect(getDcgBinaryPath("/storage", "linux", "x64")).toBe(
57+
path.join("/storage", "destructive-command-guard", "dcg"),
58+
)
59+
})
60+
61+
it("accepts only HTTPS URLs on trusted host boundaries", () => {
62+
expect(isTrustedDownloadUrl("https://github.com/release")).toBe(true)
63+
expect(isTrustedDownloadUrl("https://cdn.objects.githubusercontent.com/release")).toBe(true)
64+
expect(isTrustedDownloadUrl("http://github.com/release")).toBe(false)
65+
expect(isTrustedDownloadUrl("https://evilgithub.com/release")).toBe(false)
66+
expect(isTrustedDownloadUrl("not a URL")).toBe(false)
67+
})
68+
69+
it("rejects untrusted download URLs before opening a destination", async () => {
70+
await expect(downloadFile("https://example.com/dcg", path.join(tempDir, "archive"))).rejects.toThrow(
71+
"DCG download redirected to an untrusted host",
72+
)
73+
})
74+
75+
it("allows trusted relative redirects and rejects unsafe or exhausted redirects", () => {
76+
expect(resolveTrustedRedirect("https://github.com/release", "/asset", 5)).toBe("https://github.com/asset")
77+
expect(() => resolveTrustedRedirect("https://github.com/release", "https://example.com/asset", 5)).toThrow(
78+
"DCG download redirected to an untrusted host",
79+
)
80+
expect(() => resolveTrustedRedirect("https://github.com/release", "/asset", 0)).toThrow(
81+
"Too many DCG download redirects",
82+
)
83+
expect(() => resolveTrustedRedirect("https://github.com/release", undefined, 5)).toThrow(
84+
"Too many DCG download redirects",
85+
)
86+
})
87+
88+
it("verifies matching checksums and rejects mismatches", async () => {
89+
const filePath = path.join(tempDir, "archive")
90+
const contents = Buffer.from("verified archive")
91+
await writeFile(filePath, contents)
92+
const checksum = createHash("sha256").update(contents).digest("hex")
93+
94+
await expect(verifyChecksum(filePath, checksum)).resolves.toBeUndefined()
95+
await expect(verifyChecksum(filePath, "0".repeat(64))).rejects.toThrow(
96+
"DCG archive checksum verification failed",
97+
)
98+
})
99+
100+
it("uses the platform ZIP extractor", async () => {
101+
const child = Object.assign(new EventEmitter(), {
102+
stdout: new PassThrough(),
103+
stderr: new PassThrough(),
104+
kill: vi.fn(),
105+
})
106+
// The production code uses only the event and stream subset supplied by this test double.
107+
mockSpawn.mockReturnValue(child as unknown as ReturnType<typeof spawn>)
108+
109+
const extraction = extractSingleBinary("C:\\dcg.zip", "C:\\staging", DCG_ARCHIVES["win32-x64"])
110+
child.emit("close", 0)
111+
await extraction
112+
113+
const expectedExecutable = process.platform === "win32" ? "powershell" : "unzip"
114+
const expectedArgs =
115+
process.platform === "win32"
116+
? ["-NoProfile", "-Command", "Expand-Archive -Path 'C:\\dcg.zip' -DestinationPath 'C:\\staging' -Force"]
117+
: ["-o", "C:\\dcg.zip", "-d", "C:\\staging"]
118+
119+
expect(mockSpawn).toHaveBeenCalledWith(expectedExecutable, expectedArgs, {
120+
shell: false,
121+
stdio: ["ignore", "pipe", "pipe"],
122+
})
123+
})
124+
125+
it("extracts tar archives without imposing a single-file layout", async () => {
126+
const child = Object.assign(new EventEmitter(), {
127+
stdout: new PassThrough(),
128+
stderr: new PassThrough(),
129+
kill: vi.fn(),
130+
})
131+
// The production code uses only the event and stream subset supplied by this test double.
132+
mockSpawn.mockReturnValue(child as unknown as ReturnType<typeof spawn>)
133+
134+
const extraction = extractSingleBinary("/tmp/dcg.tar.xz", tempDir, DCG_ARCHIVES["linux-x64"])
135+
child.emit("close", 0)
136+
137+
await expect(extraction).resolves.toBeUndefined()
138+
expect(mockSpawn).toHaveBeenCalledTimes(1)
139+
expect(mockSpawn).toHaveBeenCalledWith(
140+
"tar",
141+
expect.arrayContaining(["-xJf", "/tmp/dcg.tar.xz", "-C", tempDir, "--no-same-owner"]),
142+
expect.objectContaining({ shell: false }),
143+
)
144+
})
145+
146+
it("surfaces process failures during extraction", async () => {
147+
const child = Object.assign(new EventEmitter(), {
148+
stdout: new PassThrough(),
149+
stderr: new PassThrough(),
150+
kill: vi.fn(),
151+
})
152+
mockSpawn.mockReturnValue(child as unknown as ReturnType<typeof spawn>)
153+
154+
const extraction = extractSingleBinary("/tmp/dcg.tar.xz", tempDir, DCG_ARCHIVES["linux-x64"])
155+
child.stderr.write("invalid archive")
156+
child.emit("close", 2)
157+
158+
await expect(extraction).rejects.toThrow("invalid archive")
159+
})
160+
161+
it("reuses an existing managed binary and restores its executable permissions", async () => {
162+
const binaryPath = getDcgBinaryPath(tempDir)
163+
expect(binaryPath).toBeDefined()
164+
await mkdir(path.dirname(binaryPath!), { recursive: true })
165+
await writeFile(binaryPath!, "existing binary")
166+
await writeFile(path.join(path.dirname(binaryPath!), ".dcg-version"), DCG_VERSION)
167+
if (process.platform !== "win32") {
168+
await chmod(binaryPath!, 0o600)
169+
}
170+
171+
await expect(ensureDcgInstalled(tempDir)).resolves.toBe(binaryPath)
172+
expect(mockSpawn).not.toHaveBeenCalled()
173+
if (process.platform !== "win32") {
174+
expect((await stat(binaryPath!)).mode & 0o111).toBe(0o111)
175+
}
176+
})
177+
178+
it("downloads, verifies, extracts, and deduplicates a new installation", async () => {
179+
const info = getDcgArchiveInfo()
180+
expect(info).toBeDefined()
181+
if (!info || info.archive.endsWith(".zip")) return
182+
183+
const archive = Buffer.from("test archive")
184+
const originalChecksum = info.sha256
185+
Object.defineProperty(info, "sha256", {
186+
value: createHash("sha256").update(archive).digest("hex"),
187+
configurable: true,
188+
})
189+
const response = Object.assign(new PassThrough(), {
190+
statusCode: 200,
191+
headers: { "content-length": String(archive.length) },
192+
})
193+
const request = Object.assign(new EventEmitter(), {
194+
setTimeout: vi.fn(),
195+
destroy: vi.fn(),
196+
})
197+
mockGet.mockImplementation(
198+
(
199+
_url: string | URL,
200+
optionsOrCallback: RequestOptions | ((response: IncomingMessage) => void),
201+
optionalCallback?: (response: IncomingMessage) => void,
202+
) => {
203+
const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : optionalCallback
204+
setImmediate(() => {
205+
// The downloader uses only the response stream/status subset supplied here.
206+
callback?.(response as unknown as IncomingMessage)
207+
response.end(archive)
208+
})
209+
// The downloader uses only timeout/error handling from ClientRequest.
210+
return request as unknown as ReturnType<typeof get>
211+
},
212+
)
213+
214+
mockSpawn.mockImplementation((executable, args) => {
215+
const child = Object.assign(new EventEmitter(), {
216+
stdout: new PassThrough(),
217+
stderr: new PassThrough(),
218+
kill: vi.fn(),
219+
})
220+
setImmediate(async () => {
221+
if (executable === "tar") {
222+
const stagingDir = args[args.indexOf("-C") + 1]
223+
await writeFile(path.join(stagingDir, info.binary), "executable")
224+
}
225+
child.emit("close", 0)
226+
})
227+
// The process runner uses only the event and stream subset supplied here.
228+
return child as unknown as ReturnType<typeof spawn>
229+
})
230+
231+
try {
232+
const firstInstallation = ensureDcgInstalled(tempDir)
233+
const concurrentInstallation = ensureDcgInstalled(tempDir)
234+
expect(concurrentInstallation).toBe(firstInstallation)
235+
236+
const binaryPath = await firstInstallation
237+
if (!binaryPath) throw new Error("Expected DCG to be supported in this test")
238+
expect(await readFile(binaryPath, "utf8")).toBe("executable")
239+
expect(mockGet).toHaveBeenCalledTimes(1)
240+
expect(mockSpawn).toHaveBeenCalledTimes(1)
241+
await expect(access(path.join(tempDir, `${DCG_VERSION}-${info.archive}`))).rejects.toThrow()
242+
expect(await readFile(path.join(tempDir, "destructive-command-guard", ".dcg-version"), "utf8")).toBe(
243+
DCG_VERSION,
244+
)
245+
} finally {
246+
Object.defineProperty(info, "sha256", { value: originalChecksum, configurable: true })
247+
}
248+
})
249+
})
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { EventEmitter } from "events"
2+
import { PassThrough } from "stream"
3+
4+
import { spawn } from "child_process"
5+
6+
import { DCG_MAX_OUTPUT_BYTES } from "../constants"
7+
import { runDcg } from "../runner"
8+
9+
vi.mock("child_process", () => ({ spawn: vi.fn() }))
10+
11+
type MockChild = EventEmitter & {
12+
stdout: PassThrough
13+
stderr: PassThrough
14+
kill: ReturnType<typeof vi.fn>
15+
}
16+
17+
const mockSpawn = vi.mocked(spawn)
18+
19+
const useChild = (child: MockChild): void => {
20+
// runDcg uses only the event, stream, and kill subset supplied by this test double.
21+
mockSpawn.mockReturnValue(child as unknown as ReturnType<typeof spawn>)
22+
}
23+
24+
function createChild(): MockChild {
25+
return Object.assign(new EventEmitter(), {
26+
stdout: new PassThrough(),
27+
stderr: new PassThrough(),
28+
kill: vi.fn(),
29+
})
30+
}
31+
32+
function emitResult(child: MockChild, payload: unknown, code: number): void {
33+
child.stdout.write(JSON.stringify(payload))
34+
child.emit("close", code, null)
35+
}
36+
37+
describe("runDcg", () => {
38+
beforeEach(() => {
39+
vi.useRealTimers()
40+
mockSpawn.mockReset()
41+
})
42+
43+
afterEach(() => vi.useRealTimers())
44+
45+
it.each([
46+
[{ schema_version: 1, decision: "allow" }, 0, { decision: "allow" }],
47+
[
48+
{ schema_version: 2, decision: "deny", reason: "unsafe", rule_id: "delete" },
49+
1,
50+
{ decision: "deny", reason: "unsafe", ruleId: "delete" },
51+
],
52+
[
53+
{ schema_version: 2, decision: "deny", pack_id: "core", pattern_name: "delete" },
54+
1,
55+
{ decision: "deny", ruleId: "core:delete" },
56+
],
57+
])("accepts valid DCG result %#", async (payload, code, expected) => {
58+
const child = createChild()
59+
useChild(child)
60+
61+
const result = runDcg("/dcg", "echo test", "/workspace")
62+
emitResult(child, payload, code)
63+
64+
await expect(result).resolves.toEqual(expected)
65+
})
66+
67+
it.each([
68+
["not json", 0, "DCG returned invalid JSON"],
69+
[JSON.stringify({ schema_version: 3, decision: "allow" }), 0, "DCG returned an unsupported response schema"],
70+
[JSON.stringify({ schema_version: 1, decision: "deny" }), 0, "DCG decision did not match its exit status"],
71+
])("rejects invalid output %#", async (output, code, message) => {
72+
const child = createChild()
73+
useChild(child)
74+
75+
const result = runDcg("/dcg", "echo test", "/workspace")
76+
child.stdout.write(output)
77+
child.emit("close", code, null)
78+
79+
await expect(result).rejects.toThrow(message)
80+
})
81+
82+
it("rejects non-DCG exit statuses with stderr", async () => {
83+
const child = createChild()
84+
useChild(child)
85+
86+
const result = runDcg("/dcg", "echo test", "/workspace")
87+
child.stderr.write("failure details")
88+
child.emit("close", 2, null)
89+
90+
await expect(result).rejects.toThrow("DCG evaluation failed: failure details")
91+
})
92+
93+
it("rejects process startup errors", async () => {
94+
const child = createChild()
95+
useChild(child)
96+
97+
const result = runDcg("/dcg", "echo test", "/workspace")
98+
child.emit("error", new Error("ENOENT"))
99+
100+
await expect(result).rejects.toThrow("Unable to start DCG: ENOENT")
101+
})
102+
103+
it("rejects excessive output and kills the process", async () => {
104+
const child = createChild()
105+
useChild(child)
106+
107+
const result = runDcg("/dcg", "echo test", "/workspace")
108+
child.stdout.write(Buffer.alloc(DCG_MAX_OUTPUT_BYTES + 1))
109+
110+
await expect(result).rejects.toThrow("DCG produced too much output")
111+
expect(child.kill).toHaveBeenCalledWith("SIGKILL")
112+
})
113+
114+
it("times out and kills the process", async () => {
115+
vi.useFakeTimers()
116+
const child = createChild()
117+
useChild(child)
118+
119+
const result = runDcg("/dcg", "echo test", "/workspace")
120+
const rejection = expect(result).rejects.toThrow("DCG evaluation timed out")
121+
await vi.runAllTimersAsync()
122+
123+
await rejection
124+
expect(child.kill).toHaveBeenCalledWith("SIGKILL")
125+
})
126+
})

0 commit comments

Comments
 (0)