mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 16:50:22 +00:00
refactor(exec): unify wrapper resolution and split approvals tests
This commit is contained in:
218
src/infra/exec-approvals-allow-always.test.ts
Normal file
218
src/infra/exec-approvals-allow-always.test.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { makePathEnv, makeTempDir } from "./exec-approvals-test-helpers.js";
|
||||||
|
import {
|
||||||
|
evaluateShellAllowlist,
|
||||||
|
requiresExecApproval,
|
||||||
|
resolveAllowAlwaysPatterns,
|
||||||
|
resolveSafeBins,
|
||||||
|
} from "./exec-approvals.js";
|
||||||
|
|
||||||
|
describe("resolveAllowAlwaysPatterns", () => {
|
||||||
|
function makeExecutable(dir: string, name: string): string {
|
||||||
|
const fileName = process.platform === "win32" ? `${name}.exe` : name;
|
||||||
|
const exe = path.join(dir, fileName);
|
||||||
|
fs.writeFileSync(exe, "");
|
||||||
|
fs.chmodSync(exe, 0o755);
|
||||||
|
return exe;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("returns direct executable paths for non-shell segments", () => {
|
||||||
|
const exe = path.join("/tmp", "openclaw-tool");
|
||||||
|
const patterns = resolveAllowAlwaysPatterns({
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
raw: exe,
|
||||||
|
argv: [exe],
|
||||||
|
resolution: { rawExecutable: exe, resolvedPath: exe, executableName: "openclaw-tool" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(patterns).toEqual([exe]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unwraps shell wrappers and persists the inner executable instead", () => {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dir = makeTempDir();
|
||||||
|
const whoami = makeExecutable(dir, "whoami");
|
||||||
|
const patterns = resolveAllowAlwaysPatterns({
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
raw: "/bin/zsh -lc 'whoami'",
|
||||||
|
argv: ["/bin/zsh", "-lc", "whoami"],
|
||||||
|
resolution: {
|
||||||
|
rawExecutable: "/bin/zsh",
|
||||||
|
resolvedPath: "/bin/zsh",
|
||||||
|
executableName: "zsh",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
cwd: dir,
|
||||||
|
env: makePathEnv(dir),
|
||||||
|
platform: process.platform,
|
||||||
|
});
|
||||||
|
expect(patterns).toEqual([whoami]);
|
||||||
|
expect(patterns).not.toContain("/bin/zsh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts all inner binaries from shell chains and deduplicates", () => {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dir = makeTempDir();
|
||||||
|
const whoami = makeExecutable(dir, "whoami");
|
||||||
|
const ls = makeExecutable(dir, "ls");
|
||||||
|
const patterns = resolveAllowAlwaysPatterns({
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
raw: "/bin/zsh -lc 'whoami && ls && whoami'",
|
||||||
|
argv: ["/bin/zsh", "-lc", "whoami && ls && whoami"],
|
||||||
|
resolution: {
|
||||||
|
rawExecutable: "/bin/zsh",
|
||||||
|
resolvedPath: "/bin/zsh",
|
||||||
|
executableName: "zsh",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
cwd: dir,
|
||||||
|
env: makePathEnv(dir),
|
||||||
|
platform: process.platform,
|
||||||
|
});
|
||||||
|
expect(new Set(patterns)).toEqual(new Set([whoami, ls]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not persist broad shell binaries when no inner command can be derived", () => {
|
||||||
|
const patterns = resolveAllowAlwaysPatterns({
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
raw: "/bin/zsh -s",
|
||||||
|
argv: ["/bin/zsh", "-s"],
|
||||||
|
resolution: {
|
||||||
|
rawExecutable: "/bin/zsh",
|
||||||
|
resolvedPath: "/bin/zsh",
|
||||||
|
executableName: "zsh",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
platform: process.platform,
|
||||||
|
});
|
||||||
|
expect(patterns).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects shell wrappers even when unresolved executableName is a full path", () => {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dir = makeTempDir();
|
||||||
|
const whoami = makeExecutable(dir, "whoami");
|
||||||
|
const patterns = resolveAllowAlwaysPatterns({
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
raw: "/usr/local/bin/zsh -lc whoami",
|
||||||
|
argv: ["/usr/local/bin/zsh", "-lc", "whoami"],
|
||||||
|
resolution: {
|
||||||
|
rawExecutable: "/usr/local/bin/zsh",
|
||||||
|
resolvedPath: undefined,
|
||||||
|
executableName: "/usr/local/bin/zsh",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
cwd: dir,
|
||||||
|
env: makePathEnv(dir),
|
||||||
|
platform: process.platform,
|
||||||
|
});
|
||||||
|
expect(patterns).toEqual([whoami]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unwraps known dispatch wrappers before shell wrappers", () => {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dir = makeTempDir();
|
||||||
|
const whoami = makeExecutable(dir, "whoami");
|
||||||
|
const patterns = resolveAllowAlwaysPatterns({
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
raw: "/usr/bin/nice /bin/zsh -lc whoami",
|
||||||
|
argv: ["/usr/bin/nice", "/bin/zsh", "-lc", "whoami"],
|
||||||
|
resolution: {
|
||||||
|
rawExecutable: "/usr/bin/nice",
|
||||||
|
resolvedPath: "/usr/bin/nice",
|
||||||
|
executableName: "nice",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
cwd: dir,
|
||||||
|
env: makePathEnv(dir),
|
||||||
|
platform: process.platform,
|
||||||
|
});
|
||||||
|
expect(patterns).toEqual([whoami]);
|
||||||
|
expect(patterns).not.toContain("/usr/bin/nice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed for unresolved dispatch wrappers", () => {
|
||||||
|
const patterns = resolveAllowAlwaysPatterns({
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
raw: "sudo /bin/zsh -lc whoami",
|
||||||
|
argv: ["sudo", "/bin/zsh", "-lc", "whoami"],
|
||||||
|
resolution: {
|
||||||
|
rawExecutable: "sudo",
|
||||||
|
resolvedPath: "/usr/bin/sudo",
|
||||||
|
executableName: "sudo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
platform: process.platform,
|
||||||
|
});
|
||||||
|
expect(patterns).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prevents allow-always bypass for dispatch-wrapper + shell-wrapper chains", () => {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dir = makeTempDir();
|
||||||
|
const echo = makeExecutable(dir, "echo");
|
||||||
|
makeExecutable(dir, "id");
|
||||||
|
const safeBins = resolveSafeBins(undefined);
|
||||||
|
const env = makePathEnv(dir);
|
||||||
|
|
||||||
|
const first = evaluateShellAllowlist({
|
||||||
|
command: "/usr/bin/nice /bin/zsh -lc 'echo warmup-ok'",
|
||||||
|
allowlist: [],
|
||||||
|
safeBins,
|
||||||
|
cwd: dir,
|
||||||
|
env,
|
||||||
|
platform: process.platform,
|
||||||
|
});
|
||||||
|
const persisted = resolveAllowAlwaysPatterns({
|
||||||
|
segments: first.segments,
|
||||||
|
cwd: dir,
|
||||||
|
env,
|
||||||
|
platform: process.platform,
|
||||||
|
});
|
||||||
|
expect(persisted).toEqual([echo]);
|
||||||
|
|
||||||
|
const second = evaluateShellAllowlist({
|
||||||
|
command: "/usr/bin/nice /bin/zsh -lc 'id > marker'",
|
||||||
|
allowlist: [{ pattern: echo }],
|
||||||
|
safeBins,
|
||||||
|
cwd: dir,
|
||||||
|
env,
|
||||||
|
platform: process.platform,
|
||||||
|
});
|
||||||
|
expect(second.allowlistSatisfied).toBe(false);
|
||||||
|
expect(
|
||||||
|
requiresExecApproval({
|
||||||
|
ask: "on-miss",
|
||||||
|
security: "allowlist",
|
||||||
|
analysisOk: second.analysisOk,
|
||||||
|
allowlistSatisfied: second.allowlistSatisfied,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,8 +18,9 @@ import {
|
|||||||
} from "./exec-safe-bin-policy.js";
|
} from "./exec-safe-bin-policy.js";
|
||||||
import { isTrustedSafeBinPath } from "./exec-safe-bin-trust.js";
|
import { isTrustedSafeBinPath } from "./exec-safe-bin-trust.js";
|
||||||
import {
|
import {
|
||||||
DISPATCH_WRAPPER_EXECUTABLES,
|
extractShellWrapperInlineCommand,
|
||||||
basenameLower,
|
isDispatchWrapperExecutable,
|
||||||
|
isShellWrapperExecutable,
|
||||||
unwrapKnownDispatchWrapperInvocation,
|
unwrapKnownDispatchWrapperInvocation,
|
||||||
} from "./exec-wrapper-resolution.js";
|
} from "./exec-wrapper-resolution.js";
|
||||||
|
|
||||||
@@ -221,98 +222,33 @@ export type ExecAllowlistAnalysis = {
|
|||||||
segmentSatisfiedBy: ExecSegmentSatisfiedBy[];
|
segmentSatisfiedBy: ExecSegmentSatisfiedBy[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const SHELL_WRAPPER_EXECUTABLES = new Set([
|
function hasSegmentExecutableMatch(
|
||||||
"ash",
|
segment: ExecCommandSegment,
|
||||||
"bash",
|
predicate: (token: string) => boolean,
|
||||||
"cmd",
|
): boolean {
|
||||||
"cmd.exe",
|
const candidates = [
|
||||||
"dash",
|
segment.resolution?.executableName,
|
||||||
"fish",
|
segment.resolution?.rawExecutable,
|
||||||
"ksh",
|
segment.argv[0],
|
||||||
"powershell",
|
];
|
||||||
"powershell.exe",
|
for (const candidate of candidates) {
|
||||||
"pwsh",
|
const trimmed = candidate?.trim();
|
||||||
"pwsh.exe",
|
if (!trimmed) {
|
||||||
"sh",
|
continue;
|
||||||
"zsh",
|
}
|
||||||
]);
|
if (predicate(trimmed)) {
|
||||||
|
return true;
|
||||||
function normalizeExecutableName(name: string | undefined): string {
|
}
|
||||||
return (name ?? "").trim().toLowerCase();
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isShellWrapperSegment(segment: ExecCommandSegment): boolean {
|
function isShellWrapperSegment(segment: ExecCommandSegment): boolean {
|
||||||
const candidates = [
|
return hasSegmentExecutableMatch(segment, isShellWrapperExecutable);
|
||||||
normalizeExecutableName(segment.resolution?.executableName),
|
|
||||||
normalizeExecutableName(segment.resolution?.rawExecutable),
|
|
||||||
];
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (!candidate) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (SHELL_WRAPPER_EXECUTABLES.has(candidate)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const base = candidate.split(/[\\/]/).pop();
|
|
||||||
if (base && SHELL_WRAPPER_EXECUTABLES.has(base)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDispatchWrapperSegment(segment: ExecCommandSegment): boolean {
|
function isDispatchWrapperSegment(segment: ExecCommandSegment): boolean {
|
||||||
const candidates = [
|
return hasSegmentExecutableMatch(segment, isDispatchWrapperExecutable);
|
||||||
normalizeExecutableName(segment.resolution?.executableName),
|
|
||||||
normalizeExecutableName(segment.resolution?.rawExecutable),
|
|
||||||
normalizeExecutableName(segment.argv[0]),
|
|
||||||
];
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (!candidate) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (DISPATCH_WRAPPER_EXECUTABLES.has(candidate)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const base = basenameLower(candidate);
|
|
||||||
if (DISPATCH_WRAPPER_EXECUTABLES.has(base)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractShellInlineCommand(argv: string[]): string | null {
|
|
||||||
for (let i = 1; i < argv.length; i += 1) {
|
|
||||||
const token = argv[i];
|
|
||||||
if (!token) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const lower = token.toLowerCase();
|
|
||||||
if (lower === "--") {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
lower === "-c" ||
|
|
||||||
lower === "--command" ||
|
|
||||||
lower === "-command" ||
|
|
||||||
lower === "/c" ||
|
|
||||||
lower === "/k"
|
|
||||||
) {
|
|
||||||
const next = argv[i + 1]?.trim();
|
|
||||||
return next ? next : null;
|
|
||||||
}
|
|
||||||
if (/^-[^-]*c[^-]*$/i.test(token)) {
|
|
||||||
const commandIndex = lower.indexOf("c");
|
|
||||||
const inline = token.slice(commandIndex + 1).trim();
|
|
||||||
if (inline) {
|
|
||||||
return inline;
|
|
||||||
}
|
|
||||||
const next = argv[i + 1]?.trim();
|
|
||||||
return next ? next : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectAllowAlwaysPatterns(params: {
|
function collectAllowAlwaysPatterns(params: {
|
||||||
@@ -328,15 +264,15 @@ function collectAllowAlwaysPatterns(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isDispatchWrapperSegment(params.segment)) {
|
if (isDispatchWrapperSegment(params.segment)) {
|
||||||
const unwrappedArgv = unwrapKnownDispatchWrapperInvocation(params.segment.argv);
|
const dispatchUnwrap = unwrapKnownDispatchWrapperInvocation(params.segment.argv);
|
||||||
if (!unwrappedArgv || unwrappedArgv.length === 0) {
|
if (dispatchUnwrap.kind !== "unwrapped" || dispatchUnwrap.argv.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
collectAllowAlwaysPatterns({
|
collectAllowAlwaysPatterns({
|
||||||
segment: {
|
segment: {
|
||||||
raw: unwrappedArgv.join(" "),
|
raw: dispatchUnwrap.argv.join(" "),
|
||||||
argv: unwrappedArgv,
|
argv: dispatchUnwrap.argv,
|
||||||
resolution: resolveCommandResolutionFromArgv(unwrappedArgv, params.cwd, params.env),
|
resolution: resolveCommandResolutionFromArgv(dispatchUnwrap.argv, params.cwd, params.env),
|
||||||
},
|
},
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
env: params.env,
|
env: params.env,
|
||||||
@@ -355,7 +291,7 @@ function collectAllowAlwaysPatterns(params: {
|
|||||||
params.out.add(candidatePath);
|
params.out.add(candidatePath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const inlineCommand = extractShellInlineCommand(params.segment.argv);
|
const inlineCommand = extractShellWrapperInlineCommand(params.segment.argv);
|
||||||
if (!inlineCommand) {
|
if (!inlineCommand) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
283
src/infra/exec-approvals-config.test.ts
Normal file
283
src/infra/exec-approvals-config.test.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { makeTempDir } from "./exec-approvals-test-helpers.js";
|
||||||
|
import {
|
||||||
|
isSafeBinUsage,
|
||||||
|
matchAllowlist,
|
||||||
|
normalizeExecApprovals,
|
||||||
|
normalizeSafeBins,
|
||||||
|
resolveExecApprovals,
|
||||||
|
resolveExecApprovalsFromFile,
|
||||||
|
type ExecApprovalsAgent,
|
||||||
|
type ExecAllowlistEntry,
|
||||||
|
type ExecApprovalsFile,
|
||||||
|
} from "./exec-approvals.js";
|
||||||
|
|
||||||
|
describe("exec approvals wildcard agent", () => {
|
||||||
|
it("merges wildcard allowlist entries with agent entries", () => {
|
||||||
|
const dir = makeTempDir();
|
||||||
|
const prevOpenClawHome = process.env.OPENCLAW_HOME;
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.env.OPENCLAW_HOME = dir;
|
||||||
|
const approvalsPath = path.join(dir, ".openclaw", "exec-approvals.json");
|
||||||
|
fs.mkdirSync(path.dirname(approvalsPath), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
approvalsPath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
agents: {
|
||||||
|
"*": { allowlist: [{ pattern: "/bin/hostname" }] },
|
||||||
|
main: { allowlist: [{ pattern: "/usr/bin/uname" }] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolved = resolveExecApprovals("main");
|
||||||
|
expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual([
|
||||||
|
"/bin/hostname",
|
||||||
|
"/usr/bin/uname",
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (prevOpenClawHome === undefined) {
|
||||||
|
delete process.env.OPENCLAW_HOME;
|
||||||
|
} else {
|
||||||
|
process.env.OPENCLAW_HOME = prevOpenClawHome;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exec approvals node host allowlist check", () => {
|
||||||
|
// These tests verify the allowlist satisfaction logic used by the node host path
|
||||||
|
// The node host checks: matchAllowlist() || isSafeBinUsage() for each command segment
|
||||||
|
// Using hardcoded resolution objects for cross-platform compatibility
|
||||||
|
|
||||||
|
it("matches exact and wildcard allowlist patterns", () => {
|
||||||
|
const cases: Array<{
|
||||||
|
resolution: { rawExecutable: string; resolvedPath: string; executableName: string };
|
||||||
|
entries: ExecAllowlistEntry[];
|
||||||
|
expectedPattern: string | null;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
resolution: {
|
||||||
|
rawExecutable: "python3",
|
||||||
|
resolvedPath: "/usr/bin/python3",
|
||||||
|
executableName: "python3",
|
||||||
|
},
|
||||||
|
entries: [{ pattern: "/usr/bin/python3" }],
|
||||||
|
expectedPattern: "/usr/bin/python3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Simulates symlink resolution:
|
||||||
|
// /opt/homebrew/bin/python3 -> /opt/homebrew/opt/python@3.14/bin/python3.14
|
||||||
|
resolution: {
|
||||||
|
rawExecutable: "python3",
|
||||||
|
resolvedPath: "/opt/homebrew/opt/python@3.14/bin/python3.14",
|
||||||
|
executableName: "python3.14",
|
||||||
|
},
|
||||||
|
entries: [{ pattern: "/opt/**/python*" }],
|
||||||
|
expectedPattern: "/opt/**/python*",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resolution: {
|
||||||
|
rawExecutable: "unknown-tool",
|
||||||
|
resolvedPath: "/usr/local/bin/unknown-tool",
|
||||||
|
executableName: "unknown-tool",
|
||||||
|
},
|
||||||
|
entries: [{ pattern: "/usr/bin/python3" }, { pattern: "/opt/**/node" }],
|
||||||
|
expectedPattern: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const testCase of cases) {
|
||||||
|
const match = matchAllowlist(testCase.entries, testCase.resolution);
|
||||||
|
expect(match?.pattern ?? null).toBe(testCase.expectedPattern);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat unknown tools as safe bins", () => {
|
||||||
|
const resolution = {
|
||||||
|
rawExecutable: "unknown-tool",
|
||||||
|
resolvedPath: "/usr/local/bin/unknown-tool",
|
||||||
|
executableName: "unknown-tool",
|
||||||
|
};
|
||||||
|
const safe = isSafeBinUsage({
|
||||||
|
argv: ["unknown-tool", "--help"],
|
||||||
|
resolution,
|
||||||
|
safeBins: normalizeSafeBins(["jq", "curl"]),
|
||||||
|
});
|
||||||
|
expect(safe).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("satisfies via safeBins even when not in allowlist", () => {
|
||||||
|
const resolution = {
|
||||||
|
rawExecutable: "jq",
|
||||||
|
resolvedPath: "/usr/bin/jq",
|
||||||
|
executableName: "jq",
|
||||||
|
};
|
||||||
|
// Not in allowlist
|
||||||
|
const entries: ExecAllowlistEntry[] = [{ pattern: "/usr/bin/python3" }];
|
||||||
|
const match = matchAllowlist(entries, resolution);
|
||||||
|
expect(match).toBeNull();
|
||||||
|
|
||||||
|
// But is a safe bin with non-file args
|
||||||
|
const safe = isSafeBinUsage({
|
||||||
|
argv: ["jq", ".foo"],
|
||||||
|
resolution,
|
||||||
|
safeBins: normalizeSafeBins(["jq"]),
|
||||||
|
});
|
||||||
|
// Safe bins are disabled on Windows (PowerShell parsing/expansion differences).
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
expect(safe).toBe(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(safe).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exec approvals default agent migration", () => {
|
||||||
|
it("migrates legacy default agent entries to main", () => {
|
||||||
|
const file: ExecApprovalsFile = {
|
||||||
|
version: 1,
|
||||||
|
agents: {
|
||||||
|
default: { allowlist: [{ pattern: "/bin/legacy" }] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const resolved = resolveExecApprovalsFromFile({ file });
|
||||||
|
expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual(["/bin/legacy"]);
|
||||||
|
expect(resolved.file.agents?.default).toBeUndefined();
|
||||||
|
expect(resolved.file.agents?.main?.allowlist?.[0]?.pattern).toBe("/bin/legacy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers main agent settings when both main and default exist", () => {
|
||||||
|
const file: ExecApprovalsFile = {
|
||||||
|
version: 1,
|
||||||
|
agents: {
|
||||||
|
main: { ask: "always", allowlist: [{ pattern: "/bin/main" }] },
|
||||||
|
default: { ask: "off", allowlist: [{ pattern: "/bin/legacy" }] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const resolved = resolveExecApprovalsFromFile({ file });
|
||||||
|
expect(resolved.agent.ask).toBe("always");
|
||||||
|
expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual(["/bin/main", "/bin/legacy"]);
|
||||||
|
expect(resolved.file.agents?.default).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeExecApprovals handles string allowlist entries (#9790)", () => {
|
||||||
|
function getMainAllowlistPatterns(file: ExecApprovalsFile): string[] | undefined {
|
||||||
|
const normalized = normalizeExecApprovals(file);
|
||||||
|
return normalized.agents?.main?.allowlist?.map((entry) => entry.pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectNoSpreadStringArtifacts(entries: ExecAllowlistEntry[]) {
|
||||||
|
for (const entry of entries) {
|
||||||
|
expect(entry).toHaveProperty("pattern");
|
||||||
|
expect(typeof entry.pattern).toBe("string");
|
||||||
|
expect(entry.pattern.length).toBeGreaterThan(0);
|
||||||
|
expect(entry).not.toHaveProperty("0");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it("converts bare string entries to proper ExecAllowlistEntry objects", () => {
|
||||||
|
// Simulates a corrupted or legacy config where allowlist contains plain
|
||||||
|
// strings (e.g. ["ls", "cat"]) instead of { pattern: "..." } objects.
|
||||||
|
const file = {
|
||||||
|
version: 1,
|
||||||
|
agents: {
|
||||||
|
main: {
|
||||||
|
mode: "allowlist",
|
||||||
|
allowlist: ["things", "remindctl", "memo", "which", "ls", "cat", "echo"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as ExecApprovalsFile;
|
||||||
|
|
||||||
|
const normalized = normalizeExecApprovals(file);
|
||||||
|
const entries = normalized.agents?.main?.allowlist ?? [];
|
||||||
|
|
||||||
|
// Spread-string corruption would create numeric keys — ensure none exist.
|
||||||
|
expectNoSpreadStringArtifacts(entries);
|
||||||
|
|
||||||
|
expect(entries.map((e) => e.pattern)).toEqual([
|
||||||
|
"things",
|
||||||
|
"remindctl",
|
||||||
|
"memo",
|
||||||
|
"which",
|
||||||
|
"ls",
|
||||||
|
"cat",
|
||||||
|
"echo",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves proper ExecAllowlistEntry objects unchanged", () => {
|
||||||
|
const file: ExecApprovalsFile = {
|
||||||
|
version: 1,
|
||||||
|
agents: {
|
||||||
|
main: {
|
||||||
|
allowlist: [{ pattern: "/usr/bin/ls" }, { pattern: "/usr/bin/cat", id: "existing-id" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalized = normalizeExecApprovals(file);
|
||||||
|
const entries = normalized.agents?.main?.allowlist ?? [];
|
||||||
|
|
||||||
|
expect(entries).toHaveLength(2);
|
||||||
|
expect(entries[0]?.pattern).toBe("/usr/bin/ls");
|
||||||
|
expect(entries[1]?.pattern).toBe("/usr/bin/cat");
|
||||||
|
expect(entries[1]?.id).toBe("existing-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sanitizes mixed and malformed allowlist shapes", () => {
|
||||||
|
const cases: Array<{
|
||||||
|
name: string;
|
||||||
|
allowlist: unknown;
|
||||||
|
expectedPatterns: string[] | undefined;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
name: "mixed entries",
|
||||||
|
allowlist: ["ls", { pattern: "/usr/bin/cat" }, "echo"],
|
||||||
|
expectedPatterns: ["ls", "/usr/bin/cat", "echo"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty strings dropped",
|
||||||
|
allowlist: ["", " ", "ls"],
|
||||||
|
expectedPatterns: ["ls"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed objects dropped",
|
||||||
|
allowlist: [{ pattern: "/usr/bin/ls" }, {}, { pattern: 123 }, { pattern: " " }, "echo"],
|
||||||
|
expectedPatterns: ["/usr/bin/ls", "echo"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-array dropped",
|
||||||
|
allowlist: "ls",
|
||||||
|
expectedPatterns: undefined,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const testCase of cases) {
|
||||||
|
const patterns = getMainAllowlistPatterns({
|
||||||
|
version: 1,
|
||||||
|
agents: {
|
||||||
|
main: { allowlist: testCase.allowlist } as ExecApprovalsAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(patterns, testCase.name).toEqual(testCase.expectedPatterns);
|
||||||
|
if (patterns) {
|
||||||
|
const entries = normalizeExecApprovals({
|
||||||
|
version: 1,
|
||||||
|
agents: {
|
||||||
|
main: { allowlist: testCase.allowlist } as ExecApprovalsAgent,
|
||||||
|
},
|
||||||
|
}).agents?.main?.allowlist;
|
||||||
|
expectNoSpreadStringArtifacts(entries ?? []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
37
src/infra/exec-approvals-parity.test.ts
Normal file
37
src/infra/exec-approvals-parity.test.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
loadShellParserParityFixtureCases,
|
||||||
|
loadWrapperResolutionParityFixtureCases,
|
||||||
|
} from "./exec-approvals-test-helpers.js";
|
||||||
|
import { analyzeShellCommand, resolveCommandResolutionFromArgv } from "./exec-approvals.js";
|
||||||
|
|
||||||
|
describe("exec approvals shell parser parity fixture", () => {
|
||||||
|
const fixtures = loadShellParserParityFixtureCases();
|
||||||
|
|
||||||
|
for (const fixture of fixtures) {
|
||||||
|
it(`matches fixture: ${fixture.id}`, () => {
|
||||||
|
const res = analyzeShellCommand({ command: fixture.command });
|
||||||
|
expect(res.ok).toBe(fixture.ok);
|
||||||
|
if (fixture.ok) {
|
||||||
|
const executables = res.segments.map((segment) =>
|
||||||
|
path.basename(segment.argv[0] ?? "").toLowerCase(),
|
||||||
|
);
|
||||||
|
expect(executables).toEqual(fixture.executables.map((entry) => entry.toLowerCase()));
|
||||||
|
} else {
|
||||||
|
expect(res.segments).toHaveLength(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exec approvals wrapper resolution parity fixture", () => {
|
||||||
|
const fixtures = loadWrapperResolutionParityFixtureCases();
|
||||||
|
|
||||||
|
for (const fixture of fixtures) {
|
||||||
|
it(`matches wrapper fixture: ${fixture.id}`, () => {
|
||||||
|
const resolution = resolveCommandResolutionFromArgv(fixture.argv);
|
||||||
|
expect(resolution?.rawExecutable ?? null).toBe(fixture.expectedRawExecutable);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
409
src/infra/exec-approvals-safe-bins.test.ts
Normal file
409
src/infra/exec-approvals-safe-bins.test.ts
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { makePathEnv, makeTempDir } from "./exec-approvals-test-helpers.js";
|
||||||
|
import {
|
||||||
|
evaluateExecAllowlist,
|
||||||
|
evaluateShellAllowlist,
|
||||||
|
isSafeBinUsage,
|
||||||
|
normalizeSafeBins,
|
||||||
|
resolveSafeBins,
|
||||||
|
} from "./exec-approvals.js";
|
||||||
|
import {
|
||||||
|
SAFE_BIN_PROFILE_FIXTURES,
|
||||||
|
SAFE_BIN_PROFILES,
|
||||||
|
resolveSafeBinProfiles,
|
||||||
|
} from "./exec-safe-bin-policy.js";
|
||||||
|
|
||||||
|
describe("exec approvals safe bins", () => {
|
||||||
|
type SafeBinCase = {
|
||||||
|
name: string;
|
||||||
|
argv: string[];
|
||||||
|
resolvedPath: string;
|
||||||
|
expected: boolean;
|
||||||
|
safeBins?: string[];
|
||||||
|
executableName?: string;
|
||||||
|
rawExecutable?: string;
|
||||||
|
cwd?: string;
|
||||||
|
setup?: (cwd: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildDeniedFlagVariantCases(params: {
|
||||||
|
executableName: string;
|
||||||
|
resolvedPath: string;
|
||||||
|
safeBins?: string[];
|
||||||
|
flag: string;
|
||||||
|
takesValue: boolean;
|
||||||
|
label: string;
|
||||||
|
}): SafeBinCase[] {
|
||||||
|
const value = "blocked";
|
||||||
|
const argvVariants: string[][] = [];
|
||||||
|
if (!params.takesValue) {
|
||||||
|
argvVariants.push([params.executableName, params.flag]);
|
||||||
|
} else if (params.flag.startsWith("--")) {
|
||||||
|
argvVariants.push([params.executableName, `${params.flag}=${value}`]);
|
||||||
|
argvVariants.push([params.executableName, params.flag, value]);
|
||||||
|
} else if (params.flag.startsWith("-")) {
|
||||||
|
argvVariants.push([params.executableName, `${params.flag}${value}`]);
|
||||||
|
argvVariants.push([params.executableName, params.flag, value]);
|
||||||
|
} else {
|
||||||
|
argvVariants.push([params.executableName, params.flag, value]);
|
||||||
|
}
|
||||||
|
return argvVariants.map((argv) => ({
|
||||||
|
name: `${params.label} (${argv.slice(1).join(" ")})`,
|
||||||
|
argv,
|
||||||
|
resolvedPath: params.resolvedPath,
|
||||||
|
expected: false,
|
||||||
|
safeBins: params.safeBins ?? [params.executableName],
|
||||||
|
executableName: params.executableName,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const deniedFlagCases: SafeBinCase[] = [
|
||||||
|
...buildDeniedFlagVariantCases({
|
||||||
|
executableName: "sort",
|
||||||
|
resolvedPath: "/usr/bin/sort",
|
||||||
|
flag: "-o",
|
||||||
|
takesValue: true,
|
||||||
|
label: "blocks sort output flag",
|
||||||
|
}),
|
||||||
|
...buildDeniedFlagVariantCases({
|
||||||
|
executableName: "sort",
|
||||||
|
resolvedPath: "/usr/bin/sort",
|
||||||
|
flag: "--output",
|
||||||
|
takesValue: true,
|
||||||
|
label: "blocks sort output flag",
|
||||||
|
}),
|
||||||
|
...buildDeniedFlagVariantCases({
|
||||||
|
executableName: "sort",
|
||||||
|
resolvedPath: "/usr/bin/sort",
|
||||||
|
flag: "--compress-program",
|
||||||
|
takesValue: true,
|
||||||
|
label: "blocks sort external program flag",
|
||||||
|
}),
|
||||||
|
...buildDeniedFlagVariantCases({
|
||||||
|
executableName: "grep",
|
||||||
|
resolvedPath: "/usr/bin/grep",
|
||||||
|
flag: "-R",
|
||||||
|
takesValue: false,
|
||||||
|
label: "blocks grep recursive flag",
|
||||||
|
}),
|
||||||
|
...buildDeniedFlagVariantCases({
|
||||||
|
executableName: "grep",
|
||||||
|
resolvedPath: "/usr/bin/grep",
|
||||||
|
flag: "--recursive",
|
||||||
|
takesValue: false,
|
||||||
|
label: "blocks grep recursive flag",
|
||||||
|
}),
|
||||||
|
...buildDeniedFlagVariantCases({
|
||||||
|
executableName: "grep",
|
||||||
|
resolvedPath: "/usr/bin/grep",
|
||||||
|
flag: "--file",
|
||||||
|
takesValue: true,
|
||||||
|
label: "blocks grep file-pattern flag",
|
||||||
|
}),
|
||||||
|
...buildDeniedFlagVariantCases({
|
||||||
|
executableName: "jq",
|
||||||
|
resolvedPath: "/usr/bin/jq",
|
||||||
|
flag: "-f",
|
||||||
|
takesValue: true,
|
||||||
|
label: "blocks jq file-program flag",
|
||||||
|
}),
|
||||||
|
...buildDeniedFlagVariantCases({
|
||||||
|
executableName: "jq",
|
||||||
|
resolvedPath: "/usr/bin/jq",
|
||||||
|
flag: "--from-file",
|
||||||
|
takesValue: true,
|
||||||
|
label: "blocks jq file-program flag",
|
||||||
|
}),
|
||||||
|
...buildDeniedFlagVariantCases({
|
||||||
|
executableName: "wc",
|
||||||
|
resolvedPath: "/usr/bin/wc",
|
||||||
|
flag: "--files0-from",
|
||||||
|
takesValue: true,
|
||||||
|
label: "blocks wc file-list flag",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const cases: SafeBinCase[] = [
|
||||||
|
{
|
||||||
|
name: "allows safe bins with non-path args",
|
||||||
|
argv: ["jq", ".foo"],
|
||||||
|
resolvedPath: "/usr/bin/jq",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blocks safe bins with file args",
|
||||||
|
argv: ["jq", ".foo", "secret.json"],
|
||||||
|
resolvedPath: "/usr/bin/jq",
|
||||||
|
expected: false,
|
||||||
|
setup: (cwd) => fs.writeFileSync(path.join(cwd, "secret.json"), "{}"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blocks safe bins resolved from untrusted directories",
|
||||||
|
argv: ["jq", ".foo"],
|
||||||
|
resolvedPath: "/tmp/evil-bin/jq",
|
||||||
|
expected: false,
|
||||||
|
cwd: "/tmp",
|
||||||
|
},
|
||||||
|
...deniedFlagCases,
|
||||||
|
{
|
||||||
|
name: "blocks grep file positional when pattern uses -e",
|
||||||
|
argv: ["grep", "-e", "needle", ".env"],
|
||||||
|
resolvedPath: "/usr/bin/grep",
|
||||||
|
expected: false,
|
||||||
|
safeBins: ["grep"],
|
||||||
|
executableName: "grep",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blocks grep file positional after -- terminator",
|
||||||
|
argv: ["grep", "-e", "needle", "--", ".env"],
|
||||||
|
resolvedPath: "/usr/bin/grep",
|
||||||
|
expected: false,
|
||||||
|
safeBins: ["grep"],
|
||||||
|
executableName: "grep",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const testCase of cases) {
|
||||||
|
it(testCase.name, () => {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cwd = testCase.cwd ?? makeTempDir();
|
||||||
|
testCase.setup?.(cwd);
|
||||||
|
const executableName = testCase.executableName ?? "jq";
|
||||||
|
const rawExecutable = testCase.rawExecutable ?? executableName;
|
||||||
|
const ok = isSafeBinUsage({
|
||||||
|
argv: testCase.argv,
|
||||||
|
resolution: {
|
||||||
|
rawExecutable,
|
||||||
|
resolvedPath: testCase.resolvedPath,
|
||||||
|
executableName,
|
||||||
|
},
|
||||||
|
safeBins: normalizeSafeBins(testCase.safeBins ?? [executableName]),
|
||||||
|
});
|
||||||
|
expect(ok).toBe(testCase.expected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("supports injected trusted safe-bin dirs for tests/callers", () => {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ok = isSafeBinUsage({
|
||||||
|
argv: ["jq", ".foo"],
|
||||||
|
resolution: {
|
||||||
|
rawExecutable: "jq",
|
||||||
|
resolvedPath: "/custom/bin/jq",
|
||||||
|
executableName: "jq",
|
||||||
|
},
|
||||||
|
safeBins: normalizeSafeBins(["jq"]),
|
||||||
|
trustedSafeBinDirs: new Set(["/custom/bin"]),
|
||||||
|
});
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports injected platform for deterministic safe-bin checks", () => {
|
||||||
|
const ok = isSafeBinUsage({
|
||||||
|
argv: ["jq", ".foo"],
|
||||||
|
resolution: {
|
||||||
|
rawExecutable: "jq",
|
||||||
|
resolvedPath: "/usr/bin/jq",
|
||||||
|
executableName: "jq",
|
||||||
|
},
|
||||||
|
safeBins: normalizeSafeBins(["jq"]),
|
||||||
|
platform: "win32",
|
||||||
|
});
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports injected trusted path checker for deterministic callers", () => {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const baseParams = {
|
||||||
|
argv: ["jq", ".foo"],
|
||||||
|
resolution: {
|
||||||
|
rawExecutable: "jq",
|
||||||
|
resolvedPath: "/tmp/custom/jq",
|
||||||
|
executableName: "jq",
|
||||||
|
},
|
||||||
|
safeBins: normalizeSafeBins(["jq"]),
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
isSafeBinUsage({
|
||||||
|
...baseParams,
|
||||||
|
isTrustedSafeBinPathFn: () => true,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isSafeBinUsage({
|
||||||
|
...baseParams,
|
||||||
|
isTrustedSafeBinPathFn: () => false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps safe-bin profile fixtures aligned with compiled profiles", () => {
|
||||||
|
for (const [name, fixture] of Object.entries(SAFE_BIN_PROFILE_FIXTURES)) {
|
||||||
|
const profile = SAFE_BIN_PROFILES[name];
|
||||||
|
expect(profile).toBeDefined();
|
||||||
|
const fixtureDeniedFlags = fixture.deniedFlags ?? [];
|
||||||
|
const compiledDeniedFlags = profile?.deniedFlags ?? new Set<string>();
|
||||||
|
for (const deniedFlag of fixtureDeniedFlags) {
|
||||||
|
expect(compiledDeniedFlags.has(deniedFlag)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(Array.from(compiledDeniedFlags).toSorted()).toEqual(
|
||||||
|
[...fixtureDeniedFlags].toSorted(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not include sort/grep in default safeBins", () => {
|
||||||
|
const defaults = resolveSafeBins(undefined);
|
||||||
|
expect(defaults.has("jq")).toBe(true);
|
||||||
|
expect(defaults.has("sort")).toBe(false);
|
||||||
|
expect(defaults.has("grep")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not auto-allow unprofiled safe-bin entries", () => {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = evaluateShellAllowlist({
|
||||||
|
command: "python3 -c \"print('owned')\"",
|
||||||
|
allowlist: [],
|
||||||
|
safeBins: normalizeSafeBins(["python3"]),
|
||||||
|
cwd: "/tmp",
|
||||||
|
});
|
||||||
|
expect(result.analysisOk).toBe(true);
|
||||||
|
expect(result.allowlistSatisfied).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows caller-defined custom safe-bin profiles", () => {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const safeBinProfiles = resolveSafeBinProfiles({
|
||||||
|
echo: {
|
||||||
|
maxPositional: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const allow = isSafeBinUsage({
|
||||||
|
argv: ["echo", "hello"],
|
||||||
|
resolution: {
|
||||||
|
rawExecutable: "echo",
|
||||||
|
resolvedPath: "/bin/echo",
|
||||||
|
executableName: "echo",
|
||||||
|
},
|
||||||
|
safeBins: normalizeSafeBins(["echo"]),
|
||||||
|
safeBinProfiles,
|
||||||
|
});
|
||||||
|
const deny = isSafeBinUsage({
|
||||||
|
argv: ["echo", "hello", "world"],
|
||||||
|
resolution: {
|
||||||
|
rawExecutable: "echo",
|
||||||
|
resolvedPath: "/bin/echo",
|
||||||
|
executableName: "echo",
|
||||||
|
},
|
||||||
|
safeBins: normalizeSafeBins(["echo"]),
|
||||||
|
safeBinProfiles,
|
||||||
|
});
|
||||||
|
expect(allow).toBe(true);
|
||||||
|
expect(deny).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks sort output flags independent of file existence", () => {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cwd = makeTempDir();
|
||||||
|
fs.writeFileSync(path.join(cwd, "existing.txt"), "x");
|
||||||
|
const resolution = {
|
||||||
|
rawExecutable: "sort",
|
||||||
|
resolvedPath: "/usr/bin/sort",
|
||||||
|
executableName: "sort",
|
||||||
|
};
|
||||||
|
const safeBins = normalizeSafeBins(["sort"]);
|
||||||
|
const existing = isSafeBinUsage({
|
||||||
|
argv: ["sort", "-o", "existing.txt"],
|
||||||
|
resolution,
|
||||||
|
safeBins,
|
||||||
|
});
|
||||||
|
const missing = isSafeBinUsage({
|
||||||
|
argv: ["sort", "-o", "missing.txt"],
|
||||||
|
resolution,
|
||||||
|
safeBins,
|
||||||
|
});
|
||||||
|
const longFlag = isSafeBinUsage({
|
||||||
|
argv: ["sort", "--output=missing.txt"],
|
||||||
|
resolution,
|
||||||
|
safeBins,
|
||||||
|
});
|
||||||
|
expect(existing).toBe(false);
|
||||||
|
expect(missing).toBe(false);
|
||||||
|
expect(longFlag).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("threads trusted safe-bin dirs through allowlist evaluation", () => {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const analysis = {
|
||||||
|
ok: true as const,
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
raw: "jq .foo",
|
||||||
|
argv: ["jq", ".foo"],
|
||||||
|
resolution: {
|
||||||
|
rawExecutable: "jq",
|
||||||
|
resolvedPath: "/custom/bin/jq",
|
||||||
|
executableName: "jq",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const denied = evaluateExecAllowlist({
|
||||||
|
analysis,
|
||||||
|
allowlist: [],
|
||||||
|
safeBins: normalizeSafeBins(["jq"]),
|
||||||
|
trustedSafeBinDirs: new Set(["/usr/bin"]),
|
||||||
|
cwd: "/tmp",
|
||||||
|
});
|
||||||
|
expect(denied.allowlistSatisfied).toBe(false);
|
||||||
|
|
||||||
|
const allowed = evaluateExecAllowlist({
|
||||||
|
analysis,
|
||||||
|
allowlist: [],
|
||||||
|
safeBins: normalizeSafeBins(["jq"]),
|
||||||
|
trustedSafeBinDirs: new Set(["/custom/bin"]),
|
||||||
|
cwd: "/tmp",
|
||||||
|
});
|
||||||
|
expect(allowed.allowlistSatisfied).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not auto-trust PATH-shadowed safe bins without explicit trusted dirs", () => {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tmp = makeTempDir();
|
||||||
|
const fakeDir = path.join(tmp, "fake-bin");
|
||||||
|
fs.mkdirSync(fakeDir, { recursive: true });
|
||||||
|
const fakeHead = path.join(fakeDir, "head");
|
||||||
|
fs.writeFileSync(fakeHead, "#!/bin/sh\nexit 0\n");
|
||||||
|
fs.chmodSync(fakeHead, 0o755);
|
||||||
|
|
||||||
|
const result = evaluateShellAllowlist({
|
||||||
|
command: "head -n 1",
|
||||||
|
allowlist: [],
|
||||||
|
safeBins: normalizeSafeBins(["head"]),
|
||||||
|
env: makePathEnv(fakeDir),
|
||||||
|
cwd: tmp,
|
||||||
|
});
|
||||||
|
expect(result.analysisOk).toBe(true);
|
||||||
|
expect(result.allowlistSatisfied).toBe(false);
|
||||||
|
expect(result.segmentSatisfiedBy).toEqual([null]);
|
||||||
|
expect(result.segments[0]?.resolution?.resolvedPath).toBe(fakeHead);
|
||||||
|
});
|
||||||
|
});
|
||||||
59
src/infra/exec-approvals-test-helpers.ts
Normal file
59
src/infra/exec-approvals-test-helpers.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export function makePathEnv(binDir: string): NodeJS.ProcessEnv {
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
return { PATH: binDir };
|
||||||
|
}
|
||||||
|
return { PATH: binDir, PATHEXT: ".EXE;.CMD;.BAT;.COM" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeTempDir(): string {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-exec-approvals-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ShellParserParityFixtureCase = {
|
||||||
|
id: string;
|
||||||
|
command: string;
|
||||||
|
ok: boolean;
|
||||||
|
executables: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ShellParserParityFixture = {
|
||||||
|
cases: ShellParserParityFixtureCase[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WrapperResolutionParityFixtureCase = {
|
||||||
|
id: string;
|
||||||
|
argv: string[];
|
||||||
|
expectedRawExecutable: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WrapperResolutionParityFixture = {
|
||||||
|
cases: WrapperResolutionParityFixtureCase[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loadShellParserParityFixtureCases(): ShellParserParityFixtureCase[] {
|
||||||
|
const fixturePath = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
"test",
|
||||||
|
"fixtures",
|
||||||
|
"exec-allowlist-shell-parser-parity.json",
|
||||||
|
);
|
||||||
|
const fixture = JSON.parse(fs.readFileSync(fixturePath, "utf8")) as ShellParserParityFixture;
|
||||||
|
return fixture.cases;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadWrapperResolutionParityFixtureCases(): WrapperResolutionParityFixtureCase[] {
|
||||||
|
const fixturePath = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
"test",
|
||||||
|
"fixtures",
|
||||||
|
"exec-wrapper-resolution-parity.json",
|
||||||
|
);
|
||||||
|
const fixture = JSON.parse(
|
||||||
|
fs.readFileSync(fixturePath, "utf8"),
|
||||||
|
) as WrapperResolutionParityFixture;
|
||||||
|
return fixture.cases;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,32 +2,51 @@ import path from "node:path";
|
|||||||
|
|
||||||
export const MAX_DISPATCH_WRAPPER_DEPTH = 4;
|
export const MAX_DISPATCH_WRAPPER_DEPTH = 4;
|
||||||
|
|
||||||
export const POSIX_SHELL_WRAPPERS = new Set(["ash", "bash", "dash", "fish", "ksh", "sh", "zsh"]);
|
const WINDOWS_EXE_SUFFIX = ".exe";
|
||||||
export const WINDOWS_CMD_WRAPPERS = new Set(["cmd.exe", "cmd"]);
|
|
||||||
export const POWERSHELL_WRAPPERS = new Set(["powershell", "powershell.exe", "pwsh", "pwsh.exe"]);
|
const POSIX_SHELL_WRAPPER_NAMES = ["ash", "bash", "dash", "fish", "ksh", "sh", "zsh"] as const;
|
||||||
export const DISPATCH_WRAPPER_EXECUTABLES = new Set([
|
const WINDOWS_CMD_WRAPPER_NAMES = ["cmd"] as const;
|
||||||
|
const POWERSHELL_WRAPPER_NAMES = ["powershell", "pwsh"] as const;
|
||||||
|
const DISPATCH_WRAPPER_NAMES = [
|
||||||
"chrt",
|
"chrt",
|
||||||
"chrt.exe",
|
|
||||||
"doas",
|
"doas",
|
||||||
"doas.exe",
|
|
||||||
"env",
|
"env",
|
||||||
"env.exe",
|
|
||||||
"ionice",
|
"ionice",
|
||||||
"ionice.exe",
|
|
||||||
"nice",
|
"nice",
|
||||||
"nice.exe",
|
|
||||||
"nohup",
|
"nohup",
|
||||||
"nohup.exe",
|
|
||||||
"setsid",
|
"setsid",
|
||||||
"setsid.exe",
|
|
||||||
"stdbuf",
|
"stdbuf",
|
||||||
"stdbuf.exe",
|
|
||||||
"sudo",
|
"sudo",
|
||||||
"sudo.exe",
|
|
||||||
"taskset",
|
"taskset",
|
||||||
"taskset.exe",
|
|
||||||
"timeout",
|
"timeout",
|
||||||
"timeout.exe",
|
] as const;
|
||||||
|
|
||||||
|
function withWindowsExeAliases(names: readonly string[]): string[] {
|
||||||
|
const expanded = new Set<string>();
|
||||||
|
for (const name of names) {
|
||||||
|
expanded.add(name);
|
||||||
|
expanded.add(`${name}${WINDOWS_EXE_SUFFIX}`);
|
||||||
|
}
|
||||||
|
return Array.from(expanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripWindowsExeSuffix(value: string): string {
|
||||||
|
return value.endsWith(WINDOWS_EXE_SUFFIX) ? value.slice(0, -WINDOWS_EXE_SUFFIX.length) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POSIX_SHELL_WRAPPERS = new Set(POSIX_SHELL_WRAPPER_NAMES);
|
||||||
|
export const WINDOWS_CMD_WRAPPERS = new Set(withWindowsExeAliases(WINDOWS_CMD_WRAPPER_NAMES));
|
||||||
|
export const POWERSHELL_WRAPPERS = new Set(withWindowsExeAliases(POWERSHELL_WRAPPER_NAMES));
|
||||||
|
export const DISPATCH_WRAPPER_EXECUTABLES = new Set(withWindowsExeAliases(DISPATCH_WRAPPER_NAMES));
|
||||||
|
|
||||||
|
const POSIX_SHELL_WRAPPER_CANONICAL = new Set<string>(POSIX_SHELL_WRAPPER_NAMES);
|
||||||
|
const WINDOWS_CMD_WRAPPER_CANONICAL = new Set<string>(WINDOWS_CMD_WRAPPER_NAMES);
|
||||||
|
const POWERSHELL_WRAPPER_CANONICAL = new Set<string>(POWERSHELL_WRAPPER_NAMES);
|
||||||
|
const DISPATCH_WRAPPER_CANONICAL = new Set<string>(DISPATCH_WRAPPER_NAMES);
|
||||||
|
const SHELL_WRAPPER_CANONICAL = new Set<string>([
|
||||||
|
...POSIX_SHELL_WRAPPER_NAMES,
|
||||||
|
...WINDOWS_CMD_WRAPPER_NAMES,
|
||||||
|
...POWERSHELL_WRAPPER_NAMES,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const POSIX_INLINE_COMMAND_FLAGS = new Set(["-lc", "-c", "--command"]);
|
const POSIX_INLINE_COMMAND_FLAGS = new Set(["-lc", "-c", "--command"]);
|
||||||
@@ -58,9 +77,9 @@ type ShellWrapperSpec = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SHELL_WRAPPER_SPECS: ReadonlyArray<ShellWrapperSpec> = [
|
const SHELL_WRAPPER_SPECS: ReadonlyArray<ShellWrapperSpec> = [
|
||||||
{ kind: "posix", names: POSIX_SHELL_WRAPPERS },
|
{ kind: "posix", names: POSIX_SHELL_WRAPPER_CANONICAL },
|
||||||
{ kind: "cmd", names: WINDOWS_CMD_WRAPPERS },
|
{ kind: "cmd", names: WINDOWS_CMD_WRAPPER_CANONICAL },
|
||||||
{ kind: "powershell", names: POWERSHELL_WRAPPERS },
|
{ kind: "powershell", names: POWERSHELL_WRAPPER_CANONICAL },
|
||||||
];
|
];
|
||||||
|
|
||||||
export type ShellWrapperCommand = {
|
export type ShellWrapperCommand = {
|
||||||
@@ -75,14 +94,27 @@ export function basenameLower(token: string): string {
|
|||||||
return base.trim().toLowerCase();
|
return base.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeExecutableToken(token: string): string {
|
||||||
|
return stripWindowsExeSuffix(basenameLower(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDispatchWrapperExecutable(token: string): boolean {
|
||||||
|
return DISPATCH_WRAPPER_CANONICAL.has(normalizeExecutableToken(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isShellWrapperExecutable(token: string): boolean {
|
||||||
|
return SHELL_WRAPPER_CANONICAL.has(normalizeExecutableToken(token));
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRawCommand(rawCommand?: string | null): string | null {
|
function normalizeRawCommand(rawCommand?: string | null): string | null {
|
||||||
const trimmed = rawCommand?.trim() ?? "";
|
const trimmed = rawCommand?.trim() ?? "";
|
||||||
return trimmed.length > 0 ? trimmed : null;
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findShellWrapperSpec(baseExecutable: string): ShellWrapperSpec | null {
|
function findShellWrapperSpec(baseExecutable: string): ShellWrapperSpec | null {
|
||||||
|
const canonicalBase = stripWindowsExeSuffix(baseExecutable);
|
||||||
for (const spec of SHELL_WRAPPER_SPECS) {
|
for (const spec of SHELL_WRAPPER_SPECS) {
|
||||||
if (spec.names.has(baseExecutable)) {
|
if (spec.names.has(canonicalBase)) {
|
||||||
return spec;
|
return spec;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,7 +125,16 @@ export function isEnvAssignment(token: string): boolean {
|
|||||||
return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token);
|
return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unwrapEnvInvocation(argv: string[]): string[] | null {
|
type WrapperScanDirective = "continue" | "consume-next" | "stop" | "invalid";
|
||||||
|
|
||||||
|
function scanWrapperInvocation(
|
||||||
|
argv: string[],
|
||||||
|
params: {
|
||||||
|
separators?: ReadonlySet<string>;
|
||||||
|
onToken: (token: string, lowerToken: string) => WrapperScanDirective;
|
||||||
|
adjustCommandIndex?: (commandIndex: number, argv: string[]) => number | null;
|
||||||
|
},
|
||||||
|
): string[] | null {
|
||||||
let idx = 1;
|
let idx = 1;
|
||||||
let expectsOptionValue = false;
|
let expectsOptionValue = false;
|
||||||
while (idx < argv.length) {
|
while (idx < argv.length) {
|
||||||
@@ -107,27 +148,48 @@ export function unwrapEnvInvocation(argv: string[]): string[] | null {
|
|||||||
idx += 1;
|
idx += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (token === "--" || token === "-") {
|
if (params.separators?.has(token)) {
|
||||||
idx += 1;
|
idx += 1;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (isEnvAssignment(token)) {
|
const directive = params.onToken(token, token.toLowerCase());
|
||||||
idx += 1;
|
if (directive === "stop") {
|
||||||
continue;
|
break;
|
||||||
}
|
}
|
||||||
if (token.startsWith("-") && token !== "-") {
|
if (directive === "invalid") {
|
||||||
const lower = token.toLowerCase();
|
return null;
|
||||||
|
}
|
||||||
|
if (directive === "consume-next") {
|
||||||
|
expectsOptionValue = true;
|
||||||
|
}
|
||||||
|
idx += 1;
|
||||||
|
}
|
||||||
|
if (expectsOptionValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const commandIndex = params.adjustCommandIndex ? params.adjustCommandIndex(idx, argv) : idx;
|
||||||
|
if (commandIndex === null || commandIndex >= argv.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return argv.slice(commandIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unwrapEnvInvocation(argv: string[]): string[] | null {
|
||||||
|
return scanWrapperInvocation(argv, {
|
||||||
|
separators: new Set(["--", "-"]),
|
||||||
|
onToken: (token, lower) => {
|
||||||
|
if (isEnvAssignment(token)) {
|
||||||
|
return "continue";
|
||||||
|
}
|
||||||
|
if (!token.startsWith("-") || token === "-") {
|
||||||
|
return "stop";
|
||||||
|
}
|
||||||
const [flag] = lower.split("=", 2);
|
const [flag] = lower.split("=", 2);
|
||||||
if (ENV_FLAG_OPTIONS.has(flag)) {
|
if (ENV_FLAG_OPTIONS.has(flag)) {
|
||||||
idx += 1;
|
return "continue";
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
if (ENV_OPTIONS_WITH_VALUE.has(flag)) {
|
if (ENV_OPTIONS_WITH_VALUE.has(flag)) {
|
||||||
if (!lower.includes("=")) {
|
return lower.includes("=") ? "continue" : "consume-next";
|
||||||
expectsOptionValue = true;
|
|
||||||
}
|
|
||||||
idx += 1;
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
lower.startsWith("-u") ||
|
lower.startsWith("-u") ||
|
||||||
@@ -140,195 +202,131 @@ export function unwrapEnvInvocation(argv: string[]): string[] | null {
|
|||||||
lower.startsWith("--ignore-signal=") ||
|
lower.startsWith("--ignore-signal=") ||
|
||||||
lower.startsWith("--block-signal=")
|
lower.startsWith("--block-signal=")
|
||||||
) {
|
) {
|
||||||
idx += 1;
|
return "continue";
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
return null;
|
return "invalid";
|
||||||
}
|
},
|
||||||
break;
|
});
|
||||||
}
|
|
||||||
return idx < argv.length ? argv.slice(idx) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function unwrapNiceInvocation(argv: string[]): string[] | null {
|
function unwrapNiceInvocation(argv: string[]): string[] | null {
|
||||||
let idx = 1;
|
return scanWrapperInvocation(argv, {
|
||||||
let expectsOptionValue = false;
|
separators: new Set(["--"]),
|
||||||
while (idx < argv.length) {
|
onToken: (token, lower) => {
|
||||||
const token = argv[idx]?.trim() ?? "";
|
if (!token.startsWith("-") || token === "-") {
|
||||||
if (!token) {
|
return "stop";
|
||||||
idx += 1;
|
}
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (expectsOptionValue) {
|
|
||||||
expectsOptionValue = false;
|
|
||||||
idx += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (token === "--") {
|
|
||||||
idx += 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (token.startsWith("-") && token !== "-") {
|
|
||||||
const lower = token.toLowerCase();
|
|
||||||
const [flag] = lower.split("=", 2);
|
const [flag] = lower.split("=", 2);
|
||||||
if (/^-\d+$/.test(lower)) {
|
if (/^-\d+$/.test(lower)) {
|
||||||
idx += 1;
|
return "continue";
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
if (NICE_OPTIONS_WITH_VALUE.has(flag)) {
|
if (NICE_OPTIONS_WITH_VALUE.has(flag)) {
|
||||||
if (!lower.includes("=") && lower === flag) {
|
return lower.includes("=") || lower !== flag ? "continue" : "consume-next";
|
||||||
expectsOptionValue = true;
|
|
||||||
}
|
|
||||||
idx += 1;
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
if (lower.startsWith("-n") && lower.length > 2) {
|
if (lower.startsWith("-n") && lower.length > 2) {
|
||||||
idx += 1;
|
return "continue";
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
return null;
|
return "invalid";
|
||||||
}
|
},
|
||||||
break;
|
});
|
||||||
}
|
|
||||||
if (expectsOptionValue) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return idx < argv.length ? argv.slice(idx) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function unwrapNohupInvocation(argv: string[]): string[] | null {
|
function unwrapNohupInvocation(argv: string[]): string[] | null {
|
||||||
let idx = 1;
|
return scanWrapperInvocation(argv, {
|
||||||
while (idx < argv.length) {
|
separators: new Set(["--"]),
|
||||||
const token = argv[idx]?.trim() ?? "";
|
onToken: (token, lower) => {
|
||||||
if (!token) {
|
if (!token.startsWith("-") || token === "-") {
|
||||||
idx += 1;
|
return "stop";
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (token === "--") {
|
|
||||||
idx += 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (token.startsWith("-") && token !== "-") {
|
|
||||||
const lower = token.toLowerCase();
|
|
||||||
if (lower === "--help" || lower === "--version") {
|
|
||||||
idx += 1;
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
return null;
|
return lower === "--help" || lower === "--version" ? "continue" : "invalid";
|
||||||
}
|
},
|
||||||
break;
|
});
|
||||||
}
|
|
||||||
return idx < argv.length ? argv.slice(idx) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function unwrapStdbufInvocation(argv: string[]): string[] | null {
|
function unwrapStdbufInvocation(argv: string[]): string[] | null {
|
||||||
let idx = 1;
|
return scanWrapperInvocation(argv, {
|
||||||
let expectsOptionValue = false;
|
separators: new Set(["--"]),
|
||||||
while (idx < argv.length) {
|
onToken: (token, lower) => {
|
||||||
const token = argv[idx]?.trim() ?? "";
|
if (!token.startsWith("-") || token === "-") {
|
||||||
if (!token) {
|
return "stop";
|
||||||
idx += 1;
|
}
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (expectsOptionValue) {
|
|
||||||
expectsOptionValue = false;
|
|
||||||
idx += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (token === "--") {
|
|
||||||
idx += 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (token.startsWith("-") && token !== "-") {
|
|
||||||
const lower = token.toLowerCase();
|
|
||||||
const [flag] = lower.split("=", 2);
|
const [flag] = lower.split("=", 2);
|
||||||
if (STDBUF_OPTIONS_WITH_VALUE.has(flag)) {
|
if (STDBUF_OPTIONS_WITH_VALUE.has(flag)) {
|
||||||
if (!lower.includes("=")) {
|
return lower.includes("=") ? "continue" : "consume-next";
|
||||||
expectsOptionValue = true;
|
|
||||||
}
|
|
||||||
idx += 1;
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
return null;
|
return "invalid";
|
||||||
}
|
},
|
||||||
break;
|
});
|
||||||
}
|
|
||||||
if (expectsOptionValue) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return idx < argv.length ? argv.slice(idx) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function unwrapTimeoutInvocation(argv: string[]): string[] | null {
|
function unwrapTimeoutInvocation(argv: string[]): string[] | null {
|
||||||
let idx = 1;
|
return scanWrapperInvocation(argv, {
|
||||||
let expectsOptionValue = false;
|
separators: new Set(["--"]),
|
||||||
while (idx < argv.length) {
|
onToken: (token, lower) => {
|
||||||
const token = argv[idx]?.trim() ?? "";
|
if (!token.startsWith("-") || token === "-") {
|
||||||
if (!token) {
|
return "stop";
|
||||||
idx += 1;
|
}
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (expectsOptionValue) {
|
|
||||||
expectsOptionValue = false;
|
|
||||||
idx += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (token === "--") {
|
|
||||||
idx += 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (token.startsWith("-") && token !== "-") {
|
|
||||||
const lower = token.toLowerCase();
|
|
||||||
const [flag] = lower.split("=", 2);
|
const [flag] = lower.split("=", 2);
|
||||||
if (TIMEOUT_FLAG_OPTIONS.has(flag)) {
|
if (TIMEOUT_FLAG_OPTIONS.has(flag)) {
|
||||||
idx += 1;
|
return "continue";
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
if (TIMEOUT_OPTIONS_WITH_VALUE.has(flag)) {
|
if (TIMEOUT_OPTIONS_WITH_VALUE.has(flag)) {
|
||||||
if (!lower.includes("=")) {
|
return lower.includes("=") ? "continue" : "consume-next";
|
||||||
expectsOptionValue = true;
|
|
||||||
}
|
|
||||||
idx += 1;
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
return null;
|
return "invalid";
|
||||||
}
|
},
|
||||||
break;
|
adjustCommandIndex: (commandIndex, currentArgv) => {
|
||||||
}
|
// timeout consumes a required duration token before the wrapped command.
|
||||||
if (expectsOptionValue || idx >= argv.length) {
|
const wrappedCommandIndex = commandIndex + 1;
|
||||||
return null;
|
return wrappedCommandIndex < currentArgv.length ? wrappedCommandIndex : null;
|
||||||
}
|
},
|
||||||
idx += 1; // duration
|
});
|
||||||
return idx < argv.length ? argv.slice(idx) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unwrapKnownDispatchWrapperInvocation(argv: string[]): string[] | null | undefined {
|
export type DispatchWrapperUnwrapResult =
|
||||||
|
| { kind: "not-wrapper" }
|
||||||
|
| { kind: "blocked"; wrapper: string }
|
||||||
|
| { kind: "unwrapped"; wrapper: string; argv: string[] };
|
||||||
|
|
||||||
|
function blockDispatchWrapper(wrapper: string): DispatchWrapperUnwrapResult {
|
||||||
|
return { kind: "blocked", wrapper };
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapDispatchWrapper(
|
||||||
|
wrapper: string,
|
||||||
|
unwrapped: string[] | null,
|
||||||
|
): DispatchWrapperUnwrapResult {
|
||||||
|
return unwrapped
|
||||||
|
? { kind: "unwrapped", wrapper, argv: unwrapped }
|
||||||
|
: blockDispatchWrapper(wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unwrapKnownDispatchWrapperInvocation(argv: string[]): DispatchWrapperUnwrapResult {
|
||||||
const token0 = argv[0]?.trim();
|
const token0 = argv[0]?.trim();
|
||||||
if (!token0) {
|
if (!token0) {
|
||||||
return undefined;
|
return { kind: "not-wrapper" };
|
||||||
}
|
}
|
||||||
const base = basenameLower(token0);
|
const wrapper = normalizeExecutableToken(token0);
|
||||||
const normalizedBase = base.endsWith(".exe") ? base.slice(0, -4) : base;
|
switch (wrapper) {
|
||||||
switch (normalizedBase) {
|
|
||||||
case "env":
|
case "env":
|
||||||
return unwrapEnvInvocation(argv);
|
return unwrapDispatchWrapper(wrapper, unwrapEnvInvocation(argv));
|
||||||
case "nice":
|
case "nice":
|
||||||
return unwrapNiceInvocation(argv);
|
return unwrapDispatchWrapper(wrapper, unwrapNiceInvocation(argv));
|
||||||
case "nohup":
|
case "nohup":
|
||||||
return unwrapNohupInvocation(argv);
|
return unwrapDispatchWrapper(wrapper, unwrapNohupInvocation(argv));
|
||||||
case "stdbuf":
|
case "stdbuf":
|
||||||
return unwrapStdbufInvocation(argv);
|
return unwrapDispatchWrapper(wrapper, unwrapStdbufInvocation(argv));
|
||||||
case "timeout":
|
case "timeout":
|
||||||
return unwrapTimeoutInvocation(argv);
|
return unwrapDispatchWrapper(wrapper, unwrapTimeoutInvocation(argv));
|
||||||
case "chrt":
|
case "chrt":
|
||||||
case "doas":
|
case "doas":
|
||||||
case "ionice":
|
case "ionice":
|
||||||
case "setsid":
|
case "setsid":
|
||||||
case "sudo":
|
case "sudo":
|
||||||
case "taskset":
|
case "taskset":
|
||||||
return null;
|
return blockDispatchWrapper(wrapper);
|
||||||
default:
|
default:
|
||||||
return undefined;
|
return { kind: "not-wrapper" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,32 +336,47 @@ export function unwrapDispatchWrappersForResolution(
|
|||||||
): string[] {
|
): string[] {
|
||||||
let current = argv;
|
let current = argv;
|
||||||
for (let depth = 0; depth < maxDepth; depth += 1) {
|
for (let depth = 0; depth < maxDepth; depth += 1) {
|
||||||
const unwrapped = unwrapKnownDispatchWrapperInvocation(current);
|
const unwrap = unwrapKnownDispatchWrapperInvocation(current);
|
||||||
if (unwrapped === undefined) {
|
if (unwrap.kind !== "unwrapped" || unwrap.argv.length === 0) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (!unwrapped || unwrapped.length === 0) {
|
current = unwrap.argv;
|
||||||
break;
|
|
||||||
}
|
|
||||||
current = unwrapped;
|
|
||||||
}
|
}
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractPosixShellInlineCommand(argv: string[]): string | null {
|
function extractPosixShellInlineCommand(argv: string[]): string | null {
|
||||||
const flag = argv[1]?.trim();
|
for (let i = 1; i < argv.length; i += 1) {
|
||||||
if (!flag) {
|
const token = argv[i]?.trim();
|
||||||
return null;
|
if (!token) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const lower = token.toLowerCase();
|
||||||
|
if (lower === "--") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (POSIX_INLINE_COMMAND_FLAGS.has(lower)) {
|
||||||
|
const cmd = argv[i + 1]?.trim();
|
||||||
|
return cmd ? cmd : null;
|
||||||
|
}
|
||||||
|
if (/^-[^-]*c[^-]*$/i.test(token)) {
|
||||||
|
const commandIndex = lower.indexOf("c");
|
||||||
|
const inline = token.slice(commandIndex + 1).trim();
|
||||||
|
if (inline) {
|
||||||
|
return inline;
|
||||||
|
}
|
||||||
|
const cmd = argv[i + 1]?.trim();
|
||||||
|
return cmd ? cmd : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!POSIX_INLINE_COMMAND_FLAGS.has(flag.toLowerCase())) {
|
return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const cmd = argv[2]?.trim();
|
|
||||||
return cmd ? cmd : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractCmdInlineCommand(argv: string[]): string | null {
|
function extractCmdInlineCommand(argv: string[]): string | null {
|
||||||
const idx = argv.findIndex((item) => item.trim().toLowerCase() === "/c");
|
const idx = argv.findIndex((item) => {
|
||||||
|
const token = item.trim().toLowerCase();
|
||||||
|
return token === "/c" || token === "/k";
|
||||||
|
});
|
||||||
if (idx === -1) {
|
if (idx === -1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -418,15 +431,15 @@ function extractShellWrapperCommandInternal(
|
|||||||
return { isWrapper: false, command: null };
|
return { isWrapper: false, command: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const base0 = basenameLower(token0);
|
const dispatchUnwrap = unwrapKnownDispatchWrapperInvocation(argv);
|
||||||
if (DISPATCH_WRAPPER_EXECUTABLES.has(base0)) {
|
if (dispatchUnwrap.kind === "blocked") {
|
||||||
const unwrapped = unwrapKnownDispatchWrapperInvocation(argv);
|
return { isWrapper: false, command: null };
|
||||||
if (!unwrapped) {
|
}
|
||||||
return { isWrapper: false, command: null };
|
if (dispatchUnwrap.kind === "unwrapped") {
|
||||||
}
|
return extractShellWrapperCommandInternal(dispatchUnwrap.argv, rawCommand, depth + 1);
|
||||||
return extractShellWrapperCommandInternal(unwrapped, rawCommand, depth + 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const base0 = normalizeExecutableToken(token0);
|
||||||
const wrapper = findShellWrapperSpec(base0);
|
const wrapper = findShellWrapperSpec(base0);
|
||||||
if (!wrapper) {
|
if (!wrapper) {
|
||||||
return { isWrapper: false, command: null };
|
return { isWrapper: false, command: null };
|
||||||
@@ -440,6 +453,11 @@ function extractShellWrapperCommandInternal(
|
|||||||
return { isWrapper: true, command: rawCommand ?? payload };
|
return { isWrapper: true, command: rawCommand ?? payload };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extractShellWrapperInlineCommand(argv: string[]): string | null {
|
||||||
|
const extracted = extractShellWrapperCommandInternal(argv, null, 0);
|
||||||
|
return extracted.isWrapper ? extracted.command : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function extractShellWrapperCommand(
|
export function extractShellWrapperCommand(
|
||||||
argv: string[],
|
argv: string[],
|
||||||
rawCommand?: string | null,
|
rawCommand?: string | null,
|
||||||
|
|||||||
Reference in New Issue
Block a user