Repository navigation
Make the full test suite portable to macOS (symlinked temp paths, platform-specific tests) - #126
Conversation
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]>
…te-portability-20260923
….' 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
…cos-15 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
…te-portability-20260923 # Conflicts: # manifests/evidence.json
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 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".
| _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) |
There was a problem hiding this comment.
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(): |
There was a problem hiding this comment.
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 👍 / 👎.
| 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 |
There was a problem hiding this comment.
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 👍 / 👎.
The catalog's full
python3 -m unittestsuite 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
scripts/catalog_decisions.py:68comparedpath.resolve()with an unresolvedroot. macOS temp dirs are symlinks (/var/folders→/private/var/folders)root = Path(root).resolve()at the single sharedsafe_filepointalpaca-historical/collect.py,catalyst-dataset/dataset.py,research-efficiency/experiment.py,offhost-restore/verify.py,service-reboot/run.pyandoffhost-app-state/run.pytripped over the system's own symlinked temp ancestortempfile.gettempdir()and walk only the components below it. Injected symlinks are still refused, and the existing negative tests still passbootstrap-linux.shrefuses on purpose on anything other than Linux x86_64skipUnless(linux and x86_64)on exactly those 6 test methodslinux-wsl2-x86_64--os linux --architecture x86_64explicitly.resolve()where the code under test canonicalisesoffhost-app-state/plan.jsonandoffhost-restore/hosted-plan.jsonre-pin thefrozen_sourceshashes of the two edited scripts. The historicalattempt-1/hosted-plan.jsonis left unchanged, because it records a past attempt.Evidence
TMPDIRpointing 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.validate-macos. Run 35900713738 passed with the gating step at exit 0.owned_rootaccepted a symlinked parent, thatdecode_repositoryhad lost its..refusal, and that the exemption trusted$TMPDIR.scripts/path_safety.refuse_untrusted_symlinkstolerates a symlink only when it sits directly under/and/is root-owned and not group- or world-writable. That matches macOS's/tmp,/varand/etc. Every other symlink, and every.., is refused, and$TMPDIRnever grants an exemption. The helper is pinned in every frozen-source list.Compatibility
alpaca-historical/collect.pyandresearch-efficiency/experiment.pychanged bytes: each now delegates to the shared helper in a minimal edit. Security-identityledger.py/quality.pyand 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