Skip to content

Harden runtime timeouts and network egress; add platform CI#5

Open
zac wants to merge 8 commits into
mainfrom
fixes-and-improvements
Open

Harden runtime timeouts and network egress; add platform CI#5
zac wants to merge 8 commits into
mainfrom
fixes-and-improvements

Conversation

@zac

@zac zac commented Jul 11, 2026

Copy link
Copy Markdown
Member

Addresses the critical issues from a repo evaluation: timeouts that couldn't interrupt running JavaScript, an unrestricted fetch (SSRF), and install docs that couldn't resolve. A high-effort self-review of the fixes then surfaced a compile blocker and several bugs, which are also fixed here.

Runtime: real timeouts & cancellation

  • Because bridge calls are synchronous, the whole promise graph settles inside one evaluateScript call, so the old wall-clock poll loop never ran while JS executed — while(true){} pinned a worker thread forever and timeoutMs was never enforced. Added ExecutionWatchdog, which installs a JavaScriptCore execution time limit so CPU-bound scripts are preemptively terminated and cancel() interrupts in-flight JS.
  • JSContextGroupSetExecutionTimeLimit/...Clear... are exported by JavaScriptCore but declared only in a private WebKit header, so they aren't visible through the public SDK module. They're reached through a new thin C shim target, CCodeModeJSC. This is JavaScriptCore's only interruption mechanism; the private-symbol dependency is documented in the README and the watchdog source so a host can make an informed App Store decision.
  • Fixed two bugs found in self-review: the settle loop could discard an already-fulfilled result and throw a spurious timeout (now checks settled state before the deadline via a shared waitForSettlement helper), and result serialization ran with the watchdog uninstalled so a runaway getter/toJSON hung the thread forever (the watchdog now stays armed with a fresh bounded budget for the decode phase).

Security: network egress policy (SSRF)

  • Added NetworkAccessPolicy on CodeModeConfiguration. The default blocks loopback, RFC 1918, link-local (including cloud metadata like 169.254.169.254), CGNAT, and unique-local destinations — including alternate/encoded IPv4 literals and IPv4-in-IPv6 (IPv4-mapped, IPv4-compatible, and NAT64) forms — plus localhost/.local/.internal names and fully-qualified trailing-dot spellings.
  • Redirect targets are re-validated before being followed; response bodies are capped (default 10 MB) via a Content-Length pre-check and streaming cancel; allowedHosts/blockedHosts and .permissive give hosts control. Allowed and denied egress destinations are written to the audit logger.

Docs

  • The README no longer instructs users to depend on from: "0.1.0" against a tagless repo (SPM can't resolve it); it documents branch-based install until 0.1.0 is tagged, and adds sections on the network access policy and the timeout/cancellation semantics.

CI

  • Added a platform-build matrix (xcodebuild for iOS and visionOS) so the UIKit presenters and the CCodeModeJSC shim compile against those SDKs — swift test on macOS never did — plus SwiftPM build caching and toolchain logging.

Tests

  • New ExecutionWatchdogTests (infinite loops, loop-in-promise-chain, uncatchable timeout, settles-at-deadline returns result, infinite getter during serialization, explicit cancel, search timeout) and NetworkAccessPolicyTests (private/loopback/link-local/CGNAT, encoded IPv4, IPv6 incl. NAT64, trailing-dot, allow/deny lists, size cap, redirect refusal).

Verification note

Authored in a Linux environment without a Swift toolchain or JavaScriptCore, so nothing here was compiled or run locally — this PR is opened specifically to let CI build and test it on macOS/iOS/visionOS. A remaining TODO.md (committed on this branch) tracks the follow-up work the evaluation surfaced (JS heap cap, concurrency bound, HealthKit-always-denied, calendar-span validation drift, Security-layer tests, and more).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Az9HuLR4Sk6tP5Y924z1zF


Generated by Claude Code

claude added 8 commits July 11, 2026 19:59
JavaScriptCore drains the entire promise graph inside a single
evaluateScript call because bridge calls are synchronous, so the
wall-clock poll loop after evaluation could never interrupt CPU-bound
scripts: while(true){} pinned a worker thread forever and timeoutMs
was never enforced against running JavaScript.

Install a JSContextGroupSetExecutionTimeLimit watchdog per execution
whose callback re-checks the deadline and the cancellation flag every
50ms and terminates the script when either trips. Termination is
classified as EXECUTION_TIMEOUT (or SEARCH_TIMEOUT for search) and
CANCELLED, so call.cancel() now interrupts in-flight JavaScript
instead of only flipping a flag that was polled after completion.

The deadline now starts when evaluation begins rather than after the
script settles, and the dangling-promise poll loop reuses the same
deadline instead of granting a second full timeout budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Az9HuLR4Sk6tP5Y924z1zF
network.fetch previously accepted any HTTP(S) destination once the
capability was allowlisted, so agent-authored JavaScript could reach
loopback services, RFC 1918 hosts, and cloud metadata endpoints such
as 169.254.169.254, follow redirects from public URLs to internal
ones, and buffer arbitrarily large response bodies into memory.

Introduce NetworkAccessPolicy, injected via
CodeModeConfiguration.networkAccessPolicy:

- .standard (the default) refuses loopback, private-range, link-local,
  CGNAT, and unique-local IPv4/IPv6 destinations (including encoded
  literals like 2130706433 and 0x7f000001 via inet_aton, and
  IPv4-mapped IPv6), plus localhost and .local/.localhost/.internal
  names, and caps buffered responses at 10 MB.
- allowedHosts/blockedHosts support exact and subdomain matching, with
  explicit allowlist entries able to deliberately re-enable private
  hosts. .permissive restores the previous unrestricted behavior.
- The fetch transfer now runs through a per-task URLSession delegate
  so redirect targets are re-validated before being followed and
  oversized bodies are cancelled mid-transfer instead of buffered.
- Refusals throw structured NETWORK_POLICY_VIOLATION errors with
  repair suggestions, and both denials and successful fetch
  destinations are written to the audit logger.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Az9HuLR4Sk6tP5Y924z1zF
The install snippet told users to depend on from: "0.1.0", but the
repository has no tags, so Swift Package Manager cannot resolve that
requirement. Document the branch-based dependency as the working form
until a release is tagged, and keep the versioned form as the
preferred path once 0.1.0 exists. Also drop the deprecated name:
parameter from the .package declaration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Az9HuLR4Sk6tP5Y924z1zF
Compile blocker: JSContextGroupSetExecutionTimeLimit and
JSContextGroupClearExecutionTimeLimit are exported by JavaScriptCore
but declared only in a private WebKit header, so the watchdog would
not compile against the public SDK module. Route both through a new
CCodeModeJSC C shim target that re-declares the exported symbols, and
document the private-API dependency in the watchdog and README.

Correctness (execution):
- The post-evaluation poll loop used 'while Date() < deadline' as its
  guard, so a script that had already fulfilled just as the deadline
  passed skipped the loop entirely and threw a spurious timeout that
  discarded a completed result. Replace both poll loops with a shared
  waitForSettlement helper that checks the settled state before the
  deadline on every iteration (and re-checks watchdog termination),
  and returns an already-settled result even a hair past the deadline.
- Result serialization ran with the watchdog uninstalled, so a result
  with a runaway getter/toJSON (e.g. { get x() { while(true){} } })
  hung the execution thread forever. Keep the watchdog installed and
  rearm it with a fresh bounded budget for the decode phase, so
  legitimate serialization still completes while runaway getters are
  terminated.
- Fold the duplicated execute/search termination + poll logic into the
  shared helper.

Security (network policy):
- Host normalization now strips a trailing root dot, so fully-qualified
  spellings like 'localhost.' or 'metadata.google.internal.' can no
  longer bypass the loopback/metadata block or the allow/deny lists.
- IPv6 private detection now also covers IPv4-compatible (::a.b.c.d) and
  NAT64 (64:ff9b::/96) embeddings of private/loopback IPv4 addresses.

Efficiency:
- Reserve the response buffer from a known Content-Length instead of
  growing it through repeated reallocations, and build the success
  audit/log summary string once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Az9HuLR4Sk6tP5Y924z1zF
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Az9HuLR4Sk6tP5Y924z1zF
swift test on macOS never compiled the UIKit presenters or the
CCodeModeJSC watchdog shim against the iOS/visionOS SDKs, so a
platform-specific break (like the private-header symbol the shim
resolves) could land undetected. Add a platform-build matrix that runs
xcodebuild for iOS and visionOS on every PR and push, plus SwiftPM
build caching and toolchain-version logging on the deterministic job.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Az9HuLR4Sk6tP5Y924z1zF
CLRegion / CLCircularRegion are unavailable on visionOS, so the
region-scoped CLGeocoder.geocodeAddressString(_:in:) overload and the
coreLocationRegion helper failed to compile there. The package declared
.visionOS(.v2) support but was never actually built for visionOS until
the new platform-build CI job exercised it.

Use the unscoped geocodeAddressString overload on visionOS and compile
the region helper out there; other platforms keep region-scoped
geocoding. Pre-existing issue surfaced by the new CI matrix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Az9HuLR4Sk6tP5Y924z1zF
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants