Skip to content

fix: keep framework symlinks so the recorder DMG can be signed - #216

Merged
mikehasa merged 4 commits into
mainfrom
fix/framework-symlink-codesign
Sep 14, 2026
Merged

mikehasa merged 4 commits into
mainfrom
fix/framework-symlink-codesign

Conversation

@mikehasa

Copy link
Copy Markdown
Owner

Fixes the DMG signing blocker tracked in #215.

Problem

The frozen CLI embeds a Python.framework, whose canonical bundle layout uses symlinks (Versions/Current, and the top-level Python / Resources aliases). codesign needs those symlinks to recognize the directory as a framework; without them it aborts with bundle format is ambiguous (could be app or framework). So packaging/build-dmg.sh could not sign the release DMG, and no signed/notarized build could be cut. (This surfaced only on the signed release path — an unsigned local build and CI both skip signing, which is why it went unnoticed; #190 also never built a DMG because it did not compile until #212.)

Root cause

Two contracts collided:

So the payload could either pass the installer's validator or be codesignable, but not both.

Fix

Accept symlinks that are safe instead of forbidding all of them, at both enforcement points:

  • CLIPayloadInspector.identity (install-time): a symlink is accepted only if its target is relative and contains no .. component. A forward-only relative target can only descend from its own in-payload directory, so it stays confined even through other forward-only symlinks (the framework's canonical aliases are all forward-only). Each accepted alias is bound into the payload identity as a new link record (path + target), so retargeting an alias still changes the fingerprint. A symlink-free payload's identity is byte-identical to before (the change only adds a branch), so already-recorded identities and every existing payload test are unaffected.
  • Packaging (build-time): the freeze step now validates the aliases in place — relative, in-payload, non-cyclic, not world-writable — and fails the build otherwise, instead of dereferencing them. materialize-cli.pyvalidate-cli-payload.py.

This preserves #188's real intent (no link can alias bytes from outside a stable path) while keeping the framework a codesignable bundle. build-app.sh's own blanket symlink rejection now delegates to the same validator, so all three enforcement points share one contract.

Adversarial review

An adversarial review of the first cut caught a real soundness bug in the install-time check: an earlier version resolved .. lexically, so pivot -> "." plus E -> "pivot/../etc/passwd" passed even though E physically escapes the payload (.. after a symlinked component resolves against that component's target, not the lexical path). The fix forbids .. in symlink targets outright in both validators — provably confining and accepting every real framework alias — with tests for the through-symlink escape.

Verification

  • pytest tests/test_packaging_validate_cli_payload.py — 12 passed (valid framework preserved; external / absolute / relative-.. / through-symlink / broken / cyclic / fifo / world-writable rejected; linked root rejected).
  • swift test — full macOS suite green (adds CLIPayloadSymlinkIdentityTests: accepts safe framework aliases; rejects absolute, relative-.., and through-symlink escapes; tamper-evident to a retargeted alias; distinguishes a symlink from a regular file at the same path).
  • pytest -q (full backend suite) and swift build -c release — clean.
  • packaging/build-dmg.sh --release on the release toolchain — produces a signed and notarized DMG (Apple notarization Accepted, stapled), and packaging/verify-dmg.sh passes: valid on disk, satisfies its Designated Requirement, notarized + stapled. This is the exact step that aborted before the fix.

Related

The frozen CLI embeds a Python.framework, whose canonical bundle layout uses
symlinks (Versions/Current and the top-level Python/Resources). codesign needs
those symlinks to recognize the framework; without them it aborts with
"bundle format is ambiguous", so build-dmg.sh could not sign the release DMG.

The install-time payload validator (CLIPayloadInspector) rejected every symlink,
and freeze-cli.sh dereferenced the framework aliases into real files to satisfy
it — which is exactly what broke signing. Instead, accept symlinks that are
safe: relative and resolving inside the payload. Each alias is bound into the
payload identity (a new link record) so a changed target still changes the
fingerprint, and absolute or escaping targets are rejected. A symlink-free
payload's identity is unchanged.

The freeze step now validates the aliases in place (relative, in-payload,
non-cyclic, not world-writable) and fails closed before signing, instead of
dereferencing them. Renamed packaging/materialize-cli.py to
validate-cli-payload.py to match.
…ared validator

build-app.sh rejected any symlink in the frozen CLI, the same blanket rule
as the installer and freeze step. Delegate to packaging/validate-cli-payload.py
so all three enforce one contract (relative, in-payload, non-cyclic) and the
framework keeps its codesignable symlinks. Give the validator a clean CLI error.
Adversarial review found the lexical containment check unsound. A symlink
through a shallower-pointing intermediate symlink — pivot -> "." and
E -> "pivot/../etc/passwd" — passed the lexical stack arithmetic yet physically
escaped the payload, because ".." after a symlinked component resolves against
that component's physical target, not the lexical path. That let a stable
payload path alias external bytes whose contents the identity never hashes,
defeating the tamper-evidence the validator exists to provide.

Forbid ".." in symlink targets outright, in both the install-time validator
(CLIPayloadInspector) and the build-time validator (validate-cli-payload.py). A
relative target with no ".." component can only descend from its own in-payload
directory, so it stays confined even through other forward-only symlinks. The
framework's canonical aliases are all forward-only, so nothing legitimate is
rejected. Adds tests for the through-symlink escape and a relative ".." escape.
The frozen-CLI symlink gate now accepts safe in-payload aliases and rejects
unsafe ones via the shared validator, so assert both: a relative in-payload
alias builds, an escaping alias is rejected with the validator message.
@mikehasa
mikehasa merged commit ef00930 into main Sep 14, 2026
4 checks passed
@mikehasa
mikehasa deleted the fix/framework-symlink-codesign branch September 14, 2026 08:17
mikehasa added a commit that referenced this pull request Sep 14, 2026
The app upgrades the recorder (CLI + daemon) on launch when it is newer than
the installed one, but upgradeInstalledCLIIfNeeded returned .notNeeded
identically whether the recorder was already current or could not be recognized
at all. That silent no-op made an app-newer-than-recorder skew impossible to
diagnose: nothing upgraded, and nothing said why.

Add upgradeBlockedReason: when a recorder is present but no upgrade context can
be built — the app cannot verify its own bundled recorder, or the installed
recorder is not recognized as app-owned — name the reason.
upgradeInstalledCLIIfNeeded records it on recorderUpgradeDiagnostic and logs it
instead of skipping silently, and clears it once an upgrade proceeds. The reason
is computed only inside the upgrade check (which already does the heavy
identity/provenance work), never during rendering, so it does not traverse or
hash payloads.

Add the regression test that was missing: a legacy install whose Python.framework
keeps its symlinks is auto-upgraded by a newer app (the shape #216 enabled and a
pre-#216 inspector could not recognize), plus coverage for both blocked-reason
branches (installed recorder unrecognized; bundled recorder unverifiable).
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.

1 participant