Skip to content

Commit bae4ff7

Browse files
committed
fix: address DCG service review feedback
1 parent e1a0c39 commit bae4ff7

4 files changed

Lines changed: 198 additions & 18 deletions

File tree

src/services/destructive-command-guard/__tests__/manager.spec.ts

Lines changed: 126 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ describe("Destructive Command Guard manager", () => {
6363
expect(isTrustedDownloadUrl("https://cdn.objects.githubusercontent.com/release")).toBe(true)
6464
expect(isTrustedDownloadUrl("http://github.com/release")).toBe(false)
6565
expect(isTrustedDownloadUrl("https://evilgithub.com/release")).toBe(false)
66+
expect(isTrustedDownloadUrl("https://github.com.evil.com/release")).toBe(false)
6667
expect(isTrustedDownloadUrl("not a URL")).toBe(false)
6768
})
6869

@@ -72,6 +73,61 @@ describe("Destructive Command Guard manager", () => {
7273
)
7374
})
7475

76+
it("rejects non-successful HTTP responses", async () => {
77+
const response = Object.assign(new PassThrough(), { statusCode: 503, headers: {}, destroy: vi.fn() })
78+
const request = Object.assign(new EventEmitter(), { setTimeout: vi.fn(), destroy: vi.fn() })
79+
mockGet.mockImplementation((_url, optionsOrCallback, optionalCallback) => {
80+
const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : optionalCallback
81+
setImmediate(() => callback?.(response as unknown as IncomingMessage))
82+
return request as unknown as ReturnType<typeof get>
83+
})
84+
85+
await expect(downloadFile("https://github.com/release", path.join(tempDir, "archive"))).rejects.toThrow(
86+
"DCG download failed with HTTP 503",
87+
)
88+
})
89+
90+
it("rejects request errors", async () => {
91+
const request = Object.assign(new EventEmitter(), { setTimeout: vi.fn(), destroy: vi.fn() })
92+
mockGet.mockReturnValue(request as unknown as ReturnType<typeof get>)
93+
94+
const download = downloadFile("https://github.com/release", path.join(tempDir, "archive"))
95+
request.emit("error", new Error("socket failed"))
96+
97+
await expect(download).rejects.toThrow("socket failed")
98+
})
99+
100+
it("times out stalled requests", async () => {
101+
const request = Object.assign(new EventEmitter(), {
102+
setTimeout: vi.fn((_timeout: number, callback: () => void) => setImmediate(callback)),
103+
destroy: vi.fn((error: Error) => request.emit("error", error)),
104+
})
105+
mockGet.mockReturnValue(request as unknown as ReturnType<typeof get>)
106+
107+
await expect(downloadFile("https://github.com/release", path.join(tempDir, "archive"))).rejects.toThrow(
108+
"DCG download timed out",
109+
)
110+
expect(request.setTimeout).toHaveBeenCalledWith(120_000, expect.any(Function))
111+
})
112+
113+
it("rejects archives larger than 50 MiB", async () => {
114+
const response = Object.assign(new PassThrough(), {
115+
statusCode: 200,
116+
headers: { "content-length": String(50 * 1024 * 1024 + 1) },
117+
destroy: vi.fn(),
118+
})
119+
const request = Object.assign(new EventEmitter(), { setTimeout: vi.fn(), destroy: vi.fn() })
120+
mockGet.mockImplementation((_url, optionsOrCallback, optionalCallback) => {
121+
const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : optionalCallback
122+
setImmediate(() => callback?.(response as unknown as IncomingMessage))
123+
return request as unknown as ReturnType<typeof get>
124+
})
125+
126+
await expect(downloadFile("https://github.com/release", path.join(tempDir, "archive"))).rejects.toThrow(
127+
"DCG archive exceeds the download size limit",
128+
)
129+
})
130+
75131
it("allows trusted relative redirects and rejects unsafe or exhausted redirects", () => {
76132
expect(resolveTrustedRedirect("https://github.com/release", "/asset", 5)).toBe("https://github.com/asset")
77133
expect(() => resolveTrustedRedirect("https://github.com/release", "https://example.com/asset", 5)).toThrow(
@@ -92,9 +148,7 @@ describe("Destructive Command Guard manager", () => {
92148
const checksum = createHash("sha256").update(contents).digest("hex")
93149

94150
await expect(verifyChecksum(filePath, checksum)).resolves.toBeUndefined()
95-
await expect(verifyChecksum(filePath, "0".repeat(64))).rejects.toThrow(
96-
"DCG archive checksum verification failed",
97-
)
151+
await expect(verifyChecksum(filePath, "0".repeat(64))).rejects.toThrow(`got ${checksum}`)
98152
})
99153

100154
it("uses the platform ZIP extractor", async () => {
@@ -180,6 +234,22 @@ describe("Destructive Command Guard manager", () => {
180234
}
181235
})
182236

237+
it("warns when the current platform is unsupported", async () => {
238+
const platformKey = `${process.platform}-${process.arch}`
239+
const info = DCG_ARCHIVES[platformKey]
240+
if (!info) return
241+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
242+
Reflect.deleteProperty(DCG_ARCHIVES, platformKey)
243+
244+
try {
245+
await expect(ensureDcgInstalled(tempDir)).resolves.toBeUndefined()
246+
expect(warnSpy).toHaveBeenCalledWith(`[DCG] Unsupported platform: ${platformKey}`)
247+
} finally {
248+
Reflect.set(DCG_ARCHIVES, platformKey, info)
249+
warnSpy.mockRestore()
250+
}
251+
})
252+
183253
it("downloads, verifies, extracts, and deduplicates a new installation", async () => {
184254
const info = getDcgArchiveInfo()
185255
expect(info).toBeDefined()
@@ -251,4 +321,57 @@ describe("Destructive Command Guard manager", () => {
251321
Object.defineProperty(info, "sha256", { value: originalChecksum, configurable: true })
252322
}
253323
})
324+
325+
it("downloads, verifies, extracts, and installs a ZIP archive", async () => {
326+
const info = getDcgArchiveInfo()
327+
if (!info?.archive.endsWith(".zip")) return
328+
329+
const archive = Buffer.from("test ZIP archive")
330+
const originalChecksum = info.sha256
331+
Object.defineProperty(info, "sha256", {
332+
value: createHash("sha256").update(archive).digest("hex"),
333+
configurable: true,
334+
})
335+
const response = Object.assign(new PassThrough(), {
336+
statusCode: 200,
337+
headers: { "content-length": String(archive.length) },
338+
})
339+
const request = Object.assign(new EventEmitter(), { setTimeout: vi.fn(), destroy: vi.fn() })
340+
mockGet.mockImplementation((_url, optionsOrCallback, optionalCallback) => {
341+
const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : optionalCallback
342+
setImmediate(() => {
343+
callback?.(response as unknown as IncomingMessage)
344+
response.end(archive)
345+
})
346+
return request as unknown as ReturnType<typeof get>
347+
})
348+
mockSpawn.mockImplementation((_executable, args) => {
349+
const child = Object.assign(new EventEmitter(), {
350+
stdout: new PassThrough(),
351+
stderr: new PassThrough(),
352+
kill: vi.fn(),
353+
})
354+
setImmediate(async () => {
355+
const destinationIndex = args.indexOf(
356+
"$archivePath = $args[0]; $destination = $args[1]; Expand-Archive -LiteralPath $archivePath -DestinationPath $destination -Force",
357+
)
358+
const stagingDir = args[destinationIndex + 2]
359+
await writeFile(path.join(stagingDir, info.binary), "ZIP executable")
360+
child.emit("close", 0)
361+
})
362+
return child as unknown as ReturnType<typeof spawn>
363+
})
364+
365+
try {
366+
const binaryPath = await ensureDcgInstalled(tempDir)
367+
if (!binaryPath) throw new Error("Expected DCG to be supported in this test")
368+
expect(await readFile(binaryPath, "utf8")).toBe("ZIP executable")
369+
expect(await readFile(path.join(tempDir, "destructive-command-guard", ".dcg-version"), "utf8")).toBe(
370+
DCG_VERSION,
371+
)
372+
await expect(access(path.join(tempDir, `${DCG_VERSION}-${info.archive}`))).rejects.toThrow()
373+
} finally {
374+
Object.defineProperty(info, "sha256", { value: originalChecksum, configurable: true })
375+
}
376+
})
254377
})

src/services/destructive-command-guard/__tests__/runner.spec.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,18 @@ function emitResult(child: MockChild, payload: unknown, code: number): void {
3535
}
3636

3737
describe("runDcg", () => {
38+
let warnSpy: ReturnType<typeof vi.spyOn>
39+
3840
beforeEach(() => {
3941
vi.useRealTimers()
4042
mockSpawn.mockReset()
43+
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
4144
})
4245

43-
afterEach(() => vi.useRealTimers())
46+
afterEach(() => {
47+
vi.useRealTimers()
48+
warnSpy.mockRestore()
49+
})
4450

4551
it.each([
4652
[{ schema_version: 1, decision: "allow" }, 0, { decision: "allow" }],
@@ -67,6 +73,30 @@ describe("runDcg", () => {
6773
expect.any(Array),
6874
expect.objectContaining({ cwd: "/workspace" }),
6975
)
76+
if (expected.decision === "deny") {
77+
const reason = "reason" in expected ? expected.reason : undefined
78+
expect(warnSpy).toHaveBeenCalledWith("[DCG] Command denied", reason ?? "No reason provided")
79+
}
80+
})
81+
82+
it("passes only the environment variables DCG requires", async () => {
83+
const child = createChild()
84+
useChild(child)
85+
const originalToken = process.env.GITHUB_TOKEN
86+
process.env.GITHUB_TOKEN = "secret"
87+
88+
try {
89+
const result = runDcg("/dcg", "echo test", "/workspace")
90+
emitResult(child, { schema_version: 1, decision: "allow" }, 0)
91+
await result
92+
93+
const options = mockSpawn.mock.calls[0][2]
94+
expect(options?.env).toMatchObject({ NO_COLOR: "1" })
95+
expect(options?.env).not.toHaveProperty("GITHUB_TOKEN")
96+
} finally {
97+
if (originalToken === undefined) delete process.env.GITHUB_TOKEN
98+
else process.env.GITHUB_TOKEN = originalToken
99+
}
70100
})
71101

72102
it.each([
@@ -82,6 +112,7 @@ describe("runDcg", () => {
82112
child.emit("close", code, null)
83113

84114
await expect(result).rejects.toThrow(message)
115+
expect(warnSpy).toHaveBeenCalledWith("[DCG]", message)
85116
})
86117

87118
it("rejects non-DCG exit statuses with stderr", async () => {
@@ -93,6 +124,7 @@ describe("runDcg", () => {
93124
child.emit("close", 2, null)
94125

95126
await expect(result).rejects.toThrow("DCG evaluation failed: failure details")
127+
expect(warnSpy).toHaveBeenCalledWith("[DCG]", "DCG evaluation failed: failure details")
96128
})
97129

98130
it("rejects process startup errors", async () => {
@@ -103,6 +135,7 @@ describe("runDcg", () => {
103135
child.emit("error", new Error("ENOENT"))
104136

105137
await expect(result).rejects.toThrow("Unable to start DCG: ENOENT")
138+
expect(warnSpy).toHaveBeenCalledWith("[DCG]", "Unable to start DCG: ENOENT")
106139
})
107140

108141
it("rejects excessive output and kills the process", async () => {

src/services/destructive-command-guard/manager.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
resolveTrustedRedirect as resolveManagedBinaryRedirect,
88
verifySha256Checksum,
99
} from "../managed-binary/download"
10-
import { ensureManagedBinaryInstalled, getManagedBinaryPaths } from "../managed-binary/install"
10+
import { ensureManagedBinaryInstalled } from "../managed-binary/install"
1111

1212
import {
1313
DCG_ARCHIVES,
@@ -18,6 +18,7 @@ import {
1818
} from "./constants"
1919

2020
const VERSION_FILE = ".dcg-version"
21+
const MAX_ARCHIVE_BYTES = 50 * 1024 * 1024
2122

2223
export function getDcgArchiveInfo(platform = process.platform, arch = process.arch): DcgArchiveInfo | undefined {
2324
return DCG_ARCHIVES[`${platform}-${arch}`]
@@ -53,11 +54,16 @@ export function downloadFile(url: string, destination: string, maxRedirects = 5)
5354
trustedDomains: DCG_TRUSTED_DOWNLOAD_DOMAINS,
5455
timeoutMs: 120_000,
5556
maxRedirects,
57+
maxBytes: MAX_ARCHIVE_BYTES,
5658
})
5759
}
5860

5961
export async function verifyChecksum(filePath: string, expected: string): Promise<void> {
60-
await verifySha256Checksum(filePath, expected, () => new Error("DCG archive checksum verification failed"))
62+
await verifySha256Checksum(
63+
filePath,
64+
expected,
65+
(actual) => new Error(`DCG archive checksum verification failed (got ${actual})`),
66+
)
6167
}
6268

6369
export async function extractSingleBinary(
@@ -76,6 +82,7 @@ export async function extractSingleBinary(
7682
function installDcg(storageDir: string): Promise<string | undefined> {
7783
const info = getDcgArchiveInfo()
7884
if (!info) {
85+
console.warn(`[DCG] Unsupported platform: ${process.platform}-${process.arch}`)
7986
return Promise.resolve(undefined)
8087
}
8188

src/services/destructive-command-guard/runner.ts

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,60 +13,77 @@ type DcgJsonOutput = {
1313
pack_id?: string
1414
}
1515

16+
const DCG_ENV_KEYS = ["HOME", "PATH", "TEMP", "TMP", "USERPROFILE", "SystemRoot", "WINDIR"] as const
17+
18+
function getDcgEnvironment(): NodeJS.ProcessEnv {
19+
const env: NodeJS.ProcessEnv = { NO_COLOR: "1" }
20+
for (const key of DCG_ENV_KEYS) {
21+
if (process.env[key] !== undefined) env[key] = process.env[key]
22+
}
23+
return env
24+
}
25+
1626
export function runDcg(binaryPath: string, command: string, cwd: string): Promise<DcgDecision> {
1727
return new Promise((resolve, reject) => {
1828
const child = spawn(binaryPath, ["test", "--format", "json", "--no-color", command], {
1929
cwd,
2030
shell: false,
2131
stdio: ["ignore", "pipe", "pipe"],
22-
env: { ...process.env, NO_COLOR: "1" },
32+
env: getDcgEnvironment(),
2333
})
2434
let stdout: Buffer<ArrayBufferLike> = Buffer.alloc(0)
2535
let stderr: Buffer<ArrayBufferLike> = Buffer.alloc(0)
2636
let settled = false
27-
const fail = (error: Error) => {
37+
const fail = (error: Error, killChild = true) => {
2838
if (settled) return
2939
settled = true
3040
clearTimeout(timer)
31-
child.kill("SIGKILL")
41+
if (killChild) child.kill("SIGKILL")
42+
console.warn("[DCG]", error.message)
3243
reject(error)
3344
}
34-
const append = (current: Buffer<ArrayBufferLike>, chunk: Buffer<ArrayBufferLike>): Buffer<ArrayBufferLike> => {
45+
const appendOutputOrFail = (
46+
current: Buffer<ArrayBufferLike>,
47+
chunk: Buffer<ArrayBufferLike>,
48+
): Buffer<ArrayBufferLike> => {
3549
if (current.length + chunk.length > DCG_MAX_OUTPUT_BYTES) {
3650
fail(new Error("DCG produced too much output"))
3751
return current
3852
}
3953
return Buffer.concat([current, chunk])
4054
}
4155
const timer = setTimeout(() => fail(new Error("DCG evaluation timed out")), DCG_RUN_TIMEOUT_MS)
42-
child.stdout?.on("data", (chunk: Buffer) => (stdout = append(stdout, chunk)))
43-
child.stderr?.on("data", (chunk: Buffer) => (stderr = append(stderr, chunk)))
56+
child.stdout?.on("data", (chunk: Buffer) => (stdout = appendOutputOrFail(stdout, chunk)))
57+
child.stderr?.on("data", (chunk: Buffer) => (stderr = appendOutputOrFail(stderr, chunk)))
4458
child.on("error", (error) => fail(new Error(`Unable to start DCG: ${error.message}`)))
4559
child.on("close", (code, signal) => {
4660
if (settled) return
47-
settled = true
48-
clearTimeout(timer)
4961
if (signal || (code !== 0 && code !== 1)) {
50-
reject(new Error(`DCG evaluation failed${stderr.length ? `: ${stderr.toString().trim()}` : ""}`))
62+
fail(new Error(`DCG evaluation failed${stderr.length ? `: ${stderr.toString().trim()}` : ""}`), false)
5163
return
5264
}
5365

5466
let payload: DcgJsonOutput
5567
try {
5668
payload = JSON.parse(stdout.toString("utf8")) as DcgJsonOutput
5769
} catch {
58-
reject(new Error("DCG returned invalid JSON"))
70+
fail(new Error("DCG returned invalid JSON"), false)
5971
return
6072
}
6173

6274
const schemaVersion = Number(payload.schema_version)
6375
if (![1, 2].includes(schemaVersion)) {
64-
reject(new Error("DCG returned an unsupported response schema"))
76+
fail(new Error("DCG returned an unsupported response schema"), false)
6577
return
6678
}
6779
if (payload.decision === "allow" && code === 0) {
80+
settled = true
81+
clearTimeout(timer)
6882
resolve({ decision: "allow" })
6983
} else if (payload.decision === "deny" && code === 1) {
84+
settled = true
85+
clearTimeout(timer)
86+
console.warn("[DCG] Command denied", payload.reason ?? "No reason provided")
7087
resolve({
7188
decision: "deny",
7289
reason: payload.reason,
@@ -77,7 +94,7 @@ export function runDcg(binaryPath: string, command: string, cwd: string): Promis
7794
: undefined),
7895
})
7996
} else {
80-
reject(new Error("DCG decision did not match its exit status"))
97+
fail(new Error("DCG decision did not match its exit status"), false)
8198
}
8299
})
83100
})

0 commit comments

Comments
 (0)