Repository navigation
Conversation
… read without prompts A same-user process can delete or rewrite Desktop's Keychain items, and on ad-hoc signed macOS builds (no hardened runtime) run code inside the engine and write a root Desktop reads silently. Desktop now trusts, first, a root an administrator pins in a file this user can't write (/Library/Application Support/OpenAdapt, /etc/openadapt, ProgramData\OpenAdapt), checked for owner, mode or ACL, links and hard links; written by the elevated openadapt-desktop approver-pin. A Mac without one keeps the root it set up behind Touch ID, read with Keychain prompts off so a planted or rewritten item reads as no pin. The two disagreeing is refused. Off a Mac the credential store never pins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AI review (coordinator)Reviewed at 96951a5. This PR is report-only here; I did not enable auto-merge. Verdict: not ready.
What holds (POSIX)
Blocking (Windows): a folder pre-created in ProgramData stays under the agent's control after
|
…ect against Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Windows anchor folder Windows: pinning moves an existing ProgramData\OpenAdapt aside (refused if something holds it open) and creates the folder fresh with a protected DACL (O:BA, Administrators and SYSTEM full, Users read), so a user who made it first can't keep a WRITE_DAC handle or a skipped entry. The check reads every allow entry type (object, callback, callback object) and refuses one of an unknown type; checks the link count; and walks every folder above, which may let users add but not replace entries. macOS: an extended ACL granting a write on the file or any folder above is refused, inherited ones included (the acl_to_text flags field); pinning strips ACLs (chmod -N), such as one inherited from /Library/Application Support. POSIX reads open each folder from / with openat and O_NOFOLLOW, checking each as it is opened. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AI re-review (coordinator), round 2Re-reviewed at b01c639. Report only; I did not enable auto-merge. Verdict: not ready. The anchor checks have no hole left, but the module doesn't parse on Python 3.11 (see below), and the Flow dependency from round 1 still blocks the feature working end to end. macOS (probes on this Mac)
Windows (
|
| Case | Result |
|---|---|
| Allow ACEs of types 9, 5, 11 granting a user write | refused |
| Unknown ACE type | refused |
Inherited OI-only allow (flags 0x11) granting Everyone write |
refused (OI without IO applies to the folder) |
| Deny-write followed by allow-write for the same user | refused. Conservative: Windows would deny, so at worst this refuses something safe |
MAXIMUM_ALLOWED for Users |
refused |
| CREATOR OWNER entry that applies to the object (no IO) | refused |
Default C:\ ACL (Authenticated Users AD, inherit-only M), owner TrustedInstaller, under the ancestor rule |
passes |
Default C:\ProgramData ACL (CREATOR OWNER (OI)(CI)(IO)F, Users (CI)(WD,AD,WEA,WA)), owner SYSTEM, under the ancestor rule |
passes |
ANCHOR_FOLDER_SDDL, and the file inheriting from it |
passes |
Pre-created folder.
_write_windowsnow renames an existingOpenAdaptaside, so a handle the agent holds follows the old object. It refuses if a handle withoutFILE_SHARE_DELETEblocks the rename.- It then creates the folder with the protected SDDL in a single
CreateDirectoryWcall.
Where the CREATOR OWNER default leaves the agent. It owns everything it creates in ProgramData, including the moved-aside folder, but Desktop never reads that folder. The agent can do two things, and both fail closed:
- recreate
OpenAdaptbetween the rename andCreateDirectoryW, which makesCreateDirectoryWfail; - pre-create the
OpenAdapt.replaced-<timestamp>target, which makes the rename fail.
The new folder has a protected DACL and the owner Administrators. ProgramData gives Users no DELETE_CHILD, so they can't replace it.
Minor
- Weak test coverage on Windows.
test_real_windows_aclscovershostsand a user-owned temp file. It doesn't coverProgramDataorwrite_anchor. GitHub'swindows-latestrunners run elevated, so an end-to-end test is possible: pre-createProgramData\OpenAdaptas a folder with an extra explicit ACE, keep a handle open withFILE_SHARE_DELETE, runwrite_anchor, then check thatread_anchorpasses and that the handle can't change the new folder. - The aside name is predictable. It has one-second resolution, so a pre-created target can stop
approver-pin(fails closed). Adding a random suffix would remove that.
Still open (round 1): the bundled Flow
Desktop pins openadapt-flow==2.1.0. That Flow has no --config-sha256 and no approvers section, so the anchor's root can't reach Flow. Runs that stage a config also exit on the unknown flag. Ship a Flow release that includes #1486 and bump Desktop's pin first, or gate the flag on run --help.
Blocking (new): approver_anchor.py doesn't parse on Python 3.11
approver_anchor.py:648 puts double quotes inside a double-quoted f-string:
f"Can't create {directory} (error {getattr(ctypes, "get_last_error")()}); if something "Python 3.12 accepts this (PEP 701), but Desktop declares requires-python = ">=3.11", and on 3.11 the whole module raises SyntaxError. Locally: python3.10 and python3.11 give SyntaxError: f-string: unmatched '(', and python3.12 parses it.
On 3.11, every import of approver_anchor therefore fails: FlowRoster.root(), pinned_root(), the approver status in dispatch, and approver-pin. The run path catches only ApproverKeychainError, so a run raises an uncaught SyntaxError there instead of refusing cleanly.
CI shows the same failure. In the dispatched Packages run 40203007572, "desktop test / Python engine tests (windows-latest, py3.11)" failed at ruff check with "Cannot reuse outer quote character in f-strings on Python 3.11". Because of that, pytest (and so the Windows anchor tests) never ran in that job. The py3.12 Windows job was still queued when I posted this.
Fix: ctypes.get_last_error(), or single quotes inside the braces. Then re-run the Windows jobs so that test_real_windows_acls actually runs.
… can't be checked refuses A nested double-quoted f-string only parsed on 3.12+, so on 3.11 the import failed in the run path. Use a plain variable. Any failure loading or reading the anchor is approver_anchor_untrusted, never an uncaught error. The aside folder gets a random suffix so its name can't be made first, and an elevated Windows test pins over a folder a user created and planted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Why
A same-user process (an agent) could make its own root the one Desktop pins, then sign answers to its own saves. A probe on a real Mac found three ways:
/usr/bin/security delete-generic-passworddeletes Desktop's Keychain root and head items with no prompt.scripts/build_frozen_engine.py). A same-user process can run code inside Desktop's engine (DYLD_INSERT_LIBRARIES) and write a Keychain root Desktop reads with no prompt at all.Data-protection Keychain access groups would need Developer ID plus a provisioning profile, which the shipped app doesn't have. So no Keychain design can anchor the root today; a file only an administrator can write can.
What changes
openadapt_desktop/approver_anchor.py):/Library/Application Support/OpenAdapt/approver-root.json(macOS),/etc/openadapt/approver-root.json(Linux), orOpenAdapt\approver-root.jsonunder the ProgramData known folder (Windows; the folder comes from the known-folder API, never the environment variable).O_NOFOLLOWand checked withfstat.openadapt-desktop approver-pin ROOTwrites the anchor. It refuses unless elevated.icaclssets the folder's owner to Administrators and leaves Users read-only. It refuses a junction.--showprints the current pin.FlowRoster.root):SecKeychainSetUserInteractionAllowed), so a planted or rewritten item reads as no pin.approver_root_conflict), and with an anchor present the Mac doesn't start another root.pinnedandanchored.APPROVAL_MODES.mdandCAPABILITIES.md.test_run_it_waits_for_a_person_and_saves_after_approvalexpected no config digest. It runs only whereopenadapt_flowis installed, which CI's environment doesn't have, so it never ran there.Tests, one per attack
/usr/bin) pass the folder check.hostsand a user-owned temp file.OPENADAPT_REAL_KEYCHAIN_TESTS=1, passed locally): Desktop's own item reads back. An item rewritten in place by/usr/bin/security, or deleted and planted fresh with-A, reads as no pin, instantly and without a prompt.--show, and the refusal without elevation.Local results: the Desktop Python suite passed 1469 tests. Of the seven failures, the one that came from this change (
test_stop_queue) is fixed. The other six fail only in a fresh venv that hasopenadapt-types0.15 and noopenadapt-privacy; all six pass in a synced environment. VitestStops.test.tsxpasses 8 tests, andtscand ruff are clean.Limits
An administrator, or a process with the administrator's password or a cached
sudo, can still change the anchor. Only Developer ID signing with the hardened runtime makes the Keychain path a real anchor on its own.🤖 Generated with Claude Code