fix(macos): align exec wrapper resolution
This commit is contained in:
parent
c4a4050ce4
commit
5b704a6ea4
@ -70,7 +70,7 @@ struct ExecCommandResolution {
|
||||
}
|
||||
|
||||
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
|
||||
let effective = ExecEnvInvocationUnwrapper.unwrapDispatchWrappersForResolution(command)
|
||||
let effective = ExecWrapperResolution.unwrapDispatchWrappersForResolution(command)
|
||||
guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
@ -111,7 +111,7 @@ struct ExecCommandResolution {
|
||||
{
|
||||
let tokens = self.tokenizeShellWords(segment)
|
||||
guard !tokens.isEmpty else { return nil }
|
||||
let effective = ExecEnvInvocationUnwrapper.unwrapDispatchWrappersForResolution(tokens)
|
||||
let effective = ExecWrapperResolution.unwrapDispatchWrappersForResolution(tokens)
|
||||
guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
@ -130,30 +130,36 @@ struct ExecCommandResolution {
|
||||
return
|
||||
}
|
||||
|
||||
if let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
ExecCommandToken.basenameLower(token0) == "env",
|
||||
let envUnwrapped = ExecEnvInvocationUnwrapper.unwrap(command),
|
||||
!envUnwrapped.isEmpty
|
||||
{
|
||||
switch ExecWrapperResolution.unwrapKnownDispatchWrapperInvocation(command) {
|
||||
case .blocked:
|
||||
return
|
||||
case let .unwrapped(_, argv):
|
||||
self.collectAllowAlwaysPatterns(
|
||||
command: envUnwrapped,
|
||||
command: argv,
|
||||
cwd: cwd,
|
||||
env: env,
|
||||
depth: depth + 1,
|
||||
patterns: &patterns,
|
||||
seen: &seen)
|
||||
return
|
||||
case .notWrapper:
|
||||
break
|
||||
}
|
||||
|
||||
if let shellMultiplexer = self.unwrapShellMultiplexerInvocation(command) {
|
||||
switch ExecWrapperResolution.unwrapKnownShellMultiplexerInvocation(command) {
|
||||
case .blocked:
|
||||
return
|
||||
case let .unwrapped(_, argv):
|
||||
self.collectAllowAlwaysPatterns(
|
||||
command: shellMultiplexer,
|
||||
command: argv,
|
||||
cwd: cwd,
|
||||
env: env,
|
||||
depth: depth + 1,
|
||||
patterns: &patterns,
|
||||
seen: &seen)
|
||||
return
|
||||
case .notWrapper:
|
||||
break
|
||||
}
|
||||
|
||||
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil)
|
||||
@ -188,45 +194,6 @@ struct ExecCommandResolution {
|
||||
patterns.append(pattern)
|
||||
}
|
||||
|
||||
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 = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
@ -90,24 +90,6 @@ enum ExecEnvInvocationUnwrapper {
|
||||
}
|
||||
|
||||
static func unwrapDispatchWrappersForResolution(_ command: [String]) -> [String] {
|
||||
var current = command
|
||||
var depth = 0
|
||||
while depth < self.maxWrapperDepth {
|
||||
guard let token = current.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty else {
|
||||
break
|
||||
}
|
||||
guard ExecCommandToken.basenameLower(token) == "env" else {
|
||||
break
|
||||
}
|
||||
guard let unwrapped = self.unwrapWithMetadata(current), !unwrapped.command.isEmpty else {
|
||||
break
|
||||
}
|
||||
if unwrapped.usesModifiers {
|
||||
break
|
||||
}
|
||||
current = unwrapped.command
|
||||
depth += 1
|
||||
}
|
||||
return current
|
||||
ExecWrapperResolution.unwrapDispatchWrappersForResolution(command)
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,101 +8,8 @@ enum ExecShellWrapperParser {
|
||||
static let notWrapper = ParsedShellWrapper(isWrapper: false, command: nil)
|
||||
}
|
||||
|
||||
private enum Kind {
|
||||
case posix
|
||||
case cmd
|
||||
case powershell
|
||||
}
|
||||
|
||||
private struct WrapperSpec {
|
||||
let kind: Kind
|
||||
let names: Set<String>
|
||||
}
|
||||
|
||||
private static let posixInlineFlags = Set(["-lc", "-c", "--command"])
|
||||
private static let powershellInlineFlags = Set(["-c", "-command", "--command"])
|
||||
|
||||
private static let wrapperSpecs: [WrapperSpec] = [
|
||||
WrapperSpec(kind: .posix, names: ["ash", "sh", "bash", "zsh", "dash", "ksh", "fish"]),
|
||||
WrapperSpec(kind: .cmd, names: ["cmd.exe", "cmd"]),
|
||||
WrapperSpec(kind: .powershell, names: ["powershell", "powershell.exe", "pwsh", "pwsh.exe"]),
|
||||
]
|
||||
|
||||
static func extract(command: [String], rawCommand: String?) -> ParsedShellWrapper {
|
||||
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let preferredRaw = trimmedRaw.isEmpty ? nil : trimmedRaw
|
||||
return self.extract(command: command, preferredRaw: preferredRaw, depth: 0)
|
||||
}
|
||||
|
||||
private static func extract(command: [String], preferredRaw: String?, depth: Int) -> ParsedShellWrapper {
|
||||
guard depth < ExecEnvInvocationUnwrapper.maxWrapperDepth else {
|
||||
return .notWrapper
|
||||
}
|
||||
guard let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else {
|
||||
return .notWrapper
|
||||
}
|
||||
|
||||
let base0 = ExecCommandToken.basenameLower(token0)
|
||||
if base0 == "env" {
|
||||
guard let unwrapped = ExecEnvInvocationUnwrapper.unwrap(command) else {
|
||||
return .notWrapper
|
||||
}
|
||||
return self.extract(command: unwrapped, preferredRaw: preferredRaw, depth: depth + 1)
|
||||
}
|
||||
|
||||
guard let spec = self.wrapperSpecs.first(where: { $0.names.contains(base0) }) else {
|
||||
return .notWrapper
|
||||
}
|
||||
guard let payload = self.extractPayload(command: command, spec: spec) else {
|
||||
return .notWrapper
|
||||
}
|
||||
let normalized = preferredRaw ?? payload
|
||||
return ParsedShellWrapper(isWrapper: true, command: normalized)
|
||||
}
|
||||
|
||||
private static func extractPayload(command: [String], spec: WrapperSpec) -> String? {
|
||||
switch spec.kind {
|
||||
case .posix:
|
||||
self.extractPosixInlineCommand(command)
|
||||
case .cmd:
|
||||
self.extractCmdInlineCommand(command)
|
||||
case .powershell:
|
||||
self.extractPowerShellInlineCommand(command)
|
||||
}
|
||||
}
|
||||
|
||||
private static func extractPosixInlineCommand(_ command: [String]) -> String? {
|
||||
let flag = command.count > 1 ? command[1].trimmingCharacters(in: .whitespacesAndNewlines) : ""
|
||||
guard self.posixInlineFlags.contains(flag.lowercased()) else {
|
||||
return nil
|
||||
}
|
||||
let payload = command.count > 2 ? command[2].trimmingCharacters(in: .whitespacesAndNewlines) : ""
|
||||
return payload.isEmpty ? nil : payload
|
||||
}
|
||||
|
||||
private static func extractCmdInlineCommand(_ command: [String]) -> String? {
|
||||
guard let idx = command
|
||||
.firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "/c" })
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let tail = command.suffix(from: command.index(after: idx)).joined(separator: " ")
|
||||
let payload = tail.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return payload.isEmpty ? nil : payload
|
||||
}
|
||||
|
||||
private static func extractPowerShellInlineCommand(_ command: [String]) -> String? {
|
||||
for idx in 1..<command.count {
|
||||
let token = command[idx].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if token.isEmpty { continue }
|
||||
if token == "--" { break }
|
||||
if self.powershellInlineFlags.contains(token) {
|
||||
let payload = idx + 1 < command.count
|
||||
? command[idx + 1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
: ""
|
||||
return payload.isEmpty ? nil : payload
|
||||
}
|
||||
}
|
||||
return nil
|
||||
let extracted = ExecWrapperResolution.extractShellWrapperCommand(command, rawCommand: rawCommand)
|
||||
return ParsedShellWrapper(isWrapper: extracted.isWrapper, command: extracted.command)
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,19 +11,6 @@ enum ExecSystemRunCommandValidator {
|
||||
case invalid(message: String)
|
||||
}
|
||||
|
||||
private static let shellWrapperNames = Set([
|
||||
"ash",
|
||||
"bash",
|
||||
"cmd",
|
||||
"dash",
|
||||
"fish",
|
||||
"ksh",
|
||||
"powershell",
|
||||
"pwsh",
|
||||
"sh",
|
||||
"zsh",
|
||||
])
|
||||
|
||||
private static let posixOrPowerShellInlineWrapperNames = Set([
|
||||
"ash",
|
||||
"bash",
|
||||
@ -36,15 +23,6 @@ enum ExecSystemRunCommandValidator {
|
||||
"zsh",
|
||||
])
|
||||
|
||||
private static let shellMultiplexerWrapperNames = Set(["busybox", "toybox"])
|
||||
private static let posixInlineCommandFlags = Set(["-lc", "-c", "--command"])
|
||||
private static let powershellInlineCommandFlags = Set(["-c", "-command", "--command"])
|
||||
|
||||
private struct EnvUnwrapResult {
|
||||
let argv: [String]
|
||||
let usesModifiers: Bool
|
||||
}
|
||||
|
||||
static func resolve(command: [String], rawCommand: String?) -> ValidationResult {
|
||||
let normalizedRaw = self.normalizeRaw(rawCommand)
|
||||
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil)
|
||||
@ -116,148 +94,14 @@ enum ExecSystemRunCommandValidator {
|
||||
return normalizedRaw == previewCommand ? normalizedRaw : nil
|
||||
}
|
||||
|
||||
private static func normalizeExecutableToken(_ token: String) -> String {
|
||||
let base = ExecCommandToken.basenameLower(token)
|
||||
if base.hasSuffix(".exe") {
|
||||
return String(base.dropLast(4))
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
private static func isEnvAssignment(_ token: String) -> Bool {
|
||||
token.range(of: #"^[A-Za-z_][A-Za-z0-9_]*=.*"#, options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
private static func hasEnvInlineValuePrefix(_ lowerToken: String) -> Bool {
|
||||
ExecEnvOptions.inlineValuePrefixes.contains { lowerToken.hasPrefix($0) }
|
||||
}
|
||||
|
||||
private static func unwrapEnvInvocationWithMetadata(_ argv: [String]) -> EnvUnwrapResult? {
|
||||
var idx = 1
|
||||
var expectsOptionValue = false
|
||||
var usesModifiers = false
|
||||
|
||||
while idx < argv.count {
|
||||
let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if token.isEmpty {
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if expectsOptionValue {
|
||||
expectsOptionValue = false
|
||||
usesModifiers = true
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if token == "--" || token == "-" {
|
||||
idx += 1
|
||||
break
|
||||
}
|
||||
if self.isEnvAssignment(token) {
|
||||
usesModifiers = true
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if !token.hasPrefix("-") || token == "-" {
|
||||
break
|
||||
}
|
||||
|
||||
let lower = token.lowercased()
|
||||
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
|
||||
if ExecEnvOptions.flagOnly.contains(flag) {
|
||||
usesModifiers = true
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if ExecEnvOptions.withValue.contains(flag) {
|
||||
usesModifiers = true
|
||||
if !lower.contains("=") {
|
||||
expectsOptionValue = true
|
||||
}
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if self.hasEnvInlineValuePrefix(lower) {
|
||||
usesModifiers = true
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if expectsOptionValue {
|
||||
return nil
|
||||
}
|
||||
guard idx < argv.count else {
|
||||
return nil
|
||||
}
|
||||
return EnvUnwrapResult(argv: Array(argv[idx...]), usesModifiers: usesModifiers)
|
||||
}
|
||||
|
||||
private static func unwrapShellMultiplexerInvocation(_ argv: [String]) -> [String]? {
|
||||
guard let token0 = self.trimmedNonEmpty(argv.first) else {
|
||||
return nil
|
||||
}
|
||||
let wrapper = self.normalizeExecutableToken(token0)
|
||||
guard self.shellMultiplexerWrapperNames.contains(wrapper) 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 = self.normalizeExecutableToken(applet)
|
||||
guard self.shellWrapperNames.contains(normalizedApplet) else {
|
||||
return nil
|
||||
}
|
||||
return Array(argv[appletIndex...])
|
||||
}
|
||||
|
||||
private static func hasEnvManipulationBeforeShellWrapper(
|
||||
_ argv: [String],
|
||||
depth: Int = 0,
|
||||
envManipulationSeen: Bool = false) -> Bool
|
||||
{
|
||||
if depth >= ExecEnvInvocationUnwrapper.maxWrapperDepth {
|
||||
return false
|
||||
}
|
||||
guard let token0 = self.trimmedNonEmpty(argv.first) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let normalized = self.normalizeExecutableToken(token0)
|
||||
if normalized == "env" {
|
||||
guard let envUnwrap = self.unwrapEnvInvocationWithMetadata(argv) else {
|
||||
return false
|
||||
}
|
||||
return self.hasEnvManipulationBeforeShellWrapper(
|
||||
envUnwrap.argv,
|
||||
depth: depth + 1,
|
||||
envManipulationSeen: envManipulationSeen || envUnwrap.usesModifiers)
|
||||
}
|
||||
|
||||
if let shellMultiplexer = self.unwrapShellMultiplexerInvocation(argv) {
|
||||
return self.hasEnvManipulationBeforeShellWrapper(
|
||||
shellMultiplexer,
|
||||
depth: depth + 1,
|
||||
envManipulationSeen: envManipulationSeen)
|
||||
}
|
||||
|
||||
guard self.shellWrapperNames.contains(normalized) else {
|
||||
return false
|
||||
}
|
||||
guard self.extractShellInlinePayload(argv, normalizedWrapper: normalized) != nil else {
|
||||
return false
|
||||
}
|
||||
return envManipulationSeen
|
||||
_ = depth
|
||||
_ = envManipulationSeen
|
||||
return ExecWrapperResolution.hasEnvManipulationBeforeShellWrapper(argv)
|
||||
}
|
||||
|
||||
private static func hasTrailingPositionalArgvAfterInlineCommand(_ argv: [String]) -> Bool {
|
||||
@ -265,22 +109,14 @@ enum ExecSystemRunCommandValidator {
|
||||
guard let token0 = self.trimmedNonEmpty(wrapperArgv.first) else {
|
||||
return false
|
||||
}
|
||||
let wrapper = self.normalizeExecutableToken(token0)
|
||||
let wrapper = ExecWrapperResolution.normalizeExecutableToken(token0)
|
||||
guard self.posixOrPowerShellInlineWrapperNames.contains(wrapper) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let inlineCommandIndex: Int? = if wrapper == "powershell" || wrapper == "pwsh" {
|
||||
self.resolveInlineCommandTokenIndex(
|
||||
wrapperArgv,
|
||||
flags: self.powershellInlineCommandFlags,
|
||||
allowCombinedC: false)
|
||||
} else {
|
||||
self.resolveInlineCommandTokenIndex(
|
||||
wrapperArgv,
|
||||
flags: self.posixInlineCommandFlags,
|
||||
allowCombinedC: true)
|
||||
}
|
||||
let inlineCommandIndex = ExecWrapperResolution.resolveInlineCommandValueTokenIndex(
|
||||
wrapperArgv,
|
||||
normalizedWrapper: wrapper)
|
||||
guard let inlineCommandIndex else {
|
||||
return false
|
||||
}
|
||||
@ -292,142 +128,6 @@ enum ExecSystemRunCommandValidator {
|
||||
}
|
||||
|
||||
private static func unwrapShellWrapperArgv(_ argv: [String]) -> [String] {
|
||||
var current = argv
|
||||
for _ in 0..<ExecEnvInvocationUnwrapper.maxWrapperDepth {
|
||||
guard let token0 = self.trimmedNonEmpty(current.first) else {
|
||||
break
|
||||
}
|
||||
let normalized = self.normalizeExecutableToken(token0)
|
||||
if normalized == "env" {
|
||||
guard let envUnwrap = self.unwrapEnvInvocationWithMetadata(current),
|
||||
!envUnwrap.usesModifiers,
|
||||
!envUnwrap.argv.isEmpty
|
||||
else {
|
||||
break
|
||||
}
|
||||
current = envUnwrap.argv
|
||||
continue
|
||||
}
|
||||
if let shellMultiplexer = self.unwrapShellMultiplexerInvocation(current) {
|
||||
current = shellMultiplexer
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
private struct InlineCommandTokenMatch {
|
||||
var tokenIndex: Int
|
||||
var inlineCommand: String?
|
||||
}
|
||||
|
||||
private static func findInlineCommandTokenMatch(
|
||||
_ argv: [String],
|
||||
flags: Set<String>,
|
||||
allowCombinedC: Bool) -> InlineCommandTokenMatch?
|
||||
{
|
||||
var idx = 1
|
||||
while idx < argv.count {
|
||||
let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if token.isEmpty {
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
let lower = token.lowercased()
|
||||
if lower == "--" {
|
||||
break
|
||||
}
|
||||
if flags.contains(lower) {
|
||||
return InlineCommandTokenMatch(tokenIndex: idx, inlineCommand: nil)
|
||||
}
|
||||
if allowCombinedC, let inlineOffset = self.combinedCommandInlineOffset(token) {
|
||||
let inline = String(token.dropFirst(inlineOffset))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return InlineCommandTokenMatch(
|
||||
tokenIndex: idx,
|
||||
inlineCommand: inline.isEmpty ? nil : inline)
|
||||
}
|
||||
idx += 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func resolveInlineCommandTokenIndex(
|
||||
_ argv: [String],
|
||||
flags: Set<String>,
|
||||
allowCombinedC: Bool) -> Int?
|
||||
{
|
||||
guard let match = self.findInlineCommandTokenMatch(argv, flags: flags, allowCombinedC: allowCombinedC) else {
|
||||
return nil
|
||||
}
|
||||
if match.inlineCommand != nil {
|
||||
return match.tokenIndex
|
||||
}
|
||||
let nextIndex = match.tokenIndex + 1
|
||||
return nextIndex < argv.count ? nextIndex : nil
|
||||
}
|
||||
|
||||
private static func combinedCommandInlineOffset(_ token: String) -> Int? {
|
||||
let chars = Array(token.lowercased())
|
||||
guard chars.count >= 2, chars[0] == "-", chars[1] != "-" else {
|
||||
return nil
|
||||
}
|
||||
if chars.dropFirst().contains("-") {
|
||||
return nil
|
||||
}
|
||||
guard let commandIndex = chars.firstIndex(of: "c"), commandIndex > 0 else {
|
||||
return nil
|
||||
}
|
||||
return commandIndex + 1
|
||||
}
|
||||
|
||||
private static func extractShellInlinePayload(
|
||||
_ argv: [String],
|
||||
normalizedWrapper: String) -> String?
|
||||
{
|
||||
if normalizedWrapper == "cmd" {
|
||||
return self.extractCmdInlineCommand(argv)
|
||||
}
|
||||
if normalizedWrapper == "powershell" || normalizedWrapper == "pwsh" {
|
||||
return self.extractInlineCommandByFlags(
|
||||
argv,
|
||||
flags: self.powershellInlineCommandFlags,
|
||||
allowCombinedC: false)
|
||||
}
|
||||
return self.extractInlineCommandByFlags(
|
||||
argv,
|
||||
flags: self.posixInlineCommandFlags,
|
||||
allowCombinedC: true)
|
||||
}
|
||||
|
||||
private static func extractInlineCommandByFlags(
|
||||
_ argv: [String],
|
||||
flags: Set<String>,
|
||||
allowCombinedC: Bool) -> String?
|
||||
{
|
||||
guard let match = self.findInlineCommandTokenMatch(argv, flags: flags, allowCombinedC: allowCombinedC) else {
|
||||
return nil
|
||||
}
|
||||
if let inlineCommand = match.inlineCommand {
|
||||
return inlineCommand
|
||||
}
|
||||
let nextIndex = match.tokenIndex + 1
|
||||
return self.trimmedNonEmpty(nextIndex < argv.count ? argv[nextIndex] : nil)
|
||||
}
|
||||
|
||||
private static func extractCmdInlineCommand(_ argv: [String]) -> String? {
|
||||
guard let idx = argv.firstIndex(where: {
|
||||
let token = $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return token == "/c" || token == "/k"
|
||||
}) else {
|
||||
return nil
|
||||
}
|
||||
let tailIndex = idx + 1
|
||||
guard tailIndex < argv.count else {
|
||||
return nil
|
||||
}
|
||||
let payload = argv[tailIndex...].joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return payload.isEmpty ? nil : payload
|
||||
ExecWrapperResolution.unwrapShellInspectionArgv(argv)
|
||||
}
|
||||
}
|
||||
|
||||
651
apps/macos/Sources/OpenClaw/ExecWrapperResolution.swift
Normal file
651
apps/macos/Sources/OpenClaw/ExecWrapperResolution.swift
Normal file
@ -0,0 +1,651 @@
|
||||
import Foundation
|
||||
|
||||
enum ExecWrapperResolution {
|
||||
static let maxWrapperDepth = ExecEnvInvocationUnwrapper.maxWrapperDepth
|
||||
|
||||
struct ShellWrapperCommand {
|
||||
let isWrapper: Bool
|
||||
let command: String?
|
||||
|
||||
static let notWrapper = ShellWrapperCommand(isWrapper: false, command: nil)
|
||||
}
|
||||
|
||||
enum ShellMultiplexerUnwrapResult {
|
||||
case notWrapper
|
||||
case blocked(wrapper: String)
|
||||
case unwrapped(wrapper: String, argv: [String])
|
||||
}
|
||||
|
||||
enum DispatchWrapperUnwrapResult {
|
||||
case notWrapper
|
||||
case blocked(wrapper: String)
|
||||
case unwrapped(wrapper: String, argv: [String])
|
||||
}
|
||||
|
||||
struct DispatchWrapperExecutionPlan {
|
||||
let argv: [String]
|
||||
let wrappers: [String]
|
||||
let policyBlocked: Bool
|
||||
let blockedWrapper: String?
|
||||
}
|
||||
|
||||
private enum ShellWrapperKind {
|
||||
case posix
|
||||
case cmd
|
||||
case powershell
|
||||
}
|
||||
|
||||
private struct ShellWrapperSpec {
|
||||
let kind: ShellWrapperKind
|
||||
let names: Set<String>
|
||||
}
|
||||
|
||||
private enum WrapperScanDirective {
|
||||
case continueScan
|
||||
case consumeNext
|
||||
case stop
|
||||
case invalid
|
||||
}
|
||||
|
||||
private struct InlineCommandMatch {
|
||||
let tokenIndex: Int
|
||||
let inlineCommand: String?
|
||||
}
|
||||
|
||||
private static let posixInlineFlags = Set(["-lc", "-c", "--command"])
|
||||
private static let powershellInlineFlags = Set([
|
||||
"-c",
|
||||
"-command",
|
||||
"--command",
|
||||
"-f",
|
||||
"-file",
|
||||
"-encodedcommand",
|
||||
"-enc",
|
||||
"-e",
|
||||
])
|
||||
|
||||
private static let shellWrapperNames = Set([
|
||||
"ash",
|
||||
"bash",
|
||||
"cmd",
|
||||
"dash",
|
||||
"fish",
|
||||
"ksh",
|
||||
"powershell",
|
||||
"pwsh",
|
||||
"sh",
|
||||
"zsh",
|
||||
])
|
||||
|
||||
private static let shellMultiplexerWrapperNames = Set(["busybox", "toybox"])
|
||||
private static let transparentDispatchWrappers = Set(["nice", "nohup", "stdbuf", "timeout"])
|
||||
private static let niceOptionsWithValue = Set(["-n", "--adjustment", "--priority"])
|
||||
private static let stdbufOptionsWithValue = Set(["-i", "--input", "-o", "--output", "-e", "--error"])
|
||||
private static let timeoutFlagOptions = Set(["--foreground", "--preserve-status", "-v", "--verbose"])
|
||||
private static let timeoutOptionsWithValue = Set(["-k", "--kill-after", "-s", "--signal"])
|
||||
|
||||
private static let shellWrapperSpecs: [ShellWrapperSpec] = [
|
||||
ShellWrapperSpec(kind: .posix, names: ["ash", "sh", "bash", "zsh", "dash", "ksh", "fish"]),
|
||||
ShellWrapperSpec(kind: .cmd, names: ["cmd.exe", "cmd"]),
|
||||
ShellWrapperSpec(kind: .powershell, names: ["powershell", "powershell.exe", "pwsh", "pwsh.exe"]),
|
||||
]
|
||||
|
||||
static func normalizeExecutableToken(_ token: String) -> String {
|
||||
let base = ExecCommandToken.basenameLower(token)
|
||||
if base.hasSuffix(".exe") {
|
||||
return String(base.dropLast(4))
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
static func isShellWrapperExecutable(_ token: String) -> Bool {
|
||||
self.shellWrapperNames.contains(self.normalizeExecutableToken(token))
|
||||
}
|
||||
|
||||
static func extractShellWrapperCommand(_ argv: [String], rawCommand: String?) -> ShellWrapperCommand {
|
||||
self.extractShellWrapperCommandInternal(
|
||||
argv,
|
||||
rawCommand: self.normalizeRawCommand(rawCommand),
|
||||
depth: 0)
|
||||
}
|
||||
|
||||
static func extractShellInlinePayload(_ argv: [String], normalizedWrapper: String) -> String? {
|
||||
if normalizedWrapper == "cmd" {
|
||||
return self.extractCmdInlineCommand(argv)
|
||||
}
|
||||
if normalizedWrapper == "powershell" || normalizedWrapper == "pwsh" {
|
||||
return self.extractInlineCommandByFlags(
|
||||
argv,
|
||||
flags: self.powershellInlineFlags,
|
||||
allowCombinedC: false)
|
||||
}
|
||||
return self.extractInlineCommandByFlags(
|
||||
argv,
|
||||
flags: self.posixInlineFlags,
|
||||
allowCombinedC: true)
|
||||
}
|
||||
|
||||
static func resolveInlineCommandValueTokenIndex(
|
||||
_ argv: [String],
|
||||
normalizedWrapper: String) -> Int?
|
||||
{
|
||||
if normalizedWrapper == "cmd" {
|
||||
guard let idx = argv.firstIndex(where: {
|
||||
let token = $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return token == "/c" || token == "/k"
|
||||
}) else {
|
||||
return nil
|
||||
}
|
||||
let nextIndex = idx + 1
|
||||
return nextIndex < argv.count ? nextIndex : nil
|
||||
}
|
||||
|
||||
let flags: Set<String>
|
||||
let allowCombinedC: Bool
|
||||
if normalizedWrapper == "powershell" || normalizedWrapper == "pwsh" {
|
||||
flags = self.powershellInlineFlags
|
||||
allowCombinedC = false
|
||||
} else {
|
||||
flags = self.posixInlineFlags
|
||||
allowCombinedC = true
|
||||
}
|
||||
|
||||
guard let match = self.findInlineCommandMatch(argv, flags: flags, allowCombinedC: allowCombinedC) else {
|
||||
return nil
|
||||
}
|
||||
if match.inlineCommand != nil {
|
||||
return match.tokenIndex
|
||||
}
|
||||
let nextIndex = match.tokenIndex + 1
|
||||
return nextIndex < argv.count ? nextIndex : nil
|
||||
}
|
||||
|
||||
static func unwrapKnownShellMultiplexerInvocation(_ argv: [String]) -> ShellMultiplexerUnwrapResult {
|
||||
guard let token0 = self.trimmedNonEmpty(argv.first) else {
|
||||
return .notWrapper
|
||||
}
|
||||
let wrapper = self.normalizeExecutableToken(token0)
|
||||
guard self.shellMultiplexerWrapperNames.contains(wrapper) else {
|
||||
return .notWrapper
|
||||
}
|
||||
|
||||
var appletIndex = 1
|
||||
if appletIndex < argv.count, argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" {
|
||||
appletIndex += 1
|
||||
}
|
||||
guard appletIndex < argv.count else {
|
||||
return .blocked(wrapper: wrapper)
|
||||
}
|
||||
|
||||
let applet = argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !applet.isEmpty, self.isShellWrapperExecutable(applet) else {
|
||||
return .blocked(wrapper: wrapper)
|
||||
}
|
||||
|
||||
let unwrapped = Array(argv[appletIndex...])
|
||||
guard !unwrapped.isEmpty else {
|
||||
return .blocked(wrapper: wrapper)
|
||||
}
|
||||
return .unwrapped(wrapper: wrapper, argv: unwrapped)
|
||||
}
|
||||
|
||||
static func unwrapKnownDispatchWrapperInvocation(_ argv: [String]) -> DispatchWrapperUnwrapResult {
|
||||
guard let token0 = self.trimmedNonEmpty(argv.first) else {
|
||||
return .notWrapper
|
||||
}
|
||||
let wrapper = self.normalizeExecutableToken(token0)
|
||||
switch wrapper {
|
||||
case "env":
|
||||
return self.unwrapDispatchWrapper(wrapper: wrapper, unwrapped: ExecEnvInvocationUnwrapper.unwrap(argv))
|
||||
case "nice":
|
||||
return self.unwrapDispatchWrapper(wrapper: wrapper, unwrapped: self.unwrapNiceInvocation(argv))
|
||||
case "nohup":
|
||||
return self.unwrapDispatchWrapper(wrapper: wrapper, unwrapped: self.unwrapNohupInvocation(argv))
|
||||
case "stdbuf":
|
||||
return self.unwrapDispatchWrapper(wrapper: wrapper, unwrapped: self.unwrapStdbufInvocation(argv))
|
||||
case "timeout":
|
||||
return self.unwrapDispatchWrapper(wrapper: wrapper, unwrapped: self.unwrapTimeoutInvocation(argv))
|
||||
case "chrt", "doas", "ionice", "setsid", "sudo", "taskset":
|
||||
return .blocked(wrapper: wrapper)
|
||||
default:
|
||||
return .notWrapper
|
||||
}
|
||||
}
|
||||
|
||||
static func resolveDispatchWrapperExecutionPlan(
|
||||
_ argv: [String],
|
||||
maxDepth: Int = ExecEnvInvocationUnwrapper.maxWrapperDepth) -> DispatchWrapperExecutionPlan
|
||||
{
|
||||
var current = argv
|
||||
var wrappers: [String] = []
|
||||
|
||||
for _ in 0..<maxDepth {
|
||||
let unwrap = self.unwrapKnownDispatchWrapperInvocation(current)
|
||||
switch unwrap {
|
||||
case let .blocked(wrapper):
|
||||
return DispatchWrapperExecutionPlan(
|
||||
argv: current,
|
||||
wrappers: wrappers,
|
||||
policyBlocked: true,
|
||||
blockedWrapper: wrapper)
|
||||
case let .unwrapped(wrapper, argv):
|
||||
guard !argv.isEmpty else { break }
|
||||
wrappers.append(wrapper)
|
||||
if self.isSemanticDispatchWrapperUsage(wrapper: wrapper, argv: current) {
|
||||
return DispatchWrapperExecutionPlan(
|
||||
argv: current,
|
||||
wrappers: wrappers,
|
||||
policyBlocked: true,
|
||||
blockedWrapper: wrapper)
|
||||
}
|
||||
current = argv
|
||||
case .notWrapper:
|
||||
return DispatchWrapperExecutionPlan(
|
||||
argv: current,
|
||||
wrappers: wrappers,
|
||||
policyBlocked: false,
|
||||
blockedWrapper: nil)
|
||||
}
|
||||
}
|
||||
|
||||
if wrappers.count >= maxDepth {
|
||||
let overflow = self.unwrapKnownDispatchWrapperInvocation(current)
|
||||
switch overflow {
|
||||
case let .blocked(wrapper), let .unwrapped(wrapper, _):
|
||||
return DispatchWrapperExecutionPlan(
|
||||
argv: current,
|
||||
wrappers: wrappers,
|
||||
policyBlocked: true,
|
||||
blockedWrapper: wrapper)
|
||||
case .notWrapper:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return DispatchWrapperExecutionPlan(
|
||||
argv: current,
|
||||
wrappers: wrappers,
|
||||
policyBlocked: false,
|
||||
blockedWrapper: nil)
|
||||
}
|
||||
|
||||
static func unwrapDispatchWrappersForResolution(
|
||||
_ argv: [String],
|
||||
maxDepth: Int = ExecEnvInvocationUnwrapper.maxWrapperDepth) -> [String]
|
||||
{
|
||||
self.resolveDispatchWrapperExecutionPlan(argv, maxDepth: maxDepth).argv
|
||||
}
|
||||
|
||||
static func unwrapShellInspectionArgv(_ argv: [String]) -> [String] {
|
||||
var current = self.unwrapDispatchWrappersForResolution(argv)
|
||||
for _ in 0..<self.maxWrapperDepth {
|
||||
let multiplexer = self.unwrapKnownShellMultiplexerInvocation(current)
|
||||
switch multiplexer {
|
||||
case let .unwrapped(_, argv):
|
||||
current = argv
|
||||
case .blocked, .notWrapper:
|
||||
return current
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
static func hasEnvManipulationBeforeShellWrapper(_ argv: [String]) -> Bool {
|
||||
self.hasEnvManipulationBeforeShellWrapperInternal(
|
||||
argv,
|
||||
depth: 0,
|
||||
envManipulationSeen: false)
|
||||
}
|
||||
|
||||
private static func normalizeRawCommand(_ rawCommand: String?) -> String? {
|
||||
let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func trimmedNonEmpty(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func splitFlag(_ lowerToken: String) -> String {
|
||||
lowerToken.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lowerToken
|
||||
}
|
||||
|
||||
private static func scanWrapperInvocation(
|
||||
_ argv: [String],
|
||||
separators: Set<String> = [],
|
||||
onToken: (String, String) -> WrapperScanDirective,
|
||||
adjustCommandIndex: ((Int, [String]) -> Int?)? = nil) -> [String]?
|
||||
{
|
||||
var idx = 1
|
||||
var expectsOptionValue = false
|
||||
|
||||
while idx < argv.count {
|
||||
let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if token.isEmpty {
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if expectsOptionValue {
|
||||
expectsOptionValue = false
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if separators.contains(token) {
|
||||
idx += 1
|
||||
break
|
||||
}
|
||||
|
||||
let directive = onToken(token, token.lowercased())
|
||||
switch directive {
|
||||
case .stop:
|
||||
break
|
||||
case .invalid:
|
||||
return nil
|
||||
case .consumeNext:
|
||||
expectsOptionValue = true
|
||||
case .continueScan:
|
||||
break
|
||||
}
|
||||
|
||||
if directive == .stop {
|
||||
break
|
||||
}
|
||||
idx += 1
|
||||
}
|
||||
|
||||
if expectsOptionValue {
|
||||
return nil
|
||||
}
|
||||
|
||||
let commandIndex = adjustCommandIndex?(idx, argv) ?? idx
|
||||
guard commandIndex < argv.count else {
|
||||
return nil
|
||||
}
|
||||
return Array(argv[commandIndex...])
|
||||
}
|
||||
|
||||
private static func unwrapDashOptionInvocation(
|
||||
_ argv: [String],
|
||||
onFlag: (String, String) -> WrapperScanDirective,
|
||||
adjustCommandIndex: ((Int, [String]) -> Int?)? = nil) -> [String]?
|
||||
{
|
||||
self.scanWrapperInvocation(
|
||||
argv,
|
||||
separators: ["--"],
|
||||
onToken: { token, lower in
|
||||
if !token.hasPrefix("-") || token == "-" {
|
||||
return .stop
|
||||
}
|
||||
return onFlag(self.splitFlag(lower), lower)
|
||||
},
|
||||
adjustCommandIndex: adjustCommandIndex)
|
||||
}
|
||||
|
||||
private static func envInvocationUsesModifiers(_ argv: [String]) -> Bool {
|
||||
ExecEnvInvocationUnwrapper.unwrapWithMetadata(argv)?.usesModifiers ?? true
|
||||
}
|
||||
|
||||
private static func unwrapNiceInvocation(_ argv: [String]) -> [String]? {
|
||||
self.unwrapDashOptionInvocation(argv) { flag, lower in
|
||||
if lower.range(of: #"^-\d+$"#, options: .regularExpression) != nil {
|
||||
return .continueScan
|
||||
}
|
||||
if self.niceOptionsWithValue.contains(flag) {
|
||||
return lower.contains("=") || lower != flag ? .continueScan : .consumeNext
|
||||
}
|
||||
if lower.hasPrefix("-n"), lower.count > 2 {
|
||||
return .continueScan
|
||||
}
|
||||
return .invalid
|
||||
}
|
||||
}
|
||||
|
||||
private static func unwrapNohupInvocation(_ argv: [String]) -> [String]? {
|
||||
self.scanWrapperInvocation(
|
||||
argv,
|
||||
separators: ["--"],
|
||||
onToken: { token, lower in
|
||||
if !token.hasPrefix("-") || token == "-" {
|
||||
return .stop
|
||||
}
|
||||
return lower == "--help" || lower == "--version" ? .continueScan : .invalid
|
||||
})
|
||||
}
|
||||
|
||||
private static func unwrapStdbufInvocation(_ argv: [String]) -> [String]? {
|
||||
self.unwrapDashOptionInvocation(argv) { flag, lower in
|
||||
if !self.stdbufOptionsWithValue.contains(flag) {
|
||||
return .invalid
|
||||
}
|
||||
return lower.contains("=") ? .continueScan : .consumeNext
|
||||
}
|
||||
}
|
||||
|
||||
private static func unwrapTimeoutInvocation(_ argv: [String]) -> [String]? {
|
||||
self.unwrapDashOptionInvocation(
|
||||
argv,
|
||||
onFlag: { flag, lower in
|
||||
if self.timeoutFlagOptions.contains(flag) {
|
||||
return .continueScan
|
||||
}
|
||||
if self.timeoutOptionsWithValue.contains(flag) {
|
||||
return lower.contains("=") ? .continueScan : .consumeNext
|
||||
}
|
||||
return .invalid
|
||||
},
|
||||
adjustCommandIndex: { commandIndex, currentArgv in
|
||||
let wrappedCommandIndex = commandIndex + 1
|
||||
return wrappedCommandIndex < currentArgv.count ? wrappedCommandIndex : nil
|
||||
})
|
||||
}
|
||||
|
||||
private static func unwrapDispatchWrapper(
|
||||
wrapper: String,
|
||||
unwrapped: [String]?) -> DispatchWrapperUnwrapResult
|
||||
{
|
||||
guard let unwrapped, !unwrapped.isEmpty else {
|
||||
return .blocked(wrapper: wrapper)
|
||||
}
|
||||
return .unwrapped(wrapper: wrapper, argv: unwrapped)
|
||||
}
|
||||
|
||||
private static func isSemanticDispatchWrapperUsage(wrapper: String, argv: [String]) -> Bool {
|
||||
if wrapper == "env" {
|
||||
return self.envInvocationUsesModifiers(argv)
|
||||
}
|
||||
return !self.transparentDispatchWrappers.contains(wrapper)
|
||||
}
|
||||
|
||||
private static func findShellWrapperSpec(_ baseExecutable: String) -> ShellWrapperSpec? {
|
||||
self.shellWrapperSpecs.first { $0.names.contains(baseExecutable) }
|
||||
}
|
||||
|
||||
private static func extractShellWrapperPayload(_ argv: [String], spec: ShellWrapperSpec) -> String? {
|
||||
switch spec.kind {
|
||||
case .posix:
|
||||
return self.extractInlineCommandByFlags(
|
||||
argv,
|
||||
flags: self.posixInlineFlags,
|
||||
allowCombinedC: true)
|
||||
case .cmd:
|
||||
return self.extractCmdInlineCommand(argv)
|
||||
case .powershell:
|
||||
return self.extractInlineCommandByFlags(
|
||||
argv,
|
||||
flags: self.powershellInlineFlags,
|
||||
allowCombinedC: false)
|
||||
}
|
||||
}
|
||||
|
||||
private static func extractShellWrapperCommandInternal(
|
||||
_ argv: [String],
|
||||
rawCommand: String?,
|
||||
depth: Int) -> ShellWrapperCommand
|
||||
{
|
||||
if depth > self.maxWrapperDepth {
|
||||
return .notWrapper
|
||||
}
|
||||
guard let token0 = self.trimmedNonEmpty(argv.first) else {
|
||||
return .notWrapper
|
||||
}
|
||||
|
||||
switch self.unwrapKnownDispatchWrapperInvocation(argv) {
|
||||
case .blocked:
|
||||
return .notWrapper
|
||||
case let .unwrapped(_, argv):
|
||||
return self.extractShellWrapperCommandInternal(
|
||||
argv,
|
||||
rawCommand: rawCommand,
|
||||
depth: depth + 1)
|
||||
case .notWrapper:
|
||||
break
|
||||
}
|
||||
|
||||
switch self.unwrapKnownShellMultiplexerInvocation(argv) {
|
||||
case .blocked:
|
||||
return .notWrapper
|
||||
case let .unwrapped(_, argv):
|
||||
return self.extractShellWrapperCommandInternal(
|
||||
argv,
|
||||
rawCommand: rawCommand,
|
||||
depth: depth + 1)
|
||||
case .notWrapper:
|
||||
break
|
||||
}
|
||||
|
||||
let base0 = self.normalizeExecutableToken(token0)
|
||||
guard let wrapper = self.findShellWrapperSpec(base0),
|
||||
let payload = self.extractShellWrapperPayload(argv, spec: wrapper)
|
||||
else {
|
||||
return .notWrapper
|
||||
}
|
||||
|
||||
return ShellWrapperCommand(
|
||||
isWrapper: true,
|
||||
command: rawCommand ?? payload)
|
||||
}
|
||||
|
||||
private static func hasEnvManipulationBeforeShellWrapperInternal(
|
||||
_ argv: [String],
|
||||
depth: Int,
|
||||
envManipulationSeen: Bool) -> Bool
|
||||
{
|
||||
if depth > self.maxWrapperDepth {
|
||||
return false
|
||||
}
|
||||
guard let token0 = self.trimmedNonEmpty(argv.first) else {
|
||||
return false
|
||||
}
|
||||
|
||||
switch self.unwrapKnownDispatchWrapperInvocation(argv) {
|
||||
case .blocked:
|
||||
return false
|
||||
case let .unwrapped(wrapper, unwrappedArgv):
|
||||
let nextEnvManipulationSeen = envManipulationSeen || (
|
||||
wrapper == "env" && self.envInvocationUsesModifiers(argv)
|
||||
)
|
||||
return self.hasEnvManipulationBeforeShellWrapperInternal(
|
||||
unwrappedArgv,
|
||||
depth: depth + 1,
|
||||
envManipulationSeen: nextEnvManipulationSeen)
|
||||
case .notWrapper:
|
||||
break
|
||||
}
|
||||
|
||||
switch self.unwrapKnownShellMultiplexerInvocation(argv) {
|
||||
case .blocked:
|
||||
return false
|
||||
case let .unwrapped(_, argv):
|
||||
return self.hasEnvManipulationBeforeShellWrapperInternal(
|
||||
argv,
|
||||
depth: depth + 1,
|
||||
envManipulationSeen: envManipulationSeen)
|
||||
case .notWrapper:
|
||||
break
|
||||
}
|
||||
|
||||
let normalized = self.normalizeExecutableToken(token0)
|
||||
guard let spec = self.findShellWrapperSpec(normalized),
|
||||
self.extractShellWrapperPayload(argv, spec: spec) != nil
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return envManipulationSeen
|
||||
}
|
||||
|
||||
private static func findInlineCommandMatch(
|
||||
_ argv: [String],
|
||||
flags: Set<String>,
|
||||
allowCombinedC: Bool) -> InlineCommandMatch?
|
||||
{
|
||||
var idx = 1
|
||||
while idx < argv.count {
|
||||
let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if token.isEmpty {
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
let lower = token.lowercased()
|
||||
if lower == "--" {
|
||||
break
|
||||
}
|
||||
if flags.contains(lower) {
|
||||
return InlineCommandMatch(tokenIndex: idx, inlineCommand: nil)
|
||||
}
|
||||
if allowCombinedC, let inlineOffset = self.combinedCommandInlineOffset(token) {
|
||||
let inline = String(token.dropFirst(inlineOffset))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return InlineCommandMatch(
|
||||
tokenIndex: idx,
|
||||
inlineCommand: inline.isEmpty ? nil : inline)
|
||||
}
|
||||
idx += 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func extractInlineCommandByFlags(
|
||||
_ argv: [String],
|
||||
flags: Set<String>,
|
||||
allowCombinedC: Bool) -> String?
|
||||
{
|
||||
guard let match = self.findInlineCommandMatch(argv, flags: flags, allowCombinedC: allowCombinedC) else {
|
||||
return nil
|
||||
}
|
||||
if let inlineCommand = match.inlineCommand {
|
||||
return inlineCommand
|
||||
}
|
||||
let nextIndex = match.tokenIndex + 1
|
||||
return self.trimmedNonEmpty(nextIndex < argv.count ? argv[nextIndex] : nil)
|
||||
}
|
||||
|
||||
private static func combinedCommandInlineOffset(_ token: String) -> Int? {
|
||||
let chars = Array(token.lowercased())
|
||||
guard chars.count >= 2, chars[0] == "-", chars[1] != "-" else {
|
||||
return nil
|
||||
}
|
||||
if chars.dropFirst().contains("-") {
|
||||
return nil
|
||||
}
|
||||
guard let commandIndex = chars.firstIndex(of: "c"), commandIndex > 0 else {
|
||||
return nil
|
||||
}
|
||||
return commandIndex + 1
|
||||
}
|
||||
|
||||
private static func extractCmdInlineCommand(_ argv: [String]) -> String? {
|
||||
guard let idx = argv.firstIndex(where: {
|
||||
let token = $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return token == "/c" || token == "/k"
|
||||
}) else {
|
||||
return nil
|
||||
}
|
||||
let tailIndex = idx + 1
|
||||
guard tailIndex < argv.count else {
|
||||
return nil
|
||||
}
|
||||
let payload = argv[tailIndex...].joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return payload.isEmpty ? nil : payload
|
||||
}
|
||||
}
|
||||
@ -228,6 +228,42 @@ struct ExecAllowlistTests {
|
||||
#expect(resolutions[1].executableName == "touch")
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist unwraps busybox shell applets`() throws {
|
||||
let tmp = try makeTempDirForTests()
|
||||
let busybox = tmp.appendingPathComponent("busybox")
|
||||
let whoami = tmp.appendingPathComponent("whoami")
|
||||
try makeExecutableForTests(at: busybox)
|
||||
try makeExecutableForTests(at: whoami)
|
||||
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: [busybox.path, "sh", "-lc", "echo allowlisted && whoami"],
|
||||
rawCommand: nil,
|
||||
cwd: tmp.path,
|
||||
env: ["PATH": "\(tmp.path):/usr/bin:/bin"])
|
||||
|
||||
#expect(resolutions.count == 2)
|
||||
#expect(resolutions[0].executableName == "echo")
|
||||
#expect(resolutions[1].resolvedPath == whoami.path)
|
||||
#expect(resolutions[1].executableName == "whoami")
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist unwraps dispatch wrappers before shell wrappers`() throws {
|
||||
let tmp = try makeTempDirForTests()
|
||||
let whoami = tmp.appendingPathComponent("whoami")
|
||||
try makeExecutableForTests(at: whoami)
|
||||
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: ["/usr/bin/nice", "/bin/zsh", "-lc", "echo allowlisted && whoami"],
|
||||
rawCommand: nil,
|
||||
cwd: tmp.path,
|
||||
env: ["PATH": "\(tmp.path):/usr/bin:/bin"])
|
||||
|
||||
#expect(resolutions.count == 2)
|
||||
#expect(resolutions[0].executableName == "echo")
|
||||
#expect(resolutions[1].resolvedPath == whoami.path)
|
||||
#expect(resolutions[1].executableName == "whoami")
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist unwraps env dispatch wrappers inside shell segments`() {
|
||||
let command = ["/bin/sh", "-lc", "env /usr/bin/touch /tmp/openclaw-allowlist-test"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
@ -289,6 +325,58 @@ struct ExecAllowlistTests {
|
||||
#expect(patterns == ["/usr/bin/printf"])
|
||||
}
|
||||
|
||||
@Test func `allow always patterns unwrap dispatch wrappers before shell wrappers`() throws {
|
||||
let tmp = try makeTempDirForTests()
|
||||
let whoami = tmp.appendingPathComponent("whoami")
|
||||
try makeExecutableForTests(at: whoami)
|
||||
|
||||
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
|
||||
command: ["/usr/bin/nice", "/bin/zsh", "-lc", "whoami"],
|
||||
cwd: tmp.path,
|
||||
env: ["PATH": "\(tmp.path):/usr/bin:/bin"])
|
||||
|
||||
#expect(patterns == [whoami.path])
|
||||
#expect(!patterns.contains("/usr/bin/nice"))
|
||||
}
|
||||
|
||||
@Test func `allow always patterns unwrap busybox shell applets to inner executables`() throws {
|
||||
let tmp = try makeTempDirForTests()
|
||||
let busybox = tmp.appendingPathComponent("busybox")
|
||||
let whoami = tmp.appendingPathComponent("whoami")
|
||||
try makeExecutableForTests(at: busybox)
|
||||
try makeExecutableForTests(at: whoami)
|
||||
|
||||
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
|
||||
command: [busybox.path, "sh", "-lc", "whoami"],
|
||||
cwd: tmp.path,
|
||||
env: ["PATH": "\(tmp.path):/usr/bin:/bin"])
|
||||
|
||||
#expect(patterns == [whoami.path])
|
||||
#expect(!patterns.contains(busybox.path))
|
||||
}
|
||||
|
||||
@Test func `allow always patterns fail closed for unsupported busybox applets`() throws {
|
||||
let tmp = try makeTempDirForTests()
|
||||
let busybox = tmp.appendingPathComponent("busybox")
|
||||
try makeExecutableForTests(at: busybox)
|
||||
|
||||
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
|
||||
command: [busybox.path, "sed", "-n", "1p"],
|
||||
cwd: tmp.path,
|
||||
env: ["PATH": "\(tmp.path):/usr/bin:/bin"])
|
||||
|
||||
#expect(patterns.isEmpty)
|
||||
}
|
||||
|
||||
@Test func `allow always patterns fail closed for blocked dispatch wrappers`() {
|
||||
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
|
||||
command: ["sudo", "/bin/zsh", "-lc", "whoami"],
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
|
||||
#expect(patterns.isEmpty)
|
||||
}
|
||||
|
||||
@Test func `match all requires every segment to match`() {
|
||||
let first = ExecCommandResolution(
|
||||
rawExecutable: "echo",
|
||||
|
||||
@ -64,6 +64,38 @@ struct ExecSystemRunCommandValidatorTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `validator keeps busybox shell wrapper text out of allowlist raw parsing`() throws {
|
||||
let tmp = try makeTempDirForTests()
|
||||
let busybox = tmp.appendingPathComponent("busybox")
|
||||
try makeExecutableForTests(at: busybox)
|
||||
|
||||
let command = [busybox.path, "sh", "-lc", "/usr/bin/printf ok"]
|
||||
let rawCommand = "\(busybox.path) sh -lc \"/usr/bin/printf ok\""
|
||||
let result = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: rawCommand)
|
||||
|
||||
switch result {
|
||||
case let .ok(resolved):
|
||||
#expect(resolved.displayCommand == rawCommand)
|
||||
#expect(resolved.evaluationRawCommand == nil)
|
||||
case let .invalid(message):
|
||||
Issue.record("unexpected invalid result: \(message)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `validator keeps dispatch wrapper shell text out of allowlist raw parsing`() {
|
||||
let command = ["/usr/bin/nice", "/bin/sh", "-lc", "/usr/bin/printf ok"]
|
||||
let rawCommand = "/usr/bin/nice /bin/sh -lc \"/usr/bin/printf ok\""
|
||||
let result = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: rawCommand)
|
||||
|
||||
switch result {
|
||||
case let .ok(resolved):
|
||||
#expect(resolved.displayCommand == rawCommand)
|
||||
#expect(resolved.evaluationRawCommand == nil)
|
||||
case let .invalid(message):
|
||||
Issue.record("unexpected invalid result: \(message)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadContractCases() throws -> [SystemRunCommandContractCase] {
|
||||
let fixtureURL = try self.findContractFixtureURL()
|
||||
let data = try Data(contentsOf: fixtureURL)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user