fix(FN-4624): address review — gating, audit types, download/extract bugs, exports

Fusion-Task-Id: FN-4624
Fusion-Task-Lineage: 854e6033-bc17-45be-8d04-18bcb66b31c4
This commit is contained in:
Fusion (runfusion.ai)
2026-05-15 19:57:31 -07:00
committed by gsxdsm
parent 616ee023a6
commit f0dd4c55d5
5 changed files with 67 additions and 16 deletions

View File

@@ -65,11 +65,13 @@ export const NETWORK_API_TOOLS: ReadonlySet<string> = new Set([
"fn_research_cancel",
"fn_research_retry",
"fn_web_fetch", // FN-4603: outbound HTTP fetch should be network-classified.
"worktrunk_install", // FN-4624: binary auto-install downloads from GitHub.
]);
export const ACTION_GATE_NETWORK_API_TOOLS: ReadonlySet<string> = new Set([
"fn_research_run",
"fn_web_fetch", // FN-4603: honor network_api approval policy for web fetches.
"worktrunk_install", // FN-4624: gate binary auto-install under network_api policy.
]);
export const READONLY_FN_TOOLS: ReadonlySet<string> = new Set([

View File

@@ -112,6 +112,22 @@ export { generateReservedWorktreeName, generateWorktreeName, planTaskWorktreePat
export { createLogger, type Logger } from "./logger.js";
export { fetchWebContent, assertSafeUrl, WebFetchError, type WebFetchOptions, type WebFetchResult, type WebFetchErrorCode } from "./web-fetch.js";
export { classifyTaskError, type ErrorClass, type TaskErrorClassification } from "./error-classifier.js";
export {
resolveWorktrunkBinary,
installWorktrunk,
probeWorktrunk,
clearWorktrunkResolveCache,
WorktrunkBinaryUnavailableError,
WorktrunkInstallDeniedError,
WorktrunkInstallFailedError,
WORKTRUNK_PINNED_RELEASE,
WORKTRUNK_INSTALL_DIR,
WORKTRUNK_INSTALL_PATH,
WORKTRUNK_PROBE_TIMEOUT_MS,
WORKTRUNK_DOWNLOAD_TIMEOUT_MS,
WORKTRUNK_DOWNLOAD_MAX_BYTES,
WORKTRUNK_CARGO_TIMEOUT_MS,
} from "./worktrunk-installer.js";
export { isUsageLimitError, UsageLimitPauser } from "./usage-limit-detector.js";
export { withRateLimitRetry } from "./rate-limit-retry.js";
export { ResearchOrchestrator, type ResearchOrchestratorOptions, type ResearchOrchestratorStatus, type ResearchOrchestratorStartOptions } from "./research-orchestrator.js";

View File

@@ -143,7 +143,11 @@ export type FilesystemMutationType =
| "prompt:write"
| "prompt:update"
| "session:write"
| "session:delete";
| "session:delete"
| "binary:install-requested"
| "binary:install-success"
| "binary:install-failed"
| "binary:install-denied";
export type SandboxMutationType = "sandbox:prepare" | "sandbox:run" | "sandbox:failure" | "sandbox:fallback";

View File

@@ -5,7 +5,7 @@ import fs from "node:fs/promises";
import https from "node:https";
import os from "node:os";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import { PassThrough } from "node:stream";
import { promisify } from "node:util";
import type { AgentPermissionPolicy, WorktrunkSettings } from "@fusion/core";
import { evaluateAgentActionGate, type AgentActionGateContext } from "./agent-action-gate.js";
@@ -187,14 +187,14 @@ async function applyInstallGate(opts: {
async function downloadReleaseAsset(url: string, targetPath: string): Promise<string> {
await assertSafeUrl(url, false);
const hash = createHash("sha256");
await new Promise<void>((resolve, reject) => {
return new Promise<string>((resolve, reject) => {
const req = https.get(url, (res) => {
const statusCode = res.statusCode ?? 0;
if (statusCode >= 300 && statusCode < 400 && res.headers.location) {
req.destroy();
downloadReleaseAsset(new URL(res.headers.location, url).toString(), targetPath).then(() => resolve()).catch(reject);
downloadReleaseAsset(new URL(res.headers.location, url).toString(), targetPath)
.then((hash) => resolve(hash))
.catch(reject);
return;
}
if (statusCode < 200 || statusCode >= 300) {
@@ -202,28 +202,56 @@ async function downloadReleaseAsset(url: string, targetPath: string): Promise<st
return;
}
const hash = createHash("sha256");
const file = createWriteStream(targetPath);
let bytes = 0;
res.on("data", (chunk: Buffer) => {
let sizeExceeded = false;
const meter = new PassThrough();
meter.on("data", (chunk: Buffer) => {
bytes += chunk.length;
if (bytes > WORKTRUNK_DOWNLOAD_MAX_BYTES) {
sizeExceeded = true;
req.destroy(new Error("download exceeded size cap"));
return;
}
hash.update(chunk);
});
pipeline(res, createWriteStream(targetPath)).then(resolve).catch(reject);
// Hash transform: updates sha256 as data passes through.
const hashTransform = new PassThrough();
hashTransform.on("data", (chunk: Buffer) => hash.update(chunk));
// Pipe: res -> meter -> hashTransform -> file
// meter tracks bytes; hashTransform feeds sha256; file writes to disk.
meter.pipe(hashTransform).pipe(file);
// Feed the response into the meter
res.on("data", (chunk: Buffer) => meter.write(chunk));
res.on("end", () => meter.end());
res.on("error", (err) => { meter.destroy(err); });
file.on("finish", () => {
if (sizeExceeded) {
reject(new Error("download exceeded size cap"));
} else {
resolve(hash.digest("hex"));
}
});
file.on("error", reject);
meter.on("error", reject);
});
req.setTimeout(WORKTRUNK_DOWNLOAD_TIMEOUT_MS, () => req.destroy(new Error("download timed out")));
req.setTimeout(WORKTRUNK_DOWNLOAD_TIMEOUT_MS, () =>
req.destroy(new Error("download timed out")),
);
req.on("error", reject);
});
return hash.digest("hex");
}
async function extractAsset(archivePath: string, innerBinaryName: string, targetPath: string): Promise<void> {
if (archivePath.endsWith(".tar.gz")) {
// NOTE: tar.gz archives extracted via system `tar`, .zip via system `unzip`.
// No heavy archive dependencies bundled; relies on POSIX tooling.
const name = path.basename(archivePath, ".download");
if (name.endsWith(".tar.gz")) {
await execAsync(`tar -xzf "${archivePath}" -O "${innerBinaryName}" > "${targetPath}"`, {
timeout: WORKTRUNK_PROBE_TIMEOUT_MS,
maxBuffer: WORKTRUNK_DOWNLOAD_MAX_BYTES,
@@ -231,7 +259,7 @@ async function extractAsset(archivePath: string, innerBinaryName: string, target
return;
}
if (archivePath.endsWith(".zip")) {
if (name.endsWith(".zip")) {
await execAsync(`unzip -p "${archivePath}" "${innerBinaryName}" > "${targetPath}"`, {
timeout: WORKTRUNK_PROBE_TIMEOUT_MS,
maxBuffer: WORKTRUNK_DOWNLOAD_MAX_BYTES,