fix(perms): never dereference symlinks when applying the ownership table - #1743
Merged
Conversation
`hal0 migrate model-layout --apply` (#1732) plants symlinks under /var/lib/hal0/models/... pointing at real files in the operator's model store (/mnt/ai-models). The perms table's recursive `models/` row matched those links via rglob, and `commit()`/`_apply_one` called os.chown/os.chmod, which follow symlinks (Linux has no lchmod). Every install/upgrade therefore rewrote the TARGET files to hal0:hal0 0644 — outside hal0's declared tree, directly contradicting migrate_commands.py's "does not write to /mnt/ai-models at all" invariant. The commit path is now symlink-safe at three layers: * `_expand_row` drops any glob match that is a symlink or that was reached by walking through one (a symlinked model DIRECTORY yields real, non-symlink paths under /mnt/ai-models that a link check alone would miss). Checked explicitly rather than relying on pathlib's no-follow behaviour, so the guarantee is version-independent. * `PermObservation` records `is_symlink` (observations were already lstat-based) and `PermDiff.changed` is never true for a symlink, so a declared row whose own target is a link plans as a no-op and audits as the new `symlink` status instead of `drift`. * `_apply_one` hard-refuses a symlink as a last-line guard, covering the rollback path and any future caller. The skip is surgical: real files under the same row are still reconciled. Also corrects the install.sh ordering comment. #1732 deliberately ran the migration BEFORE `doctor perms --fix` so the planted links would be swept into the recursive re-chown — that intent is exactly the bug, and is now moot since perms skips links; the migration itself is unchanged (it correctly fixes the #1615 doctor warning). Adds the first symlink coverage to tests/install/test_perms.py: all five new tests fail against the previous code (the target's mode flips 0600 -> 0644) and pass with the fix. Closes #1739
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
hal0 doctor perms --fix(and every install/upgrade, which runs it) dereferenced symlinks and rewrote the files they point at. Fixes #1739 (P0, regression from #1732).hal0 migrate model-layout --applyplants/var/lib/hal0/models/<name>→/mnt/ai-models/<name>symlinks. The perms table's recursivemodels/row matched them viarglob, andcommit()→_apply_one()calledos.chown/os.chmod, both of which follow symlinks (Linux has nolchmod). Result: the operator's real model-store files were re-ownedhal0:hal0and re-moded0644, outside hal0's declared tree, on every install and every upgrade — directly contradictingmigrate_commands.py's documented "does not write to/mnt/ai-modelsat all" invariant.How
The whole commit path is now
lstat-based and symlink-skipping, at three layers:_expand_rowdrops any glob match that is a symlink or that was reached by walking through one. The second case matters:migrate model-layoutlinks whole per-model directories, so recursing into the link yields real paths under/mnt/ai-modelsthat are not themselves symlinks — a link check in_apply_onealone would not save them. Checked explicitly rather than relying onPath.rglob's no-follow behaviour, so the guarantee does not depend on the pathlib version.PermObservation.is_symlink(observations were alreadylstat-based) +PermDiff.changedis never true for a symlink. A declared row whose own target is a link plans as a no-op and audits as a newsymlinkstatus instead ofdrift(badge added todoctor_commands._render_audit;_diagnose_audit_rowscarries no finding for it, likeabsent)._apply_onehard-refuses a symlink — last-line guard covering the rollback path and any future caller.Skipping (rather than
chown(follow_symlinks=False)on the link itself) is the safer choice and matches migrate's invariant: a link target's ownership is not hal0's concern. The skip is surgical — real files under the same row are still reconciled.install.sh ordering comment
#1732 deliberately placed the migration before
doctor perms --fixso planted symlinks would be "swept into that recursive re-chown". That intent is the bug. With this fix symlinks are skipped, so the ordering no longer matters; the comment is corrected to say so. The migration itself is not reverted — it correctly fixes the #1615 doctor warning.Verification
Red-first. The five new tests in
tests/install/test_perms.py(which had zero symlink coverage) all fail against the pre-fix code — the review's exact repro, a symlink under a recursivePermRow, shows the target's mode flipping0o600→0o644:With the fix:
tests/install tests/installer— 570 passed, 1 skipped (shellcheck not installed locally)+ tests/security tests/agents/test_hermes_security_deliverables.py— 763 passedtests/cli(covers the_render_auditbadge change) — 677 passedruff checkclean,ruff format --checkcleanmypyon both touched files: only the pre-existingdoctor_commands.py:386 no-any-return, outside this diffRisk
Strictly safer: this only stops writes to paths hal0 never should have touched. No path that was correctly reconciled before is skipped now (covered by
test_regular_files_are_still_reconciled_alongside_symlinks).Closes #1739
🤖 Generated with Claude Code