Compare commits

...

7 Commits

Author SHA1 Message Date
Nimrod Gutman
1d45febea8 fix(macos): fail closed on env-modified shell wrappers 2026-03-19 15:31:26 +02:00
Nimrod Gutman
e4b70ea497 refactor(macos): tighten wrapper resolution follow-ups 2026-03-19 15:20:20 +02:00
Nimrod Gutman
fbbf389359 fix(macos): align shell script allowlist parity 2026-03-19 15:18:17 +02:00
Nimrod Gutman
66b5bfcc19 fix(macos): bound nested shell allowlist recursion 2026-03-19 15:05:08 +02:00
Nimrod Gutman
939481e22f fix(macos): address remaining exec review comments 2026-03-19 14:53:44 +02:00
Nimrod Gutman
f55e51afb5 fix(macos): address wrapper review feedback 2026-03-19 14:27:05 +02:00
Nimrod Gutman
5b704a6ea4 fix(macos): align exec wrapper resolution 2026-03-19 14:14:28 +02:00
8 changed files with 1175 additions and 491 deletions

View File

@ -5,12 +5,20 @@ enum ExecAllowlistMatcher {
guard let resolution, !entries.isEmpty else { return nil }
let rawExecutable = resolution.rawExecutable
let resolvedPath = resolution.resolvedPath
let scriptCandidatePath = resolution.scriptCandidatePath
for entry in entries {
switch ExecApprovalHelpers.validateAllowlistPattern(entry.pattern) {
case let .valid(pattern):
let target = resolvedPath ?? rawExecutable
if self.matches(pattern: pattern, target: target) { return entry }
let primaryTarget = resolvedPath ?? rawExecutable
if self.matches(pattern: pattern, target: primaryTarget) {
return entry
}
if let scriptCandidatePath,
self.matches(pattern: pattern, target: scriptCandidatePath)
{
return entry
}
case .invalid:
continue
}

View File

@ -3,20 +3,54 @@ import Foundation
struct ExecCommandResolution {
let rawExecutable: String
let resolvedPath: String?
let scriptCandidatePath: String?
let executableName: String
let cwd: String?
private static let maxAllowAlwaysTraversalDepth = 2
init(
rawExecutable: String,
resolvedPath: String?,
scriptCandidatePath: String? = nil,
executableName: String,
cwd: String?)
{
self.rawExecutable = rawExecutable
self.resolvedPath = resolvedPath
self.scriptCandidatePath = scriptCandidatePath
self.executableName = executableName
self.cwd = cwd
}
static func resolve(
command: [String],
rawCommand: String?,
cwd: String?,
env: [String: String]?) -> ExecCommandResolution?
{
let effective = ExecWrapperResolution.unwrapDispatchWrappersForResolution(command)
guard let effectiveRaw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !effectiveRaw.isEmpty else {
return nil
}
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) {
return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
let normalizedToken = ExecWrapperResolution.normalizeExecutableToken(token)
let normalizedEffective = ExecWrapperResolution.normalizeExecutableToken(effectiveRaw)
if normalizedToken == normalizedEffective {
let resolution = self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
return self.attachingScriptCandidatePath(
to: resolution,
command: command,
cwd: cwd)
}
}
return self.resolve(command: command, cwd: cwd, env: env)
let resolution = self.resolveExecutable(rawExecutable: effectiveRaw, cwd: cwd, env: env)
return self.attachingScriptCandidatePath(
to: resolution,
command: command,
cwd: cwd)
}
static func resolveForAllowlist(
@ -25,6 +59,30 @@ struct ExecCommandResolution {
cwd: String?,
env: [String: String]?) -> [ExecCommandResolution]
{
self.resolveForAllowlist(
command: command,
rawCommand: rawCommand,
cwd: cwd,
env: env,
depth: 0)
}
private static func resolveForAllowlist(
command: [String],
rawCommand: String?,
cwd: String?,
env: [String: String]?,
depth: Int) -> [ExecCommandResolution]
{
guard depth <= ExecWrapperResolution.maxWrapperDepth, !command.isEmpty else {
return []
}
if ExecWrapperResolution.hasEnvManipulationBeforeShellWrapper(command) {
// Fail closed for semantic env wrappers that can alter shell lookup
// semantics before we would analyze inner shell payloads.
return []
}
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: rawCommand)
if shell.isWrapper {
guard let shellCommand = shell.command,
@ -35,13 +93,16 @@ struct ExecCommandResolution {
return []
}
var resolutions: [ExecCommandResolution] = []
resolutions.reserveCapacity(segments.count)
for segment in segments {
guard let resolution = self.resolveShellSegmentExecutable(segment, cwd: cwd, env: env)
else {
let segmentResolutions = self.resolveShellSegmentExecutions(
segment,
cwd: cwd,
env: env,
depth: depth + 1)
guard !segmentResolutions.isEmpty else {
return []
}
resolutions.append(resolution)
resolutions.append(contentsOf: segmentResolutions)
}
return resolutions
}
@ -70,11 +131,15 @@ 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
}
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
let resolution = self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
return self.attachingScriptCandidatePath(
to: resolution,
command: command,
cwd: cwd)
}
private static func resolveExecutable(
@ -104,18 +169,23 @@ struct ExecCommandResolution {
cwd: cwd)
}
private static func resolveShellSegmentExecutable(
private static func resolveShellSegmentExecutions(
_ segment: String,
cwd: String?,
env: [String: String]?) -> ExecCommandResolution?
env: [String: String]?,
depth: Int) -> [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
guard depth <= ExecWrapperResolution.maxWrapperDepth else {
return []
}
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
let tokens = self.tokenizeShellWords(segment)
guard !tokens.isEmpty else { return [] }
return self.resolveForAllowlist(
command: tokens,
rawCommand: nil,
cwd: cwd,
env: env,
depth: depth)
}
private static func collectAllowAlwaysPatterns(
@ -126,34 +196,51 @@ struct ExecCommandResolution {
patterns: inout [String],
seen: inout Set<String>)
{
guard depth < 3, !command.isEmpty else {
guard depth <= Self.maxAllowAlwaysTraversalDepth, !command.isEmpty else {
return
}
if ExecWrapperResolution.hasEnvManipulationBeforeShellWrapper(command) {
// Mirror the conservative node-host policy for env-modified shell
// launches: require explicit approval each time instead of persisting
// an inner-executable pattern that the modified environment can subvert.
return
}
if let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines),
ExecCommandToken.basenameLower(token0) == "env",
let envUnwrapped = ExecEnvInvocationUnwrapper.unwrap(command),
!envUnwrapped.isEmpty
{
// Allow-always persistence intentionally peels known dispatch wrappers
// directly so approvals stay scoped to the launched executable instead of
// the wrapper binary. The allowlist path stays stricter for semantic
// wrapper usage (for example `env FOO=bar ...`) and may still require
// re-approval in those cases.
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)
@ -179,6 +266,13 @@ struct ExecCommandResolution {
return
}
if let scriptCandidatePath = ExecWrapperResolution.resolveShellWrapperScriptCandidatePath(command, cwd: cwd),
seen.insert(scriptCandidatePath).inserted
{
patterns.append(scriptCandidatePath)
return
}
guard let resolution = self.resolve(command: command, cwd: cwd, env: env),
let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution),
seen.insert(pattern).inserted
@ -188,43 +282,23 @@ struct ExecCommandResolution {
patterns.append(pattern)
}
private static func unwrapShellMultiplexerInvocation(_ argv: [String]) -> [String]? {
guard let token0 = argv.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else {
private static func attachingScriptCandidatePath(
to resolution: ExecCommandResolution?,
command: [String],
cwd: String?) -> ExecCommandResolution?
{
guard let resolution else {
return nil
}
let wrapper = ExecCommandToken.basenameLower(token0)
guard wrapper == "busybox" || wrapper == "toybox" else {
return nil
guard let scriptCandidatePath = ExecWrapperResolution.resolveShellWrapperScriptCandidatePath(command, cwd: cwd) else {
return resolution
}
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...])
return ExecCommandResolution(
rawExecutable: resolution.rawExecutable,
resolvedPath: resolution.resolvedPath,
scriptCandidatePath: scriptCandidatePath,
executableName: resolution.executableName,
cwd: resolution.cwd)
}
private static func parseFirstToken(_ command: String) -> String? {

View File

@ -88,26 +88,4 @@ enum ExecEnvInvocationUnwrapper {
guard !expectsOptionValue, idx < command.count else { return nil }
return UnwrapResult(command: Array(command[idx...]), usesModifiers: usesModifiers)
}
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
}
}

View File

@ -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)
}
}

View File

@ -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,8 @@ 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
private static func hasEnvManipulationBeforeShellWrapper(_ argv: [String]) -> Bool {
return ExecWrapperResolution.hasEnvManipulationBeforeShellWrapper(argv)
}
private static func hasTrailingPositionalArgvAfterInlineCommand(_ argv: [String]) -> Bool {
@ -265,22 +103,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 +122,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)
}
}

View File

@ -0,0 +1,736 @@
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 shellWrapperOptionsWithValue = Set([
"-c",
"--command",
"-o",
"+o",
"--rcfile",
"--init-file",
"--startup-file",
])
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)
}
return .unwrapped(wrapper: wrapper, argv: Array(argv[appletIndex...]))
}
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):
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 resolveShellWrapperScriptCandidatePath(_ argv: [String], cwd: String?) -> String? {
let effective = self.unwrapShellInspectionArgv(argv)
guard let token0 = self.trimmedNonEmpty(effective.first) else {
return nil
}
let normalized = self.normalizeExecutableToken(token0)
guard let spec = self.findShellWrapperSpec(normalized) else {
return nil
}
guard self.extractShellWrapperPayload(effective, spec: spec) == nil else {
return nil
}
guard let scriptIndex = self.findShellWrapperScriptTokenIndex(effective) else {
return nil
}
let scriptToken = effective[scriptIndex].trimmingCharacters(in: .whitespacesAndNewlines)
guard !scriptToken.isEmpty else {
return nil
}
let expanded = scriptToken.hasPrefix("~")
? (scriptToken as NSString).expandingTildeInPath
: scriptToken
if expanded.hasPrefix("/") {
return expanded
}
let trimmedCwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
let root = (trimmedCwd?.isEmpty == false) ? trimmedCwd! : FileManager().currentDirectoryPath
return URL(fileURLWithPath: root)
.appendingPathComponent(expanded)
.standardizedFileURL
.path
}
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
scanLoop: 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 scanLoop
case .invalid:
return nil
case .consumeNext:
expectsOptionValue = true
case .continueScan:
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 findShellWrapperScriptTokenIndex(_ argv: [String]) -> Int? {
guard argv.count >= 2 else {
return nil
}
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 == "--" {
idx += 1
break
}
if lower == "-c" || lower == "--command" || self.isCombinedShellModeFlag(lower, flag: "c") {
return nil
}
if lower == "-s" || self.isCombinedShellModeFlag(lower, flag: "s") {
return nil
}
if self.shellWrapperOptionsWithValue.contains(lower) {
idx += 2
continue
}
if token.hasPrefix("-") || token.hasPrefix("+") {
idx += 1
continue
}
break
}
return idx < argv.count ? idx : nil
}
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 isCombinedShellModeFlag(_ lowerToken: String, flag: Character) -> Bool {
let chars = Array(lowerToken)
guard chars.count >= 2, chars[0] == "-", chars[1] != "-" else {
return false
}
if chars.dropFirst().contains("-") {
return false
}
return chars.dropFirst().contains(flag)
}
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
}
}

View File

@ -59,6 +59,18 @@ struct ExecAllowlistTests {
cwd: nil)
}
private static func shellScriptFixture() throws -> (dir: URL, bash: URL, script: URL) {
let dir = try makeTempDirForTests()
let bash = dir.appendingPathComponent("bash")
try makeExecutableForTests(at: bash)
let scriptsDir = dir.appendingPathComponent("scripts", isDirectory: true)
try FileManager.default.createDirectory(at: scriptsDir, withIntermediateDirectories: true)
let script = scriptsDir.appendingPathComponent("save_crystal.sh")
FileManager.default.createFile(atPath: script.path, contents: Data("echo ok\n".utf8))
return (dir, bash, script)
}
@Test func `match uses resolved path`() {
let entry = ExecAllowlistEntry(pattern: "/opt/homebrew/bin/rg")
let resolution = Self.homebrewRGResolution()
@ -211,6 +223,20 @@ struct ExecAllowlistTests {
#expect(resolutions[0].executableName == "sh")
}
@Test func `resolve for allowlist attaches shell script candidate path`() throws {
let fixture = try Self.shellScriptFixture()
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: ["bash", "scripts/save_crystal.sh"],
rawCommand: nil,
cwd: fixture.dir.path,
env: ["PATH": "\(fixture.dir.path):/usr/bin:/bin"])
#expect(resolutions.count == 1)
#expect(resolutions[0].resolvedPath == fixture.bash.path)
#expect(resolutions[0].scriptCandidatePath == fixture.script.path)
}
@Test func `resolve for allowlist unwraps env shell wrapper chains`() {
let command = [
"/usr/bin/env",
@ -228,6 +254,110 @@ 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 resolves blocked busybox applets to busybox itself`() throws {
let tmp = try makeTempDirForTests()
let busybox = tmp.appendingPathComponent("busybox")
try makeExecutableForTests(at: busybox)
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: [busybox.path, "sed", "-n", "1p"],
rawCommand: nil,
cwd: tmp.path,
env: ["PATH": "\(tmp.path):/usr/bin:/bin"])
#expect(resolutions.count == 1)
#expect(resolutions[0].rawExecutable == busybox.path)
#expect(resolutions[0].resolvedPath == busybox.path)
#expect(resolutions[0].executableName == "busybox")
}
@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 recurses through nested shell wrappers after dispatch wrappers`() throws {
let tmp = try makeTempDirForTests()
let whoami = tmp.appendingPathComponent("whoami")
try makeExecutableForTests(at: whoami)
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: ["/bin/sh", "-lc", "nice /bin/zsh -lc whoami"],
rawCommand: nil,
cwd: tmp.path,
env: ["PATH": "\(tmp.path):/usr/bin:/bin"])
#expect(resolutions.count == 1)
#expect(resolutions[0].resolvedPath == whoami.path)
#expect(resolutions[0].executableName == "whoami")
}
@Test func `resolve for allowlist fails closed when nested shell wrapper depth exceeds max`() throws {
let tmp = try makeTempDirForTests()
let whoami = tmp.appendingPathComponent("whoami")
try makeExecutableForTests(at: whoami)
var payload = "whoami"
for _ in 0...ExecWrapperResolution.maxWrapperDepth {
let escaped = payload
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
payload = "/bin/sh -lc \"\(escaped)\""
}
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: ["/bin/sh", "-lc", payload],
rawCommand: nil,
cwd: tmp.path,
env: ["PATH": "\(tmp.path):/usr/bin:/bin"])
#expect(resolutions.isEmpty)
}
@Test func `resolve for allowlist unwraps direct dispatch wrappers with canonical raw command`() {
let command = ["/usr/bin/nice", "/usr/bin/printf", "ok"]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: "/usr/bin/nice /usr/bin/printf ok",
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.count == 1)
#expect(resolutions[0].resolvedPath == "/usr/bin/printf")
#expect(resolutions[0].executableName == "printf")
}
@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(
@ -252,6 +382,17 @@ struct ExecAllowlistTests {
#expect(resolutions[0].executableName == "env")
}
@Test func `resolve for allowlist fails closed on env manipulation before shell wrapper`() {
let command = ["/usr/bin/env", "PATH=/tmp", "/bin/sh", "-lc", "whoami"]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: nil,
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.isEmpty)
}
@Test func `resolve for allowlist preserves env wrapper with modifiers`() {
let command = ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"]
let resolutions = ExecCommandResolution.resolveForAllowlist(
@ -280,6 +421,22 @@ struct ExecAllowlistTests {
#expect(evaluation.allowlistResolutions[0].executableName == "printf")
}
@Test func `allowlist matcher falls back to shell script candidate path`() throws {
let fixture = try Self.shellScriptFixture()
let resolution = ExecCommandResolution.resolveForAllowlist(
command: ["/usr/bin/nice", "bash", "scripts/save_crystal.sh"],
rawCommand: nil,
cwd: fixture.dir.path,
env: ["PATH": "\(fixture.dir.path):/usr/bin:/bin"]).first
let match = ExecAllowlistMatcher.match(
entries: [ExecAllowlistEntry(pattern: fixture.script.path)],
resolution: resolution)
#expect(match?.pattern == fixture.script.path)
}
@Test func `allow always patterns unwrap env wrapper modifiers to the inner executable`() {
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
command: ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"],
@ -289,6 +446,104 @@ struct ExecAllowlistTests {
#expect(patterns == ["/usr/bin/printf"])
}
@Test func `allow always patterns fail closed on env manipulation before shell wrapper`() {
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
command: ["/usr/bin/env", "PATH=/tmp", "/bin/sh", "-lc", "whoami"],
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(patterns.isEmpty)
}
@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 persist shell script paths without inline commands`() throws {
let fixture = try Self.shellScriptFixture()
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
command: ["bash", "scripts/save_crystal.sh"],
cwd: fixture.dir.path,
env: ["PATH": "\(fixture.dir.path):/usr/bin:/bin"])
#expect(patterns == [fixture.script.path])
#expect(!patterns.contains(fixture.bash.path))
}
@Test func `allow always patterns persist shell script paths through dispatch wrappers`() throws {
let fixture = try Self.shellScriptFixture()
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
command: ["/usr/bin/nice", "bash", "scripts/save_crystal.sh"],
cwd: fixture.dir.path,
env: ["PATH": "\(fixture.dir.path):/usr/bin:/bin"])
#expect(patterns == [fixture.script.path])
#expect(!patterns.contains(fixture.bash.path))
}
@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 `allow always patterns stop at shared transparent wrapper depth limit`() throws {
let tmp = try makeTempDirForTests()
let whoami = tmp.appendingPathComponent("whoami")
try makeExecutableForTests(at: whoami)
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
command: ["nice", "nohup", "timeout", "5", "whoami"],
cwd: tmp.path,
env: ["PATH": "\(tmp.path):/usr/bin:/bin"])
#expect(patterns.isEmpty)
}
@Test func `match all requires every segment to match`() {
let first = ExecCommandResolution(
rawExecutable: "echo",

View File

@ -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)