fix(pet): validate whole shared frames natively and bound Apple reads

P01-06 (Refs #6561). The native Shared-mode views render frames polled
from the companion's loopback port; after the owner exits any local
process can bind that port and answer.

Android PetSharedClient.poll() validated only the pose it was about to
draw (980 finite points), and did so after it had already stored the
frame, refreshed freshness, leased sound and flushed a pending action
with that frame's source revision. epoch, timeMs, digest, surface,
caustic, behaviour/needs and both poses' state/style were unchecked, and
org.json's getDouble accepts "NaN"/"Infinity" strings, so non-finite
values (and NaN dotScale/glow, which coerceIn passes through) reached the
renderer. It now validates the whole frame — both poses against the
core's validatePetState bounds and known channels, style colour/alpha
ranges, UUID epoch, finite times — before any state changes.

Apple PetSharedHost.request() buffered the entire body with
session.data(for:) (under a 2 s resource timeout) before comparing its
size with the 8/64 MiB bound. It now rejects a declared oversize
Content-Length up front and streams session.bytes(for:) into the buffer,
cancelling the task at the bound. PetSharedFrame.validate() adds the same
state/style/behaviour/needs/activity checks as Android.

Validation:
- swiftc -typecheck, macOS 14 target, the full macos/build.sh source set:
  exit 0.
- Swift runtime check (scratch harness, not committed): a frame built from
  the real core's presentation() plus the owner's fields is accepted;
  frames with still.state.activity 7, style.channel "bogus" or needs
  "panic" are rejected. Negative control: HEAD PetShared.swift accepted
  all three invalid frames.
- Kotlin: typechecked PetSharedClient.kt + android/PetSim.kt with Gradle
  8.14.5's bundled Kotlin 2.0.21 compiler (OpenJDK 17) against local stubs
  for android.os.SystemClock, org.json and PetNativeCore: exit 0; a
  deliberately wrong argument produced a type error, so the check is real.
- Not run: Android Gradle build/instrumented tests, iOS build, the
  streaming bound against an oversized live response.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-30 01:16:59 -07:00
1 parent 9f33ed4098
commit 251470477c
2 files changed
+57 -14

No files matched your search

@@ -46,25 +46,38 @@ class PetSharedClient(private val descriptor: File) {
fun poll(still: Boolean, sound: Boolean): PetScene {
val next = request("/v1/frame")
require(next.getInt("version") == 1 && next.getString("identity") == connection().getString("identity")) { "Shared identity changed; select its connection again." }
if (frame?.optString("epoch") != next.getString("epoch") || frame?.optLong("tick") != next.getLong("tick")) changed = SystemClock.elapsedRealtime()
frame = next; identity = "${next.getString("identity")} · tick ${next.getLong("tick")} · ${next.getString("digest")}"
// Validate every rendered field of both poses before this frame can
// refresh freshness, lease sound or flush a pending action. org.json
// parses "NaN"/"Infinity" strings through getDouble, so check finiteness.
val epoch = next.getString("epoch"); UUID.fromString(epoch)
val tick = next.getLong("tick"); val timeMs = next.getDouble("timeMs")
val surface = next.getDouble("surface"); val caustic = next.getDouble("caustic")
require(tick >= 0 && timeMs.isFinite() && timeMs >= 0 && surface.isFinite() && caustic.isFinite()
&& next.getString("digest").length == 16 && next.getString("behaviour") in BEHAVIOURS && next.getString("needs") in NEEDS) { "Invalid shared pet frame." }
val poses = listOf(next, next.getJSONObject("still")).map { pose ->
val points = pose.getJSONArray("points"); require(points.length() == 980) { "Invalid shared pet frame." }
val dots = FloatArray(1960) { i -> points.getJSONArray(i / 2).also { require(it.length() == 2) }.getDouble(i % 2).also { require(it.isFinite() && kotlin.math.abs(it) <= 8) }.toFloat() }
val state = PetNativeCore.decodeState(pose.getJSONObject("state")); val style = PetNativeCore.decodeStyle(pose.getJSONObject("style"))
require(validState(state) && validStyle(style)) { "Invalid shared pet frame." }
Triple(dots, state, style)
}
val appearance = next.optJSONObject("appearance")?.let { a ->
fun color(key: String): List<Int> { val c=a.getJSONArray(key); require(c.length()==3); return (0..2).map { c.getInt(it).also { n -> require(n in 0..255) } } }
val dotScale = a.getDouble("dotScale"); val glow = a.getDouble("glow"); require(dotScale.isFinite() && glow.isFinite())
PetAppearance(color("background"), color("backgroundTop"), dotScale.toFloat().coerceIn(.65f,1.8f), glow.toFloat().coerceIn(0f,1f), a.getBoolean("environment"))
}
if (frame?.optString("epoch") != epoch || frame?.optLong("tick") != tick) changed = SystemClock.elapsedRealtime()
frame = next; identity = "${next.getString("identity")} · tick $tick · ${next.getString("digest")}"
val fresh = SystemClock.elapsedRealtime() - changed < 800
message = if (fresh && next.getBoolean("producerConnected")) "Following ${next.getString("source")}" else "${next.getString("source")} · telemetry unobserved"
if (!next.getBoolean("storageAvailable")) message += " · storage unavailable; previous recording kept"
if (next.getBoolean("audioUnavailable")) granted = false
if (SystemClock.elapsedRealtime() - lease >= 500) setSound(sound && fresh)
flush()
val pose = if (still) next.getJSONObject("still") else next
val points = pose.getJSONArray("points"); require(points.length() == 980)
val dots = FloatArray(1960) { i -> points.getJSONArray(i / 2).getDouble(i % 2).also { require(it.isFinite() && kotlin.math.abs(it) <= 8) }.toFloat() }
val state = PetNativeCore.decodeState(pose.getJSONObject("state")); val style = PetNativeCore.decodeStyle(pose.getJSONObject("style"))
val (dots, state, style) = poses[if (still) 1 else 0]
if (!fresh || !next.getBoolean("producerConnected")) { style.hollow = true }
val appearance = next.optJSONObject("appearance")?.let { a ->
fun color(key: String): List<Int> { val c=a.getJSONArray(key); require(c.length()==3); return (0..2).map { c.getInt(it).also { n -> require(n in 0..255) } } }
PetAppearance(color("background"), color("backgroundTop"), a.getDouble("dotScale").toFloat().coerceIn(.65f,1.8f), a.getDouble("glow").toFloat().coerceIn(0f,1f), a.getBoolean("environment"))
}
return PetScene(next.getDouble("timeMs"), state, style, dots, next.getString("behaviour"), next.getString("needs"),
next.getDouble("surface").toFloat(), if (still) 0f else next.getDouble("caustic").toFloat(), null, 0, next.getString("digest"), appearance,
return PetScene(timeMs, state, style, dots, next.getString("behaviour"), next.getString("needs"),
surface.toFloat(), if (still) 0f else caustic.toFloat(), null, 0, next.getString("digest"), appearance,
next.optJSONObject("activity")?.takeIf { it.optBoolean("observed") && fresh }?.let { a ->
a.getString("label").take(96) + (if (a.isNull("tool")) "" else " · " + a.getString("tool").take(96)) +
(if (a.optInt("parallel") > 0) " · ${a.getInt("parallel")} parallel agents" else "")
@@ -82,6 +95,13 @@ class PetSharedClient(private val descriptor: File) {
fun detach() { if (granted) runCatching { setSound(false) }; granted = false }
fun export(): ByteArray = request("/v1/export").toString().toByteArray(Charsets.UTF_8)
companion object {
private val BEHAVIOURS = setOf("swim", "dive", "roll", "breathe", "drift", "doze", "wake")
private val NEEDS = setOf("none", "orient", "approach", "call")
/** The shared core's validatePetState bounds. */
private fun validState(s: PetState) = listOf(s.activity, s.coherence, s.attention, s.observed, s.lit).all { it.isFinite() && it in 0.0..1.0 }
&& listOf(s.roamX, s.roamY, s.flip).all { it.isFinite() && kotlin.math.abs(it) <= 1.0 } && CHANNELS.any { it.key == s.channel }
private fun validStyle(f: Frame) = listOf(f.r, f.g, f.b).all { it.isFinite() && it in 0.0..255.0 } && f.alpha.isFinite() && f.alpha in 0.0..1.0
&& f.work.isFinite() && CHANNELS.any { it.key == f.channel }
fun validateConnection(d: JSONObject) {
require(d.getInt("version") == 1 && d.getInt("port") in 1..65535 && d.getString("token").matches(Regex("[a-f0-9]{64}"))) { "Invalid shared pet connection." }
UUID.fromString(d.getString("identity"))
+25 -2
View File
@@ -37,12 +37,27 @@ public struct PetSharedFrame: Decodable {
public let appearance: PetAppearance?
public let activity: PetActionCue?
public let producerConnected: Bool, storageAvailable: Bool, audioOwner: String?, audioUnavailable: Bool
/// Every rendered field of both poses, not only the points: after the
/// owner exits, any local process can bind its loopback port and answer.
public func validate() throws {
guard version == 1, UUID(uuidString: identity) != nil, UUID(uuidString: epoch) != nil,
timeMs.isFinite, timeMs >= 0, digest.count == 16, appearance?.valid != false,
["swim", "dive", "roll", "breathe", "drift", "doze", "wake"].contains(behaviour),
["none", "orient", "approach", "call"].contains(needs),
activity.map({ $0.label.count <= 256 && ($0.tool?.count ?? 0) <= 256 && (0...256).contains($0.parallel) }) != false,
[state, still.state].allSatisfy(Self.valid), [style, still.style].allSatisfy(Self.valid),
[points, still.points].allSatisfy({ $0.count == 980 && $0.allSatisfy { $0.count == 2 && $0.allSatisfy { $0.isFinite && abs($0) <= 8 } } })
else { throw PetCoreError.invalid("Invalid shared pet frame.") }
}
/// The shared core's validatePetState bounds.
static func valid(_ s: PetState) -> Bool {
[s.activity, s.coherence, s.attention, s.observed, s.lit].allSatisfy { $0.isFinite && (0...1).contains($0) }
&& [s.roamX, s.roamY, s.flip].allSatisfy { $0.isFinite && abs($0) <= 1 } && channelIndex(s.channel) != nil
}
static func valid(_ f: Frame) -> Bool {
[f.r, f.g, f.b].allSatisfy { $0.isFinite && (0...255).contains($0) } && f.alpha.isFinite && (0...1).contains(f.alpha)
&& f.work.isFinite && channelIndex(f.channel) != nil
}
}
/// A view, never a world. Its task polls immutable owner snapshots. Reopening
@@ -135,8 +150,16 @@ public struct PetSharedFrame: Decodable {
var request = URLRequest(url: URL(string: "http://127.0.0.1:\(connection.port)\(path)")!)
request.setValue("Bearer " + connection.token, forHTTPHeaderField: "Authorization")
if let body { request.httpMethod = "POST"; request.httpBody = body; request.setValue("application/json", forHTTPHeaderField: "Content-Type") }
let (data, response) = try await session.data(for: request)
guard data.count <= (path == "/v1/export" ? 64 : 8) * 1024 * 1024, (response as? HTTPURLResponse)?.statusCode == 200 else {
// Stream against the bound; never buffer an oversized body before checking it.
let limit = (path == "/v1/export" ? 64 : 8) * 1024 * 1024
let (bytes, response) = try await session.bytes(for: request)
guard response.expectedContentLength <= Int64(limit) else { bytes.task.cancel(); throw PetCoreError.invalid("Shared pet response exceeds its bound.") }
var data = Data(); data.reserveCapacity(Int(max(0, response.expectedContentLength)))
for try await byte in bytes {
guard data.count < limit else { bytes.task.cancel(); throw PetCoreError.invalid("Shared pet response exceeds its bound.") }
data.append(byte)
}
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
let error = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
let message = error?["error"] as? String ?? "Shared pet unavailable."
if path == "/v1/action", (response as? HTTPURLResponse)?.statusCode == 409, !message.contains("storage") { pending = nil }