Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/codegraph/sandbox-runner.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -293,19 +293,19 @@ export function runBoundedCommand(
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
const chunks = [];
const outputBuffer = Buffer.allocUnsafe(maxOutputBytes);
let outputBytes = 0;
let outputExceeded = false;
let timedOut = false;

function capture(chunk) {
outputBytes += chunk.length;
if (outputBytes > maxOutputBytes) {
if (chunk.length > maxOutputBytes - outputBytes) {
outputExceeded = true;
child.kill("SIGKILL");
return;
}
chunks.push(Buffer.from(chunk));
chunk.copy(outputBuffer, outputBytes);
outputBytes += chunk.length;
}

child.stdout.on("data", capture);
Expand All @@ -329,7 +329,7 @@ export function runBoundedCommand(
reject(new Error(`CodeGraph command output exceeded ${maxOutputBytes} bytes`));
return;
}
const output = Buffer.concat(chunks).toString("utf8");
const output = outputBuffer.subarray(0, outputBytes).toString("utf8");
if (code !== 0) {
reject(new Error(`CodeGraph command exited ${code}: ${boundedDiagnostic(output)}`));
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ The trusted Node entrypoint recursively copies regular files from `/input` into
- limiting aggregate copied bytes to 200 MiB;
- stripping executable bits and preserving no ownership metadata.

CodeGraph commands run without a shell. Each command has a 180-second timeout and a 128-KiB combined stdout/stderr limit. The complete sandbox session is bounded by a 10-minute host timeout and its final output is still truncated by the existing manifest budget.
CodeGraph commands run without a shell. Each command has a 180-second timeout and a 128-KiB combined stdout/stderr limit. Accepted output bytes are copied directly into one fixed-capacity buffer rather than retained as one allocation per stream event, so retained command-output memory is bounded by the byte ceiling instead of stream fragmentation. A chunk that cannot fit in the remaining budget is rejected and the child is killed before that chunk is copied. The complete sandbox session is bounded by a 10-minute host timeout and its final output is still truncated by the existing manifest budget.

## Analysis flow

Expand Down Expand Up @@ -83,6 +83,7 @@ Tests and CI must prove:
- current manifest collection records sandbox failure as missing evidence;
- the workflow resolves and verifies the image before the GitHub-token-bearing collection step and never executes host CodeGraph against target source;
- the entrypoint rejects symlinks, oversized files, excessive file counts, excessive aggregate bytes, and excessive command output;
- command-output retention stays bounded by the byte ceiling rather than the number of stdout/stderr chunks;
- reviewer CI successfully runs the actual container against a small untrusted fixture.

## Non-goals
Expand Down
22 changes: 21 additions & 1 deletion test/codegraph-sandbox-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
copyInputTree,
normalizeChangedPaths,
Expand Down Expand Up @@ -170,6 +170,26 @@ describe("CodeGraph sandbox entrypoint", () => {
expect(result).toBe("ready");
});

it("does not retain bounded command chunks for a final concatenation", async () => {
const concatSpy = vi.spyOn(Buffer, "concat");
try {
await expect(
runBoundedCommand(
process.execPath,
["-e", "process.stdout.write('fragment-safe')"],
{
cwd: process.cwd(),
timeoutMs: 1000,
maxOutputBytes: 64,
},
),
).resolves.toBe("fragment-safe");
expect(concatSpy).not.toHaveBeenCalled();
} finally {
concatSpy.mockRestore();
}
});

it("rejects non-zero commands with their bounded diagnostics", async () => {
await expect(
runBoundedCommand(
Expand Down
Loading