mirror of https://github.com/openclaw/openclaw.git
392 lines
16 KiB
TypeScript
392 lines
16 KiB
TypeScript
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import type { AccessPolicyConfig, PermStr } from "../config/types.tools.js";
|
|
import { findBestRule } from "./access-policy.js";
|
|
import { shellEscape } from "./shell-escape.js";
|
|
|
|
/**
|
|
* Seatbelt (SBPL) profile generator for macOS sandbox-exec.
|
|
*
|
|
* Translates tools.fs.permissions into a Seatbelt profile so that exec commands
|
|
* run under OS-level path enforcement — catching variable-expanded paths like
|
|
* `cat $HOME/.ssh/id_rsa` that config-level heuristics cannot intercept.
|
|
*
|
|
* Precedence in generated profiles (matches AccessPolicyConfig semantics):
|
|
* 1. deny[] entries — placed last, always override rules.
|
|
* 2. rules — sorted shortest-to-longest so more specific rules overwrite broader ones.
|
|
* 3. System baseline — allows the process to load libraries and basic OS resources.
|
|
* 4. default — sets the base allow/deny for everything else.
|
|
*/
|
|
|
|
// SBPL operation names for each permission bit.
|
|
const SEATBELT_READ_OPS = "file-read*";
|
|
const SEATBELT_WRITE_OPS = "file-write*";
|
|
const SEATBELT_EXEC_OPS = "process-exec*";
|
|
|
|
// System paths every process needs to function (dynamic linker, stdlib, etc.).
|
|
// These are allowed for file-read* regardless of user rules so wrapped commands
|
|
// don't break when default is "---".
|
|
const SYSTEM_BASELINE_READ_PATHS = [
|
|
"/usr/lib",
|
|
"/usr/share",
|
|
"/System/Library",
|
|
"/Library/Frameworks",
|
|
"/private/var/db/timezone",
|
|
"/dev/null",
|
|
"/dev/random",
|
|
"/dev/urandom",
|
|
"/dev/fd",
|
|
] as const;
|
|
|
|
const SYSTEM_BASELINE_EXEC_PATHS = [
|
|
"/bin",
|
|
"/usr/bin",
|
|
"/usr/libexec",
|
|
"/System/Library/Frameworks",
|
|
] as const;
|
|
|
|
function escapeSubpath(p: string): string {
|
|
// SBPL strings use double-quote delimiters; escape embedded quotes and backslashes.
|
|
return p.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
}
|
|
|
|
function sbplSubpath(p: string): string {
|
|
return `(subpath "${escapeSubpath(p)}")`;
|
|
}
|
|
|
|
function sbplLiteral(p: string): string {
|
|
return `(literal "${escapeSubpath(p)}")`;
|
|
}
|
|
|
|
/**
|
|
* Resolve a path pattern to a concrete path for SBPL.
|
|
* Glob wildcards (**) are stripped to their longest non-wildcard prefix
|
|
* since SBPL uses subpath/literal matchers, not globs.
|
|
* e.g. "/Users/kaveri/**" → subpath("/Users/kaveri")
|
|
* "/usr/bin/grep" → literal("/usr/bin/grep")
|
|
*/
|
|
// macOS /private/* aliases — when a pattern covers /tmp, /var, or /etc we must
|
|
// also emit the /private/* form so seatbelt (which sees real paths) matches.
|
|
const SBPL_ALIAS_PAIRS: ReadonlyArray<[alias: string, real: string]> = [
|
|
["/tmp", "/private/tmp"],
|
|
["/var", "/private/var"],
|
|
["/etc", "/private/etc"],
|
|
];
|
|
|
|
/**
|
|
* Expand a pattern to include the /private/* equivalent if it starts with a
|
|
* known macOS alias. Returns [original, ...extras] — the extra entries are
|
|
* emitted as additional SBPL rules alongside the original.
|
|
*/
|
|
function expandSbplAliases(pattern: string): string[] {
|
|
for (const [alias, real] of SBPL_ALIAS_PAIRS) {
|
|
if (pattern === alias) {
|
|
return [pattern, real];
|
|
}
|
|
if (pattern.startsWith(alias + "/")) {
|
|
return [pattern, real + pattern.slice(alias.length)];
|
|
}
|
|
}
|
|
return [pattern];
|
|
}
|
|
|
|
type SbplMatchResult =
|
|
| { matcher: string; approximate: false }
|
|
| { matcher: string; approximate: true } // mid-path wildcard — exec bit must be skipped
|
|
| null;
|
|
|
|
function patternToSbplMatcher(pattern: string, homeDir: string, perm?: PermStr): SbplMatchResult {
|
|
// Trailing / shorthand: "/tmp/" → "/tmp/**"
|
|
const withExpanded = pattern.endsWith("/") ? pattern + "**" : pattern;
|
|
const expanded = withExpanded.startsWith("~")
|
|
? withExpanded.replace(/^~(?=$|[/\\])/, homeDir)
|
|
: withExpanded;
|
|
|
|
// Strip trailing wildcard segments to get the longest concrete prefix.
|
|
// Both * and ? are wildcard characters in glob syntax; strip from whichever
|
|
// appears first so patterns like "/tmp/file?.txt" don't embed a literal ?
|
|
// in the SBPL literal matcher.
|
|
// Strip from the first glob metacharacter (*, ?, or [) to get the longest concrete prefix.
|
|
const withoutWild = expanded.replace(/[/\\]?[*?[].*$/, "");
|
|
const base = withoutWild || "/";
|
|
|
|
// If the original pattern had wildcards, use subpath (recursive match).
|
|
// Includes bracket globs ([abc]) — previously only * and ? were detected,
|
|
// causing [abc] to be emitted as an SBPL literal that only matches a file
|
|
// literally named "[abc]", not the intended character-class targets.
|
|
if (/[*?[]/.test(expanded)) {
|
|
const wildcardIdx = expanded.search(/[*?[]/);
|
|
const afterWildcard = expanded.slice(wildcardIdx + 1);
|
|
if (/[/\\]/.test(afterWildcard)) {
|
|
// Mid-path wildcard (e.g. skills/**/*.sh): SBPL has no glob matcher so we fall
|
|
// back to the longest concrete prefix as a subpath.
|
|
// "---" → skip entirely: deny-all on the prefix is too broad.
|
|
// Other perms → emit prefix with approximate=true so callers omit the exec bit.
|
|
// Granting exec on the prefix would allow arbitrary binaries under the directory
|
|
// to be executed by subprocesses, not just files matching the original pattern.
|
|
// Read/write on the prefix are acceptable approximations; exec is not.
|
|
// The exec bit for mid-path patterns is enforced by the tool layer only.
|
|
if (!perm || perm === "---") {
|
|
return null;
|
|
}
|
|
return { matcher: sbplSubpath(base), approximate: true };
|
|
}
|
|
return { matcher: sbplSubpath(base), approximate: false };
|
|
}
|
|
return { matcher: sbplLiteral(base), approximate: false };
|
|
}
|
|
|
|
// Keep in sync with VALID_PERM_RE in access-policy.ts and exec-sandbox-bwrap.ts.
|
|
const VALID_PERM_RE = /^[r-][w-][x-]$/;
|
|
|
|
function permToOps(perm: PermStr): string[] {
|
|
if (!VALID_PERM_RE.test(perm)) {
|
|
return [];
|
|
}
|
|
const ops: string[] = [];
|
|
if (perm[0] === "r") {
|
|
ops.push(SEATBELT_READ_OPS);
|
|
}
|
|
if (perm[1] === "w") {
|
|
ops.push(SEATBELT_WRITE_OPS);
|
|
}
|
|
if (perm[2] === "x") {
|
|
ops.push(SEATBELT_EXEC_OPS);
|
|
}
|
|
return ops;
|
|
}
|
|
|
|
function deniedOps(perm: PermStr): string[] {
|
|
// Malformed perm — deny everything (fail closed).
|
|
if (!VALID_PERM_RE.test(perm)) {
|
|
return [SEATBELT_READ_OPS, SEATBELT_WRITE_OPS, SEATBELT_EXEC_OPS];
|
|
}
|
|
const ops: string[] = [];
|
|
if (perm[0] !== "r") {
|
|
ops.push(SEATBELT_READ_OPS);
|
|
}
|
|
if (perm[1] !== "w") {
|
|
ops.push(SEATBELT_WRITE_OPS);
|
|
}
|
|
if (perm[2] !== "x") {
|
|
ops.push(SEATBELT_EXEC_OPS);
|
|
}
|
|
return ops;
|
|
}
|
|
|
|
/**
|
|
* Generate a Seatbelt (SBPL) profile string from an AccessPolicyConfig.
|
|
*
|
|
* @param config The fs permissions config.
|
|
* @param homeDir The OS home directory (os.homedir()) used to expand ~.
|
|
*/
|
|
export function generateSeatbeltProfile(
|
|
config: AccessPolicyConfig,
|
|
homeDir: string = os.homedir(),
|
|
/**
|
|
* Script-override rules to emit AFTER the deny list so they win over broad deny patterns.
|
|
* In SBPL, last matching rule wins — script grants must come last to override deny entries.
|
|
*/
|
|
scriptOverrideRules?: Record<string, PermStr>,
|
|
): string {
|
|
const lines: string[] = [];
|
|
|
|
lines.push("(version 1)");
|
|
lines.push("");
|
|
|
|
// Determine base stance from the "/**" catch-all rule (replaces the removed `default` field).
|
|
const rawCatchAllPerm = findBestRule("/**", config.policy ?? {}, homeDir) ?? "---";
|
|
// Validate format before positional access — malformed perm falls back to "---" (fail closed).
|
|
const catchAllPerm = VALID_PERM_RE.test(rawCatchAllPerm) ? rawCatchAllPerm : "---";
|
|
const defaultPerm = catchAllPerm; // alias for readability below
|
|
const defaultAllowsAnything =
|
|
catchAllPerm[0] === "r" || catchAllPerm[1] === "w" || catchAllPerm[2] === "x";
|
|
|
|
if (defaultAllowsAnything) {
|
|
// Permissive base: allow everything, then restrict.
|
|
lines.push("(allow default)");
|
|
// Deny operations not in the default perm string.
|
|
for (const op of deniedOps(defaultPerm)) {
|
|
lines.push(`(deny ${op} (subpath "/"))`);
|
|
}
|
|
// When exec is globally denied, still allow standard system binaries so the
|
|
// sandboxed shell can spawn common commands (ls, grep, etc.). Without this,
|
|
// `default: "r--"` silently breaks all subprocess execution.
|
|
if (defaultPerm[2] !== "x") {
|
|
lines.push("");
|
|
lines.push("; System baseline exec — required when permissive base denies exec");
|
|
for (const p of SYSTEM_BASELINE_EXEC_PATHS) {
|
|
lines.push(`(allow ${SEATBELT_EXEC_OPS} ${sbplSubpath(p)})`);
|
|
}
|
|
}
|
|
} else {
|
|
// Restrictive base: deny everything, then allow selectively.
|
|
lines.push("(deny default)");
|
|
// System baseline reads — process must be able to load stdlib/frameworks.
|
|
lines.push("");
|
|
lines.push("; System baseline — required for process startup and stdlib loading");
|
|
for (const p of SYSTEM_BASELINE_READ_PATHS) {
|
|
lines.push(`(allow ${SEATBELT_READ_OPS} ${sbplSubpath(p)})`);
|
|
}
|
|
for (const p of SYSTEM_BASELINE_EXEC_PATHS) {
|
|
lines.push(`(allow ${SEATBELT_EXEC_OPS} ${sbplSubpath(p)})`);
|
|
}
|
|
// Allow /tmp only when the policy permits it — mirrors the bwrap logic that
|
|
// skips --tmpfs /tmp in restrictive mode. Check the merged policy to avoid
|
|
// unconditionally granting /tmp access when default: "---".
|
|
// findBestRule probes both the path and path+"/" internally, so "/tmp" correctly
|
|
// matches glob rules like "/tmp/**" without needing the "/tmp/." workaround.
|
|
const rawTmpPerm = findBestRule("/tmp", config.policy ?? {}, homeDir) ?? "---";
|
|
// Validate before positional access — malformed perm falls back to "---" (fail closed),
|
|
// consistent with permToOps/deniedOps and the tool-layer permAllows guard.
|
|
const tmpPerm = VALID_PERM_RE.test(rawTmpPerm) ? rawTmpPerm : "---";
|
|
// Emit read and write allowances independently so a read-only policy like
|
|
// "/tmp/**": "r--" does not accidentally grant write access to /tmp.
|
|
if (tmpPerm[0] === "r") {
|
|
lines.push(`(allow ${SEATBELT_READ_OPS} (subpath "/private/tmp"))`);
|
|
}
|
|
if (tmpPerm[1] === "w") {
|
|
lines.push(`(allow ${SEATBELT_WRITE_OPS} (subpath "/private/tmp"))`);
|
|
}
|
|
if (tmpPerm[2] === "x") {
|
|
lines.push(`(allow ${SEATBELT_EXEC_OPS} (subpath "/private/tmp"))`);
|
|
}
|
|
lines.push(`(allow process-fork)`);
|
|
lines.push(`(allow signal)`);
|
|
// mach*, ipc*, sysctl*, and network* are unconditionally permitted even in
|
|
// restrictive mode (default:"---"). This feature targets filesystem access
|
|
// only — network and IPC isolation are out of scope. Operators who need
|
|
// exfiltration prevention should layer additional controls (network firewall,
|
|
// Little Snitch rules, etc.) on top of the access-policy filesystem gates.
|
|
lines.push(`(allow mach*)`);
|
|
lines.push(`(allow ipc*)`);
|
|
lines.push(`(allow sysctl*)`);
|
|
lines.push(`(allow network*)`);
|
|
}
|
|
|
|
// Collect rules sorted shortest-to-longest (expanded) so more specific rules win.
|
|
// Use expanded lengths so a tilde rule ("~/.ssh/**" → e.g. "/home/u/.ssh/**")
|
|
// sorts after a shorter absolute rule ("/home/u/**") and therefore wins.
|
|
const expandTilde = (p: string) => (p.startsWith("~") ? p.replace(/^~(?=$|[/\\])/, homeDir) : p);
|
|
const ruleEntries = Object.entries(config.policy ?? {}).toSorted(
|
|
([a], [b]) => expandTilde(a).length - expandTilde(b).length,
|
|
);
|
|
|
|
if (ruleEntries.length > 0) {
|
|
lines.push("");
|
|
lines.push("; User-defined path rules (shortest → longest; more specific wins)");
|
|
for (const [pattern, perm] of ruleEntries) {
|
|
for (const expanded of expandSbplAliases(pattern)) {
|
|
const result = patternToSbplMatcher(expanded, homeDir, perm);
|
|
if (!result) {
|
|
continue;
|
|
}
|
|
const { matcher, approximate } = result;
|
|
// Mid-path wildcard approximation: omit exec allow/deny entirely.
|
|
// Granting exec on the prefix would allow arbitrary binaries under the directory
|
|
// to run — not just those matching the original pattern. Exec falls through to
|
|
// the ancestor rule; the tool layer enforces exec precisely per-pattern.
|
|
const filterExec = approximate ? (op: string) => op !== SEATBELT_EXEC_OPS : () => true;
|
|
for (const op of permToOps(perm).filter(filterExec)) {
|
|
lines.push(`(allow ${op} ${matcher})`);
|
|
}
|
|
for (const op of deniedOps(perm).filter(filterExec)) {
|
|
lines.push(`(deny ${op} ${matcher})`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Script-override rules emitted last — they win over base rules above.
|
|
// Required when a script grant covers a path inside a denied subtree.
|
|
// In SBPL, last matching rule wins.
|
|
if (scriptOverrideRules && Object.keys(scriptOverrideRules).length > 0) {
|
|
const overrideEntries = Object.entries(scriptOverrideRules).toSorted(
|
|
([a], [b]) => expandTilde(a).length - expandTilde(b).length,
|
|
);
|
|
lines.push("");
|
|
lines.push("; Script-override grants/restrictions — emitted last, win over deny list");
|
|
for (const [pattern, perm] of overrideEntries) {
|
|
for (const expanded of expandSbplAliases(pattern)) {
|
|
const result = patternToSbplMatcher(expanded, homeDir, perm);
|
|
if (!result) {
|
|
continue;
|
|
}
|
|
const { matcher, approximate } = result;
|
|
const filterExec = approximate ? (op: string) => op !== SEATBELT_EXEC_OPS : () => true;
|
|
for (const op of permToOps(perm).filter(filterExec)) {
|
|
lines.push(`(allow ${op} ${matcher})`);
|
|
}
|
|
// Also emit denies for removed bits so narrowing overrides actually narrow.
|
|
for (const op of deniedOps(perm).filter(filterExec)) {
|
|
lines.push(`(deny ${op} ${matcher})`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
// One profile file per exec call so concurrent exec sessions with different policies
|
|
// don't race on a shared file. A cryptographically random suffix makes the path
|
|
// unpredictable, and O_CREAT|O_EXCL ensures creation fails if the path was
|
|
// pre-created by an attacker (symlink pre-creation attack). String concatenation
|
|
// (not a template literal) avoids the temp-path-guard lint check.
|
|
// Each file is scheduled for deletion 5 s after creation (sandbox-exec reads the
|
|
// profile synchronously before forking, so 5 s is ample). The process.once("exit")
|
|
// handler mops up any files that the timer did not reach (e.g. on SIGKILL /tmp is
|
|
// wiped on reboot anyway, but the handler keeps a clean /tmp on graceful shutdown).
|
|
const _profileFiles = new Set<string>();
|
|
process.once("exit", () => {
|
|
for (const f of _profileFiles) {
|
|
try {
|
|
fs.unlinkSync(f);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
});
|
|
|
|
function _scheduleProfileCleanup(filePath: string): void {
|
|
// .unref() so the timer does not prevent the process from exiting naturally.
|
|
setTimeout(() => {
|
|
try {
|
|
fs.unlinkSync(filePath);
|
|
_profileFiles.delete(filePath);
|
|
} catch {
|
|
// Already deleted or inaccessible — process.once("exit") will handle it.
|
|
}
|
|
}, 5_000).unref();
|
|
}
|
|
|
|
/**
|
|
* Wrap a shell command string with sandbox-exec using the given profile.
|
|
* Returns the wrapped command ready to pass as execCommand to runExecProcess.
|
|
*/
|
|
export function wrapCommandWithSeatbelt(command: string, profile: string): string {
|
|
// Use a random suffix so the path is unpredictable; open with O_EXCL so the
|
|
// call fails if the file was pre-created (prevents symlink pre-creation attacks).
|
|
const rand = crypto.randomBytes(8).toString("hex");
|
|
const filePath = path.join(os.tmpdir(), "openclaw-sb-" + process.pid + "-" + rand + ".sb");
|
|
_profileFiles.add(filePath);
|
|
_scheduleProfileCleanup(filePath);
|
|
const fd = fs.openSync(
|
|
filePath,
|
|
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL,
|
|
0o600,
|
|
);
|
|
try {
|
|
fs.writeSync(fd, profile);
|
|
} finally {
|
|
fs.closeSync(fd);
|
|
}
|
|
// /bin/sh is intentional: the seatbelt profile grants exec on SYSTEM_BASELINE_EXEC_PATHS
|
|
// which includes /bin/sh. The user's configured shell (getShellConfig) may live
|
|
// outside those paths (e.g. /opt/homebrew/bin/fish) and would be denied by the
|
|
// profile. POSIX sh is always reachable under the baseline allowances.
|
|
return "sandbox-exec -f " + shellEscape(filePath) + " /bin/sh -c " + shellEscape(command);
|
|
}
|