M3: verify RENAME_EXCHANGE, gate inode_mkdir - #3
Conversation
An exchange re-parents both inodes and destroys neither, so the destroy check's `write` floor does not cover it. The protected side leaving is an escape, but only if the hook is asked about that side at all — which the plan recorded as believed, not verified. It is now verified: security_inode_rename calls inode_rename a second time with the pair swapped when RENAME_EXCHANGE is set, so both directions reach the escape check. Measured in the VM rather than read off the source. The scenario is built around the one case that actually tests this. On a denying object an exchange is refused by the destroy or clobber check on the *first* call, which proves nothing about the second; so the object here deliberately ALLOWS write, and the unprotected name is passed first. The first call then sees an ordinary permitted clobber and the swapped call is the sole thing keeping the secret inside its object. Both inodes are checked to have stayed put, the mirror argument order is covered, and an exchange wholly inside one object is pinned as still allowed. The helper preflights renameat2 against two unprotected files before any policy is live: an unavailable syscall exits non-zero exactly like a refusal, so without the preflight the deny assertions would pass for the wrong reason. Also retires the /tmp self-test probe note from the plan and STATUS now that the fix has shipped — the analysis lives in the code's doc comment — and records the twice-fired-hook finding under Durable kernel facts, including the warning not to "simplify" the scenario to a denying object. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…moving one `inode_rmdir` was gated and `inode_mkdir` was not, so a subject that could not remove a directory inside a protected object could still add one. Adding a name inside an object is an ordinary create/write on it; the asymmetry was the only reason it went unmediated. The new dentry is *negative* — no inode exists yet — so `find_object` on it misses on the first step of the walk and the verdict has to come from the parent, the same shape as the clobber arm of `decide_reparent`. That makes this the first hook classified off a to-be-created name rather than an existing one, which is why the daemon's self-test grows a probe for it: every other probe starts from a positive dentry, so none of them would catch a wrong argument index here. It deliberately does not cover `rm -rf ~/.ssh && mkdir ~/.ssh`. That case is closed by `inode_rmdir` refusing the removal — once the object's own directory is gone there is no protected ancestor left for this hook to find. Recorded in DESIGN §15.5 and the plan, since the hook name invites the opposite reading. The smoke scenario checks both polarities in one policy: mkdir inside an object that allows create/write still works (this must not break legitimate structure), and mkdir inside a nested denying object is refused with no directory left behind. HOOKS drops its hardcoded length — `&[&str]` has nothing to keep in sync, and `HOOKS.len()` still feeds the startup log. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Added a second commit:
The new dentry is negative — no inode yet — so It deliberately does not cover Smoke scenario covers both polarities in one policy: mkdir inside an object allowing create/write still works, and mkdir inside a nested denying object is refused with nothing left behind.
Remaining in M3 after this: |
M3, continued. Answers the open question the plan recorded as believed, not verified.
The question
An exchange re-parents both inodes and destroys neither, so the destroy check's
writefloor does not cover it. The protected side leaving is an escape — but only if the hook is asked about that side at all.The answer
It is:
security_inode_renamecallsinode_renamea second time with the pair swapped whenRENAME_EXCHANGEis set, so both directions reach the escape check. Measured in the VM rather than read off the source.Why the scenario looks the way it does
The obvious version of this test would pass while testing nothing. On a denying object, an exchange is refused by the destroy or clobber check on the first hook call — which proves nothing about the second.
So the object here deliberately allows write, and the unprotected name is passed first:
$TMP— escapeThe swapped call is the sole thing between the secret and a name outside its object. The scenario also checks both inodes stayed put, covers the mirror argument order (refused by the first call), and pins that an exchange wholly inside one object is still allowed as an internal shuffle.
The helper preflights
renameat2against two unprotected files before any policy is live. An unavailable syscall exits non-zero exactly like a refusal, so without the preflight the deny assertions would pass for the wrong reason.Also here
/tmpself-test probe note from the plan and STATUS now that fix(daemon): move the self-test probe out of the shared /tmp namespace #2 shipped — the analysis lives in the code's doc comment, andgit loghas the rest. Item 1 of the queue is marked done.Verification
Full VM suite green on the pinned v6.12 — exit 0, zero FAILs, new scenario included.
bash -nclean.Remaining in M3:
inode_mkdir,bprm_check_security(theexecuteaction), and thelistdircoarseness note.🤖 Generated with Claude Code