mirror of https://github.com/openclaw/openclaw.git
fix: harden macos env wrapper resolution
This commit is contained in:
parent
0643c0d15a
commit
e1fedd4388
|
|
@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai
|
||||||
- Security/exec approvals: unwrap more `pnpm` runtime forms during approval binding, including `pnpm --reporter ... exec` and direct `pnpm node` file runs, with matching regression coverage and docs updates.
|
- Security/exec approvals: unwrap more `pnpm` runtime forms during approval binding, including `pnpm --reporter ... exec` and direct `pnpm node` file runs, with matching regression coverage and docs updates.
|
||||||
- Security/exec approvals: fail closed for Perl `-M` and `-I` approval flows so preload and load-path module resolution stays outside approval-backed runtime execution unless the operator uses a broader explicit trust path.
|
- Security/exec approvals: fail closed for Perl `-M` and `-I` approval flows so preload and load-path module resolution stays outside approval-backed runtime execution unless the operator uses a broader explicit trust path.
|
||||||
- Security/exec approvals: recognize PowerShell `-File` and `-f` wrapper forms during inline-command extraction so approval and command-analysis paths treat file-based PowerShell launches like the existing `-Command` variants.
|
- Security/exec approvals: recognize PowerShell `-File` and `-f` wrapper forms during inline-command extraction so approval and command-analysis paths treat file-based PowerShell launches like the existing `-Command` variants.
|
||||||
|
- Security/exec approvals: unwrap `env` dispatch wrappers inside shell-segment allowlist resolution on macOS so `env FOO=bar /path/to/bin` resolves against the effective executable instead of the wrapper token.
|
||||||
- Security/external content: strip zero-width and soft-hyphen marker-splitting characters during boundary sanitization so spoofed `EXTERNAL_UNTRUSTED_CONTENT` markers fall back to the existing hardening path instead of bypassing marker normalization.
|
- Security/external content: strip zero-width and soft-hyphen marker-splitting characters during boundary sanitization so spoofed `EXTERNAL_UNTRUSTED_CONTENT` markers fall back to the existing hardening path instead of bypassing marker normalization.
|
||||||
- Control UI/insecure auth: preserve explicit shared token and password auth on plain-HTTP Control UI connects so LAN and reverse-proxy sessions no longer drop shared auth before the first WebSocket handshake. (#45088) Thanks @velvet-shark.
|
- Control UI/insecure auth: preserve explicit shared token and password auth on plain-HTTP Control UI connects so LAN and reverse-proxy sessions no longer drop shared auth before the first WebSocket handshake. (#45088) Thanks @velvet-shark.
|
||||||
- macOS/onboarding: avoid self-restarting freshly bootstrapped launchd gateways and give new daemon installs longer to become healthy, so `openclaw onboard --install-daemon` no longer false-fails on slower Macs and fresh VM snapshots.
|
- macOS/onboarding: avoid self-restarting freshly bootstrapped launchd gateways and give new daemon installs longer to become healthy, so `openclaw onboard --install-daemon` no longer false-fails on slower Macs and fresh VM snapshots.
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,7 @@ struct ExecCommandResolution {
|
||||||
var resolutions: [ExecCommandResolution] = []
|
var resolutions: [ExecCommandResolution] = []
|
||||||
resolutions.reserveCapacity(segments.count)
|
resolutions.reserveCapacity(segments.count)
|
||||||
for segment in segments {
|
for segment in segments {
|
||||||
guard let token = self.parseFirstToken(segment),
|
guard let resolution = self.resolveShellSegmentExecutable(segment, cwd: cwd, env: env)
|
||||||
let resolution = self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
|
|
||||||
else {
|
else {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
@ -88,6 +87,20 @@ struct ExecCommandResolution {
|
||||||
cwd: cwd)
|
cwd: cwd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func resolveShellSegmentExecutable(
|
||||||
|
_ segment: String,
|
||||||
|
cwd: String?,
|
||||||
|
env: [String: String]?) -> ExecCommandResolution?
|
||||||
|
{
|
||||||
|
let tokens = self.tokenizeShellWords(segment)
|
||||||
|
guard !tokens.isEmpty else { return nil }
|
||||||
|
let effective = ExecEnvInvocationUnwrapper.unwrapDispatchWrappersForResolution(tokens)
|
||||||
|
guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
|
||||||
|
}
|
||||||
|
|
||||||
private static func parseFirstToken(_ command: String) -> String? {
|
private static func parseFirstToken(_ command: String) -> String? {
|
||||||
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return nil }
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
|
@ -102,6 +115,59 @@ struct ExecCommandResolution {
|
||||||
return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
|
return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func tokenizeShellWords(_ command: String) -> [String] {
|
||||||
|
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return [] }
|
||||||
|
|
||||||
|
var tokens: [String] = []
|
||||||
|
var current = ""
|
||||||
|
var inSingle = false
|
||||||
|
var inDouble = false
|
||||||
|
var escaped = false
|
||||||
|
|
||||||
|
func appendCurrent() {
|
||||||
|
guard !current.isEmpty else { return }
|
||||||
|
tokens.append(current)
|
||||||
|
current.removeAll(keepingCapacity: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
for ch in trimmed {
|
||||||
|
if escaped {
|
||||||
|
current.append(ch)
|
||||||
|
escaped = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch == "\\", !inSingle {
|
||||||
|
escaped = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch == "'", !inDouble {
|
||||||
|
inSingle.toggle()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch == "\"", !inSingle {
|
||||||
|
inDouble.toggle()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch.isWhitespace, !inSingle, !inDouble {
|
||||||
|
appendCurrent()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
current.append(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
if escaped {
|
||||||
|
current.append("\\")
|
||||||
|
}
|
||||||
|
appendCurrent()
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
private enum ShellTokenContext {
|
private enum ShellTokenContext {
|
||||||
case unquoted
|
case unquoted
|
||||||
case doubleQuoted
|
case doubleQuoted
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,30 @@ struct ExecAllowlistTests {
|
||||||
#expect(resolutions[1].executableName == "touch")
|
#expect(resolutions[1].executableName == "touch")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func `resolve for allowlist unwraps env dispatch wrappers inside shell segments`() {
|
||||||
|
let command = ["/bin/sh", "-lc", "env /usr/bin/touch /tmp/openclaw-allowlist-test"]
|
||||||
|
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||||
|
command: command,
|
||||||
|
rawCommand: "env /usr/bin/touch /tmp/openclaw-allowlist-test",
|
||||||
|
cwd: nil,
|
||||||
|
env: ["PATH": "/usr/bin:/bin"])
|
||||||
|
#expect(resolutions.count == 1)
|
||||||
|
#expect(resolutions[0].resolvedPath == "/usr/bin/touch")
|
||||||
|
#expect(resolutions[0].executableName == "touch")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func `resolve for allowlist unwraps env assignments inside shell segments`() {
|
||||||
|
let command = ["/bin/sh", "-lc", "env FOO=bar /usr/bin/touch /tmp/openclaw-allowlist-test"]
|
||||||
|
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||||
|
command: command,
|
||||||
|
rawCommand: "env FOO=bar /usr/bin/touch /tmp/openclaw-allowlist-test",
|
||||||
|
cwd: nil,
|
||||||
|
env: ["PATH": "/usr/bin:/bin"])
|
||||||
|
#expect(resolutions.count == 1)
|
||||||
|
#expect(resolutions[0].resolvedPath == "/usr/bin/touch")
|
||||||
|
#expect(resolutions[0].executableName == "touch")
|
||||||
|
}
|
||||||
|
|
||||||
@Test func `resolve for allowlist unwraps env to effective direct executable`() {
|
@Test func `resolve for allowlist unwraps env to effective direct executable`() {
|
||||||
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(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue