Skip to content

Commit fc065b2

Browse files
pgondhi987Ishaan
andauthored
Harden macOS shell wrapper allowlist parsing [AI] (#78518)
* fix: harden shell wrapper allowlist parsing * fix: harden shell wrapper approval binding * docs: add changelog entry for PR merge --------- Co-authored-by: Ishaan <[email protected]>
1 parent eabae02 commit fc065b2

23 files changed

Lines changed: 1200 additions & 204 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ Docs: https://docs.openclaw.ai
172172

173173
### Fixes
174174

175+
- Harden macOS shell wrapper allowlist parsing [AI]. (#78518) Thanks @pgondhi987.
175176
- Gateway/macOS: `openclaw gateway stop` now uses `launchctl bootout` by default instead of unconditionally calling `launchctl disable`, so KeepAlive auto-recovery still works after unexpected crashes; use the new `--disable` flag to opt into the persistent-disable behavior when a manual stop should survive reboots. Fixes #77934. Thanks @bmoran1022.
176177
- Gateway/macOS: `repairLaunchAgentBootstrap` no longer kickstarts an already-running LaunchAgent, preventing unnecessary service restarts and session disconnects when repair runs against a healthy gateway. Fixes #77428. Thanks @ramitrkar-hash.
177178
- Gateway/macOS: `openclaw gateway stop --disable` now persists the LaunchAgent disable bit even after a previous bootout left the service not loaded, keeping the explicit stay-down path reliable. (#78412) Thanks @wdeveloper16.

apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ enum ExecApprovalEvaluator {
4343
let allowAlwaysPatterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
4444
command: command,
4545
cwd: cwd,
46-
env: env)
46+
env: env,
47+
rawCommand: allowlistRawCommand)
4748
let allowlistMatches = security == .allowlist
4849
? ExecAllowlistMatcher.matchAll(entries: approvals.allowlist, resolutions: allowlistResolutions)
4950
: []

apps/macos/Sources/OpenClaw/ExecCommandResolution.swift

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ struct ExecCommandResolution {
2727
{
2828
// Allowlist resolution must follow actual argv execution for wrappers.
2929
// `rawCommand` is caller-supplied display text and may be canonicalized.
30-
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil)
30+
let shell = ExecShellWrapperParser.extractForAllowlist(command: command, rawCommand: rawCommand)
3131
if shell.isWrapper {
3232
// Fail closed when env modifiers precede a shell wrapper. This mirrors
3333
// system-run binding behavior where such invocations must stay bound to
@@ -68,14 +68,16 @@ struct ExecCommandResolution {
6868
static func resolveAllowAlwaysPatterns(
6969
command: [String],
7070
cwd: String?,
71-
env: [String: String]?) -> [String]
71+
env: [String: String]?,
72+
rawCommand: String? = nil) -> [String]
7273
{
7374
var patterns: [String] = []
7475
var seen = Set<String>()
7576
self.collectAllowAlwaysPatterns(
7677
command: command,
7778
cwd: cwd,
7879
env: env,
80+
rawCommand: rawCommand,
7981
depth: 0,
8082
patterns: &patterns,
8183
seen: &seen)
@@ -152,6 +154,7 @@ struct ExecCommandResolution {
152154
command: [String],
153155
cwd: String?,
154156
env: [String: String]?,
157+
rawCommand: String?,
155158
depth: Int,
156159
patterns: inout [String],
157160
seen: inout Set<String>)
@@ -162,13 +165,19 @@ struct ExecCommandResolution {
162165

163166
if let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines),
164167
ExecCommandToken.basenameLower(token0) == "env",
165-
let envUnwrapped = ExecEnvInvocationUnwrapper.unwrap(command),
166-
!envUnwrapped.isEmpty
168+
let envUnwrapped = ExecEnvInvocationUnwrapper.unwrapWithMetadata(command),
169+
!envUnwrapped.command.isEmpty
167170
{
171+
if envUnwrapped.usesModifiers,
172+
self.isAllowlistShellWrapper(command: envUnwrapped.command, rawCommand: rawCommand)
173+
{
174+
return
175+
}
168176
self.collectAllowAlwaysPatterns(
169-
command: envUnwrapped,
177+
command: envUnwrapped.command,
170178
cwd: cwd,
171179
env: env,
180+
rawCommand: rawCommand,
172181
depth: depth + 1,
173182
patterns: &patterns,
174183
seen: &seen)
@@ -180,13 +189,14 @@ struct ExecCommandResolution {
180189
command: shellMultiplexer,
181190
cwd: cwd,
182191
env: env,
192+
rawCommand: rawCommand,
183193
depth: depth + 1,
184194
patterns: &patterns,
185195
seen: &seen)
186196
return
187197
}
188198

189-
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil)
199+
let shell = ExecShellWrapperParser.extractForAllowlist(command: command, rawCommand: rawCommand)
190200
if shell.isWrapper {
191201
guard let shellCommand = shell.command,
192202
let segments = self.splitShellCommandChain(shellCommand)
@@ -202,6 +212,7 @@ struct ExecCommandResolution {
202212
command: tokens,
203213
cwd: cwd,
204214
env: env,
215+
rawCommand: nil,
205216
depth: depth + 1,
206217
patterns: &patterns,
207218
seen: &seen)
@@ -218,6 +229,10 @@ struct ExecCommandResolution {
218229
patterns.append(pattern)
219230
}
220231

232+
private static func isAllowlistShellWrapper(command: [String], rawCommand: String?) -> Bool {
233+
ExecShellWrapperParser.extractForAllowlist(command: command, rawCommand: rawCommand).isWrapper
234+
}
235+
221236
private static func unwrapShellMultiplexerInvocation(_ argv: [String]) -> [String]? {
222237
guard let token0 = argv.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else {
223238
return nil
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
import Foundation
2+
3+
enum ExecInlineCommandParser {
4+
struct Match {
5+
let tokenIndex: Int
6+
let inlineCommand: String?
7+
let valueTokenOffset: Int
8+
9+
init(tokenIndex: Int, inlineCommand: String?, valueTokenOffset: Int = 1) {
10+
self.tokenIndex = tokenIndex
11+
self.inlineCommand = inlineCommand
12+
self.valueTokenOffset = valueTokenOffset
13+
}
14+
}
15+
16+
private struct CombinedCommandFlag {
17+
let attachedCommand: String?
18+
let separateValueCount: Int
19+
}
20+
21+
private static let posixShellOptionsWithSeparateValues = Set([
22+
"--init-file",
23+
"--rcfile",
24+
"-O",
25+
"-o",
26+
"+O",
27+
"+o",
28+
])
29+
30+
static func hasPosixInteractiveStartupBeforeInlineCommand(
31+
_ argv: [String],
32+
flags: Set<String>) -> Bool
33+
{
34+
var idx = 1
35+
var sawInteractiveMode = false
36+
while idx < argv.count {
37+
let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines)
38+
if token.isEmpty {
39+
idx += 1
40+
continue
41+
}
42+
if token == "--" {
43+
return false
44+
}
45+
if self.isPosixInteractiveModeOption(token) {
46+
sawInteractiveMode = true
47+
}
48+
if flags.contains(token) || self.isCombinedCommandFlag(token) {
49+
return sawInteractiveMode
50+
}
51+
if !token.hasPrefix("-"), !token.hasPrefix("+") {
52+
return false
53+
}
54+
let combinedValueCount = self.combinedSeparateValueOptionCount(token)
55+
if combinedValueCount > 0 {
56+
idx += 1 + combinedValueCount
57+
continue
58+
}
59+
if self.consumesSeparateValue(token) {
60+
idx += 2
61+
continue
62+
}
63+
idx += 1
64+
}
65+
return false
66+
}
67+
68+
static func hasPosixLoginStartupBeforeInlineCommand(
69+
_ argv: [String],
70+
flags: Set<String>) -> Bool
71+
{
72+
var idx = 1
73+
var sawLoginMode = false
74+
while idx < argv.count {
75+
let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines)
76+
if token.isEmpty {
77+
idx += 1
78+
continue
79+
}
80+
if token == "--" {
81+
return false
82+
}
83+
if token == "--login" || self.isPosixShortOption(token, containing: "l") {
84+
sawLoginMode = true
85+
}
86+
if flags.contains(token) || self.isCombinedCommandFlag(token) {
87+
return sawLoginMode
88+
}
89+
if !token.hasPrefix("-"), !token.hasPrefix("+") {
90+
return false
91+
}
92+
let combinedValueCount = self.combinedSeparateValueOptionCount(token)
93+
if combinedValueCount > 0 {
94+
idx += 1 + combinedValueCount
95+
continue
96+
}
97+
if self.consumesSeparateValue(token) {
98+
idx += 2
99+
continue
100+
}
101+
idx += 1
102+
}
103+
return false
104+
}
105+
106+
static func hasFishInitCommandOption(_ argv: [String]) -> Bool {
107+
var idx = 1
108+
while idx < argv.count {
109+
let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines)
110+
if token.isEmpty {
111+
idx += 1
112+
continue
113+
}
114+
if token == "--" {
115+
return false
116+
}
117+
if token == "-C" || token == "--init-command" {
118+
return true
119+
}
120+
if token.hasPrefix("-C"), token != "-C" {
121+
return true
122+
}
123+
if token.hasPrefix("--init-command=") {
124+
return true
125+
}
126+
if !token.hasPrefix("-"), !token.hasPrefix("+") {
127+
return false
128+
}
129+
idx += 1
130+
}
131+
return false
132+
}
133+
134+
static func hasFishAttachedCommandOption(_ argv: [String]) -> Bool {
135+
var idx = 1
136+
while idx < argv.count {
137+
let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines)
138+
if token.isEmpty {
139+
idx += 1
140+
continue
141+
}
142+
if token == "--" {
143+
return false
144+
}
145+
if token.hasPrefix("-c"), token != "-c" {
146+
return true
147+
}
148+
if !token.hasPrefix("-"), !token.hasPrefix("+") {
149+
return false
150+
}
151+
idx += 1
152+
}
153+
return false
154+
}
155+
156+
static func findMatch(
157+
_ argv: [String],
158+
flags: Set<String>,
159+
allowCombinedC: Bool) -> Match?
160+
{
161+
var idx = 1
162+
while idx < argv.count {
163+
let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines)
164+
if token.isEmpty {
165+
idx += 1
166+
continue
167+
}
168+
if token == "--" {
169+
break
170+
}
171+
let comparableToken = allowCombinedC ? token : token.lowercased()
172+
if flags.contains(comparableToken) {
173+
return Match(tokenIndex: idx, inlineCommand: nil)
174+
}
175+
if allowCombinedC, let combined = self.parseCombinedCommandFlag(token) {
176+
if let attachedCommand = combined.attachedCommand {
177+
return Match(tokenIndex: idx, inlineCommand: attachedCommand, valueTokenOffset: 0)
178+
}
179+
return Match(
180+
tokenIndex: idx,
181+
inlineCommand: nil,
182+
valueTokenOffset: 1 + combined.separateValueCount)
183+
}
184+
if allowCombinedC, !token.hasPrefix("-"), !token.hasPrefix("+") {
185+
break
186+
}
187+
let combinedValueCount = allowCombinedC ? self.combinedSeparateValueOptionCount(token) : 0
188+
if combinedValueCount > 0 {
189+
idx += 1 + combinedValueCount
190+
continue
191+
}
192+
if allowCombinedC, self.consumesSeparateValue(token) {
193+
idx += 2
194+
continue
195+
}
196+
idx += 1
197+
}
198+
return nil
199+
}
200+
201+
static func extractInlineCommand(
202+
_ argv: [String],
203+
flags: Set<String>,
204+
allowCombinedC: Bool) -> String?
205+
{
206+
guard let match = self.findMatch(argv, flags: flags, allowCombinedC: allowCombinedC) else {
207+
return nil
208+
}
209+
if let inlineCommand = match.inlineCommand {
210+
return inlineCommand
211+
}
212+
let nextIndex = match.tokenIndex + match.valueTokenOffset
213+
let payload = nextIndex < argv.count
214+
? argv[nextIndex].trimmingCharacters(in: .whitespacesAndNewlines)
215+
: ""
216+
return payload.isEmpty ? nil : payload
217+
}
218+
219+
private static func isCombinedCommandFlag(_ token: String) -> Bool {
220+
self.parseCombinedCommandFlag(token) != nil
221+
}
222+
223+
private static func parseCombinedCommandFlag(_ token: String) -> CombinedCommandFlag? {
224+
let chars = Array(token)
225+
guard chars.count >= 2, chars[0] == "-", chars[1] != "-" else {
226+
return nil
227+
}
228+
let optionChars = Array(chars.dropFirst())
229+
guard let commandFlagIndex = optionChars.firstIndex(of: "c") else {
230+
return nil
231+
}
232+
if optionChars.contains("-") {
233+
return nil
234+
}
235+
let suffix = String(optionChars.dropFirst(commandFlagIndex + 1))
236+
if !suffix.isEmpty,
237+
suffix.range(of: #"[^A-Za-z]"#, options: .regularExpression) != nil
238+
{
239+
return CombinedCommandFlag(attachedCommand: suffix, separateValueCount: 0)
240+
}
241+
let separateValueCount = optionChars.reduce(0) { count, char in
242+
count + ((char == "o" || char == "O") ? 1 : 0)
243+
}
244+
return CombinedCommandFlag(attachedCommand: nil, separateValueCount: separateValueCount)
245+
}
246+
247+
private static func combinedSeparateValueOptionCount(_ token: String) -> Int {
248+
let chars = Array(token)
249+
guard chars.count >= 2, chars[0] == "-" || chars[0] == "+", chars[1] != "-" else {
250+
return 0
251+
}
252+
if chars.dropFirst().contains("-") {
253+
return 0
254+
}
255+
return chars.dropFirst().reduce(0) { count, char in
256+
count + ((char == "o" || char == "O") ? 1 : 0)
257+
}
258+
}
259+
260+
private static func consumesSeparateValue(_ token: String) -> Bool {
261+
self.posixShellOptionsWithSeparateValues.contains(token)
262+
}
263+
264+
private static func isPosixInteractiveModeOption(_ token: String) -> Bool {
265+
token == "--interactive" || self.isPosixShortOption(token, containing: "i")
266+
}
267+
268+
private static func isPosixShortOption(_ token: String, containing option: Character) -> Bool {
269+
let chars = Array(token)
270+
guard chars.count >= 2, chars[0] == "-", chars[1] != "-" else {
271+
return false
272+
}
273+
if chars.dropFirst().contains("-") {
274+
return false
275+
}
276+
return chars.dropFirst().contains(option)
277+
}
278+
}

0 commit comments

Comments
 (0)