fix(node-host): bind approved script operands

This commit is contained in:
Peter Steinberger
2026-03-07 23:03:26 +00:00
parent bfbe80ab7d
commit c76d29208b
12 changed files with 374 additions and 4 deletions

View File

@@ -1,8 +1,22 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import type { SystemRunApprovalPlan } from "../infra/exec-approvals.js";
import type {
SystemRunApprovalFileOperand,
SystemRunApprovalPlan,
} from "../infra/exec-approvals.js";
import { resolveCommandResolutionFromArgv } from "../infra/exec-command-resolution.js";
import {
POSIX_SHELL_WRAPPERS,
normalizeExecutableToken,
unwrapKnownDispatchWrapperInvocation,
unwrapKnownShellMultiplexerInvocation,
} from "../infra/exec-wrapper-resolution.js";
import { sameFileIdentity } from "../infra/file-identity.js";
import {
POSIX_INLINE_COMMAND_FLAGS,
resolveInlineCommandMatch,
} from "../infra/shell-inline-command.js";
import { formatExecCommand, resolveSystemRunCommand } from "../infra/system-run-command.js";
export type ApprovedCwdSnapshot = {
@@ -10,6 +24,14 @@ export type ApprovedCwdSnapshot = {
stat: fs.Stats;
};
const MUTABLE_ARGV1_INTERPRETER_PATTERNS = [
/^(?:node|nodejs)$/,
/^perl$/,
/^php$/,
/^python(?:\d+(?:\.\d+)*)?$/,
/^ruby$/,
] as const;
function normalizeString(value: unknown): string | null {
if (typeof value !== "string") {
return null;
@@ -68,6 +90,125 @@ function shouldPinExecutableForApproval(params: {
return (params.wrapperChain?.length ?? 0) === 0;
}
function hashFileContentsSync(filePath: string): string {
return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
}
function unwrapArgvForMutableOperand(argv: string[]): { argv: string[]; baseIndex: number } {
let current = argv;
let baseIndex = 0;
while (true) {
const dispatchUnwrap = unwrapKnownDispatchWrapperInvocation(current);
if (dispatchUnwrap.kind === "unwrapped") {
baseIndex += current.length - dispatchUnwrap.argv.length;
current = dispatchUnwrap.argv;
continue;
}
const shellMultiplexerUnwrap = unwrapKnownShellMultiplexerInvocation(current);
if (shellMultiplexerUnwrap.kind === "unwrapped") {
baseIndex += current.length - shellMultiplexerUnwrap.argv.length;
current = shellMultiplexerUnwrap.argv;
continue;
}
return { argv: current, baseIndex };
}
}
function resolvePosixShellScriptOperandIndex(argv: string[]): number | null {
if (
resolveInlineCommandMatch(argv, POSIX_INLINE_COMMAND_FLAGS, {
allowCombinedC: true,
}).valueTokenIndex !== null
) {
return null;
}
let afterDoubleDash = false;
for (let i = 1; i < argv.length; i += 1) {
const token = argv[i]?.trim() ?? "";
if (!token) {
continue;
}
if (token === "-") {
return null;
}
if (!afterDoubleDash && token === "--") {
afterDoubleDash = true;
continue;
}
if (!afterDoubleDash && token === "-s") {
return null;
}
if (!afterDoubleDash && token.startsWith("-")) {
continue;
}
return i;
}
return null;
}
function resolveMutableFileOperandIndex(argv: string[]): number | null {
const unwrapped = unwrapArgvForMutableOperand(argv);
const executable = normalizeExecutableToken(unwrapped.argv[0] ?? "");
if (!executable) {
return null;
}
if ((POSIX_SHELL_WRAPPERS as ReadonlySet<string>).has(executable)) {
const shellIndex = resolvePosixShellScriptOperandIndex(unwrapped.argv);
return shellIndex === null ? null : unwrapped.baseIndex + shellIndex;
}
if (!MUTABLE_ARGV1_INTERPRETER_PATTERNS.some((pattern) => pattern.test(executable))) {
return null;
}
const operand = unwrapped.argv[1]?.trim() ?? "";
if (!operand || operand === "-" || operand.startsWith("-")) {
return null;
}
return unwrapped.baseIndex + 1;
}
function resolveMutableFileOperandSnapshotSync(params: {
argv: string[];
cwd: string | undefined;
}): { ok: true; snapshot: SystemRunApprovalFileOperand | null } | { ok: false; message: string } {
const argvIndex = resolveMutableFileOperandIndex(params.argv);
if (argvIndex === null) {
return { ok: true, snapshot: null };
}
const rawOperand = params.argv[argvIndex]?.trim();
if (!rawOperand) {
return {
ok: false,
message: "SYSTEM_RUN_DENIED: approval requires a stable script operand",
};
}
const resolvedPath = path.resolve(params.cwd ?? process.cwd(), rawOperand);
let realPath: string;
let stat: fs.Stats;
try {
realPath = fs.realpathSync(resolvedPath);
stat = fs.statSync(realPath);
} catch {
return {
ok: false,
message: "SYSTEM_RUN_DENIED: approval requires an existing script operand",
};
}
if (!stat.isFile()) {
return {
ok: false,
message: "SYSTEM_RUN_DENIED: approval requires a file script operand",
};
}
return {
ok: true,
snapshot: {
argvIndex,
path: realPath,
sha256: hashFileContentsSync(realPath),
},
};
}
function resolveCanonicalApprovalCwdSync(cwd: string):
| {
ok: true;
@@ -135,6 +276,32 @@ export function revalidateApprovedCwdSnapshot(params: { snapshot: ApprovedCwdSna
return sameFileIdentity(params.snapshot.stat, current.snapshot.stat);
}
export function revalidateApprovedMutableFileOperand(params: {
snapshot: SystemRunApprovalFileOperand;
argv: string[];
cwd: string | undefined;
}): boolean {
const operand = params.argv[params.snapshot.argvIndex]?.trim();
if (!operand) {
return false;
}
const resolvedPath = path.resolve(params.cwd ?? process.cwd(), operand);
let realPath: string;
try {
realPath = fs.realpathSync(resolvedPath);
} catch {
return false;
}
if (realPath !== params.snapshot.path) {
return false;
}
try {
return hashFileContentsSync(realPath) === params.snapshot.sha256;
} catch {
return false;
}
}
export function hardenApprovedExecutionPaths(params: {
approvedByAsk: boolean;
argv: string[];
@@ -257,6 +424,13 @@ export function buildSystemRunApprovalPlan(params: {
const rawCommand = hardening.argvChanged
? formatExecCommand(hardening.argv) || null
: command.cmdText.trim() || null;
const mutableFileOperand = resolveMutableFileOperandSnapshotSync({
argv: hardening.argv,
cwd: hardening.cwd,
});
if (!mutableFileOperand.ok) {
return { ok: false, message: mutableFileOperand.message };
}
return {
ok: true,
plan: {
@@ -265,7 +439,8 @@ export function buildSystemRunApprovalPlan(params: {
rawCommand,
agentId: normalizeString(params.agentId),
sessionKey: normalizeString(params.sessionKey),
mutableFileOperand: mutableFileOperand.snapshot ?? undefined,
},
cmdText: command.cmdText,
cmdText: rawCommand ?? formatExecCommand(hardening.argv),
};
}