Files
openclaw/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.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

164 lines
6.7 KiB
Swift

import Foundation
import JavaScriptCore
enum ExecAllowlistMatcher {
static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? {
guard let resolution, !entries.isEmpty else { return nil }
if let wildcard = entries.first(where: {
$0.pattern.trimmingCharacters(in: .whitespacesAndNewlines) == "*" &&
($0.argPattern?.isEmpty ?? true)
}) {
return wildcard
}
guard resolution.resolvedRealPath?.isEmpty == false || resolution.resolvedPath?.isEmpty == false else {
return nil
}
var pathOnlyMatch: ExecAllowlistEntry?
for entry in entries {
let controlPattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines)
// Shared stores preserve TypeScript's durable-command markers.
// They are metadata, never basename patterns for native execution.
if controlPattern.hasPrefix("=command:") || controlPattern.hasPrefix("=node-command:") {
continue
}
switch ExecApprovalHelpers.validateAllowlistPattern(entry.pattern) {
case let .valid(pattern):
guard self.matchesExecutable(pattern: pattern, resolution: resolution) else { continue }
guard let argPattern = entry.argPattern, !argPattern.isEmpty else {
if pathOnlyMatch == nil {
pathOnlyMatch = entry
}
continue
}
if let argv = resolution.argv, matchesArgPattern(argPattern, argv: argv) {
return entry
}
case .invalid:
continue
}
}
return pathOnlyMatch
}
static func matchAll(
entries: [ExecAllowlistEntry],
resolutions: [ExecCommandResolution]) -> [ExecAllowlistEntry]
{
guard !entries.isEmpty, !resolutions.isEmpty else { return [] }
var matches: [ExecAllowlistEntry] = []
matches.reserveCapacity(resolutions.count)
for resolution in resolutions {
guard let match = match(entries: entries, resolution: resolution) else {
return []
}
matches.append(match)
}
return matches
}
private static func matchesExecutableBasename(
pattern: String,
resolution: ExecCommandResolution) -> Bool
{
var candidates = Set<String>()
if !resolution.executableName.isEmpty {
candidates.insert(resolution.executableName)
}
if let resolvedPath = resolution.resolvedPath, !resolvedPath.isEmpty {
candidates.insert(URL(fileURLWithPath: resolvedPath).lastPathComponent)
}
return candidates.contains { self.matches(pattern: pattern, target: $0) }
}
private static func matchesExecutable(
pattern: String,
resolution: ExecCommandResolution) -> Bool
{
if ExecApprovalHelpers.patternHasPathSelector(pattern) {
guard let trustPath = resolution.resolvedRealPath ?? resolution.resolvedPath else { return false }
return self.matches(pattern: pattern, target: trustPath)
}
return pattern != "*" &&
!ExecApprovalHelpers.patternHasPathSelector(resolution.rawExecutable) &&
self.matchesExecutableBasename(pattern: pattern, resolution: resolution)
}
/// Mirrors the TypeScript exec-approval argv contract. Generated patterns
/// use NUL separators plus a trailing sentinel; hand-authored patterns use
/// one space between parsed arguments. Redirect-shaped tokens stay literal
/// because resolution does not retain enough shell syntax provenance.
private static func matchesArgPattern(_ argPattern: String, argv: [String]) -> Bool {
let nul = "\0"
let arguments = Array(argv.dropFirst())
let usesNulSeparator = argPattern.contains(nul)
let joined = if usesNulSeparator {
arguments.isEmpty ? nul + nul : arguments.joined(separator: nul) + nul
} else {
arguments.joined(separator: " ")
}
// The shared policy contract is JavaScript RegExp. Foundation uses ICU,
// whose broader character classes and extra syntax can grant more than
// the Gateway would, so compile and match with the system JS engine.
guard let context = JSContext(),
let constructor = context.objectForKeyedSubscript("RegExp"),
let regex = constructor.construct(withArguments: [argPattern]),
context.exception == nil,
let result = regex.invokeMethod("test", withArguments: [joined]),
context.exception == nil
else { return false }
return result.toBool()
}
private static func matches(pattern: String, target: String) -> Bool {
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
let expanded = ExecApprovalsStore.expandPath(trimmed)
let normalizedPattern = self.normalizeMatchTarget(expanded)
let normalizedTarget = self.normalizeMatchTarget(target)
guard let regex = regex(for: normalizedPattern) else { return false }
let range = NSRange(location: 0, length: normalizedTarget.utf16.count)
return regex.firstMatch(in: normalizedTarget, options: [], range: range) != nil
}
private static func normalizeMatchTarget(_ value: String) -> String {
let normalized = value.replacingOccurrences(of: "\\\\", with: "/")
if normalized == "/private/var" {
return "/var"
}
if normalized.hasPrefix("/private/var/") {
return String(normalized.dropFirst("/private".count))
}
return normalized
}
private static func regex(for pattern: String) -> NSRegularExpression? {
var regex = "^"
var idx = pattern.startIndex
while idx < pattern.endIndex {
let ch = pattern[idx]
if ch == "*" {
let next = pattern.index(after: idx)
if next < pattern.endIndex, pattern[next] == "*" {
regex += ".*"
idx = pattern.index(after: next)
} else {
regex += "[^/]*"
idx = next
}
continue
}
if ch == "?" {
regex += "[^/]"
idx = pattern.index(after: idx)
continue
}
regex += NSRegularExpression.escapedPattern(for: String(ch))
idx = pattern.index(after: idx)
}
regex += "$"
return try? NSRegularExpression(pattern: regex)
}
}