fix(security): harden node exec approvals against symlink rebind

This commit is contained in:
Peter Steinberger
2026-02-26 21:47:38 +01:00
parent 611dff985d
commit 78a7ff2d50
15 changed files with 489 additions and 43 deletions

View File

@@ -16,6 +16,7 @@ import {
type ExecAsk,
type ExecCommandSegment,
type ExecSecurity,
type SystemRunApprovalPlanV2,
type SkillBinTrustEntry,
} from "../infra/exec-approvals.js";
import type { ExecHostRequest, ExecHostResponse, ExecHostRunResult } from "../infra/exec-host.js";
@@ -113,6 +114,14 @@ function normalizeDeniedReason(reason: string | null | undefined): SystemRunDeni
}
}
function normalizeString(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function isPathLikeExecutableToken(value: string): boolean {
if (!value) {
return false;
@@ -129,6 +138,46 @@ function isPathLikeExecutableToken(value: string): boolean {
return false;
}
function pathComponentsFromRootSync(targetPath: string): string[] {
const absolute = path.resolve(targetPath);
const parts: string[] = [];
let cursor = absolute;
while (true) {
parts.unshift(cursor);
const parent = path.dirname(cursor);
if (parent === cursor) {
return parts;
}
cursor = parent;
}
}
function isWritableByCurrentProcessSync(candidate: string): boolean {
try {
fs.accessSync(candidate, fs.constants.W_OK);
return true;
} catch {
return false;
}
}
function hasMutableSymlinkPathComponentSync(targetPath: string): boolean {
for (const component of pathComponentsFromRootSync(targetPath)) {
try {
if (!fs.lstatSync(component).isSymbolicLink()) {
continue;
}
const parentDir = path.dirname(component);
if (isWritableByCurrentProcessSync(parentDir)) {
return true;
}
} catch {
return true;
}
}
return false;
}
function hardenApprovedExecutionPaths(params: {
approvedByAsk: boolean;
argv: string[];
@@ -163,6 +212,12 @@ function hardenApprovedExecutionPaths(params: {
message: "SYSTEM_RUN_DENIED: approval requires cwd to be a directory",
};
}
if (hasMutableSymlinkPathComponentSync(requestedCwd)) {
return {
ok: false,
message: "SYSTEM_RUN_DENIED: approval requires canonical cwd (no symlink path components)",
};
}
if (cwdLstat.isSymbolicLink()) {
return {
ok: false,
@@ -207,6 +262,46 @@ function hardenApprovedExecutionPaths(params: {
return { ok: true, argv, cwd: hardenedCwd };
}
export function buildSystemRunApprovalPlanV2(params: {
command?: unknown;
rawCommand?: unknown;
cwd?: unknown;
agentId?: unknown;
sessionKey?: unknown;
}): { ok: true; plan: SystemRunApprovalPlanV2; cmdText: string } | { ok: false; message: string } {
const command = resolveSystemRunCommand({
command: params.command,
rawCommand: params.rawCommand,
});
if (!command.ok) {
return { ok: false, message: command.message };
}
if (command.argv.length === 0) {
return { ok: false, message: "command required" };
}
const hardening = hardenApprovedExecutionPaths({
approvedByAsk: true,
argv: command.argv,
shellCommand: command.shellCommand,
cwd: normalizeString(params.cwd) ?? undefined,
});
if (!hardening.ok) {
return { ok: false, message: hardening.message };
}
return {
ok: true,
plan: {
version: 2,
argv: hardening.argv,
cwd: hardening.cwd ?? null,
rawCommand: command.cmdText.trim() || null,
agentId: normalizeString(params.agentId),
sessionKey: normalizeString(params.sessionKey),
},
cmdText: command.cmdText,
};
}
export type HandleSystemRunInvokeOptions = {
client: GatewayClient;
params: SystemRunParams;