Repository navigation
refactor(agent): keep private files with private-fs - #2154
Benoît Cortier (CBenoit) wants to merge 60 commits into
Conversation
private-fs becomes the one place where files and directories are kept from other accounts: callers define rules naming accounts and kinds of access, and private-fs checks, creates and repairs according to them on every platform. The device-key file backend needs to read the permissions of its key directory's ancestors to check them, so its intent now names both rights it needs there. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…files One rule for every platform now governs pending enrollment files, and Windows already accepts only SYSTEM, the agent's account or an administrator as their owner. On Linux and macOS only root or the agent's account can create a file in the owner-only pending directory, so no file the agent should process is refused. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Callers describe who may own and access a file or directory with a rule, and create and open what lies below a checked base directory through handles, so that each platform's access control stays inside private-fs. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Files and directories are created and opened with NtCreateFile relative to a checked directory handle, never by path, and a new one carries its owner and protected DACL in its creation security descriptor, so it has its rule from the moment it exists. Checks compare the owner and DACL with the rule, narrower or exact, and refuse links, hard links and volumes without persistent ACLs; an OWNER RIGHTS entry takes away the implicit access of an owner the rule gives less than full access. A base directory is opened only when the directories above it, on its resolved path, cannot be renamed or replaced by an account other than the system accounts, administrators and the accounts of the rule. A repair sets the DACL with SetKernelObjectSecurity, which leaves the children's DACLs untouched. Errors carry the Win32 error code an NTSTATUS maps to, so callers can match a sharing violation or an existing entry as they do for Win32 calls. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Directories and files are created and opened relative to checked directory handles with the openat family, never by resolving a path again, and each is checked against its rule: owner, group and other bits, Linux POSIX ACLs read from their extended attributes, macOS extended ACLs, links, hard links, file type and volumes that ignore or fake ownership. A base directory's first existing directory and those above it on its resolved path are checked as OpenSSH's StrictModes does, so another account that could rename or replace one of them is refused, while write access limited by the sticky bit is not. New directories get mode 0700 and new files 0600, under a parent without inheritable ACLs, and directories and renames are synced so they survive a power cut. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A file can be created under a rule that gives its creator no access once it exists, as an administrator's drop-box file does, so removing it by name again can be refused. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The file backend of device-key now creates, opens, renames and removes its key directory, key files and lock file through private-fs handles checked against one rule each, instead of its own permission, ACL, link and volume code for each platform. Key files and the lock file may be owned only by the caller's account or SYSTEM (root), and no other account may access them. A key directory the crate creates gives access only to those two accounts; an existing one, and every directory above it, must not be replaceable by another account, as OpenSSH's StrictModes requires. Linux key files with POSIX ACL entries, key stores on volumes that ignore ownership, Windows lock files with a broader DACL and Windows key directories that are junctions are now refused. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Renames and removals use POSIX semantics, falling back to the legacy information classes where the file system lacks them, so a reader that shares deletion no longer blocks a replacement and a removed name can be created again at once. Dir::sync flushes the directory, entries lists through a handle of its own, and a created directory that fails its rule is removed as a file is. Only name-surrogate reparse points count as links, so cloud placeholders and deduplicated files are not refused. Entry names that Win32 path functions reach differently, with a trailing dot or space or a reserved device name, are refused, and a thread holds one backup privilege guard at a time. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…every reparse point A new directory's handle no longer holds DELETE, which made every open of the directory that doesn't share deletion fail while it lived; a directory that fails its rule is removed through a separate handle. Listings and flushes go through the directory's own handle, so they work on a directory opened exclusively; listings take turns on its one enumeration cursor. An item that is any reparse point is refused: one that stands for another name is a link, and any other is not a file private-fs can vouch for, since its content is not what is opened. Listings report the latter as other entries. A backup privilege guard restores the earlier thread token only over its own, and the legacy information classes are also used where the extended ones report an invalid device request or no implementation. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent Identity now provisions, checks, writes and removes its directories, stored identities, pending-enrollment files, in-progress records, rejection markers and Unix lock file through private-fs handles checked against one rule each, instead of its own permission, ACL, link, reparse-point and volume code for each platform. An administrator's `identity enroll` on Windows opens the drop box the same way, with the backup privilege, and publishes its file with an exclusive rename through the handle that created it. The data directory and every directory above it must not be replaceable by another account, as OpenSSH's StrictModes requires. Linux and macOS pending files owned by an account other than the agent's or root are now ignored and kept, and state with extended ACL entries or on a volume that ignores ownership is refused on every Unix platform, not only macOS. Root may own identity state on Linux and macOS, as SYSTEM may on Windows. Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Group write on a directory above now counts whatever the group, since administrators are root alone and a member of root's group or of macOS's admin group need not be root. Volumes are also refused by macOS file system name (SMB, NFS, WebDAV, AFP, MS-DOS, exFAT, NTFS, FUSE) and by Linux type (ntfs3, NTFS, UDF). A directory that can't be searched while looking for the first existing directory is reported as unreadable, with its path. An existing directory is opened before the parent's inheritable ACL is checked, so only one that must be created is refused for it, and a created file gets mode 0600 when the umask narrowed it. The exclusive rename falls back to link and unlink only when the file system lacks it, and undoes the link when the old name can't be removed. The ACL tests set POSIX ACLs through extended attributes so they run without setfacl, and the root-only tests run as root in CI. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An ACL attribute is read into a buffer that fits a few dozen entries, and read again at the size the kernel reports when it is larger, instead of zero-filling 64 KiB for every check. The renameat2 system call gets each argument as the long that the variadic syscall function reads. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While looking for the first existing directory, a link is now refused as a link even when the directory it points to can't be searched, and only a directory is reported as unreadable. Tests also pin that an existing entry under an inheritable ACL is checked as it is, and that the stock macOS Application Support directory, under which the agent keeps its data, is a valid base. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The identity directories, authority directories, `identity.json`, rejection markers, in-progress records and the Unix lock file are now accepted only when the agent's account owns them, on every platform, as the agent-identity intent states. SYSTEM or root ownership no longer passes when the agent runs as another account; in production the agent is SYSTEM or root, so nothing changes there. Repairing an identity directory makes the agent's account its owner. Pending-enrollment files keep their owners: the agent's account, SYSTEM or root, and administrators. Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'s access An exact check now refuses any OWNER RIGHTS entry when the rule gives the owner full access, and otherwise requires exactly one, with exactly the owner's access, so an exact DACL is the one private-fs writes and nothing more. A repair opens the directory with only what the owner's implicit access and its parent grant, so a directory whose DACL no longer grants the current user anything can still be repaired; it is opened normally once repaired and checked exactly. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ve it A directory that doesn't exist has nothing to protect, so `open_base` reports it missing whatever the directories above allow; a caller that must refuse a path another account could hide creates the directory instead. The documentation also states what a base directory gets checked against, what `discard` removes, and when a Windows directory can be synced. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
device-key creates only regular files, so a link or other entry named like one of its temporaries is not its own; treating it as one failed every later operation on the key store. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A directory created through private-fs stays openable by others that don't share deletion, and a directory opened exclusively still lists its entries. The reparse-tag tests assert the refusal itself, and specification citations name their headings. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Discarding deleted the file through its handle wherever it was, so after a persist whose rename succeeded but whose flush failed, it deleted the file just installed while the file it replaced was already gone. It now deletes the file only while its path is still the name in this directory, compared case-insensitively, and still through the file's own handle, which an account that may only add to the directory needs. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An administrator's submission keeps the backup privilege only while it opens the identity directories, creates its staging file unshared, and checks the drop box against the rules the service provisions it with, built from one rule parameterised by the owning account. A stored identity is read through the authority directory handle its check opened, rather than by opening the path again. The Windows state lock and the administrator's submission share one sharing-violation retry. Tests share one temporary data directory helper, an elevated test submits into a drop box that only SYSTEM can read and replays the same token, and the conformance test runs in its own temporary directory. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Unicode's full case mapping uppercases ß to SS, so a file renamed from `xß` to `xSS` compared as still under its name and was deleted. Names now compare with CompareStringOrdinal ignoring case, which uses the simple one-to-one upcase table NTFS uses. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…irectory The elevated test of an administrator's submission now checks the test's own data directory with the current user's rule, as its sibling test does, while `identity` and the drop box keep the SYSTEM rules and the backup privilege. It provisions `identity` before the drop box and gives both back to the current user even when the test fails. The administrator's staging handle, which shares nothing, is closed as soon as the file is published, and the submission and the state lock each keep their own sharing-violation retry interval. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nprotected DACLs and exact masks Add tests for check decisions no existing test isolated: the directory directly above the first existing one is checked on Windows and Unix, a DACL holding exactly the rule's entries but not protected is refused, and an exact check refuses a narrower mask in place of the rule's. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Such a directory fails to open rather than failing its rule, so start-up left it as it was; repair needs only the access its owner always keeps. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A repair of an existing directory on Linux and macOS first opened it, so a directory whose mode shut out its own owner, such as mode 0000, could never be repaired. When that open is denied, a directory the current user owns gets mode 0700 back by name in its checked parent, then is opened, repaired and checked as usual. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…refusal A rule that doesn't let the current user own what it creates is refused as Refusal::Owner, as on Unix and as Rule::owner states, rather than as an invalid input. The Windows tests write their SKIP lines to standard error, so a CI log shows them, and the name comparison test cites the documentation of ordinal comparison. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… access When the identity data directory is missing, its missing parents are now created as the platform creates any directory, so that they keep the access they inherit, and only the data directory itself gets the identity rule. Before, every missing parent got that rule too. The pending-file form tests now name the expected outcome in each test rather than taking it as a parameter, and a test pins that a key directory can't be created under a parent the caller can only traverse, since creating it needs the parent open to sync the new entry. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A repair on Linux and macOS now refuses a volume that ignores or fakes ownership before it changes the owner, mode or ACLs, as the Windows repair does, rather than only in the check that follows. The tests of a file repair use the public API from the testsuite. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A folder above the first existing directory on which a volume without a drive letter is mounted resolves to its own path but opens the mounted volume's root, whose DACL is not the folder's, so it is refused as replaceable. The test of a directory above that only lets the user read its access also takes FILE_LIST_DIRECTORY away from its parent, so it fails if the check asks for FILE_READ_ATTRIBUTES, and repair_file is tested through the public API. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A leftover temporary or tombstone follows the crate's naming scheme, so it counts as the crate's own key material, and no leftover copy of a private key may survive a later operation. An operation that can't remove one, as while another process holds it open, again fails at once, listing included. Listing still skips a lock file that breaks its rule, which it doesn't need. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every skipped Windows test reports its name and reason on standard error through one helper, as the Unix tests do, and the test of a rule that doesn't let the current user own skips when the process runs as SYSTEM, which such a rule lets own. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A directory created under a umask that removes the owner's read access gets mode 0700 back before it is opened and checked, through the same path that lets a repair open a directory its owner was shut out of. That path now refuses an owner the rule doesn't allow, and a rule it can't enforce, before changing the mode. A rule whose owners include any account that is the effective user can create, so a rule naming System creates when running as root, as on Windows. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ty rule Provisioning again creates every missing directory of the data directory's path by handle with the identity rule, refusing links, as before the parents were left to the platform's defaults: a parent created that way could be reached through a planted link, or fail the check of the directories above with a umask or volume-root default. The installer and the configuration create the data directory in production, so this applies only where they didn't. Device-key's execute-only parent test now asserts only that no key directory is created, and the two execute-only tests share one guard. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… Windows The SYSTEM fixture's agent runs as SYSTEM, which refuses a data directory under a user's profile, since that user can rename or replace the directories above it. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…te's own docs A test whose expected value comes from the crate's own rustdoc is self-derived. Tests that an intent or spec sentence decides now cite it. Tests that alone catch a fault no source decides keep a mutant marker naming it. Tests that catch nothing the rest of the suite misses are removed. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Linux and macOS an exact check also refuses owner bits other than the rule's, 0700 for a directory and 0600 for a file, so a repair resets a narrowed mode such as 0500 instead of keeping it. The setuid, setgid and sticky bits stay ignored, and a narrower check is unchanged. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ck file Listing device keys now skips a key file with another hard link, which makes it a link rather than a key, and still returns a key that breaks the access rule, so that a caller can clean it up. The lock file is device-key's own entry, so listing again fails at once, as every other operation does, when the lock file breaks its rule, naming the file and why; leftovers are then never skipped. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Its owner and DACL can't be read through the mounted volume's root, so the check of the directories above fails for that reason, not because another account could replace it. Tests now guard the private parents of a new data directory and the refusal of a key directory under an execute-only parent, and markers say exactly what decides them. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… rule's An exact rule on Linux and macOS goes back to ignoring the owner's own bits; a repair resets them instead. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entry gains links, the entry's number of hard links, read without opening the entry for its content or its owner and access, so a caller can leave out a file with another hard link even when it can't open the file to check it. On Linux and macOS it comes from fstatat without following a link, which needs only search access on the directory; on Windows from the entry opened with FILE_READ_ATTRIBUTES only, which the directory's list access grants. An entry removed meanwhile is left out. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An exact check on Linux and macOS ignores the owner's own bits again, since an owner can always change them back, so a narrowed mode such as 0500 still counts as private. A repair resets it to the rule's mode instead, and writes nothing to a file or directory that already has the rule's owner and mode and no extended ACL, so repairing at every start doesn't touch a correct one. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nwritten A repair writes nothing when the item already passes the exact check with an allowed owner, so a repair at every start-up changes nothing on a correct item and needs no WRITE_DAC on it; an existing directory to repair is opened with what reading its owner and DACL needs, and the repair asks for WRITE_DAC only when it writes. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…valid UTF-16 Reading an entry's link count converted its listed name to a string, so a name with an unpaired surrogate, which NTFS allows, failed every listing of its directory. The entry is now opened with its name as listed. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…link count Provisioning now repairs every identity directory rather than only one that fails its check, so that a directory whose mode is narrower than the rule's, such as 0500, is reset too; a repair leaves a directory already in line with its rule unwritten. Listing device keys now leaves out a key file with another hard link from the link count the listing reports, without opening the file, so that a hard-linked key file the caller can't open is left out too. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tory's mode The test of an identity directory narrowed to 0500 now asserts that provisioning leaves it 0700, since a check alone accepts a narrowed mode and only the repair resets it. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An entry another program marked for deletion with the legacy semantics stays listed until its last handle closes, but opening it to read its link count fails with STATUS_DELETE_PENDING, which the Win32 mapping makes access denied and failed the whole listing. Such an entry is now left out, as one removed since it was listed is. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entry.links is now None when the count can't be read, instead of one such entry failing the whole listing: on Windows any failure to open the entry or read its count other than its absence, and on Linux and macOS a failed fstatat other than ENOENT, whose kind then comes from the directory entry. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An entry whose link count can't be read could be a link, so listing skips it instead of failing. The repair docs say which mode a repair gives on Linux and macOS. Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Changelog: ignore Issue: DGW-491 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Acceptance evidenceHead What changed, by area (lines added / removed against
Checks
Decisions taken within the brief
Intent readings (each also stated in the audit)
Deviation from the brief
Follow-ups, not in this PR
Test pruning (two passes, at most one hour each): 31 domain mutants written from the intent files, a positive control caught on both platforms; four gap tests added; of the 25 tests that cited their own crate's docs as an oracle, 11 were re-cited to the deciding intent sentence, 9 kept as Dependencies: no new package and no version change in the lockfile; private-fs adds Note LLM-assisted content (no human feedback). |
|
Properties this PR adds that no test checks, each marked Durability (no test can cut power)
Races (no test can schedule them reliably)
Environments CI doesn't have
Owners only root or an elevated administrator can set
Elevated-only and root-only tests print a Note LLM-assisted content (no human feedback). |
Intent drift audit: PR #2154 (private-fs) at
|
| ID | Statement (abridged) | Status | Evidence | Note |
|---|---|---|---|---|
| private-fs/INTENT.md#overview-2 | Meant for every crate that keeps files from other accounts | Not yet realised | crates/private-fs/Cargo.toml (only device-key and agent-identity depend on it) |
Interpretation: moving the other crates is a later step by maintainer decision. The part this PR can realise, rules that name accounts, is judged under rules-5. |
| device-key/INTENT.md#operations-6 | Listing returns only well-formed names with the prefix, never a link | Conforms | crates/device-key/src/file.rs:174-178, crates/private-fs/src/unix.rs:392-399,858-866, crates/private-fs/src/windows/mod.rs:380-386,409-433 |
Listing keeps only a regular file whose link count is known to be one, so it leaves out a symbolic link, a name-surrogate reparse point, a file with another hard link, and a file whose count can't be read. The count comes from fstatat without following links on Unix, and from an open for FILE_READ_ATTRIBUTES alone on Windows, so a file the caller can't open is counted too. Tests: file.rs:596,686,702,841,857,1364, testsuite/tests/private_fs/unix.rs:818,847, windows.rs:841. |
| device-key/INTENT.md#operations-10 | An unexpected entry never panics, blocks or reads unbounded; listing skips it | Conforms (fixed) | crates/private-fs/src/windows/mod.rs:380-386,409-433, crates/private-fs/src/windows/nt.rs:87-97, crates/private-fs/src/unix.rs:392-399, crates/device-key/src/file.rs:174,217-241 |
No single entry fails a listing any more. On Windows, an entry pending deletion is left out, as one removed since it was listed is, and any other entry whose link count can't be read is listed without one. On Unix, an entry fstatat can't inspect is listed with the kind its directory entry reports and no count. device-key lists only a file whose count is known to be one, so it skips both, and its cleanup of leftovers no longer fails on an entry pending deletion. Reading the count opens the entry for FILE_READ_ATTRIBUTES without following a reparse point, so it reads no content. Test: testsuite/tests/private_fs/windows.rs:893. |
| private-fs/INTENT.md#overview-1 | Unsafe OS permission/ACL calls live in one place | Conforms (judgement) | crates/private-fs/src/{unix,linux,macos}.rs, windows/*.rs |
No file-permission code is left in device-key or agent-identity production code. The ACL APIs that remain there are in tests, plus device-key/src/windows_acl.rs for CNG keys, which aren't files. |
| private-fs/INTENT.md#overview-3 | Covers files and directories only | Conforms | crates/private-fs/src/lib.rs:39-44,193-305 |
|
| private-fs/INTENT.md#vocabulary-1 | Rule: which accounts may own, which may access with which access | Conforms | crates/private-fs/src/rule.rs:51-56 |
|
| private-fs/INTENT.md#vocabulary-2 | Check: compare an existing item with a rule | Conforms | crates/private-fs/src/lib.rs:273,296 |
|
| private-fs/INTENT.md#vocabulary-3 | Repair: bring an existing item into line | Conforms | crates/private-fs/src/lib.rs:47-53,278,303 |
|
| private-fs/INTENT.md#vocabulary-4 | Account: user, group or built-in; avoid user/principal/trustee | Conforms | crates/private-fs/src/rule.rs:12-16 |
None of the avoided words appears in the API or docs. |
| private-fs/INTENT.md#vocabulary-5 | Owner in the OS's sense | Conforms | unix.rs:667, windows/security.rs:215-218 |
|
| private-fs/INTENT.md#vocabulary-6 | Access: full, read and write, add only; avoid permission/right/privilege/mode as general words | Conforms | crates/private-fs/src/rule.rs:28-35 |
BackupPrivilege names the Windows mechanism, and "permission bits" the Unix one. |
| private-fs/INTENT.md#vocabulary-7 | Supported: whether a platform can enforce an access | Conforms | error.rs:21, unix.rs:737-742, windows/security.rs:383-385 |
|
| private-fs/INTENT.md#vocabulary-8 | Private: satisfies its rule; avoid restricted/protected/secure/safe | Conforms | grep of crates/private-fs/src |
"protected" appears only as the Windows DACL term. |
| private-fs/INTENT.md#vocabulary-9 | Handle: an open, checked directory or file | Conforms | lib.rs:150-172, unix.rs:89-121, windows/mod.rs:95-134 |
Interpretation: an existing base directory is the caller's starting point and gets the owner-and-replace check, not its rule, as documented on Dir::create_base and Dir::open_base. |
| private-fs/INTENT.md#vocabulary-10 | Avoid capability, trusted account | Conforms | grep of crates/private-fs/src |
Only "capability SID", the Windows term (windows/security.rs:351). |
| private-fs/INTENT.md#rules-1 | Callers define rules; private-fs checks, creates, repairs per rule | Conforms | lib.rs:179-305 |
Every operation takes the caller's Rule. |
| private-fs/INTENT.md#rules-2 | private-fs knows OS access control; callers know formats, locations, accounts | Conforms | grep of crates/private-fs/src |
No caller names, paths or formats. |
| private-fs/INTENT.md#rules-3 | Checks live in private-fs, not callers | Conforms | device-key/src/file.rs:44-64, agent-identity/src/private_file.rs:78-140 |
Callers only build rules. Listing reads a link count from private-fs and leaves the check to it. |
| private-fs/INTENT.md#rules-4 | A rule names accounts, each with an access | Conforms | rule.rs:52-63,81-99 |
|
| private-fs/INTENT.md#rules-5 | Accounts, not kinds of file; accesses, not modes or DACLs | Conforms | rule.rs:12-35 |
|
| private-fs/INTENT.md#rules-6 | One rule for every platform | Conforms | rule.rs:44-56 |
The Rule API has no platform-specific part. |
| private-fs/INTENT.md#rules-7 | Unsupported access needs a stated fallback; never broader | Conforms | unix.rs:689-742, windows/security.rs:370-385, rule.rs:44-48 |
Tests: unix.rs:947-1002, testsuite/tests/private_fs/unix.rs:675,701, windows.rs:370,392. |
| private-fs/INTENT.md#rules-8 | Created and opened through a checked handle, never by a path again | Conforms | unix.rs:778-804 (openat, O_NOFOLLOW), windows/nt.rs:71-132 |
Only the base is opened by path. Directories above it are read by resolved path for the check alone, and compared with the handle (unix.rs:571-579,604-609; windows/mod.rs:953-971). Listing reads link counts relative to the directory handle (unix.rs:392, windows/mod.rs:400-411). |
| private-fs/INTENT.md#mechanisms-1 | Owner and permission bits | Conforms | unix.rs:667-673,613-636 |
Interpretation: on Linux and macOS, administrators means root only, and an item's owner always has full access there because it can change the mode, so a check accepts an owner mode narrower than 0700 or 0600. |
| private-fs/INTENT.md#mechanisms-2 | Linux and macOS ACLs | Conforms | linux.rs:92-114,147-172, macos.rs:100-116,153-155 |
Tests: testsuite/tests/private_fs/unix.rs:479,501,569,587,629,760. |
| private-fs/INTENT.md#mechanisms-3 | Windows owner and DACL, inherited entries included | Conforms | windows/security.rs:193-276,280-348, :603 |
An unprotected DACL or an inherited entry is refused. Tests: windows.rs:424,456. |
| private-fs/INTENT.md#mechanisms-4 | Links and reparse points | Conforms | unix.rs:492,539,662,780, windows/mod.rs:57-59,816-856 |
Hard links are covered too, and listing now reports each entry's link count (lib.rs:86-88). Tests: unix.rs:287,305,324,818, windows.rs:587,603,619,838. |
| private-fs/INTENT.md#mechanisms-5 | Volumes that ignore or fake ownership | Conforms | linux.rs:67-90, macos.rs:73-98, windows/mod.rs:836-839, nt.rs:181,195 |
UNTESTED: at linux.rs:75, macos.rs:81, nt.rs:178,192. Applies to checked items, not to directories above the base (see Not covered). |
| private-fs/INTENT.md#paths-1 | A new directory has exactly its rule from the moment it exists | Conforms | unix.rs:142-144,161-165,638-653, windows/mod.rs:139-151 |
Tests: testsuite/tests/private_fs/unix.rs:197, windows.rs:262. |
| private-fs/INTENT.md#paths-2 | A directory another process created first counts only if it passes | Conforms | unix.rs:139-170, windows/mod.rs:172-186 |
Test: unix.rs:209 (deterministic, not the race). |
| private-fs/INTENT.md#paths-3 | A directory whose files were reported written survives a power cut | Conforms | unix.rs:172-182,322-326, windows/mod.rs:165-169,312-319,438-454 |
UNTESTED: at each. Interpretation: on Windows, durability is the outcome of flushing the file or directory handle after the operation, not a directory fsync. |
| private-fs/INTENT.md#paths-4 | Refuses a link from the first existing directory down | Conforms | unix.rs:491-493,537-546, windows/mod.rs:868-917 |
|
| private-fs/INTENT.md#paths-5 | StrictModes: directories above, resolved, to the root; replaceable by another account refuses; add-only doesn't count | Conforms | unix.rs:558-636, windows/mod.rs:901-974, windows/security.rs:280-348 |
The base itself is checked too (see vocabulary-9). On Windows, NT SERVICE\TrustedInstaller and members of Administrators count as allowed, and AppContainer SIDs are ignored (see Not covered). Tests: unix.rs:386,415,428,445,467, windows.rs:635,655,698. |
| private-fs/INTENT.md#paths-6 | A directory above whose permissions can't be read fails | Conforms | unix.rs:497-504,559-565,588-611, windows/mod.rs:923-973 |
Test: unix.rs:520. |
| private-fs/INTENT.md#paths-7 | private-fs can repair; each caller decides | Conforms | lib.rs:47-53,278,303, unix.rs:425-462, windows/mod.rs:477-529 |
A repair writes nothing to an item already in line with its rule (unix.rs:445-454, windows/mod.rs:481-485), and on Unix resets an owner mode narrower than the rule's. Tests: unix.rs:209,228,244,264,629,744, windows.rs:472,504,524,540,749. |
| device-key/INTENT.md#access-1 | Only the caller's account and SYSTEM/root can access a device key | Conforms (file backend) | crates/device-key/src/file.rs:44-64,90-92,116-117 |
|
| device-key/INTENT.md#access-2 | Opening a key another account can access fails | Conforms (file backend) | file.rs:116-118 |
Tests: file.rs:915,933,1129,1173. |
| device-key/INTENT.md#operations-1 | Creating never replaces; of two concurrent creators one fails | Conforms (file backend) | file.rs:81,96 (Replace::Refuse under the lock) |
Test: file.rs:484. |
| device-key/INTENT.md#operations-3 | Opening a missing key reports it absent | Conforms | file.rs:112-121, lib.rs:161-169 |
Interpretation: a missing key directory is reported missing without checking the directories above it. Test: file.rs:713. |
| device-key/INTENT.md#operations-4 | Opening fails on access rule, exportable, link, algorithm | Conforms | file.rs:116-118,134 |
"Exportable" applies only to the Windows provider. Tests: file.rs:1093,1355. |
| device-key/INTENT.md#operations-5 | Deleting is idempotent | Conforms | file.rs:148-160 |
Same interpretation as operations-3. Test: file.rs:428-429. |
| device-key/INTENT.md#operations-7 | Listing and deleting treat a rule-breaking key like any other | Conforms | file.rs:154-158,171-176 (no rule check) |
Listing reads only the kind and link count, so a single-link key file that breaks the access rule is listed. Listing is asserted on every platform (file.rs:896), and deletion on Windows only (file.rs:1164-1166,1210-1213). The Unix root-only test doesn't assert that the refused key is removed (file.rs:974). |
| device-key/INTENT.md#operations-8 | No leftover copy of a private key survives a later operation | Conforms | crates/device-key/src/file.rs:81,115,152,166,192-196,216-256 |
Every operation, listing included, removes leftovers under the lock before it succeeds. Interpretation: .device-key.lock is the crate's own entry, not an unexpected one, so a lock file that fails its rule fails every operation promptly. Its rule is not exact, so a lock file with a narrowed mode passes the check and fails only if the caller can't open it for writing. Tests: file.rs:515,767 (the error must name the lock file, :804). |
| device-key/INTENT.md#operations-9 | Others' work in progress is never a leftover; only scheme names removed, any prefix | Conforms | file.rs:81,198-256 |
Interpretation: a leftover's name follows the crate's scheme, so it counts as the crate's own. Tests: file.rs:515,551,728. |
| device-key/INTENT.md#operations-11 | Waiting for another process has a time limit | Conforms | file.rs:18,263-315 |
Tests: file.rs:747,902. |
| device-key/INTENT.md#file-backend-1 | Supported backend, not a test stand-in | Conforms (judgement) | devolutions-agent/src/identity/mod.rs:78 |
|
| device-key/INTENT.md#file-backend-2 | Key files are PKCS#8 | Conforms | file.rs:83,134 |
|
| device-key/INTENT.md#file-backend-3 | On Unix, created key directories are 0700 |
Conforms | file.rs:80, unix.rs:28,144,638-653 |
Test: file.rs:984. |
| device-key/INTENT.md#file-backend-4 | Only traverse and read-permissions rights on ancestors | Conforms | unix.rs:559,596-611, windows/mod.rs:923-945 (READ_CONTROL only) |
Interpretation: this covers keeping and using keys once the key directory exists; creating it needs its parent opened to sync it. Listing the key directory itself needs only search access on it for the link counts. Tests: file.rs:1014,1033, testsuite/tests/private_fs/windows.rs:713. |
| device-key/INTENT.md#file-backend-5 | A key file over 64 KiB is refused without reading past the bound | Conforms | file.rs:122-133 |
Reads one byte past the bound to detect it. UNTESTED: at file.rs:122. Test: file.rs:1275. |
| agent-identity/INTENT.md#local-state-1 | Location and format of the files are stable | Conforms | pending.rs:44-56, rejected.rs:31-38, state.rs:152-168, state_lock.rs:45-47 |
|
| agent-identity/INTENT.md#local-state-2 | Provisions identity directories; at start-up resets a wrong owner or wrong permissions, not authority directories | Conforms | crates/agent-identity/src/private_file.rs:184-208, crates/private-fs/src/unix.rs:425-462, windows/mod.rs:187-207,477-529 |
Every identity directory is now provisioned with a repair, which writes nothing when the directory is already in line, and resets a wrong owner, extra access, an extended ACL or, on Unix, a narrowed mode such as 0500. Authority directories are never provisioned. Tests: private_file.rs:801,1043,1068, testsuite/tests/private_fs/unix.rs:228. |
| agent-identity/INTENT.md#local-state-3 | Every authority directory is checked for owner and protection before use | Conforms | private_file.rs:214-245, state.rs:140-143,183-191,227-229 |
|
| agent-identity/INTENT.md#local-state-4 | One that fails is never repaired and stops all processing until fixed | Conforms | private_file.rs:304-320 (Existing::Check), devolutions-agent/src/identity/mod.rs:116-138 |
|
| agent-identity/INTENT.md#local-state-5 | The running agent rechecks before each use and on each poll, stops all identities and streams, resumes without restart | Conforms (pre-existing code) | devolutions-agent/src/identity/mod.rs:181-188,320-322, runner.rs:190 |
A live stream stops at the next poll, within the poll interval. UNTESTED: at mod.rs:182. Tests: mod.rs:634,682. |
| agent-identity/INTENT.md#local-state-6 | Passes only if owned by the agent's account, closed to group and other (Unix), exact DACL (Windows); link, reparse point, non-directory and case variants checked | Conforms | private_file.rs:97-105,222-240,258-267, unix.rs:655-675, windows/security.rs:260-274 |
On Unix the check is owner, no group or other bit and no extended ACL; an owner mode narrower than 0700 passes, which the statement allows. Test: private_file.rs:870. |
| agent-identity/INTENT.md#local-state-7 | A failing identity.json is never repaired or read as absent, and stops all processing |
Conforms | private_file.rs:292-302, state.rs:296-316 |
The same check as local-state-6, so an identity.json at 0400 passes. UNTESTED: at private_file.rs:287. Test: state.rs:1326. |
| agent-identity/INTENT.md#local-state-8 | Ignores and keeps an untrusted rejection marker or in-progress record | Conforms | rejected.rs:52, pending.rs:188, private_file.rs:442-459 |
Interpretation: "ignores" means acting as if the file weren't there, including writing the agent's own record at that name (private_file.rs:362, Replace::Allow); "keeps" rules out deleting it as cleanup. Test: state.rs:1326. |
| agent-identity/INTENT.md#local-state-9 | Every state change holds the exclusive lock: Unix lock file, Windows unshared handle, 30 s retry | Conforms | state_lock.rs:30-96, state.rs:183-187, pending.rs:86, private_file.rs:592-613 |
Tests: state.rs:1451, private_file.rs:1402, state_lock.rs:114. |
| agent-identity/INTENT.md#local-state-10 | Size limits 16 KiB / 16 KiB / 2 MiB / 1 KiB | Conforms | pending.rs:13,130-147,192-197, state.rs:17,207-216, rejected.rs:55 |
The marker is checked by metadata length and then read without take (rejected.rs:55-56). Low risk, because the file is the agent's own. |
| agent-identity/INTENT.md#pending-enrollment-files-1 | <hex> lowercase SHA-256 is the only stored encoding |
Conforms | pending.rs:48-56,260-265, state.rs:788-791 |
|
| agent-identity/INTENT.md#pending-enrollment-files-2 | Unix <hex>.json, mode 0600, {version, token} |
Conforms | pending.rs:20,60-89, unix.rs:32,276 |
Test: private_file.rs:858 asserts the written mode. |
| agent-identity/INTENT.md#pending-enrollment-files-3 | Unix file owned by the agent's account or root; any other owner or a group/other bit is ignored and kept | Conforms | private_file.rs:131-140,419-431,442-459, unix.rs:667-675, devolutions-agent/src/identity/mod.rs:345-353 |
A file at 0400 is now read rather than ignored, which the statement allows. UNTESTED: at private_file.rs:124. Tests: private_file.rs:825,845,858. |
| agent-identity/INTENT.md#pending-enrollment-files-4 | Windows .dat, DPAPI with entropy, LOCAL_MACHINE |
Conforms | pending.rs:18,77-78, windows.rs:12,61-84 |
|
| agent-identity/INTENT.md#pending-enrollment-files-5 | Windows accepts exactly two forms (SYSTEM/agent owner; Administrators member plus zero OWNER RIGHTS) | Conforms | private_file.rs:131-140, windows/security.rs:182-276 |
Tests: private_file.rs:1134,1155,1203,1215,1229. |
| agent-identity/INTENT.md#pending-enrollment-files-6 | Drop box: protected, SYSTEM full, Administrators add-file only; provisioned at start-up | Conforms | private_file.rs:109-111,196-200, windows/security.rs:404,414 |
Tests: private_file.rs:945,1245,1364,1451. |
| agent-identity/INTENT.md#pending-enrollment-files-7 | Writing the same token again leaves one file | Conforms | private_file.rs:362 (Replace::Allow), :562-572 (replay is a no-op) |
Test: state.rs:1097. |
| agent-identity/INTENT.md#pending-enrollment-files-8 | The in-progress record <hex>.in-progress.json with {version, token_sha256, key_name} |
Conforms | pending.rs:31-36,53-56,208-221, state.rs:539-550 |
|
| agent-identity/INTENT.md#pending-enrollment-files-9 | On success, the pending file and record are deleted | Conforms | state.rs:644-664 |
|
| agent-identity/INTENT.md#pending-enrollment-files-10 | Permanent outcome deletes pending file, record and key, and writes a marker | Conforms | state.rs:561-587 |
Tests: state.rs:1146,1514. |
| agent-identity/INTENT.md#pending-enrollment-files-11 | An undecryptable Windows file is deleted with no marker | Conforms | state.rs:626-630, pending.rs:133-137 |
|
| agent-identity/INTENT.md#pending-enrollment-files-12 | Unusable content is deleted with a warning and no marker | Conforms | state.rs:622-625, pending.rs:150-175 |
Tests: state.rs:1215,1303. |
| agent-identity/INTENT.md#pending-enrollment-files-13 | Any other failure keeps the pending state | Conforms | devolutions-agent/src/identity/mod.rs:345-353 |
|
| agent-identity/INTENT.md#pending-enrollment-files-14 | Marker path, content and codes | Conforms | rejected.rs:12-38,68-88 |
|
| agent-identity/INTENT.md#pending-enrollment-files-15 | A marker holds no secret and is never deleted | Conforms | rejected.rs:12-18,110-120 |
Only temporaries are removed. |
| agent-identity/INTENT.md#pending-enrollment-files-16 | A marker only under the hash of a token read from a file named by it | Conforms | pending.rs:123,168, state.rs:283-288,561-563,612-619 |
Test: state.rs:1181. |
| agent-identity/INTENT.md#pending-enrollment-files-17 | A hash matching an identity or marker deletes pending state without a request | Conforms | state.rs:519-530 |
|
| agent-identity/INTENT.md#pending-enrollment-files-18 | The new identity replaces the stored one, and the old keys are deleted | Conforms | state.rs:344-356 |
|
| agent-identity/INTENT.md#stored-identities-1 | authorities/<id>/identity.json format |
Conforms | state.rs:22-60,162-168 |
|
| agent-identity/INTENT.md#stored-identities-2 | Key names devolutions-agent-identity_<uuid>; file backend identity/keys/<name>.p8 |
Conforms | state.rs:18,152-156,823-832, device-key/src/file.rs:66-70 |
|
| agent-identity/INTENT.md#stored-identities-3 | Name recorded before the key is created; key deleted before its name is removed | Conforms | state.rs:385-396,490-502,539-555,344-351 |
UNTESTED: at state.rs:488. |
| agent-identity/INTENT.md#stored-identities-4 | At start-up, a missing recorded pending or enrollment key is regenerated; a missing current key makes key_lost |
Conforms | state.rs:699-728,765-767 |
Tests: state.rs:2525,2653. |
| agent-identity/INTENT.md#stored-identities-5 | At start-up, every key with the prefix that no record names is deleted, across the key store | Conforms | state.rs:738-774 |
|
| agent-identity/INTENT.md#stored-identities-6 | Writes are atomic: temporary, then rename | Conforms | private_file.rs:355-366, lib.rs:231-235 |
|
| agent-identity/INTENT.md#stored-identities-7 | An interrupted write leaves at most a temporary; start-up removes only its own scheme | Conforms | private_file.rs:461-489, state.rs:669-692, rejected.rs:110-120 |
See Not covered for the Unix pending temporary. Test: private_file.rs:735. |
| agent-identity/INTENT.md#stored-identities-8 | Windows replacement of a held file is retried for a bounded time | Conforms | private_file.rs:383-405 (5 s) |
Test: private_file.rs:1018. |
Interpretation for local-state-2: missing parents of a missing <data-dir> get the identity rule, with links refused, at private_file.rs:184-191, and in production the installer creates <data-dir>.
Not yet realised
private-fs/INTENT.md#overview-2.
Only device-key and agent-identity use private-fs.
The package broker, the updater and the Gateway's secrets have not moved to it yet.
Not covered by intent
- Provisioning, including repair, runs on every state-lock acquisition (
state_lock.rs:36,79), not only at start-up aslocal-state-2says.
A repair now writes nothing to a directory already in line, so a correct tree is left untouched on each acquisition. - On Windows, the check of directories above allows NT SERVICE\TrustedInstaller as owner and writer (
windows/security.rs:39,288-298).
It treats any member of BUILTIN\Administrators as an allowed owner (:305-309) and ignores AppContainer package and capability SIDs (:343,357).
paths-5names only "the system account, administrators and the accounts its rule allows"; these readings are reasonable but unstated. - The public
BackupPrivilege(windows/mod.rs:536-640) lets private-fs itself use an administrator override to read directories a token can't open.
§Mechanisms puts administrator overrides outside the guarantees but doesn't say whether private-fs may use one. - On Linux and macOS, the volume check applies to checked items, not to the base or the directories above it (
unix.rs:588-611).
Nothing below a base on an ownership-faking volume passes.
A directory above, on such a volume, mounted over a local one, is judged by the owner and mode its driver reports. - device-key
openresolves a key file name case-insensitively on NTFS and APFS (file.rs:116-117), whiledeleteandlistmatch the exact spelling (file.rs:154,167-178,319-325).
An uppercase alias owned by the caller would open as the key whiledeleteandlistreport it absent.
Pre-existing. - On Unix,
reconcileremoves pending-directory temporaries under the state-file rule rather than the pending-file rule (state.rs:673-678).
A temporary thatidentity enrollleft as root is kept when the agent runs as another account, which happens only with the debug grant. - On Windows, device-key treats a key file pending deletion as gone: listing leaves it out, and
deletereports it already deleted (crates/device-key/src/file.rs:154,crates/private-fs/src/windows/nt.rs:94).
Under the legacy semantics, a holder of a handle with delete access can still clear that disposition and keep the file.
Only the caller's account, SYSTEM and administrators have that access to a key that keeps its rule, and the start-up sweep of unrecorded keys removes a key kept this way. - On Linux and macOS, a check accepts an owner mode narrower than the rule's, and only a repair resets it (
unix.rs:445-460,655-675).
An authority directory at0500passes and is used as it is; an agent running as root writes into it regardless, and one running as another account under the debug grant fails its next write with an I/O error rather than stopping identity processing as untrusted.
Intent rot
None found.
Every path, file name, configuration key and section the audited statements cite exists at this revision.
Note
LLM-assisted content (no human feedback).
private-fs becomes the one place where the agent's identity state and device keys are kept from other accounts, so the unsafe calls into each operating system's permission and ACL interfaces live in one crate instead of being repeated in each caller.
Callers describe who may own a file or directory and which accounts may access it, with full, read-and-write or add-only access, in a rule written once for every platform; where a platform can't enforce an access, the rule states the fallback. private-fs creates, opens, checks and repairs files and directories through handles checked against their rule: on Linux and macOS with
openatand its relatives, on Windows withNtCreateFilerelative to a directory handle and a security descriptor applied at creation. It covers the owner and permission bits, Linux and macOS ACLs, the Windows owner and DACL including inherited entries and OWNER RIGHTS, links and reparse points, and volumes that ignore or fake ownership. As OpenSSH's StrictModes does, it refuses a path when an account other than the system account, administrators and the rule's accounts can rename or replace the first existing directory or one above it.agent-identity and device-key now keep their files through private-fs, with their rules unchanged except where their intent asks: a Unix pending enrollment file must be owned by the agent's account or root, as on Windows, and the identity tree's owner is the agent's account on every platform. Both crates now also refuse Linux ACLs, network and FUSE volumes, and paths whose parents another account can replace, which only macOS or nothing checked before.
Changelog: ignore
Issue: DGW-491
🤖 Generated with Claude Code