FN-7056: guard configured commands against execSync

Add a focused engine regression guard for user-configured command paths.

- Add a static Vitest registry that slices protected configured-command helpers and fails on execSync usage.
- Assert each protected command path keeps bounded async safeguards such as timeout, maxBuffer, or maxLifetimeMs.
- Document the guard and its deliberate git-plumbing exclusions in the testing guide.

Files changed:
 docs/testing.md                                    |   5 +
 .../user-configured-command-no-execsync.test.ts    | 288 +++++++++++++++++++++
 2 files changed, 293 insertions(+)

Fusion-Task-Id: FN-7056

Fusion-Task-Lineage: b92f19cf-526e-4345-b18e-869a278e0e10
This commit is contained in:
gsxdsm
2026-06-26 04:10:53 -07:00
parent d03d6c23d6
commit 2b554142f5
2 changed files with 293 additions and 0 deletions

View File

@@ -59,6 +59,11 @@ Agents running verification through `fn_run_verification` are bounded by default
Public `@fusion/core` exports consumed by runtime tools should include a literal built-dist guard (for example importing `packages/core/dist/index.js`) when package test aliases otherwise resolve `@fusion/core` to source.
## Engine static process guards
<!-- FNXC:EngineProcessRules 2026-06-26-03:58: FN-7056 adds a focused static guard for user-configured command paths. Keep the protected-path registry in the test file, not as a whole-file execSync ban, because engine git plumbing still has legitimate deterministic execSync uses. -->
`packages/engine/src/__tests__/user-configured-command-no-execsync.test.ts` guards user-configured command execution helpers against accidental `execSync` usage or dropped async bounds. Its registry covers verification helpers, `fn_run_verification`, executor configured-command execution, merger post-merge script execution, routine command execution, and the native/bubblewrap/sandbox-exec sandbox backends. Each protected slice must keep the appropriate bounded async safeguard (`timeout`/`timeoutMs`, `maxBuffer`, or `maxLifetimeMs`). The test intentionally slices named function bodies instead of scanning whole files; deterministic git-plumbing `execSync` in merger/self-healing/already-merged/integration/worktree-prune paths and the executor git ancestry check are explicitly out of scope.
## Dashboard Test Lanes
```bash

View File

@@ -0,0 +1,288 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
/**
* FNXC:EngineProcessRules 2026-06-26-03:45:
* User-configured command paths must stay on bounded async execution APIs; this focused registry intentionally excludes git-plumbing execSync call sites so deterministic repository checks can keep using synchronous child-process APIs where appropriate.
*
* Protected registry for user-configured commands:
* - packages/engine/src/verification-utils.ts :: execWithProcessGroup — delegates command execution through the sandbox backend streaming API; caller-owned options must carry bounds.
* - packages/engine/src/verification-utils.ts :: runVerificationCommand — runs configured test/build commands through execWithProcessGroup with timeout and VERIFICATION_COMMAND_MAX_BUFFER.
* - packages/engine/src/run-verification-tool.ts :: runVerificationCommand — backs fn_run_verification with superviseSpawn and maxLifetimeMs.
* - packages/engine/src/executor.ts :: runConfiguredCommand — runs settings.scripts, settings.setupScript, settings.worktreeInitCommand, and workflow script commands through backend.run with timeoutMs and maxBuffer.
* - packages/engine/src/merger.ts :: runConfiguredMergeWorktreeCommand — runs configured merge-worktree commands through backend.run with timeoutMs and maxBuffer.
* - packages/engine/src/merger.ts :: executePostMergeScriptStep — runs post-merge settings.scripts entries through backend.run with timeoutMs and maxBuffer.
* - packages/engine/src/routine-runner.ts :: executeCommand — runs configured automation/routine commands through backend.run with timeoutMs and maxBuffer.
* - packages/engine/src/sandbox/native.ts :: NativeSandboxBackend.run — default sandbox backend uses superviseSpawn with maxLifetimeMs plus timeoutMs/maxBuffer enforcement.
* - packages/engine/src/sandbox/bubblewrap-backend.ts :: BubblewrapBackend.run — isolating backend delegates to native fallback or runBwrapSpawn; runBwrapSpawn uses spawn with timeoutMs and maxBuffer enforcement.
* - packages/engine/src/sandbox/bubblewrap-backend.ts :: BubblewrapBackend.runBwrapSpawn — concrete bubblewrap spawn path uses setTimeout(options.timeoutMs) and options.maxBuffer.
* - packages/engine/src/sandbox/sandbox-exec-backend.ts :: SandboxExecBackend.run — macOS isolating backend uses async exec with timeout, maxBuffer, and signal.
*
* Explicit exclusions: git-only execSync in merger.ts, self-healing.ts, already-merged-detector.ts, integration-branch.ts, worktree-prune.ts, and executor.ts git merge-base ancestry checks. The guard slices only registry function bodies instead of asserting over whole files.
*/
type GuardEntry = {
file: string;
name: string;
signature: string;
requiredSafeguards: Array<{ label: string; pattern: RegExp }>;
};
const protectedCommandPaths: GuardEntry[] = [
{
file: "src/verification-utils.ts",
name: "execWithProcessGroup",
signature: "export async function execWithProcessGroup(",
requiredSafeguards: [
{ label: "sandbox streaming backend", pattern: /backend\.runStreaming\(/ },
],
},
{
file: "src/verification-utils.ts",
name: "runVerificationCommand",
signature: "export async function runVerificationCommand(",
requiredSafeguards: [
{ label: "execWithProcessGroup async runner", pattern: /execWithProcessGroup\(/ },
{ label: "timeout option", pattern: /timeout\s*:\s*timeoutMs/ },
{ label: "verification maxBuffer", pattern: /maxBuffer\s*:\s*VERIFICATION_COMMAND_MAX_BUFFER/ },
],
},
{
file: "src/run-verification-tool.ts",
name: "runVerificationCommand",
signature: "export async function runVerificationCommand(",
requiredSafeguards: [
{ label: "superviseSpawn async runner", pattern: /superviseSpawn\(/ },
{ label: "process lifetime cap", pattern: /maxLifetimeMs\s*:/ },
],
},
{
file: "src/executor.ts",
name: "runConfiguredCommand",
signature: "async function runConfiguredCommand(",
requiredSafeguards: [
{ label: "sandbox backend.run", pattern: /backend\.run\(/ },
{ label: "timeoutMs option", pattern: /timeoutMs\s*,/ },
{ label: "maxBuffer option", pattern: /maxBuffer\s*:/ },
],
},
{
file: "src/merger.ts",
name: "runConfiguredMergeWorktreeCommand",
signature: "async function runConfiguredMergeWorktreeCommand(",
requiredSafeguards: [
{ label: "sandbox backend.run", pattern: /backend\.run\(/ },
{ label: "timeoutMs option", pattern: /timeoutMs\s*,/ },
{ label: "maxBuffer option", pattern: /maxBuffer\s*:/ },
],
},
{
file: "src/merger.ts",
name: "executePostMergeScriptStep",
signature: "async function executePostMergeScriptStep(",
requiredSafeguards: [
{ label: "sandbox backend.run", pattern: /backend\.run\(/ },
{ label: "timeoutMs option", pattern: /timeoutMs\s*:/ },
{ label: "maxBuffer option", pattern: /maxBuffer\s*:/ },
],
},
{
file: "src/routine-runner.ts",
name: "executeCommand",
signature: "private async executeCommand(",
requiredSafeguards: [
{ label: "sandbox backend.run", pattern: /backend\.run\(/ },
{ label: "timeoutMs option", pattern: /timeoutMs\s*:/ },
{ label: "maxBuffer option", pattern: /maxBuffer\s*:/ },
],
},
{
file: "src/sandbox/native.ts",
name: "NativeSandboxBackend.run",
signature: "async run(command: string, options: SandboxRunOptions)",
requiredSafeguards: [
{ label: "superviseSpawn async runner", pattern: /superviseSpawn\(/ },
{ label: "process lifetime cap", pattern: /maxLifetimeMs\s*:/ },
{ label: "timeoutMs enforcement", pattern: /options\.timeoutMs/ },
{ label: "maxBuffer enforcement", pattern: /options\.maxBuffer/ },
],
},
{
file: "src/sandbox/bubblewrap-backend.ts",
name: "BubblewrapBackend.run",
signature: "async run(command: string, options: SandboxRunOptions)",
requiredSafeguards: [
{ label: "native fallback", pattern: /nativeBackend\.run\(/ },
{ label: "bounded bubblewrap runner", pattern: /runBwrapSpawn/ },
],
},
{
file: "src/sandbox/bubblewrap-backend.ts",
name: "BubblewrapBackend.runBwrapSpawn",
signature: "private runBwrapSpawn(command: string, args: string[], options: SandboxRunOptions)",
requiredSafeguards: [
{ label: "async spawn", pattern: /spawn\(/ },
{ label: "timeoutMs enforcement", pattern: /options\.timeoutMs/ },
{ label: "maxBuffer enforcement", pattern: /options\.maxBuffer/ },
],
},
{
file: "src/sandbox/sandbox-exec-backend.ts",
name: "SandboxExecBackend.run",
signature: "async run(command: string, options: SandboxRunOptions)",
requiredSafeguards: [
{ label: "async exec", pattern: /execAsync\(/ },
{ label: "timeout option", pattern: /timeout\s*:\s*options\.timeoutMs/ },
{ label: "maxBuffer option", pattern: /maxBuffer\s*:\s*options\.maxBuffer/ },
{ label: "abort signal", pattern: /signal\s*:\s*options\.signal/ },
],
},
];
function readSource(file: string): string {
return readFileSync(resolve(process.cwd(), file), "utf-8");
}
function sliceFunctionBody(source: string, signature: string, label: string): string {
const signatureIndex = source.indexOf(signature);
expect(signatureIndex, `${label}: registry signature must resolve to a real function`).toBeGreaterThanOrEqual(0);
const paramsStart = source.indexOf("(", signatureIndex);
expect(paramsStart, `${label}: function signature must have parameters`).toBeGreaterThanOrEqual(0);
let parenDepth = 0;
let paramsEnd = -1;
for (let index = paramsStart; index < source.length; index += 1) {
const char = source[index];
if (char === "(") parenDepth += 1;
if (char === ")") {
parenDepth -= 1;
if (parenDepth === 0) {
paramsEnd = index;
break;
}
}
}
expect(paramsEnd, `${label}: function parameters must close`).toBeGreaterThanOrEqual(0);
let angleDepth = 0;
let openBraceIndex = -1;
for (let index = paramsEnd + 1; index < source.length; index += 1) {
const char = source[index];
if (char === "<") angleDepth += 1;
if (char === ">" && angleDepth > 0) angleDepth -= 1;
if (char === "{" && angleDepth === 0) {
openBraceIndex = index;
break;
}
}
expect(openBraceIndex, `${label}: function body must have an opening brace`).toBeGreaterThanOrEqual(0);
let depth = 0;
let state: "code" | "single" | "double" | "template" | "line-comment" | "block-comment" = "code";
let escaped = false;
for (let index = openBraceIndex; index < source.length; index += 1) {
const char = source[index];
const next = source[index + 1];
if (state === "line-comment") {
if (char === "\n") state = "code";
continue;
}
if (state === "block-comment") {
if (char === "*" && next === "/") {
state = "code";
index += 1;
}
continue;
}
if (state === "single" || state === "double" || state === "template") {
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if ((state === "single" && char === "'") || (state === "double" && char === '"') || (state === "template" && char === "`")) {
state = "code";
}
continue;
}
if (char === "/" && next === "/") {
state = "line-comment";
index += 1;
continue;
}
if (char === "/" && next === "*") {
state = "block-comment";
index += 1;
continue;
}
if (char === "'") {
state = "single";
continue;
}
if (char === '"') {
state = "double";
continue;
}
if (char === "`") {
state = "template";
continue;
}
if (char === "{") depth += 1;
if (char === "}") {
depth -= 1;
if (depth === 0) {
return source.slice(openBraceIndex, index + 1);
}
}
}
throw new Error(`${label}: function body did not close; registry may be stale`);
}
function assertGuardInvariants(body: string, entryName: string, requiredSafeguards: GuardEntry["requiredSafeguards"]): void {
expect(body, `${entryName}: protected body must not call execSync`).not.toContain("execSync(");
expect(body, `${entryName}: protected body must not reference execSync at all`).not.toMatch(/\bexecSync\b/);
for (const safeguard of requiredSafeguards) {
expect(body, `${entryName}: missing required safeguard ${safeguard.label}`).toMatch(safeguard.pattern);
}
}
describe("user-configured command static execSync guard", () => {
it("protects the complete registry and required async safeguards", () => {
expect(protectedCommandPaths.length, "registry must enumerate the protected command paths").toBeGreaterThan(0);
for (const entry of protectedCommandPaths) {
const source = readSource(entry.file);
const body = sliceFunctionBody(source, entry.signature, `${entry.file} :: ${entry.name}`);
expect(body.trim().length, `${entry.file} :: ${entry.name}: sliced body must be non-empty`).toBeGreaterThan(2);
assertGuardInvariants(body, `${entry.file} :: ${entry.name}`, entry.requiredSafeguards);
}
});
it("bites on synthetic execSync and missing-safeguard regressions", () => {
const requiredSafeguards = [{ label: "timeout option", pattern: /timeout\s*:/ }];
expect(() => assertGuardInvariants("{ execSync('pnpm test'); timeout: 1000; }", "synthetic execSync", requiredSafeguards)).toThrow(/execSync/);
expect(() => assertGuardInvariants("{ execAsync('pnpm test'); }", "synthetic missing timeout", requiredSafeguards)).toThrow(/timeout option/);
expect(() => assertGuardInvariants("{ execAsync('pnpm test', { timeout: 1000 }); }", "synthetic safe", requiredSafeguards)).not.toThrow();
});
it("does not false-positive on allowed git-plumbing execSync outside protected slices", () => {
const executorSource = readSource("src/executor.ts");
expect(executorSource, "executor keeps a git-only execSync ancestry check outside the protected configured-command helper").toContain("execSync(`git merge-base --is-ancestor");
const configuredCommand = protectedCommandPaths.find((entry) => entry.file === "src/executor.ts" && entry.name === "runConfiguredCommand");
expect(configuredCommand, "executor runConfiguredCommand registry entry must exist").toBeDefined();
const body = sliceFunctionBody(executorSource, configuredCommand!.signature, "src/executor.ts :: runConfiguredCommand");
assertGuardInvariants(body, "src/executor.ts :: runConfiguredCommand", configuredCommand!.requiredSafeguards);
});
});