Files
openclaw-openclaw/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift
Peter Steinberger 9b1c36d23c fix: prevent exec approval revocation races (#103515)
* fix(security): serialize exec approval mutations

* fix(security): preserve additive approval writes

* test(cli): expect normalized approval shape

* fix(security): preserve exec approval compatibility

* test(security): exercise locked approval initialization

* test(security): mock serialized approval helpers

* test(exec): derive enforced command path from plan

* fix(gateway): always return approval CAS conflicts

* fix(macos): serialize exec approvals writes

* fix(security): repair approval build errors

* fix(security): serialize exec approval mutations

* fix(security): fail closed on approval persistence errors

* test(security): cover detached approval persistence failures

* fix(security): harden exec approval state

* style(macos): format exec approval sources

* fix(security): complete exec approval hardening

Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com>

* fix(macos): preserve approved login-shell semantics

* fix(macos): keep login shell approvals one-shot

* fix(security): linearize exec authorization

Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com>

* fix(security): preserve durable approval basis

Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com>

* fix(security): bind exec grants to current policy

Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com>

* test(security): fix exec revocation fixtures

* test(security): align gateway approval fixtures

* fix(macos): return approval decisions

* chore(i18n): sync native approval strings

* test(security): align approval hardening fixtures

* test(node): authorize completed event fixture

* test(security): fix approval decision fixtures

* test(security): await durable approval visibility

* fix(exec): preserve concurrent approval grants

* fix(exec): address exact-head CI failures

* fix(exec): preserve concurrent approval promotions

* fix(exec): make Swift shutdown state explicit

* test(macos): handle approval read failures

* fix(macos): harden approval socket paths

* fix(macos): preserve exact shell payload bytes

* test(macos): make approval fixtures explicit

* test(macos): fix approval suite compilation

* fix(macos): bound approval socket JSONL reads

* chore: move exec approval note to release process

* chore: move exec approval note to release process

---------

Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com>
2026-07-10 21:35:05 +01:00

780 lines
26 KiB
Swift

import Foundation
struct ExecCommandResolution {
let rawExecutable: String
let resolvedPath: String?
let resolvedRealPath: String?
let executableName: String
let cwd: String?
let argv: [String]?
init(
rawExecutable: String,
resolvedPath: String?,
resolvedRealPath: String? = nil,
executableName: String,
cwd: String?,
argv: [String]? = nil)
{
self.rawExecutable = rawExecutable
self.resolvedPath = resolvedPath
self.resolvedRealPath = resolvedRealPath
self.executableName = executableName
self.cwd = cwd
self.argv = argv
}
static func resolve(
command: [String],
rawCommand: String?,
cwd: String?,
env: [String: String]?) -> ExecCommandResolution?
{
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedRaw.isEmpty, let token = parseFirstToken(trimmedRaw) {
let argv = command.isEmpty ? nil : [token] + Array(command.dropFirst())
return self.resolveExecutable(rawExecutable: token, argv: argv, cwd: cwd, env: env)
}
return self.resolve(command: command, cwd: cwd, env: env)
}
static func resolveForAllowlist(
command: [String],
rawCommand: String?,
cwd: String?,
env: [String: String]?) -> [ExecCommandResolution]
{
// Allowlist resolution must follow actual argv execution for wrappers.
// `rawCommand` is caller-supplied display text and may be canonicalized.
let shell = ExecShellWrapperParser.extractForAllowlist(command: command, rawCommand: rawCommand)
if shell.isWrapper {
// Fail closed when env modifiers precede a shell wrapper. This mirrors
// system-run binding behavior where such invocations must stay bound to
// full argv and must not be auto-allowlisted by payload-only matches.
if ExecSystemRunCommandValidator.hasEnvManipulationBeforeShellWrapper(command) {
return []
}
guard let shellCommand = shell.command,
let segments = splitShellCommandChain(shellCommand)
else {
// Fail closed: if we cannot safely parse a shell wrapper payload,
// treat this as an allowlist miss and require approval.
return []
}
var resolutions: [ExecCommandResolution] = []
resolutions.reserveCapacity(segments.count)
for segment in segments {
guard let resolution = resolveShellSegmentExecutable(segment, cwd: cwd, env: env)
else {
return []
}
resolutions.append(resolution)
}
return resolutions
}
guard let resolution = resolveForAllowlistCommand(
command: command,
cwd: cwd,
env: env)
else {
return []
}
return [resolution]
}
static func resolveAllowAlwaysPatterns(
command: [String],
cwd: String?,
env: [String: String]?,
rawCommand: String? = nil) -> [String]
{
var patterns: [String] = []
var seen = Set<String>()
self.collectAllowAlwaysPatterns(
command: command,
cwd: cwd,
env: env,
rawCommand: rawCommand,
depth: 0,
patterns: &patterns,
seen: &seen)
return patterns
}
/// Reusable authorization must execute the same canonical executable that
/// was matched. A non-login `sh -c` transport may bind a static command
/// directly; login shells and other modes stay gated.
static func bindForAllowlistExecution(
command: [String],
rawCommand: String?,
resolutions: [ExecCommandResolution]) -> [String]?
{
let shell = ExecShellWrapperParser.extractForAllowlist(
command: command,
rawCommand: rawCommand)
guard resolutions.count == 1,
let resolution = resolutions.first,
let argv = resolution.argv,
!argv.isEmpty
else { return nil }
if shell.isWrapper {
guard let payload = self.staticNodeShellPayload(command: command, parsed: shell),
self.tokenizeShellWords(payload) == argv
else { return nil }
}
guard let realPath = resolution.resolvedRealPath,
FileManager().isExecutableFile(atPath: realPath)
else { return nil }
guard !self.isUnsafeReusableExecutionTarget(resolution) else { return nil }
return [realPath] + Array(argv.dropFirst())
}
/// Bind only the static non-login transport. Login startup files execute
/// outside the matched argv, and other shell modes change semantics.
private static func staticNodeShellPayload(
command: [String],
parsed: ExecShellWrapperParser.ParsedShellWrapper) -> String?
{
guard command.count == 3,
ExecCommandToken.basenameLower(command[0]) == "sh",
command[1] == "-c",
let payload = parsed.command,
self.isStaticShellPayload(payload),
!self.hasLeadingShellAssignment(payload),
splitShellCommandChain(payload)?.count == 1
else { return nil }
return payload
}
private static func hasLeadingShellAssignment(_ payload: String) -> Bool {
guard let first = self.tokenizeShellWords(payload).first else { return false }
return first.range(
of: #"^[A-Za-z_][A-Za-z0-9_]*\+?="#,
options: .regularExpression) != nil
}
private static func isStaticShellPayload(_ payload: String) -> Bool {
var inSingle = false
var inDouble = false
var escaped = false
let chars = Array(payload)
for idx in chars.indices {
let ch = chars[idx]
if escaped {
if ch == "\n" {
return false
}
if inDouble, ch != "$", ch != "`", ch != "\"", ch != "\\" {
// POSIX double quotes preserve the backslash here, while
// our argv tokenizer removes it. Reject semantic drift.
return false
}
escaped = false
continue
}
if ch == "\\", !inSingle {
escaped = true
continue
}
if ch == "'", !inDouble {
inSingle.toggle()
continue
}
if ch == "\"", !inSingle {
inDouble.toggle()
continue
}
if inSingle {
continue
}
if ch == "$" || ch == "`" {
return false
}
if !inDouble,
"*?[]<>|&;\n{}()#~!".contains(ch)
{
return false
}
}
return !escaped && !inSingle && !inDouble
}
static func isUnsafeReusableExecutionTarget(_ resolution: ExecCommandResolution) -> Bool {
[resolution.rawExecutable, resolution.resolvedPath, resolution.resolvedRealPath]
.compactMap(\.self)
.map(ExecCommandToken.basenameLower)
.contains {
ExecShellWrapperParser.isShellWrapperExecutable($0) ||
self.unsafeReusableDispatchCarrierNames.contains($0)
}
}
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
let effective = ExecEnvInvocationUnwrapper.unwrapTransparentDispatchWrappersForResolution(command)
guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
return nil
}
return self.resolveExecutable(rawExecutable: raw, argv: effective, cwd: cwd, env: env)
}
private static func resolveForAllowlistCommand(
command: [String],
cwd: String?,
env: [String: String]?) -> ExecCommandResolution?
{
let effective = ExecEnvInvocationUnwrapper.unwrapDispatchWrappersForResolution(command)
guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
return nil
}
return self.resolveExecutable(rawExecutable: raw, argv: effective, cwd: cwd, env: env)
}
private static func resolveExecutable(
rawExecutable: String,
argv: [String]?,
cwd: String?,
env: [String: String]?) -> ExecCommandResolution?
{
let expanded = rawExecutable.hasPrefix("~") ? (rawExecutable as NSString).expandingTildeInPath : rawExecutable
let hasPathSeparator = expanded.contains("/") || expanded.contains("\\")
let resolvedPath: String? = {
if hasPathSeparator {
if expanded.hasPrefix("/") {
return expanded
}
let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
let root = (base?.isEmpty == false) ? base! : FileManager().currentDirectoryPath
return URL(fileURLWithPath: root).appendingPathComponent(expanded).path
}
let searchPaths = self.searchPaths(from: env)
return CommandResolver.findExecutable(named: expanded, searchPaths: searchPaths)
}()
let normalizedPath = resolvedPath.map { URL(fileURLWithPath: $0).standardizedFileURL.path }
let resolvedRealPath = normalizedPath.map {
URL(fileURLWithPath: $0).resolvingSymlinksInPath().standardizedFileURL.path
}
let name = normalizedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded
return ExecCommandResolution(
rawExecutable: expanded,
resolvedPath: normalizedPath,
resolvedRealPath: resolvedRealPath,
executableName: name,
cwd: cwd,
argv: argv)
}
private static func resolveShellSegmentExecutable(
_ segment: String,
cwd: String?,
env: [String: String]?) -> ExecCommandResolution?
{
let tokens = self.tokenizeShellWords(segment)
guard !tokens.isEmpty else { return nil }
let effective = ExecEnvInvocationUnwrapper.unwrapDispatchWrappersForResolution(tokens)
guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
return nil
}
return self.resolveExecutable(rawExecutable: raw, argv: effective, cwd: cwd, env: env)
}
private static func collectAllowAlwaysPatterns(
command: [String],
cwd: String?,
env: [String: String]?,
rawCommand: String?,
depth: Int,
patterns: inout [String],
seen: inout Set<String>)
{
guard depth < 3, !command.isEmpty else {
return
}
if let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines),
ExecCommandToken.basenameLower(token0) == "env",
let envUnwrapped = ExecEnvInvocationUnwrapper.unwrapWithMetadata(command),
!envUnwrapped.command.isEmpty
{
if envUnwrapped.usesModifiers,
self.isAllowlistShellWrapper(command: envUnwrapped.command, rawCommand: rawCommand)
{
return
}
self.collectAllowAlwaysPatterns(
command: envUnwrapped.command,
cwd: cwd,
env: env,
rawCommand: rawCommand,
depth: depth + 1,
patterns: &patterns,
seen: &seen)
return
}
if let shellMultiplexer = unwrapShellMultiplexerInvocation(command) {
self.collectAllowAlwaysPatterns(
command: shellMultiplexer,
cwd: cwd,
env: env,
rawCommand: rawCommand,
depth: depth + 1,
patterns: &patterns,
seen: &seen)
return
}
let shell = ExecShellWrapperParser.extractForAllowlist(command: command, rawCommand: rawCommand)
if shell.isWrapper {
guard let shellCommand = shell.command,
let segments = splitShellCommandChain(shellCommand)
else {
return
}
for segment in segments {
let tokens = self.tokenizeShellWords(segment)
guard !tokens.isEmpty else {
continue
}
self.collectAllowAlwaysPatterns(
command: tokens,
cwd: cwd,
env: env,
rawCommand: nil,
depth: depth + 1,
patterns: &patterns,
seen: &seen)
}
return
}
guard let resolution = resolve(command: command, cwd: cwd, env: env),
!self.isInterpreterLikePersistentGrantTarget(resolution),
let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution),
seen.insert(pattern).inserted
else {
return
}
patterns.append(pattern)
}
/// Path-only durable grants are too broad for tools that can execute code
/// from ordinary argv. This mirrors the node host's default allow-always
/// policy; users may still configure an explicit manual rule.
static func isInterpreterLikePersistentGrantTarget(_ resolution: ExecCommandResolution) -> Bool {
[resolution.rawExecutable, resolution.resolvedPath, resolution.resolvedRealPath]
.compactMap(\.self)
.map(ExecCommandToken.basenameLower)
.contains(where: self.isInterpreterLikeName)
}
private static func isInterpreterLikeName(_ value: String) -> Bool {
let normalized = value.hasSuffix(".exe") ? String(value.dropLast(4)) : value
if self.interpreterLikePersistentGrantNames.contains(normalized) {
return true
}
let stripped = normalized.replacingOccurrences(
of: #"-?\d+(?:\.\d+)*$"#,
with: "",
options: .regularExpression)
return stripped.count >= 2 && self.interpreterLikePersistentGrantNames.contains(stripped)
}
private static let interpreterLikePersistentGrantNames = Set([
"awk", "bun", "deno", "find", "gawk", "gmake", "gsed", "lua", "make", "mawk", "nawk",
"node", "nodejs", "osascript", "perl", "php", "pypy", "pypy3", "python", "python2", "python3",
"r", "rscript", "ruby", "sed", "xargs",
])
private static func isAllowlistShellWrapper(command: [String], rawCommand: String?) -> Bool {
ExecShellWrapperParser.extractForAllowlist(command: command, rawCommand: rawCommand).isWrapper
}
private static func unwrapShellMultiplexerInvocation(_ argv: [String]) -> [String]? {
guard let token0 = argv.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else {
return nil
}
let wrapper = ExecCommandToken.basenameLower(token0)
guard wrapper == "busybox" || wrapper == "toybox" else {
return nil
}
var appletIndex = 1
if appletIndex < argv.count, argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" {
appletIndex += 1
}
guard appletIndex < argv.count else {
return nil
}
let applet = argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines)
guard !applet.isEmpty else {
return nil
}
let normalizedApplet = ExecCommandToken.basenameLower(applet)
let shellWrappers = Set([
"ash",
"bash",
"dash",
"fish",
"ksh",
"powershell",
"pwsh",
"sh",
"zsh",
])
guard shellWrappers.contains(normalizedApplet) else {
return nil
}
return Array(argv[appletIndex...])
}
private static func parseFirstToken(_ command: String) -> String? {
let trimmed = self.trimmingShellWordSeparators(command)
guard !trimmed.isEmpty else { return nil }
guard let first = trimmed.first else { return nil }
if first == "\"" || first == "'" {
let rest = trimmed.dropFirst()
if let end = rest.firstIndex(of: first) {
return String(rest[..<end])
}
return String(rest)
}
return trimmed.split(whereSeparator: self.isShellWordSeparator).first.map(String.init)
}
private static let unsafeReusableDispatchCarrierNames = Set([
"arch",
"busybox",
"bun",
"bunx",
"caffeinate",
"chrt",
"deno",
"doas",
"env",
"flock",
"ionice",
"nice",
"nohup",
"npm",
"npx",
"pnpm",
"sandbox-exec",
"script",
"setsid",
"stdbuf",
"sudo",
"taskset",
"time",
"timeout",
"toybox",
"xcrun",
"yarn",
])
private static func tokenizeShellWords(_ command: String) -> [String] {
let trimmed = self.trimmingShellWordSeparators(command)
guard !trimmed.isEmpty else { return [] }
var tokens: [String] = []
var current = ""
var inSingle = false
var inDouble = false
var escaped = false
var tokenStarted = false
func appendCurrent() {
guard tokenStarted else { return }
tokens.append(current)
current.removeAll(keepingCapacity: true)
tokenStarted = false
}
for ch in trimmed {
if escaped {
current.append(ch)
tokenStarted = true
escaped = false
continue
}
if ch == "\\", !inSingle {
tokenStarted = true
escaped = true
continue
}
if ch == "'", !inDouble {
tokenStarted = true
inSingle.toggle()
continue
}
if ch == "\"", !inSingle {
tokenStarted = true
inDouble.toggle()
continue
}
if self.isShellWordSeparator(ch), !inSingle, !inDouble {
appendCurrent()
continue
}
current.append(ch)
tokenStarted = true
}
if escaped { current.append("\\") }
appendCurrent()
return tokens
}
private static func isShellWordSeparator(_ ch: Character) -> Bool {
ch == " " || ch == "\t" || ch == "\n"
}
private static func trimmingShellWordSeparators(_ value: String) -> String {
var start = value.startIndex
while start < value.endIndex, self.isShellWordSeparator(value[start]) {
value.formIndex(after: &start)
}
var end = value.endIndex
while end > start {
let previous = value.index(before: end)
guard self.isShellWordSeparator(value[previous]) else { break }
end = previous
}
return String(value[start..<end])
}
private enum ShellTokenContext {
case unquoted
case doubleQuoted
}
private struct ShellFailClosedRule {
let token: Character
let next: Character?
}
private static let shellFailClosedRules: [ShellTokenContext: [ShellFailClosedRule]] = [
.unquoted: [
ShellFailClosedRule(token: "`", next: nil),
ShellFailClosedRule(token: "$", next: "("),
ShellFailClosedRule(token: "<", next: "("),
ShellFailClosedRule(token: ">", next: "("),
],
.doubleQuoted: [
ShellFailClosedRule(token: "`", next: nil),
ShellFailClosedRule(token: "$", next: "("),
],
]
private static func splitShellCommandChain(_ command: String) -> [String]? {
let trimmed = self.trimmingShellWordSeparators(command)
guard !trimmed.isEmpty else { return nil }
var segments: [String] = []
var current = ""
var inSingle = false
var inDouble = false
var escaped = false
let chars = Array(trimmed)
var idx = 0
func appendCurrent() -> Bool {
let segment = self.trimmingShellWordSeparators(current)
guard !segment.isEmpty else { return false }
segments.append(segment)
current.removeAll(keepingCapacity: true)
return true
}
while idx < chars.count {
let ch = chars[idx]
let next: Character? = idx + 1 < chars.count ? chars[idx + 1] : nil
let lookahead = self.nextShellSignificantCharacter(chars: chars, after: idx, inSingle: inSingle)
if escaped {
if ch == "\n" {
escaped = false
idx += 1
continue
}
current.append(ch)
escaped = false
idx += 1
continue
}
if ch == "\\", !inSingle {
if next == "\n" {
idx += 2
continue
}
current.append(ch)
escaped = true
idx += 1
continue
}
if ch == "'", !inDouble {
inSingle.toggle()
current.append(ch)
idx += 1
continue
}
if ch == "\"", !inSingle {
inDouble.toggle()
current.append(ch)
idx += 1
continue
}
if !inSingle, self.shouldFailClosedForShell(ch: ch, next: lookahead, inDouble: inDouble) {
// Fail closed on command/process substitution in allowlist mode,
// including command substitution inside double-quoted shell strings.
return nil
}
if !inSingle, !inDouble {
let prev: Character? = idx > 0 ? chars[idx - 1] : nil
if let delimiterStep = chainDelimiterStep(ch: ch, prev: prev, next: next) {
guard appendCurrent() else { return nil }
idx += delimiterStep
continue
}
}
current.append(ch)
idx += 1
}
if escaped || inSingle || inDouble {
return nil
}
guard appendCurrent() else { return nil }
return segments
}
private static func nextShellSignificantCharacter(
chars: [Character],
after idx: Int,
inSingle: Bool) -> Character?
{
guard !inSingle else {
return idx + 1 < chars.count ? chars[idx + 1] : nil
}
var cursor = idx + 1
while cursor < chars.count {
if chars[cursor] == "\\", cursor + 1 < chars.count, chars[cursor + 1] == "\n" {
cursor += 2
continue
}
return chars[cursor]
}
return nil
}
private static func shouldFailClosedForShell(ch: Character, next: Character?, inDouble: Bool) -> Bool {
let context: ShellTokenContext = inDouble ? .doubleQuoted : .unquoted
guard let rules = shellFailClosedRules[context] else {
return false
}
for rule in rules {
if ch == rule.token, rule.next == nil || next == rule.next {
return true
}
}
return false
}
private static func chainDelimiterStep(ch: Character, prev: Character?, next: Character?) -> Int? {
if ch == ";" || ch == "\n" {
return 1
}
if ch == "&" {
if next == "&" {
return 2
}
// Keep fd redirections like 2>&1 or &>file intact.
let prevIsRedirect = prev == ">"
let nextIsRedirect = next == ">"
return (!prevIsRedirect && !nextIsRedirect) ? 1 : nil
}
if ch == "|" {
if next == "|" || next == "&" {
return 2
}
return 1
}
return nil
}
private static func searchPaths(from env: [String: String]?) -> [String] {
let raw = env?["PATH"]
if let raw, !raw.isEmpty {
return raw.split(separator: ":").map(String.init)
}
return CommandResolver.preferredPaths()
}
}
enum ExecCommandFormatter {
private static let safeDisplayScalars = CharacterSet(
charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_@%+=:,./-")
static func displayString(for argv: [String]) -> String {
argv.map { arg in
guard !arg.isEmpty else { return "\"\"" }
if arg.unicodeScalars.allSatisfy({ self.safeDisplayScalars.contains($0) }) {
return arg
}
return "\"\(self.escapeDisplayArg(arg))\""
}.joined(separator: " ")
}
static func legacyDisplayString(for argv: [String]) -> String {
argv.map { arg in
guard !arg.isEmpty else { return "\"\"" }
let needsQuotes = arg.contains { $0.isWhitespace || $0 == "\"" }
guard needsQuotes else { return arg }
return "\"\(arg.replacingOccurrences(of: "\"", with: "\\\""))\""
}.joined(separator: " ")
}
private static func escapeDisplayArg(_ arg: String) -> String {
var escaped = ""
for scalar in arg.unicodeScalars {
switch scalar.value {
case 0x5C: escaped += "\\\\"
case 0x22: escaped += "\\\""
case 0x08: escaped += "\\b"
case 0x0C: escaped += "\\f"
case 0x0A: escaped += "\\n"
case 0x0D: escaped += "\\r"
case 0x09: escaped += "\\t"
case 0x00...0x1F, 0x7F:
escaped += "\\u{\(String(scalar.value, radix: 16, uppercase: true))}"
default:
escaped.append(String(scalar))
}
}
return escaped
}
static func displayString(for argv: [String], rawCommand: String?) -> String {
let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmed.isEmpty {
return trimmed
}
return self.displayString(for: argv)
}
}