mirror of https://github.com/openclaw/openclaw.git
refactor: unify exec wrapper resolution and parity fixtures
This commit is contained in:
parent
f4dd0577b0
commit
a96d89f343
|
|
@ -25,7 +25,7 @@ struct ExecCommandResolution: Sendable {
|
||||||
cwd: String?,
|
cwd: String?,
|
||||||
env: [String: String]?) -> [ExecCommandResolution]
|
env: [String: String]?) -> [ExecCommandResolution]
|
||||||
{
|
{
|
||||||
let shell = self.extractShellCommandFromArgv(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,
|
||||||
let segments = self.splitShellCommandChain(shellCommand)
|
let segments = self.splitShellCommandChain(shellCommand)
|
||||||
|
|
@ -54,7 +54,7 @@ struct ExecCommandResolution: Sendable {
|
||||||
}
|
}
|
||||||
|
|
||||||
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
|
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
|
||||||
let effective = self.unwrapDispatchWrappersForResolution(command)
|
let effective = ExecEnvInvocationUnwrapper.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
|
||||||
}
|
}
|
||||||
|
|
@ -102,166 +102,6 @@ struct ExecCommandResolution: Sendable {
|
||||||
return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
|
return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func basenameLower(_ token: String) -> String {
|
|
||||||
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !trimmed.isEmpty else { return "" }
|
|
||||||
let normalized = trimmed.replacingOccurrences(of: "\\", with: "/")
|
|
||||||
return normalized.split(separator: "/").last.map { String($0).lowercased() } ?? normalized.lowercased()
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func extractShellCommandFromArgv(
|
|
||||||
command: [String],
|
|
||||||
rawCommand: String?) -> (isWrapper: Bool, command: String?)
|
|
||||||
{
|
|
||||||
guard let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else {
|
|
||||||
return (false, nil)
|
|
||||||
}
|
|
||||||
let base0 = self.basenameLower(token0)
|
|
||||||
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
let preferredRaw = trimmedRaw.isEmpty ? nil : trimmedRaw
|
|
||||||
|
|
||||||
if base0 == "env" {
|
|
||||||
guard let unwrapped = self.unwrapEnvInvocation(command) else {
|
|
||||||
return (false, nil)
|
|
||||||
}
|
|
||||||
return self.extractShellCommandFromArgv(command: unwrapped, rawCommand: rawCommand)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ["ash", "sh", "bash", "zsh", "dash", "ksh", "fish"].contains(base0) {
|
|
||||||
let flag = command.count > 1 ? command[1].trimmingCharacters(in: .whitespacesAndNewlines) : ""
|
|
||||||
let normalizedFlag = flag.lowercased()
|
|
||||||
guard normalizedFlag == "-lc" || normalizedFlag == "-c" || normalizedFlag == "--command" else {
|
|
||||||
return (false, nil)
|
|
||||||
}
|
|
||||||
let payload = command.count > 2 ? command[2].trimmingCharacters(in: .whitespacesAndNewlines) : ""
|
|
||||||
let normalized = preferredRaw ?? (payload.isEmpty ? nil : payload)
|
|
||||||
return (true, normalized)
|
|
||||||
}
|
|
||||||
|
|
||||||
if base0 == "cmd.exe" || base0 == "cmd" {
|
|
||||||
guard let idx = command
|
|
||||||
.firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "/c" })
|
|
||||||
else {
|
|
||||||
return (false, nil)
|
|
||||||
}
|
|
||||||
let tail = command.suffix(from: command.index(after: idx)).joined(separator: " ")
|
|
||||||
let payload = tail.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let normalized = preferredRaw ?? (payload.isEmpty ? nil : payload)
|
|
||||||
return (true, normalized)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ["powershell", "powershell.exe", "pwsh", "pwsh.exe"].contains(base0) {
|
|
||||||
for idx in 1..<command.count {
|
|
||||||
let token = command[idx].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
||||||
if token.isEmpty { continue }
|
|
||||||
if token == "--" { break }
|
|
||||||
if token == "-c" || token == "-command" || token == "--command" {
|
|
||||||
let payload = idx + 1 < command.count
|
|
||||||
? command[idx + 1].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
: ""
|
|
||||||
let normalized = preferredRaw ?? (payload.isEmpty ? nil : payload)
|
|
||||||
return (true, normalized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (false, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static let envOptionsWithValue = Set([
|
|
||||||
"-u",
|
|
||||||
"--unset",
|
|
||||||
"-c",
|
|
||||||
"--chdir",
|
|
||||||
"-s",
|
|
||||||
"--split-string",
|
|
||||||
"--default-signal",
|
|
||||||
"--ignore-signal",
|
|
||||||
"--block-signal",
|
|
||||||
])
|
|
||||||
private static let envFlagOptions = Set(["-i", "--ignore-environment", "-0", "--null"])
|
|
||||||
|
|
||||||
private static func isEnvAssignment(_ token: String) -> Bool {
|
|
||||||
let pattern = #"^[A-Za-z_][A-Za-z0-9_]*=.*"#
|
|
||||||
return token.range(of: pattern, options: .regularExpression) != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func unwrapEnvInvocation(_ command: [String]) -> [String]? {
|
|
||||||
var idx = 1
|
|
||||||
var expectsOptionValue = false
|
|
||||||
while idx < command.count {
|
|
||||||
let token = command[idx].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if token.isEmpty {
|
|
||||||
idx += 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if expectsOptionValue {
|
|
||||||
expectsOptionValue = false
|
|
||||||
idx += 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if token == "--" || token == "-" {
|
|
||||||
idx += 1
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if self.isEnvAssignment(token) {
|
|
||||||
idx += 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if token.hasPrefix("-"), token != "-" {
|
|
||||||
let lower = token.lowercased()
|
|
||||||
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
|
|
||||||
if self.envFlagOptions.contains(flag) {
|
|
||||||
idx += 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if self.envOptionsWithValue.contains(flag) {
|
|
||||||
if !lower.contains("=") {
|
|
||||||
expectsOptionValue = true
|
|
||||||
}
|
|
||||||
idx += 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if lower.hasPrefix("-u") ||
|
|
||||||
lower.hasPrefix("-c") ||
|
|
||||||
lower.hasPrefix("-s") ||
|
|
||||||
lower.hasPrefix("--unset=") ||
|
|
||||||
lower.hasPrefix("--chdir=") ||
|
|
||||||
lower.hasPrefix("--split-string=") ||
|
|
||||||
lower.hasPrefix("--default-signal=") ||
|
|
||||||
lower.hasPrefix("--ignore-signal=") ||
|
|
||||||
lower.hasPrefix("--block-signal=")
|
|
||||||
{
|
|
||||||
idx += 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
guard idx < command.count else { return nil }
|
|
||||||
return Array(command[idx...])
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func unwrapDispatchWrappersForResolution(_ command: [String]) -> [String] {
|
|
||||||
var current = command
|
|
||||||
var depth = 0
|
|
||||||
while depth < 4 {
|
|
||||||
guard let token = current.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
guard self.basenameLower(token) == "env" else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
guard let unwrapped = self.unwrapEnvInvocation(current), !unwrapped.isEmpty else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
current = unwrapped
|
|
||||||
depth += 1
|
|
||||||
}
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum ShellTokenContext {
|
private enum ShellTokenContext {
|
||||||
case unquoted
|
case unquoted
|
||||||
case doubleQuoted
|
case doubleQuoted
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum ExecCommandToken {
|
||||||
|
static func basenameLower(_ token: String) -> String {
|
||||||
|
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return "" }
|
||||||
|
let normalized = trimmed.replacingOccurrences(of: "\\", with: "/")
|
||||||
|
return normalized.split(separator: "/").last.map { String($0).lowercased() } ?? normalized.lowercased()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExecEnvInvocationUnwrapper {
|
||||||
|
static let maxWrapperDepth = 4
|
||||||
|
|
||||||
|
private static let optionsWithValue = Set([
|
||||||
|
"-u",
|
||||||
|
"--unset",
|
||||||
|
"-c",
|
||||||
|
"--chdir",
|
||||||
|
"-s",
|
||||||
|
"--split-string",
|
||||||
|
"--default-signal",
|
||||||
|
"--ignore-signal",
|
||||||
|
"--block-signal",
|
||||||
|
])
|
||||||
|
private static let flagOptions = Set(["-i", "--ignore-environment", "-0", "--null"])
|
||||||
|
|
||||||
|
private static func isEnvAssignment(_ token: String) -> Bool {
|
||||||
|
let pattern = #"^[A-Za-z_][A-Za-z0-9_]*=.*"#
|
||||||
|
return token.range(of: pattern, options: .regularExpression) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func unwrap(_ command: [String]) -> [String]? {
|
||||||
|
var idx = 1
|
||||||
|
var expectsOptionValue = false
|
||||||
|
while idx < command.count {
|
||||||
|
let token = command[idx].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if token.isEmpty {
|
||||||
|
idx += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if expectsOptionValue {
|
||||||
|
expectsOptionValue = false
|
||||||
|
idx += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if token == "--" || token == "-" {
|
||||||
|
idx += 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if self.isEnvAssignment(token) {
|
||||||
|
idx += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if token.hasPrefix("-"), token != "-" {
|
||||||
|
let lower = token.lowercased()
|
||||||
|
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
|
||||||
|
if self.flagOptions.contains(flag) {
|
||||||
|
idx += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if self.optionsWithValue.contains(flag) {
|
||||||
|
if !lower.contains("=") {
|
||||||
|
expectsOptionValue = true
|
||||||
|
}
|
||||||
|
idx += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if lower.hasPrefix("-u") ||
|
||||||
|
lower.hasPrefix("-c") ||
|
||||||
|
lower.hasPrefix("-s") ||
|
||||||
|
lower.hasPrefix("--unset=") ||
|
||||||
|
lower.hasPrefix("--chdir=") ||
|
||||||
|
lower.hasPrefix("--split-string=") ||
|
||||||
|
lower.hasPrefix("--default-signal=") ||
|
||||||
|
lower.hasPrefix("--ignore-signal=") ||
|
||||||
|
lower.hasPrefix("--block-signal=")
|
||||||
|
{
|
||||||
|
idx += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
guard idx < command.count else { return nil }
|
||||||
|
return Array(command[idx...])
|
||||||
|
}
|
||||||
|
|
||||||
|
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.unwrap(current), !unwrapped.isEmpty else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
current = unwrapped
|
||||||
|
depth += 1
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum ExecShellWrapperParser {
|
||||||
|
struct ParsedShellWrapper {
|
||||||
|
let isWrapper: Bool
|
||||||
|
let command: String?
|
||||||
|
|
||||||
|
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:
|
||||||
|
return self.extractPosixInlineCommand(command)
|
||||||
|
case .cmd:
|
||||||
|
return self.extractCmdInlineCommand(command)
|
||||||
|
case .powershell:
|
||||||
|
return 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,14 +16,31 @@ struct ExecAllowlistTests {
|
||||||
let cases: [Case]
|
let cases: [Case]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct WrapperResolutionParityFixture: Decodable {
|
||||||
|
struct Case: Decodable {
|
||||||
|
let id: String
|
||||||
|
let argv: [String]
|
||||||
|
let expectedRawExecutable: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
let cases: [Case]
|
||||||
|
}
|
||||||
|
|
||||||
private static func loadShellParserParityCases() throws -> [ShellParserParityFixture.Case] {
|
private static func loadShellParserParityCases() throws -> [ShellParserParityFixture.Case] {
|
||||||
let fixtureURL = self.shellParserParityFixtureURL()
|
let fixtureURL = self.fixtureURL(filename: "exec-allowlist-shell-parser-parity.json")
|
||||||
let data = try Data(contentsOf: fixtureURL)
|
let data = try Data(contentsOf: fixtureURL)
|
||||||
let fixture = try JSONDecoder().decode(ShellParserParityFixture.self, from: data)
|
let fixture = try JSONDecoder().decode(ShellParserParityFixture.self, from: data)
|
||||||
return fixture.cases
|
return fixture.cases
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func shellParserParityFixtureURL() -> URL {
|
private static func loadWrapperResolutionParityCases() throws -> [WrapperResolutionParityFixture.Case] {
|
||||||
|
let fixtureURL = self.fixtureURL(filename: "exec-wrapper-resolution-parity.json")
|
||||||
|
let data = try Data(contentsOf: fixtureURL)
|
||||||
|
let fixture = try JSONDecoder().decode(WrapperResolutionParityFixture.self, from: data)
|
||||||
|
return fixture.cases
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fixtureURL(filename: String) -> URL {
|
||||||
var repoRoot = URL(fileURLWithPath: #filePath)
|
var repoRoot = URL(fileURLWithPath: #filePath)
|
||||||
for _ in 0..<5 {
|
for _ in 0..<5 {
|
||||||
repoRoot.deleteLastPathComponent()
|
repoRoot.deleteLastPathComponent()
|
||||||
|
|
@ -31,7 +48,7 @@ struct ExecAllowlistTests {
|
||||||
return repoRoot
|
return repoRoot
|
||||||
.appendingPathComponent("test")
|
.appendingPathComponent("test")
|
||||||
.appendingPathComponent("fixtures")
|
.appendingPathComponent("fixtures")
|
||||||
.appendingPathComponent("exec-allowlist-shell-parser-parity.json")
|
.appendingPathComponent(filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func matchUsesResolvedPath() {
|
@Test func matchUsesResolvedPath() {
|
||||||
|
|
@ -160,6 +177,17 @@ struct ExecAllowlistTests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func resolveMatchesSharedWrapperResolutionFixture() throws {
|
||||||
|
let fixtures = try Self.loadWrapperResolutionParityCases()
|
||||||
|
for fixture in fixtures {
|
||||||
|
let resolution = ExecCommandResolution.resolve(
|
||||||
|
command: fixture.argv,
|
||||||
|
cwd: nil,
|
||||||
|
env: ["PATH": "/usr/bin:/bin"])
|
||||||
|
#expect(resolution?.rawExecutable == fixture.expectedRawExecutable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test func resolveForAllowlistTreatsPlainShInvocationAsDirectExec() {
|
@Test func resolveForAllowlistTreatsPlainShInvocationAsDirectExec() {
|
||||||
let command = ["/bin/sh", "./script.sh"]
|
let command = ["/bin/sh", "./script.sh"]
|
||||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { splitShellArgs } from "../utils/shell-argv.js";
|
import { splitShellArgs } from "../utils/shell-argv.js";
|
||||||
import type { ExecAllowlistEntry } from "./exec-approvals.js";
|
import type { ExecAllowlistEntry } from "./exec-approvals.js";
|
||||||
|
import { unwrapDispatchWrappersForResolution } from "./exec-wrapper-resolution.js";
|
||||||
import { expandHomePrefix } from "./home-dir.js";
|
import { expandHomePrefix } from "./home-dir.js";
|
||||||
|
|
||||||
export const DEFAULT_SAFE_BINS = ["jq", "cut", "uniq", "head", "tail", "tr", "wc"];
|
export const DEFAULT_SAFE_BINS = ["jq", "cut", "uniq", "head", "tail", "tr", "wc"];
|
||||||
|
|
@ -12,106 +13,6 @@ export type CommandResolution = {
|
||||||
executableName: string;
|
executableName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ENV_OPTIONS_WITH_VALUE = new Set([
|
|
||||||
"-u",
|
|
||||||
"--unset",
|
|
||||||
"-c",
|
|
||||||
"--chdir",
|
|
||||||
"-s",
|
|
||||||
"--split-string",
|
|
||||||
"--default-signal",
|
|
||||||
"--ignore-signal",
|
|
||||||
"--block-signal",
|
|
||||||
]);
|
|
||||||
const ENV_FLAG_OPTIONS = new Set(["-i", "--ignore-environment", "-0", "--null"]);
|
|
||||||
|
|
||||||
function basenameLower(token: string): string {
|
|
||||||
const win = path.win32.basename(token);
|
|
||||||
const posix = path.posix.basename(token);
|
|
||||||
const base = win.length < posix.length ? win : posix;
|
|
||||||
return base.trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEnvAssignment(token: string): boolean {
|
|
||||||
return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
function unwrapEnvInvocation(argv: string[]): string[] | null {
|
|
||||||
let idx = 1;
|
|
||||||
let expectsOptionValue = false;
|
|
||||||
while (idx < argv.length) {
|
|
||||||
const token = argv[idx]?.trim() ?? "";
|
|
||||||
if (!token) {
|
|
||||||
idx += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (expectsOptionValue) {
|
|
||||||
expectsOptionValue = false;
|
|
||||||
idx += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (token === "--" || token === "-") {
|
|
||||||
idx += 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (isEnvAssignment(token)) {
|
|
||||||
idx += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (token.startsWith("-") && token !== "-") {
|
|
||||||
const lower = token.toLowerCase();
|
|
||||||
const [flag] = lower.split("=", 2);
|
|
||||||
if (ENV_FLAG_OPTIONS.has(flag)) {
|
|
||||||
idx += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (ENV_OPTIONS_WITH_VALUE.has(flag)) {
|
|
||||||
if (!lower.includes("=")) {
|
|
||||||
expectsOptionValue = true;
|
|
||||||
}
|
|
||||||
idx += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
lower.startsWith("-u") ||
|
|
||||||
lower.startsWith("-c") ||
|
|
||||||
lower.startsWith("-s") ||
|
|
||||||
lower.startsWith("--unset=") ||
|
|
||||||
lower.startsWith("--chdir=") ||
|
|
||||||
lower.startsWith("--split-string=") ||
|
|
||||||
lower.startsWith("--default-signal=") ||
|
|
||||||
lower.startsWith("--ignore-signal=") ||
|
|
||||||
lower.startsWith("--block-signal=")
|
|
||||||
) {
|
|
||||||
idx += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return idx < argv.length ? argv.slice(idx) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function unwrapDispatchWrappersForResolution(argv: string[]): string[] {
|
|
||||||
let current = argv;
|
|
||||||
for (let depth = 0; depth < 4; depth += 1) {
|
|
||||||
const token0 = current[0]?.trim();
|
|
||||||
if (!token0) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (basenameLower(token0) !== "env") {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const unwrapped = unwrapEnvInvocation(current);
|
|
||||||
if (!unwrapped || unwrapped.length === 0) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
current = unwrapped;
|
|
||||||
}
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isExecutableFile(filePath: string): boolean {
|
function isExecutableFile(filePath: string): boolean {
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(filePath);
|
const stat = fs.statSync(filePath);
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,16 @@ type ShellParserParityFixture = {
|
||||||
cases: ShellParserParityFixtureCase[];
|
cases: ShellParserParityFixtureCase[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type WrapperResolutionParityFixtureCase = {
|
||||||
|
id: string;
|
||||||
|
argv: string[];
|
||||||
|
expectedRawExecutable: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WrapperResolutionParityFixture = {
|
||||||
|
cases: WrapperResolutionParityFixtureCase[];
|
||||||
|
};
|
||||||
|
|
||||||
function loadShellParserParityFixtureCases(): ShellParserParityFixtureCase[] {
|
function loadShellParserParityFixtureCases(): ShellParserParityFixtureCase[] {
|
||||||
const fixturePath = path.join(
|
const fixturePath = path.join(
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
|
|
@ -64,6 +74,19 @@ function loadShellParserParityFixtureCases(): ShellParserParityFixtureCase[] {
|
||||||
return fixture.cases;
|
return fixture.cases;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadWrapperResolutionParityFixtureCases(): WrapperResolutionParityFixtureCase[] {
|
||||||
|
const fixturePath = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
"test",
|
||||||
|
"fixtures",
|
||||||
|
"exec-wrapper-resolution-parity.json",
|
||||||
|
);
|
||||||
|
const fixture = JSON.parse(
|
||||||
|
fs.readFileSync(fixturePath, "utf8"),
|
||||||
|
) as WrapperResolutionParityFixture;
|
||||||
|
return fixture.cases;
|
||||||
|
}
|
||||||
|
|
||||||
describe("exec approvals allowlist matching", () => {
|
describe("exec approvals allowlist matching", () => {
|
||||||
const baseResolution = {
|
const baseResolution = {
|
||||||
rawExecutable: "rg",
|
rawExecutable: "rg",
|
||||||
|
|
@ -447,6 +470,17 @@ describe("exec approvals shell parser parity fixture", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("exec approvals wrapper resolution parity fixture", () => {
|
||||||
|
const fixtures = loadWrapperResolutionParityFixtureCases();
|
||||||
|
|
||||||
|
for (const fixture of fixtures) {
|
||||||
|
it(`matches wrapper fixture: ${fixture.id}`, () => {
|
||||||
|
const resolution = resolveCommandResolutionFromArgv(fixture.argv);
|
||||||
|
expect(resolution?.rawExecutable ?? null).toBe(fixture.expectedRawExecutable);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
describe("exec approvals shell allowlist (chained commands)", () => {
|
describe("exec approvals shell allowlist (chained commands)", () => {
|
||||||
it("evaluates chained command allowlist scenarios", () => {
|
it("evaluates chained command allowlist scenarios", () => {
|
||||||
const cases: Array<{
|
const cases: Array<{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,242 @@
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export const MAX_DISPATCH_WRAPPER_DEPTH = 4;
|
||||||
|
|
||||||
|
export const POSIX_SHELL_WRAPPERS = new Set(["ash", "bash", "dash", "fish", "ksh", "sh", "zsh"]);
|
||||||
|
export const WINDOWS_CMD_WRAPPERS = new Set(["cmd.exe", "cmd"]);
|
||||||
|
export const POWERSHELL_WRAPPERS = new Set(["powershell", "powershell.exe", "pwsh", "pwsh.exe"]);
|
||||||
|
|
||||||
|
const POSIX_INLINE_COMMAND_FLAGS = new Set(["-lc", "-c", "--command"]);
|
||||||
|
const POWERSHELL_INLINE_COMMAND_FLAGS = new Set(["-c", "-command", "--command"]);
|
||||||
|
|
||||||
|
const ENV_OPTIONS_WITH_VALUE = new Set([
|
||||||
|
"-u",
|
||||||
|
"--unset",
|
||||||
|
"-c",
|
||||||
|
"--chdir",
|
||||||
|
"-s",
|
||||||
|
"--split-string",
|
||||||
|
"--default-signal",
|
||||||
|
"--ignore-signal",
|
||||||
|
"--block-signal",
|
||||||
|
]);
|
||||||
|
const ENV_FLAG_OPTIONS = new Set(["-i", "--ignore-environment", "-0", "--null"]);
|
||||||
|
|
||||||
|
type ShellWrapperKind = "posix" | "cmd" | "powershell";
|
||||||
|
|
||||||
|
type ShellWrapperSpec = {
|
||||||
|
kind: ShellWrapperKind;
|
||||||
|
names: ReadonlySet<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SHELL_WRAPPER_SPECS: ReadonlyArray<ShellWrapperSpec> = [
|
||||||
|
{ kind: "posix", names: POSIX_SHELL_WRAPPERS },
|
||||||
|
{ kind: "cmd", names: WINDOWS_CMD_WRAPPERS },
|
||||||
|
{ kind: "powershell", names: POWERSHELL_WRAPPERS },
|
||||||
|
];
|
||||||
|
|
||||||
|
export type ShellWrapperCommand = {
|
||||||
|
isWrapper: boolean;
|
||||||
|
command: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function basenameLower(token: string): string {
|
||||||
|
const win = path.win32.basename(token);
|
||||||
|
const posix = path.posix.basename(token);
|
||||||
|
const base = win.length < posix.length ? win : posix;
|
||||||
|
return base.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRawCommand(rawCommand?: string | null): string | null {
|
||||||
|
const trimmed = rawCommand?.trim() ?? "";
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findShellWrapperSpec(baseExecutable: string): ShellWrapperSpec | null {
|
||||||
|
for (const spec of SHELL_WRAPPER_SPECS) {
|
||||||
|
if (spec.names.has(baseExecutable)) {
|
||||||
|
return spec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEnvAssignment(token: string): boolean {
|
||||||
|
return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unwrapEnvInvocation(argv: string[]): string[] | null {
|
||||||
|
let idx = 1;
|
||||||
|
let expectsOptionValue = false;
|
||||||
|
while (idx < argv.length) {
|
||||||
|
const token = argv[idx]?.trim() ?? "";
|
||||||
|
if (!token) {
|
||||||
|
idx += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (expectsOptionValue) {
|
||||||
|
expectsOptionValue = false;
|
||||||
|
idx += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === "--" || token === "-") {
|
||||||
|
idx += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (isEnvAssignment(token)) {
|
||||||
|
idx += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token.startsWith("-") && token !== "-") {
|
||||||
|
const lower = token.toLowerCase();
|
||||||
|
const [flag] = lower.split("=", 2);
|
||||||
|
if (ENV_FLAG_OPTIONS.has(flag)) {
|
||||||
|
idx += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ENV_OPTIONS_WITH_VALUE.has(flag)) {
|
||||||
|
if (!lower.includes("=")) {
|
||||||
|
expectsOptionValue = true;
|
||||||
|
}
|
||||||
|
idx += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
lower.startsWith("-u") ||
|
||||||
|
lower.startsWith("-c") ||
|
||||||
|
lower.startsWith("-s") ||
|
||||||
|
lower.startsWith("--unset=") ||
|
||||||
|
lower.startsWith("--chdir=") ||
|
||||||
|
lower.startsWith("--split-string=") ||
|
||||||
|
lower.startsWith("--default-signal=") ||
|
||||||
|
lower.startsWith("--ignore-signal=") ||
|
||||||
|
lower.startsWith("--block-signal=")
|
||||||
|
) {
|
||||||
|
idx += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return idx < argv.length ? argv.slice(idx) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unwrapDispatchWrappersForResolution(
|
||||||
|
argv: string[],
|
||||||
|
maxDepth = MAX_DISPATCH_WRAPPER_DEPTH,
|
||||||
|
): string[] {
|
||||||
|
let current = argv;
|
||||||
|
for (let depth = 0; depth < maxDepth; depth += 1) {
|
||||||
|
const token0 = current[0]?.trim();
|
||||||
|
if (!token0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (basenameLower(token0) !== "env") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const unwrapped = unwrapEnvInvocation(current);
|
||||||
|
if (!unwrapped || unwrapped.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current = unwrapped;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPosixShellInlineCommand(argv: string[]): string | null {
|
||||||
|
const flag = argv[1]?.trim();
|
||||||
|
if (!flag) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!POSIX_INLINE_COMMAND_FLAGS.has(flag.toLowerCase())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const cmd = argv[2]?.trim();
|
||||||
|
return cmd ? cmd : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCmdInlineCommand(argv: string[]): string | null {
|
||||||
|
const idx = argv.findIndex((item) => item.trim().toLowerCase() === "/c");
|
||||||
|
if (idx === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const tail = argv.slice(idx + 1);
|
||||||
|
if (tail.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const cmd = tail.join(" ").trim();
|
||||||
|
return cmd.length > 0 ? cmd : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPowerShellInlineCommand(argv: string[]): string | null {
|
||||||
|
for (let i = 1; i < argv.length; i += 1) {
|
||||||
|
const token = argv[i]?.trim();
|
||||||
|
if (!token) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const lower = token.toLowerCase();
|
||||||
|
if (lower === "--") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (POWERSHELL_INLINE_COMMAND_FLAGS.has(lower)) {
|
||||||
|
const cmd = argv[i + 1]?.trim();
|
||||||
|
return cmd ? cmd : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractShellWrapperPayload(argv: string[], spec: ShellWrapperSpec): string | null {
|
||||||
|
switch (spec.kind) {
|
||||||
|
case "posix":
|
||||||
|
return extractPosixShellInlineCommand(argv);
|
||||||
|
case "cmd":
|
||||||
|
return extractCmdInlineCommand(argv);
|
||||||
|
case "powershell":
|
||||||
|
return extractPowerShellInlineCommand(argv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractShellWrapperCommandInternal(
|
||||||
|
argv: string[],
|
||||||
|
rawCommand: string | null,
|
||||||
|
depth: number,
|
||||||
|
): ShellWrapperCommand {
|
||||||
|
if (depth >= MAX_DISPATCH_WRAPPER_DEPTH) {
|
||||||
|
return { isWrapper: false, command: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const token0 = argv[0]?.trim();
|
||||||
|
if (!token0) {
|
||||||
|
return { isWrapper: false, command: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const base0 = basenameLower(token0);
|
||||||
|
if (base0 === "env") {
|
||||||
|
const unwrapped = unwrapEnvInvocation(argv);
|
||||||
|
if (!unwrapped) {
|
||||||
|
return { isWrapper: false, command: null };
|
||||||
|
}
|
||||||
|
return extractShellWrapperCommandInternal(unwrapped, rawCommand, depth + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapper = findShellWrapperSpec(base0);
|
||||||
|
if (!wrapper) {
|
||||||
|
return { isWrapper: false, command: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = extractShellWrapperPayload(argv, wrapper);
|
||||||
|
if (!payload) {
|
||||||
|
return { isWrapper: false, command: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isWrapper: true, command: rawCommand ?? payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractShellWrapperCommand(
|
||||||
|
argv: string[],
|
||||||
|
rawCommand?: string | null,
|
||||||
|
): ShellWrapperCommand {
|
||||||
|
return extractShellWrapperCommandInternal(argv, normalizeRawCommand(rawCommand), 0);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import path from "node:path";
|
import { extractShellWrapperCommand } from "./exec-wrapper-resolution.js";
|
||||||
|
|
||||||
export type SystemRunCommandValidation =
|
export type SystemRunCommandValidation =
|
||||||
| {
|
| {
|
||||||
|
|
@ -26,163 +26,6 @@ export type ResolvedSystemRunCommand =
|
||||||
details?: Record<string, unknown>;
|
details?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function basenameLower(token: string): string {
|
|
||||||
const win = path.win32.basename(token);
|
|
||||||
const posix = path.posix.basename(token);
|
|
||||||
const base = win.length < posix.length ? win : posix;
|
|
||||||
return base.trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
const POSIX_SHELL_WRAPPERS = new Set(["ash", "bash", "dash", "fish", "ksh", "sh", "zsh"]);
|
|
||||||
const WINDOWS_CMD_WRAPPERS = new Set(["cmd.exe", "cmd"]);
|
|
||||||
const POWERSHELL_WRAPPERS = new Set(["powershell", "powershell.exe", "pwsh", "pwsh.exe"]);
|
|
||||||
const ENV_OPTIONS_WITH_VALUE = new Set([
|
|
||||||
"-u",
|
|
||||||
"--unset",
|
|
||||||
"-c",
|
|
||||||
"--chdir",
|
|
||||||
"-s",
|
|
||||||
"--split-string",
|
|
||||||
"--default-signal",
|
|
||||||
"--ignore-signal",
|
|
||||||
"--block-signal",
|
|
||||||
]);
|
|
||||||
const ENV_FLAG_OPTIONS = new Set(["-i", "--ignore-environment", "-0", "--null"]);
|
|
||||||
|
|
||||||
function isEnvAssignment(token: string): boolean {
|
|
||||||
return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
function unwrapEnvInvocation(argv: string[]): string[] | null {
|
|
||||||
let idx = 1;
|
|
||||||
let expectsOptionValue = false;
|
|
||||||
while (idx < argv.length) {
|
|
||||||
const token = argv[idx]?.trim() ?? "";
|
|
||||||
if (!token) {
|
|
||||||
idx += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (expectsOptionValue) {
|
|
||||||
expectsOptionValue = false;
|
|
||||||
idx += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (token === "--" || token === "-") {
|
|
||||||
idx += 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (isEnvAssignment(token)) {
|
|
||||||
idx += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (token.startsWith("-") && token !== "-") {
|
|
||||||
const lower = token.toLowerCase();
|
|
||||||
const [flag] = lower.split("=", 2);
|
|
||||||
if (ENV_FLAG_OPTIONS.has(flag)) {
|
|
||||||
idx += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (ENV_OPTIONS_WITH_VALUE.has(flag)) {
|
|
||||||
if (!lower.includes("=")) {
|
|
||||||
expectsOptionValue = true;
|
|
||||||
}
|
|
||||||
idx += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
lower.startsWith("-u") ||
|
|
||||||
lower.startsWith("-c") ||
|
|
||||||
lower.startsWith("-s") ||
|
|
||||||
lower.startsWith("--unset=") ||
|
|
||||||
lower.startsWith("--chdir=") ||
|
|
||||||
lower.startsWith("--split-string=") ||
|
|
||||||
lower.startsWith("--default-signal=") ||
|
|
||||||
lower.startsWith("--ignore-signal=") ||
|
|
||||||
lower.startsWith("--block-signal=")
|
|
||||||
) {
|
|
||||||
idx += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return idx < argv.length ? argv.slice(idx) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractPosixShellInlineCommand(argv: string[]): string | null {
|
|
||||||
const flag = argv[1]?.trim();
|
|
||||||
if (!flag) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const lower = flag.toLowerCase();
|
|
||||||
if (lower !== "-lc" && lower !== "-c" && lower !== "--command") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const cmd = argv[2]?.trim();
|
|
||||||
return cmd ? cmd : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractCmdInlineCommand(argv: string[]): string | null {
|
|
||||||
const idx = argv.findIndex((item) => String(item).trim().toLowerCase() === "/c");
|
|
||||||
if (idx === -1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const tail = argv.slice(idx + 1).map((item) => String(item));
|
|
||||||
if (tail.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const cmd = tail.join(" ").trim();
|
|
||||||
return cmd.length > 0 ? cmd : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractPowerShellInlineCommand(argv: string[]): string | null {
|
|
||||||
for (let i = 1; i < argv.length; i += 1) {
|
|
||||||
const token = argv[i]?.trim();
|
|
||||||
if (!token) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const lower = token.toLowerCase();
|
|
||||||
if (lower === "--") {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (lower === "-c" || lower === "-command" || lower === "--command") {
|
|
||||||
const cmd = argv[i + 1]?.trim();
|
|
||||||
return cmd ? cmd : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractShellCommandFromArgvInternal(argv: string[], depth: number): string | null {
|
|
||||||
if (depth >= 4) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const token0 = argv[0]?.trim();
|
|
||||||
if (!token0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const base0 = basenameLower(token0);
|
|
||||||
if (base0 === "env") {
|
|
||||||
const unwrapped = unwrapEnvInvocation(argv);
|
|
||||||
if (!unwrapped) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return extractShellCommandFromArgvInternal(unwrapped, depth + 1);
|
|
||||||
}
|
|
||||||
if (POSIX_SHELL_WRAPPERS.has(base0)) {
|
|
||||||
return extractPosixShellInlineCommand(argv);
|
|
||||||
}
|
|
||||||
if (WINDOWS_CMD_WRAPPERS.has(base0)) {
|
|
||||||
return extractCmdInlineCommand(argv);
|
|
||||||
}
|
|
||||||
if (POWERSHELL_WRAPPERS.has(base0)) {
|
|
||||||
return extractPowerShellInlineCommand(argv);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatExecCommand(argv: string[]): string {
|
export function formatExecCommand(argv: string[]): string {
|
||||||
return argv
|
return argv
|
||||||
.map((arg) => {
|
.map((arg) => {
|
||||||
|
|
@ -200,7 +43,7 @@ export function formatExecCommand(argv: string[]): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractShellCommandFromArgv(argv: string[]): string | null {
|
export function extractShellCommandFromArgv(argv: string[]): string | null {
|
||||||
return extractShellCommandFromArgvInternal(argv, 0);
|
return extractShellWrapperCommand(argv).command;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateSystemRunCommandConsistency(params: {
|
export function validateSystemRunCommandConsistency(params: {
|
||||||
|
|
@ -211,7 +54,7 @@ export function validateSystemRunCommandConsistency(params: {
|
||||||
typeof params.rawCommand === "string" && params.rawCommand.trim().length > 0
|
typeof params.rawCommand === "string" && params.rawCommand.trim().length > 0
|
||||||
? params.rawCommand.trim()
|
? params.rawCommand.trim()
|
||||||
: null;
|
: null;
|
||||||
const shellCommand = extractShellCommandFromArgv(params.argv);
|
const shellCommand = extractShellWrapperCommand(params.argv).command;
|
||||||
const inferred = shellCommand !== null ? shellCommand.trim() : formatExecCommand(params.argv);
|
const inferred = shellCommand !== null ? shellCommand.trim() : formatExecCommand(params.argv);
|
||||||
|
|
||||||
if (raw && raw !== inferred) {
|
if (raw && raw !== inferred) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
{
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"id": "direct-absolute-executable",
|
||||||
|
"argv": ["/usr/bin/printf", "ok"],
|
||||||
|
"expectedRawExecutable": "/usr/bin/printf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "env-assignment-prefix",
|
||||||
|
"argv": ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"],
|
||||||
|
"expectedRawExecutable": "/usr/bin/printf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "env-option-with-separate-value",
|
||||||
|
"argv": ["/usr/bin/env", "-u", "HOME", "/usr/bin/printf", "ok"],
|
||||||
|
"expectedRawExecutable": "/usr/bin/printf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "env-option-with-inline-value",
|
||||||
|
"argv": ["/usr/bin/env", "-uHOME", "/usr/bin/printf", "ok"],
|
||||||
|
"expectedRawExecutable": "/usr/bin/printf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nested-env-wrappers",
|
||||||
|
"argv": ["/usr/bin/env", "/usr/bin/env", "FOO=bar", "printf", "ok"],
|
||||||
|
"expectedRawExecutable": "printf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "env-shell-wrapper-stops-at-shell",
|
||||||
|
"argv": ["/usr/bin/env", "bash", "-lc", "echo ok"],
|
||||||
|
"expectedRawExecutable": "bash"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "env-missing-effective-command",
|
||||||
|
"argv": ["/usr/bin/env", "FOO=bar"],
|
||||||
|
"expectedRawExecutable": "/usr/bin/env"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue