fix(macos): parse model catalog without JavaScriptCore

Replaces JavaScriptCore catalog evaluation with a bounded fail-closed object-literal parser for the generated macOS model catalog.\n\nValidation: macos-node, macos-swift, security-fast, security-scm-fast, security-dependency-audit, workflow sanity checks passed on PR #73112.
This commit is contained in:
Vincent Koc
2026-04-27 20:16:51 -07:00
committed by GitHub
parent 4b4cde7187
commit 4102f8d28d
2 changed files with 514 additions and 36 deletions

View File

@@ -1,11 +1,11 @@
import Foundation
import JavaScriptCore
enum ModelCatalogLoader {
static var defaultPath: String {
self.resolveDefaultPath()
}
private static let maxCatalogBytes: UInt64 = 2 * 1024 * 1024
private static let logger = Logger(subsystem: "ai.openclaw", category: "models")
private nonisolated static let appSupportDir: URL = {
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
@@ -26,23 +26,8 @@ enum ModelCatalogLoader {
userInfo: [NSLocalizedDescriptionKey: "Model catalog file not found"])
}
self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: resolved.path).lastPathComponent)")
let source = try String(contentsOfFile: resolved.path, encoding: .utf8)
let sanitized = self.sanitize(source: source)
let ctx = JSContext()
ctx?.exceptionHandler = { _, exception in
if let exception {
self.logger.warning("model catalog JS exception: \(exception)")
}
}
ctx?.evaluateScript(sanitized)
guard let rawModels = ctx?.objectForKeyedSubscript("MODELS")?.toDictionary() as? [String: Any] else {
self.logger.error("model catalog parse failed: MODELS missing")
throw NSError(
domain: "ModelCatalogLoader",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Failed to parse models.generated.ts"])
}
let source = try self.readCatalogSource(path: resolved.path)
let rawModels = try self.parseModels(source: source)
var choices: [ModelChoice] = []
for (provider, value) in rawModels {
@@ -138,22 +123,465 @@ enum ModelCatalogLoader {
}
}
private static func sanitize(source: String) -> String {
guard let exportRange = source.range(of: "export const MODELS"),
let firstBrace = source[exportRange.upperBound...].firstIndex(of: "{"),
let lastBrace = source.lastIndex(of: "}")
else {
return "var MODELS = {}"
private static func readCatalogSource(path: String) throws -> String {
let attrs = try FileManager().attributesOfItem(atPath: path)
if let size = attrs[.size] as? NSNumber,
size.uint64Value > self.maxCatalogBytes
{
throw NSError(
domain: "ModelCatalogLoader",
code: 2,
userInfo: [NSLocalizedDescriptionKey: "Model catalog file is too large"])
}
var body = String(source[firstBrace...lastBrace])
body = body.replacingOccurrences(
of: #"(?m)\bsatisfies\s+[^,}\n]+"#,
with: "",
options: .regularExpression)
body = body.replacingOccurrences(
of: #"(?m)\bas\s+[^;,\n]+"#,
with: "",
options: .regularExpression)
return "var MODELS = \(body);"
return try String(contentsOfFile: path, encoding: .utf8)
}
private static func parseModels(source: String) throws -> [String: Any] {
guard let assignmentEnd = self.findModelsAssignmentEnd(in: source) else {
throw ModelCatalogParseError.missingModelsExport
}
var parser = ModelCatalogObjectParser(source: String(source[assignmentEnd...]))
return try parser.parseObject()
}
private static func findModelsAssignmentEnd(in source: String) -> String.Index? {
var index = source.startIndex
while index < source.endIndex {
if self.consumeIf("//", in: source, at: &index) {
self.skipLineComment(in: source, from: &index)
continue
}
if self.consumeIf("/*", in: source, at: &index) {
self.skipBlockComment(in: source, from: &index)
continue
}
if source[index] == "\"" || source[index] == "'" || source[index] == "`" {
self.skipString(in: source, quote: source[index], from: &index)
continue
}
var cursor = index
if self.consumeKeyword("export", in: source, at: &cursor) {
self.skipWhitespaceAndComments(in: source, from: &cursor)
if self.consumeKeyword("const", in: source, at: &cursor) {
self.skipWhitespaceAndComments(in: source, from: &cursor)
if self.consumeKeyword("MODELS", in: source, at: &cursor) {
self.skipWhitespaceAndComments(in: source, from: &cursor)
if self.consumeIf("=", in: source, at: &cursor) {
return cursor
}
}
}
}
index = source.index(after: index)
}
return nil
}
private static func skipWhitespaceAndComments(in source: String, from index: inout String.Index) {
while index < source.endIndex {
if source[index].isWhitespace {
index = source.index(after: index)
continue
}
if self.consumeIf("//", in: source, at: &index) {
self.skipLineComment(in: source, from: &index)
continue
}
if self.consumeIf("/*", in: source, at: &index) {
self.skipBlockComment(in: source, from: &index)
continue
}
return
}
}
private static func skipLineComment(in source: String, from index: inout String.Index) {
while index < source.endIndex, source[index] != "\n" {
index = source.index(after: index)
}
}
private static func skipBlockComment(in source: String, from index: inout String.Index) {
while index < source.endIndex, !self.consumeIf("*/", in: source, at: &index) {
index = source.index(after: index)
}
}
private static func skipString(in source: String, quote: Character, from index: inout String.Index) {
index = source.index(after: index)
while index < source.endIndex {
let char = source[index]
index = source.index(after: index)
if char == "\\" {
if index < source.endIndex {
index = source.index(after: index)
}
continue
}
if char == quote {
return
}
}
}
private static func consumeKeyword(_ keyword: String, in source: String, at index: inout String.Index) -> Bool {
guard source[index...].hasPrefix(keyword) else {
return false
}
let end = source.index(index, offsetBy: keyword.count)
if index > source.startIndex {
let previous = source[source.index(before: index)]
if self.isIdentifierCharacter(previous) {
return false
}
}
if end < source.endIndex, self.isIdentifierCharacter(source[end]) {
return false
}
index = end
return true
}
private static func consumeIf(_ token: String, in source: String, at index: inout String.Index) -> Bool {
guard source[index...].hasPrefix(token) else {
return false
}
index = source.index(index, offsetBy: token.count)
return true
}
private static func isIdentifierCharacter(_ char: Character) -> Bool {
char.isLetter || char.isNumber || char == "_" || char == "$"
}
}
private enum ModelCatalogParseError: Error {
case expectedObject
case expectedKey
case expectedColon
case expectedValue
case maxDepthExceeded
case missingModelsExport
case unterminatedString
case invalidNumber
case unexpectedToken
}
private struct ModelCatalogObjectParser {
private let maxDepth: Int
private let source: String
private var index: String.Index
init(source: String, maxDepth: Int = 80) {
self.maxDepth = maxDepth
self.source = source
self.index = source.startIndex
}
mutating func parseObject(depth: Int = 0) throws -> [String: Any] {
guard depth <= self.maxDepth else {
throw ModelCatalogParseError.maxDepthExceeded
}
try self.consume("{", or: .expectedObject)
var result: [String: Any] = [:]
while true {
self.skipWhitespaceAndComments()
if self.consumeIf("}") {
return result
}
let key = try self.parseKey()
self.skipWhitespaceAndComments()
try self.consume(":", or: .expectedColon)
let value = try self.parseValue(depth: depth)
self.skipTypeAssertion()
result[key] = value
self.skipWhitespaceAndComments()
if self.consumeIf(",") {
continue
}
if self.consumeIf("}") {
return result
}
throw ModelCatalogParseError.unexpectedToken
}
}
private mutating func parseArray(depth: Int) throws -> [Any] {
guard depth <= self.maxDepth else {
throw ModelCatalogParseError.maxDepthExceeded
}
try self.consume("[", or: .expectedValue)
var result: [Any] = []
while true {
self.skipWhitespaceAndComments()
if self.consumeIf("]") {
return result
}
try result.append(self.parseValue(depth: depth))
self.skipTypeAssertion()
self.skipWhitespaceAndComments()
if self.consumeIf(",") {
continue
}
if self.consumeIf("]") {
return result
}
throw ModelCatalogParseError.unexpectedToken
}
}
private mutating func parseValue(depth: Int) throws -> Any {
self.skipWhitespaceAndComments()
guard let char = self.current else {
throw ModelCatalogParseError.expectedValue
}
switch char {
case "{":
return try self.parseObject(depth: depth + 1)
case "[":
return try self.parseArray(depth: depth + 1)
case "\"", "'":
return try self.parseString()
case "-", "0"..."9":
return try self.parseNumber()
default:
let identifier = try self.parseIdentifier()
switch identifier {
case "true":
return true
case "false":
return false
case "null", "undefined":
return NSNull()
default:
throw ModelCatalogParseError.unexpectedToken
}
}
}
private mutating func parseKey() throws -> String {
self.skipWhitespaceAndComments()
guard let char = self.current else {
throw ModelCatalogParseError.expectedKey
}
if char == "\"" || char == "'" {
return try self.parseString()
}
return try self.parseIdentifier()
}
private mutating func parseIdentifier() throws -> String {
self.skipWhitespaceAndComments()
let start = self.index
while let char = self.current, self.isIdentifierCharacter(char) {
self.advance()
}
guard start != self.index else {
throw ModelCatalogParseError.expectedKey
}
return String(self.source[start..<self.index])
}
private mutating func parseString() throws -> String {
guard let quote = self.current, quote == "\"" || quote == "'" else {
throw ModelCatalogParseError.expectedValue
}
self.advance()
var result = ""
while let char = self.current {
self.advance()
if char == quote {
return result
}
if char == "\\" {
try result.append(self.parseEscapedCharacter())
} else {
result.append(char)
}
}
throw ModelCatalogParseError.unterminatedString
}
private mutating func parseEscapedCharacter() throws -> Character {
guard let char = self.current else {
throw ModelCatalogParseError.unterminatedString
}
self.advance()
switch char {
case "\"", "'", "\\", "/":
return char
case "b":
return "\u{08}"
case "f":
return "\u{0c}"
case "n":
return "\n"
case "r":
return "\r"
case "t":
return "\t"
case "u":
return try self.parseUnicodeEscape()
default:
return char
}
}
private mutating func parseUnicodeEscape() throws -> Character {
var hex = ""
for _ in 0..<4 {
guard let char = self.current else {
throw ModelCatalogParseError.unterminatedString
}
hex.append(char)
self.advance()
}
guard let value = UInt32(hex, radix: 16),
let scalar = UnicodeScalar(value)
else {
throw ModelCatalogParseError.unterminatedString
}
return Character(scalar)
}
private mutating func parseNumber() throws -> Any {
let start = self.index
if self.current == "-" {
self.advance()
}
while let char = self.current, ("0"..."9").contains(char) {
self.advance()
}
var isFloatingPoint = false
if self.current == "." {
isFloatingPoint = true
self.advance()
while let char = self.current, ("0"..."9").contains(char) {
self.advance()
}
}
if self.current == "e" || self.current == "E" {
isFloatingPoint = true
self.advance()
if self.current == "-" || self.current == "+" {
self.advance()
}
while let char = self.current, ("0"..."9").contains(char) {
self.advance()
}
}
let raw = String(self.source[start..<self.index])
if !isFloatingPoint, let int = Int(raw) {
return int
}
if let double = Double(raw) {
return double
}
throw ModelCatalogParseError.invalidNumber
}
private mutating func skipTypeAssertion() {
while true {
self.skipWhitespaceAndComments()
if self.consumeKeyword("satisfies") || self.consumeKeyword("as") {
self.skipTypeExpression()
} else {
return
}
}
}
private mutating func skipTypeExpression() {
var angleDepth = 0
while let char = self.current {
if char == "<" {
angleDepth += 1
self.advance()
continue
}
if char == ">", angleDepth > 0 {
angleDepth -= 1
self.advance()
continue
}
if angleDepth == 0, char == "," || char == "}" || char == "]" {
return
}
self.advance()
}
}
private mutating func skipWhitespaceAndComments() {
while true {
while let char = self.current, char.isWhitespace {
self.advance()
}
if self.consumeIf("//") {
while let char = self.current, char != "\n" {
self.advance()
}
continue
}
if self.consumeIf("/*") {
while self.index < self.source.endIndex, !self.consumeIf("*/") {
self.advance()
}
continue
}
return
}
}
private mutating func consume(_ token: String, or error: ModelCatalogParseError) throws {
self.skipWhitespaceAndComments()
guard self.consumeIf(token) else {
throw error
}
}
private mutating func consumeIf(_ token: String) -> Bool {
guard self.source[self.index...].hasPrefix(token) else {
return false
}
self.index = self.source.index(self.index, offsetBy: token.count)
return true
}
private mutating func consumeKeyword(_ keyword: String) -> Bool {
guard self.source[self.index...].hasPrefix(keyword) else {
return false
}
let end = self.source.index(self.index, offsetBy: keyword.count)
if end < self.source.endIndex, self.isIdentifierCharacter(self.source[end]) {
return false
}
self.index = end
return true
}
private var current: Character? {
guard self.index < self.source.endIndex else {
return nil
}
return self.source[self.index]
}
private mutating func advance() {
self.index = self.source.index(after: self.index)
}
private func isIdentifierCharacter(_ char: Character) -> Bool {
char.isLetter || char.isNumber || char == "_" || char == "$"
}
}

View File

@@ -39,14 +39,64 @@ struct ModelCatalogLoaderTests {
}
@Test
func `load with no export returns empty choices`() async throws {
func `load with no export rejects catalog`() async throws {
let src = "const NOPE = 1;"
let tmp = FileManager().temporaryDirectory
.appendingPathComponent("models-\(UUID().uuidString).ts")
defer { try? FileManager().removeItem(at: tmp) }
try src.write(to: tmp, atomically: true, encoding: .utf8)
do {
_ = try await ModelCatalogLoader.load(from: tmp.path)
Issue.record("expected missing MODELS export rejection")
} catch {
#expect(String(describing: error).isEmpty == false)
}
}
@Test
func `load ignores fake exports in comments and strings`() async throws {
let src = #"""
// export const MODELS = { bad: { "bad": { name: "Bad", contextWindow: 1 } } };
const text = "export const MODELS = { alsoBad: {} }";
export const MODELS = {
openai: {
"gpt-4o": { name: "GPT-4o", contextWindow: 128000 } satisfies ModelConfig<string, number>,
},
};
"""#
let tmp = FileManager().temporaryDirectory
.appendingPathComponent("models-\(UUID().uuidString).ts")
defer { try? FileManager().removeItem(at: tmp) }
try src.write(to: tmp, atomically: true, encoding: .utf8)
let choices = try await ModelCatalogLoader.load(from: tmp.path)
#expect(choices.isEmpty)
#expect(choices.count == 1)
#expect(choices.first?.id == "gpt-4o")
#expect(choices.first?.provider == "openai")
}
@Test
func `load rejects executable catalog expressions`() async throws {
let src = """
export const MODELS = {
openai: {
"gpt-4o": { name: (() => { throw new Error("nope") })(), contextWindow: 128000 },
},
};
"""
let tmp = FileManager().temporaryDirectory
.appendingPathComponent("models-\(UUID().uuidString).ts")
defer { try? FileManager().removeItem(at: tmp) }
try src.write(to: tmp, atomically: true, encoding: .utf8)
do {
_ = try await ModelCatalogLoader.load(from: tmp.path)
Issue.record("expected executable catalog expression rejection")
} catch {
#expect(String(describing: error).isEmpty == false)
}
}
}