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 }
|
guard let resolution, !entries.isEmpty else { return nil }
|
||||||
let rawExecutable = resolution.rawExecutable
|
let rawExecutable = resolution.rawExecutable
|
||||||
let resolvedPath = resolution.resolvedPath
|
let resolvedPath = resolution.resolvedPath
|
||||||
|
let scriptCandidatePath = resolution.scriptCandidatePath
|
||||||
|
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
switch ExecApprovalHelpers.validateAllowlistPattern(entry.pattern) {
|
switch ExecApprovalHelpers.validateAllowlistPattern(entry.pattern) {
|
||||||
case let .valid(pattern):
|
case let .valid(pattern):
|
||||||
let target = resolvedPath ?? rawExecutable
|
let primaryTarget = resolvedPath ?? rawExecutable
|
||||||
if self.matches(pattern: pattern, target: target) { return entry }
|
if self.matches(pattern: pattern, target: primaryTarget) {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
if let scriptCandidatePath,
|
||||||
|
self.matches(pattern: pattern, target: scriptCandidatePath)
|
||||||
|
{
|
||||||
|
return entry
|
||||||
|
}
|
||||||
case .invalid:
|
case .invalid:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,20 +3,54 @@ import Foundation
|
|||||||
struct ExecCommandResolution {
|
struct ExecCommandResolution {
|
||||||
let rawExecutable: String
|
let rawExecutable: String
|
||||||
let resolvedPath: String?
|
let resolvedPath: String?
|
||||||
|
let scriptCandidatePath: String?
|
||||||
let executableName: String
|
let executableName: String
|
||||||
let cwd: 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(
|
static func resolve(
|
||||||
command: [String],
|
command: [String],
|
||||||
rawCommand: String?,
|
rawCommand: String?,
|
||||||
cwd: String?,
|
cwd: String?,
|
||||||
env: [String: String]?) -> ExecCommandResolution?
|
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) ?? ""
|
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) {
|
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(
|
static func resolveForAllowlist(
|
||||||
@ -25,6 +59,30 @@ struct ExecCommandResolution {
|
|||||||
cwd: String?,
|
cwd: String?,
|
||||||
env: [String: String]?) -> [ExecCommandResolution]
|
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)
|
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: rawCommand)
|
||||||
if shell.isWrapper {
|
if shell.isWrapper {
|
||||||
guard let shellCommand = shell.command,
|
guard let shellCommand = shell.command,
|
||||||
@ -35,13 +93,16 @@ struct ExecCommandResolution {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
var resolutions: [ExecCommandResolution] = []
|
var resolutions: [ExecCommandResolution] = []
|
||||||
resolutions.reserveCapacity(segments.count)
|
|
||||||
for segment in segments {
|
for segment in segments {
|
||||||
guard let resolution = self.resolveShellSegmentExecutable(segment, cwd: cwd, env: env)
|
let segmentResolutions = self.resolveShellSegmentExecutions(
|
||||||
else {
|
segment,
|
||||||
|
cwd: cwd,
|
||||||
|
env: env,
|
||||||
|
depth: depth + 1)
|
||||||
|
guard !segmentResolutions.isEmpty else {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
resolutions.append(resolution)
|
resolutions.append(contentsOf: segmentResolutions)
|
||||||
}
|
}
|
||||||
return resolutions
|
return resolutions
|
||||||
}
|
}
|
||||||
@ -70,11 +131,15 @@ struct ExecCommandResolution {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> 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 {
|
guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
||||||
return nil
|
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(
|
private static func resolveExecutable(
|
||||||
@ -104,18 +169,23 @@ struct ExecCommandResolution {
|
|||||||
cwd: cwd)
|
cwd: cwd)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func resolveShellSegmentExecutable(
|
private static func resolveShellSegmentExecutions(
|
||||||
_ segment: String,
|
_ segment: String,
|
||||||
cwd: String?,
|
cwd: String?,
|
||||||
env: [String: String]?) -> ExecCommandResolution?
|
env: [String: String]?,
|
||||||
|
depth: Int) -> [ExecCommandResolution]
|
||||||
{
|
{
|
||||||
let tokens = self.tokenizeShellWords(segment)
|
guard depth <= ExecWrapperResolution.maxWrapperDepth else {
|
||||||
guard !tokens.isEmpty else { return nil }
|
return []
|
||||||
let effective = ExecEnvInvocationUnwrapper.unwrapDispatchWrappersForResolution(tokens)
|
|
||||||
guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
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(
|
private static func collectAllowAlwaysPatterns(
|
||||||
@ -126,34 +196,51 @@ struct ExecCommandResolution {
|
|||||||
patterns: inout [String],
|
patterns: inout [String],
|
||||||
seen: inout Set<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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines),
|
// Allow-always persistence intentionally peels known dispatch wrappers
|
||||||
ExecCommandToken.basenameLower(token0) == "env",
|
// directly so approvals stay scoped to the launched executable instead of
|
||||||
let envUnwrapped = ExecEnvInvocationUnwrapper.unwrap(command),
|
// the wrapper binary. The allowlist path stays stricter for semantic
|
||||||
!envUnwrapped.isEmpty
|
// 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(
|
self.collectAllowAlwaysPatterns(
|
||||||
command: envUnwrapped,
|
command: argv,
|
||||||
cwd: cwd,
|
cwd: cwd,
|
||||||
env: env,
|
env: env,
|
||||||
depth: depth + 1,
|
depth: depth + 1,
|
||||||
patterns: &patterns,
|
patterns: &patterns,
|
||||||
seen: &seen)
|
seen: &seen)
|
||||||
return
|
return
|
||||||
|
case .notWrapper:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if let shellMultiplexer = self.unwrapShellMultiplexerInvocation(command) {
|
switch ExecWrapperResolution.unwrapKnownShellMultiplexerInvocation(command) {
|
||||||
|
case .blocked:
|
||||||
|
return
|
||||||
|
case let .unwrapped(_, argv):
|
||||||
self.collectAllowAlwaysPatterns(
|
self.collectAllowAlwaysPatterns(
|
||||||
command: shellMultiplexer,
|
command: argv,
|
||||||
cwd: cwd,
|
cwd: cwd,
|
||||||
env: env,
|
env: env,
|
||||||
depth: depth + 1,
|
depth: depth + 1,
|
||||||
patterns: &patterns,
|
patterns: &patterns,
|
||||||
seen: &seen)
|
seen: &seen)
|
||||||
return
|
return
|
||||||
|
case .notWrapper:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil)
|
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil)
|
||||||
@ -179,6 +266,13 @@ struct ExecCommandResolution {
|
|||||||
return
|
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),
|
guard let resolution = self.resolve(command: command, cwd: cwd, env: env),
|
||||||
let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution),
|
let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution),
|
||||||
seen.insert(pattern).inserted
|
seen.insert(pattern).inserted
|
||||||
@ -188,43 +282,23 @@ struct ExecCommandResolution {
|
|||||||
patterns.append(pattern)
|
patterns.append(pattern)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func unwrapShellMultiplexerInvocation(_ argv: [String]) -> [String]? {
|
private static func attachingScriptCandidatePath(
|
||||||
guard let token0 = argv.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else {
|
to resolution: ExecCommandResolution?,
|
||||||
|
command: [String],
|
||||||
|
cwd: String?) -> ExecCommandResolution?
|
||||||
|
{
|
||||||
|
guard let resolution else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let wrapper = ExecCommandToken.basenameLower(token0)
|
guard let scriptCandidatePath = ExecWrapperResolution.resolveShellWrapperScriptCandidatePath(command, cwd: cwd) else {
|
||||||
guard wrapper == "busybox" || wrapper == "toybox" else {
|
return resolution
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
return ExecCommandResolution(
|
||||||
var appletIndex = 1
|
rawExecutable: resolution.rawExecutable,
|
||||||
if appletIndex < argv.count, argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" {
|
resolvedPath: resolution.resolvedPath,
|
||||||
appletIndex += 1
|
scriptCandidatePath: scriptCandidatePath,
|
||||||
}
|
executableName: resolution.executableName,
|
||||||
guard appletIndex < argv.count else {
|
cwd: resolution.cwd)
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let applet = argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !applet.isEmpty else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let normalizedApplet = ExecCommandToken.basenameLower(applet)
|
|
||||||
let shellWrappers = Set([
|
|
||||||
"ash",
|
|
||||||
"bash",
|
|
||||||
"dash",
|
|
||||||
"fish",
|
|
||||||
"ksh",
|
|
||||||
"powershell",
|
|
||||||
"pwsh",
|
|
||||||
"sh",
|
|
||||||
"zsh",
|
|
||||||
])
|
|
||||||
guard shellWrappers.contains(normalizedApplet) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return Array(argv[appletIndex...])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func parseFirstToken(_ command: String) -> String? {
|
private static func parseFirstToken(_ command: String) -> String? {
|
||||||
|
|||||||
@ -88,26 +88,4 @@ enum ExecEnvInvocationUnwrapper {
|
|||||||
guard !expectsOptionValue, idx < command.count else { return nil }
|
guard !expectsOptionValue, idx < command.count else { return nil }
|
||||||
return UnwrapResult(command: Array(command[idx...]), usesModifiers: usesModifiers)
|
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)
|
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 {
|
static func extract(command: [String], rawCommand: String?) -> ParsedShellWrapper {
|
||||||
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let extracted = ExecWrapperResolution.extractShellWrapperCommand(command, rawCommand: rawCommand)
|
||||||
let preferredRaw = trimmedRaw.isEmpty ? nil : trimmedRaw
|
return ParsedShellWrapper(isWrapper: extracted.isWrapper, command: extracted.command)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,19 +11,6 @@ enum ExecSystemRunCommandValidator {
|
|||||||
case invalid(message: String)
|
case invalid(message: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let shellWrapperNames = Set([
|
|
||||||
"ash",
|
|
||||||
"bash",
|
|
||||||
"cmd",
|
|
||||||
"dash",
|
|
||||||
"fish",
|
|
||||||
"ksh",
|
|
||||||
"powershell",
|
|
||||||
"pwsh",
|
|
||||||
"sh",
|
|
||||||
"zsh",
|
|
||||||
])
|
|
||||||
|
|
||||||
private static let posixOrPowerShellInlineWrapperNames = Set([
|
private static let posixOrPowerShellInlineWrapperNames = Set([
|
||||||
"ash",
|
"ash",
|
||||||
"bash",
|
"bash",
|
||||||
@ -36,15 +23,6 @@ enum ExecSystemRunCommandValidator {
|
|||||||
"zsh",
|
"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 {
|
static func resolve(command: [String], rawCommand: String?) -> ValidationResult {
|
||||||
let normalizedRaw = self.normalizeRaw(rawCommand)
|
let normalizedRaw = self.normalizeRaw(rawCommand)
|
||||||
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil)
|
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil)
|
||||||
@ -116,148 +94,8 @@ enum ExecSystemRunCommandValidator {
|
|||||||
return normalizedRaw == previewCommand ? normalizedRaw : nil
|
return normalizedRaw == previewCommand ? normalizedRaw : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func normalizeExecutableToken(_ token: String) -> String {
|
private static func hasEnvManipulationBeforeShellWrapper(_ argv: [String]) -> Bool {
|
||||||
let base = ExecCommandToken.basenameLower(token)
|
return ExecWrapperResolution.hasEnvManipulationBeforeShellWrapper(argv)
|
||||||
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 hasTrailingPositionalArgvAfterInlineCommand(_ argv: [String]) -> Bool {
|
private static func hasTrailingPositionalArgvAfterInlineCommand(_ argv: [String]) -> Bool {
|
||||||
@ -265,22 +103,14 @@ enum ExecSystemRunCommandValidator {
|
|||||||
guard let token0 = self.trimmedNonEmpty(wrapperArgv.first) else {
|
guard let token0 = self.trimmedNonEmpty(wrapperArgv.first) else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
let wrapper = self.normalizeExecutableToken(token0)
|
let wrapper = ExecWrapperResolution.normalizeExecutableToken(token0)
|
||||||
guard self.posixOrPowerShellInlineWrapperNames.contains(wrapper) else {
|
guard self.posixOrPowerShellInlineWrapperNames.contains(wrapper) else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
let inlineCommandIndex: Int? = if wrapper == "powershell" || wrapper == "pwsh" {
|
let inlineCommandIndex = ExecWrapperResolution.resolveInlineCommandValueTokenIndex(
|
||||||
self.resolveInlineCommandTokenIndex(
|
wrapperArgv,
|
||||||
wrapperArgv,
|
normalizedWrapper: wrapper)
|
||||||
flags: self.powershellInlineCommandFlags,
|
|
||||||
allowCombinedC: false)
|
|
||||||
} else {
|
|
||||||
self.resolveInlineCommandTokenIndex(
|
|
||||||
wrapperArgv,
|
|
||||||
flags: self.posixInlineCommandFlags,
|
|
||||||
allowCombinedC: true)
|
|
||||||
}
|
|
||||||
guard let inlineCommandIndex else {
|
guard let inlineCommandIndex else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@ -292,142 +122,6 @@ enum ExecSystemRunCommandValidator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func unwrapShellWrapperArgv(_ argv: [String]) -> [String] {
|
private static func unwrapShellWrapperArgv(_ argv: [String]) -> [String] {
|
||||||
var current = argv
|
ExecWrapperResolution.unwrapShellInspectionArgv(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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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)
|
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`() {
|
@Test func `match uses resolved path`() {
|
||||||
let entry = ExecAllowlistEntry(pattern: "/opt/homebrew/bin/rg")
|
let entry = ExecAllowlistEntry(pattern: "/opt/homebrew/bin/rg")
|
||||||
let resolution = Self.homebrewRGResolution()
|
let resolution = Self.homebrewRGResolution()
|
||||||
@ -211,6 +223,20 @@ struct ExecAllowlistTests {
|
|||||||
#expect(resolutions[0].executableName == "sh")
|
#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`() {
|
@Test func `resolve for allowlist unwraps env shell wrapper chains`() {
|
||||||
let command = [
|
let command = [
|
||||||
"/usr/bin/env",
|
"/usr/bin/env",
|
||||||
@ -228,6 +254,110 @@ struct ExecAllowlistTests {
|
|||||||
#expect(resolutions[1].executableName == "touch")
|
#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`() {
|
@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 command = ["/bin/sh", "-lc", "env /usr/bin/touch /tmp/openclaw-allowlist-test"]
|
||||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||||
@ -252,6 +382,17 @@ struct ExecAllowlistTests {
|
|||||||
#expect(resolutions[0].executableName == "env")
|
#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`() {
|
@Test func `resolve for allowlist preserves env wrapper with modifiers`() {
|
||||||
let command = ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"]
|
let command = ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"]
|
||||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||||
@ -280,6 +421,22 @@ struct ExecAllowlistTests {
|
|||||||
#expect(evaluation.allowlistResolutions[0].executableName == "printf")
|
#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`() {
|
@Test func `allow always patterns unwrap env wrapper modifiers to the inner executable`() {
|
||||||
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
|
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
|
||||||
command: ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"],
|
command: ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"],
|
||||||
@ -289,6 +446,104 @@ struct ExecAllowlistTests {
|
|||||||
#expect(patterns == ["/usr/bin/printf"])
|
#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`() {
|
@Test func `match all requires every segment to match`() {
|
||||||
let first = ExecCommandResolution(
|
let first = ExecCommandResolution(
|
||||||
rawExecutable: "echo",
|
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] {
|
private static func loadContractCases() throws -> [SystemRunCommandContractCase] {
|
||||||
let fixtureURL = try self.findContractFixtureURL()
|
let fixtureURL = try self.findContractFixtureURL()
|
||||||
let data = try Data(contentsOf: fixtureURL)
|
let data = try Data(contentsOf: fixtureURL)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user