Skip to content

Make the full test suite portable to macOS (symlinked temp paths, platform-specific tests) - #126

Merged
seathatflowsinourveins merged 19 commits into
mainfrom
claude/macos-full-suite-portability-20260923
Sep 23, 2026
Merged

seathatflowsinourveins merged 19 commits into
mainfrom
claude/macos-full-suite-portability-20260923

Conversation

@seathatflowsinourveins

@seathatflowsinourveins seathatflowsinourveins commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

The catalog's full python3 -m unittest suite should pass on macOS as it does on Linux, because future macOS hosts run these tools and tests when they contribute evidence. Hosted macos-15 run 35875188590 recorded 2929 tests: 29 failures and 95 errors. The same suite passes on Linux.

Root causes and fixes

Class Root cause Fix
"source path escapes the repository" (~50 tests) scripts/catalog_decisions.py:68 compared path.resolve() with an unresolved root. macOS temp dirs are symlinks (/var/folders → /private/var/folders) root = Path(root).resolve() at the single shared safe_file point
symlink or parent-traversal refusals (~55 tests) ancestor-symlink walks and resolve comparisons in alpaca-historical/collect.py, catalyst-dataset/dataset.py, research-efficiency/experiment.py, offhost-restore/verify.py, service-reboot/run.py and offhost-app-state/run.py tripped over the system's own symlinked temp ancestor anchor on the resolved tempfile.gettempdir() and walk only the components below it. Injected symlinks are still refused, and the existing negative tests still pass
Linux-only bootstrap tests (~9) bootstrap-linux.sh refuses on purpose on anything other than Linux x86_64 skipUnless(linux and x86_64) on exactly those 6 test methods
host-OS fixture assumptions (~5) record fixtures relied on auto-detected OS but claimed linux-wsl2-x86_64 pass --os linux --architecture x86_64 explicitly
singles expected strings built from an unresolved temp path compare against .resolve() where the code under test canonicalises

offhost-app-state/plan.json and offhost-restore/hosted-plan.json re-pin the frozen_sources hashes of the two edited scripts. The historical attempt-1/hosted-plan.json is left unchanged, because it records a past attempt.

Evidence

  • Linux full suite: 3065 tests OK, both normally and with TMPDIR pointing through a symlink, which reproduces the macOS failure classes on Linux (skipped=333 and 342 respectively). Each class was reproduced before its fix and passes after.
  • Validators: all pass. Guarded gitleaks was run on each commit.
  • Hosted macOS full suite: 124 failures before this PR, then 3 after round 1 and 2 after round 2. Run 35898879109 passed with 3156 tests OK (skipped=435).
  • Gating: this PR makes the macOS full suite a gating step of validate-macos. Run 35900713738 passed with the gating step at exit 0.
  • Independent security review: three rounds, by Opus/high evidence-reviewer.
    • Round 1 found that owned_root accepted a symlinked parent, that decode_repository had lost its .. refusal, and that the exemption trusted $TMPDIR.
    • Round 2 found that a trusted link's target went unchecked, that the link-mode check was meaningless, and that the helper was unpinned.
    • All are fixed. The shared scripts/path_safety.refuse_untrusted_symlinks tolerates a symlink only when it sits directly under / and / is root-owned and not group- or world-writable. That matches macOS's /tmp, /var and /etc. Every other symlink, and every .., is refused, and $TMPDIR never grants an exemption. The helper is pinned in every frozen-source list.

Compatibility

alpaca-historical/collect.py and research-efficiency/experiment.py changed bytes: each now delegates to the shared helper in a minimal edit. Security-identity ledger.py/quality.py and the research-efficiency freeze check compare those bytes, so re-verifying identity-readiness private runs or frozen experiments captured before this PR will fail closed with a version-mismatch error. Re-capture those runs to re-verify them.

🤖 Generated with Claude Code

safe_file(root, relative) compared path.resolve() (fully resolved) against
root as given, unresolved. On macOS, TMPDIR sits under a symlinked system
directory (/var -> /private/var), so a legitimate root under TMPDIR no
longer prefix-matched its own resolved descendant, and safe_file raised
"source path escapes the repository" for every caller (catalog_decisions,
host_receipts, component_matrix, freshness_propose, lane_packets) even
though nothing escaped.

Resolve root once at the top of safe_file so both sides of the containment
check are canonicalised the same way. The per-component
"source symlinks are forbidden" check (which detects a symlink actually
placed inside the tree) is untouched and keeps refusing real escapes.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Several independent safe_path()/safe()/target-symlink checks walked every
ancestor of a path (or compared path.resolve() to path.absolute()) looking
for an injected symlink. On macOS, TMPDIR resolves through /var ->
/private/var (or /tmp -> /private/tmp), so a legitimate path under the
system temp directory always contained a "symlink ancestor" and was
refused, even with nothing actually escaping.

Anchor each check on tempfile.gettempdir(): resolve that one system
boundary once, then walk only the components below it (or, for a path
outside the temp tree, every component from the filesystem root, exactly
as before) looking for an injected symlink. A symlink placed inside the
job-owned tree -- an alias pointing back into it, a swapped destination, a
tampered receipt.json -- still breaks that walk and is refused; only the
tolerated system boundary above it stops causing false positives. Applies
to:
  - alpaca-historical/collect.py and catalyst-dataset/dataset.py
    (symlink_or_parent_traversal_refused, shared by security-identity,
    lifecycle-sample and catalyst provenance via the same bridge module)
  - research-efficiency/experiment.py (symlink refused)
  - offhost-restore/verify.py (repository target traverses a symlink)
  - service-reboot/run.py (--work must be a new absolute path)

offhost-app-state/run.py's owned_root() takes a different, narrower shape:
it requires a path directly under the literal /tmp, so instead of walking
ancestors it now resolves both /tmp and the candidate's parent for the
containment compare, while path.is_symlink() (not a resolve() comparison
on path itself) keeps catching a symlinked leaf; the returned path stays
the literal /tmp form the caller passed in.

offhost-app-state/run.py and offhost-restore/verify.py are both pinned by
frozen_sources sha256 entries in offhost-app-state/plan.json and
offhost-restore/hosted-plan.json; both are re-pinned to the new bytes so
test_active_recovery_plans.py's dispatch-source guard passes again.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
bootstrap-linux.sh has its own uname-based guard ("This verified asset set
targets x86_64 Linux, not Windows/Git Bash or ARM.") and refuses to run at
all off Linux x86_64 -- correctly, since it installs x86_64 Linux binaries.
On macOS this guard fires before every later check the affected tests
exercise (unknown profile, null-hash pin refusal, unpinned-component
fail-closed, the ECO_INSTALL_ROOT-resolves-to-HOME refusal, and the
missing-tool detection under --skip-system-packages), so those tests saw
the platform-guard message instead of the behavior under test.

Mark exactly the affected test methods (not their whole classes, which
also hold platform-portable tests like --help and missing --profile) with
skipUnless(sys.platform.startswith('linux') and platform.machine() ==
'x86_64'). bootstrap-macos.sh is the already-covered macOS path; no
assertion is deleted or weakened.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Four RecorderRoundTripTests fixtures called the record CLI with an
explicit --platform-id linux-wsl2-x86_64 but left host.os/architecture on
auto-detection, then ran cmd_validate(), which cross-checks a receipt's
host.os/architecture against adoption/manifest.json's platform_profiles
for the claimed platform_id. On a Linux x86_64 host the auto-detected
values happened to match; on macOS (auto-detected os=macos,
architecture=arm64) they legitimately do not match linux-wsl2-x86_64's
declared linux/x86_64, so validation correctly failed.

Pass --os linux --architecture x86_64 explicitly (the CLI already supports
overriding auto-detection for exactly this), so the fixture is consistent
with its own hardcoded platform_id on every host. This also fixes two
platform_profile_map() KeyErrors elsewhere in the suite: that function
swallows InvalidDecisionIndex and returns {}, which was the effect of the
adjacent safe_file() containment bug now fixed separately -- no schema or
assertion changed here.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Three tests compared subprocess/rendered output to a literal, unresolved
temp path, while the code under test deliberately resolves that same path
before emitting it:
  - grand-dashboard install.py resolves dagu_bin/dagu_home before writing
    them into the generated systemd unit (so the unit references a real,
    unambiguous path);
  - worker-recovery run.py's exact_read_rule() resolves its workspace
    argument before embedding it in a generated Bash permission rule (so
    the rule targets the real path, not a symlink alias of it);
  - render_config.py's default_project_root() falls back to Path.cwd(),
    and the getcwd() syscall always returns the fully resolved directory
    (POSIX, not macOS-specific).

On a host where the temp root has a symlinked ancestor (macOS's
/tmp -> /private/tmp, /var -> /private/var, or TMPDIR itself symlinked),
the resolved output legitimately differs from the tests' hardcoded literal
form. Compare against the same .resolve() the source already applies
instead of hardcoding the unresolved path; the source's canonicalisation
is correct and unchanged.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Re-hash the source, test and plan files touched by the macOS
portability fixes (host_receipts.register_file per file, then
evidence_manifest.py --write to keep files[] sorted).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
….' fixes

An independent Opus security review of the round-1 macOS portability fixes
found three weakenings, all unreachable through current callers but real:

1. owned_root() (offhost-app-state/run.py) compared path.parent.resolve()
   to Path('/tmp').resolve(), so /tmp/x -> /tmp made path.parent.resolve()
   match even though path.parent was NOT literally /tmp -- and /tmp/x could
   be repointed elsewhere after this check and before the root is actually
   used (TOCTOU). Fixed: path.parent must literally be /tmp or its resolved
   form, and path.resolve() must land exactly at <resolved /tmp>/<name>.
   The same resolve()-vs-literal-/tmp pattern was also live in three other
   offhost-app-state checks discovered by the round-2 macOS CI run
   (35890224379): cleanup()'s and password()'s checks always differ once
   /tmp itself is a symlink, even with nothing wrong, since self.root is
   already a vetted literal path; fixed by checking only the specific
   component that could actually be swapped (the leaf, or the intermediate
   'private' directory) instead of resolve()-comparing the whole path.
   validate_source() had the identical bug for an unvetted folder argument;
   fixed by routing it through the new shared helper (see 3).

2. decode_repository() (offhost-restore/verify.py) lost its '..' refusal
   when the old target.resolve() != target.absolute() check (which caught
   '..' incidentally, since resolve() collapses it and absolute() does not)
   was replaced with the TMPDIR-anchored symlink walk. Restored as an
   explicit `'..' in target.parts` refusal.

3. The TMPDIR exemption in every one of the six affected checks trusted
   $TMPDIR unconditionally: everything at or above tempfile.gettempdir()
   was skipped, so an attacker-controlled symlink set as TMPDIR (or a
   shared TMPDIR an attacker can plant a link under) was tolerated. Added
   scripts/path_safety.refuse_untrusted_symlinks(), one shared helper used
   by alpaca-historical/collect.py, catalyst-dataset/dataset.py,
   research-efficiency/experiment.py, offhost-restore/verify.py,
   service-reboot/run.py and offhost-app-state/run.py's validate_source():
   it walks every component from the filesystem root (never anchored on
   $TMPDIR) and tolerates a symlinked ancestor only when it is owned by
   root and not group/world-writable -- exactly macOS's own
   /tmp -> /private/tmp, /var -> /private/var, /etc -> /private/etc, never
   a symlink any unprivileged account (including the one running this
   process) could have planted or repointed. catalog_decisions.safe_file()
   does not adopt it: its containment model is a confined, already-resolved
   root with zero symlink tolerance inside the tree, which this helper's
   "tolerate a root-owned ancestor" relaxation would only weaken.

collect.py's own diff is kept to an import and a one-line call (per
compatibility note below); the other five callers follow the same shape.

New regression tests (each reproduces the weakening against c1f30cb before
passing against this fix): scripts/path_safety's own ownership/mode
behavior including the TMPDIR case (tests/test_path_safety.py, with the
root-owned/non-writable case simulated via a Path.lstat mock -- Linux does
not enforce meaningful symlink permission bits the way macOS does, so a
real Linux symlink cannot demonstrate the "tolerated" branch); owned_root's
symlinked-parent TOCTOU gap and decode_repository's '..' refusal
(tests/test_offhost_app_state.py, tests/test_offhost_restore.py); the same
TMPDIR-trust weakening reproduced end-to-end through experiment.safe() and
service-reboot's run() (tests/test_research_efficiency.py,
tests/test_service_reboot.py).

Compatibility: security-identity/ledger.py and quality.py (frozen
base-collector.py = collect.py bytes) and research-efficiency/experiment.py
(frozen self-comparison) fail-closed re-verification of any
identity-readiness private run captured before this change, since the
guarded files' bytes changed. This is the correct, deliberate consequence
of fixing a real check in a file those runs pin; there is no way to fix the
check without changing the file it lives in.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
offhost-app-state/run.py, offhost-restore/verify.py and the two test files
that gained new regression coverage are all pinned by frozen_sources
sha256 entries in these two plans; re-pin them to the fixed bytes so
test_active_recovery_plans.py's dispatch-source guard passes again.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Re-hash the files touched by the round-2 security fixes (host_receipts
.register_file per file, then evidence_manifest.py --write to keep
files[] sorted).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
…te-portability-20260923

# Conflicts:
#	manifests/evidence.json
Two lows in the round-2 shared helper:

A. Once a root-owned link was tolerated, the walk followed it and every
   component through it, with no check on where in the tree that link
   sat. A root-owned link deeper in the tree (a hard link to, or a copy
   of, a relative framework link such as macOS's Versions/Current,
   planted somewhere under /tmp) could redirect the path. Fixed: a
   symlink is tolerated only when it sits directly under the filesystem
   root (candidate.parent == Path(candidate.anchor)) -- exactly macOS's
   /tmp, /var and /etc; anything else is refused regardless of ownership.

B. Checking the *link's own* mode did nothing: a symlink's permission
   bits are not what gates replacing it, and Linux does not even enforce
   them (lstat reports 0o777 for every symlink regardless of chmod).
   What actually controls whether an unprivileged account can repoint or
   replace the link is whether it can write to the directory the link
   lives in. Fixed: check that the link's parent directory (which, given
   A, is always /) is root-owned and not group/world-writable, instead
   of the link's own stat.

New/adjusted tests in tests/test_path_safety.py, each exercising the two
conditions independently: a root-owned link (mocked) NOT directly under /
is still refused; a link directly under / (mocked, since a real one
cannot be planted at the actual filesystem root here) whose parent is
mocked writable is refused; the positive case (link directly under /,
real /'s own genuinely root-owned/non-writable stat, no mock needed for
the parent check) is tolerated.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Hosted macOS run 35894394138 on 51f4145 (3147 tests, failures=2, down
from 124 at the start):

1. tests.test_host_receipts.QualifiedModelRecordTests.
   test_qualified_model_flag_is_recorded_and_validates: the same
   auto-detected-host.os-vs-declared-platform-id mismatch fixed
   elsewhere in this file for #131's new class -- --platform-id
   linux-wsl2-x86_64 with host.os/architecture left on auto-detection,
   then cmd_validate()'s cross-check. Pinned --os linux --architecture
   x86_64 in the shared _record() helper (the only other test using it
   that also calls cmd_validate). Grepped every other record fixture in
   the file claiming a linux platform: none of the others call
   cmd_validate, so none needed the same fix.

2. tests.test_offhost_app_state.EvidenceGuards.
   test_command_timeout_retains_output_and_stops_owned_group: raced a
   full /usr/bin/python3 interpreter's fork/exec + bootstrap against a
   0.1s timeout. macOS process creation (especially on GitHub-hosted
   runners) is measurably slower than Linux's, so the child was
   sometimes killed before it printed anything, leaving stdout empty --
   this looked like a process-group/kill bug but was a startup-latency
   race; os.killpg/setsid semantics are POSIX and unaffected. Fixed
   deterministically, not with a longer sleep: the child is now
   /bin/sh -c 'printf ...; sleep 3', which has no interpreter to
   bootstrap and no stdio buffering to flush, so the timeout budget
   (raised slightly, to .5s, for headroom) is spent almost entirely on
   the kernel's own fork/exec rather than CPython startup. The behavior
   under test (partial output retained, process group killed) is
   unchanged and still asserted on every platform.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
The shared helper added in round 2 was adopted by six checks but never
itself pinned anywhere, so a changed helper would silently run different
code than reviewed:

  - offhost-app-state/plan.json and offhost-restore/hosted-plan.json:
    added to frozen_sources, checked by Trial.verify_frozen() and
    verify_sources() respectively (run.py:262-ish in each) -- see the
    companion commit for the re-pinned hashes.
  - service-reboot/run.py: added to the repository_inputs digest set (a
    host-side-only dependency of run()'s own check, so tracked alongside
    source_paths rather than added to it, since source_paths is also
    copied into the guest payload and path_safety.py has no guest-side
    role).
  - research-efficiency/experiment.py: folded 'experiment.py' and
    'run_codex.py' plus the new 'path_safety.py' into one RUNTIME_SOURCES
    mapping, reused by both prepare()'s freeze-time copy and run()'s
    source-changed-after-freeze comparison, so all three trip the same
    'runtime source changed after freeze' check.

New tests in tests/test_active_recovery_plans.py confirm the pin is
present in both plans and that corrupting the recorded hash (simulating
a changed helper, since editing the real checked-in file would not be
reverted) trips verify_frozen()/verify_sources() with the expected
message.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
path_safety.py (findings A/B), tests/test_offhost_app_state.py (the
timeout-test fix) and scripts/path_safety.py's new pin itself all changed
bytes that offhost-app-state/plan.json and offhost-restore/hosted-plan.json
pin; re-pin them to the fixed/new bytes so
test_active_recovery_plans.py's dispatch-source guard passes again.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Re-hash the files touched by the round-3 fixes (host_receipts
.register_file per file, then evidence_manifest.py --write to keep
files[] sorted).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
…te-portability-20260923

# Conflicts:
#	manifests/evidence.json
@seathatflowsinourveins
seathatflowsinourveins marked this pull request as ready for review September 23, 2026 18:23
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-23T18:33:53.207906Z 7556399 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@seathatflowsinourveins
seathatflowsinourveins merged commit 9e643b1 into main Sep 23, 2026
21 checks passed
@seathatflowsinourveins
seathatflowsinourveins deleted the claude/macos-full-suite-portability-20260923 branch September 23, 2026 18:33

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7556399965

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +27 to +29
_PATH_SAFETY_SPEC = importlib.util.spec_from_file_location("path_safety", HERE.parents[2] / "scripts/path_safety.py")
_path_safety = importlib.util.module_from_spec(_PATH_SAFETY_SPEC)
_PATH_SAFETY_SPEC.loader.exec_module(_path_safety)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include path_safety in every evidence identity

When an Alpaca run is collected or reverified, or a catalyst dataset is built after scripts/path_safety.py changes, this separately executed module is not covered by the Alpaca freeze (collect() retains only the plan, collector, and SDK identities) or catalyst bridge_identity() (which hashes only dataset.py and catalyst.py). Consequently, receipts with identical recorded source identities can be evaluated using different path-acceptance code; add the helper's bytes/hash to both identities and verify it during replay, as the research-efficiency runtime already does.

AGENTS.md reference: AGENTS.md:L10-L15

Useful? React with 👍 / 👎.

# not compare its resolve() to itself -- /tmp's own resolved form (macOS's
# /tmp -> /private/tmp) would always differ from the literal path even
# with nothing wrong. Only the leaf itself must not be a symlink.
if private.exists() and private.is_symlink():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Revalidate the owned root before destructive cleanup

If the job-owned /tmp/native-offhost-app.* directory is replaced by a symlink after Trial construction but before cleanup(), this leaf-only check accepts the redirected private directory and the loop below deletes private, home, tools, repositories, and other matching paths in the symlink target. The previous private.resolve() != private guard detected a changed ancestor; preserve macOS /tmp compatibility by rechecking that trial.root.resolve() still equals the expected resolved temporary path immediately before destructive cleanup rather than checking only private.is_symlink().

AGENTS.md reference: AGENTS.md:L3-L8

Useful? React with 👍 / 👎.

Comment on lines +323 to +324
Hosted run 35898879109 then passed the full suite on macos-15 (3156
tests, `OK`, skipped=435), and the full suite is now a **gating** step

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retain a receipt for the macOS pass claim

A repo-wide search for run 35898879109 and the reported 3156-test result finds only this prose, while the workflow log artifact expires after 30 days. Once that artifact expires, the newly asserted hosted-runner pass and exact test/skip counts cannot be independently checked or reused as historical evidence; retain a compact sanitized receipt containing the command, run/job identity, actual returned result, and relevant hashes instead of relying only on the run number.

AGENTS.md reference: AGENTS.md:L63-L63

Useful? React with 👍 / 👎.

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