Compare commits
7 Commits
main
...
fix/macos-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d45febea8 | ||
|
|
e4b70ea497 | ||
|
|
fbbf389359 | ||
|
|
66b5bfcc19 | ||
|
|
939481e22f | ||
|
|
f55e51afb5 | ||
|
|
5b704a6ea4 |
@ -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
|
||||
}
|
||||
|
||||
@ -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? {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,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)
|
||||
}
|
||||
}
|
||||
|
||||
736
apps/macos/Sources/OpenClaw/ExecWrapperResolution.swift
Normal file
736
apps/macos/Sources/OpenClaw/ExecWrapperResolution.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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