Fix silent Copilot MDM hook-install failure#171
Open
MohamedAklamaash wants to merge 4 commits into
Open
Conversation
Copilot was the only MDM tool installing per-user by downloading unbound.py to a root-owned staging dir and copyfile-ing it into each home after privilege-drop. If the dropped user could not read the staged file, the copy failed silently and main() still exited 0, so the orchestrator marked Copilot installed when no hook was written. - Read the hook script once as root and write it as the target user, so the only cross-privilege op is a write into the user's own home (no staged read). - Add modular _fetch_hook_script / _apply_gateway_url / _write_file helpers; _write_file uses O_NOFOLLOW + fchmod. - main() now returns success and exits non-zero when no user gets installed, so the MDM orchestrator stops reporting a silent failure as success. - Add outermost-layer tests for install_hooks_for_user and main() exit semantics. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…y tests - Only call notify_setup_complete when every user was installed, so the backend is not told setup completed on a partial/failed run (exit code already reflects this to the orchestrator). - Add tests: O_NOFOLLOW refuses a symlinked unbound.py path; _apply_gateway_url rewrite vs identity; notify is/ isn't called on success vs partial failure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Collaborator
|
✅ Security consensus: no issues found. (reviewers: Cursor, Claude, Semgrep, Gitleaks) 🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head |
… read - _fetch_hook_script catches a read failure (corrupt/transient) and returns None so main() prints the clean download-failed message instead of a raw traceback. - _install debug_prints the underlying reason before re-raising, so a per-user install failure is diagnosable (it is swallowed in the _run_as_user fork). - _write_file closes the fd if os.fdopen raises before taking ownership. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contributor
Author
|
Addressed all three Greptile P2s in
All checks green; full copilot suite |
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Collaborator
|
✅ Security consensus: no issues found. (reviewers: Cursor, Claude, Semgrep, Gitleaks) 🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head |
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.
Summary
unbound.pyper-user by downloading it to a root-owned staging dir andshutil.copyfile-ing it into each home after privilege-drop. The dropped user had to read the staged file; when it couldn't (swallowedchmodwidening, or a non-traversable/var/folderstemp path on macOS undersudo), the copy failed silently andmain()still exited 0 — so the orchestrator (mdm/onboard.py) marked Copilot installed when no hook was written.claude-code/codex/cursoralready behave and removes a root-owned, world-readable file from disk entirely.main()now returns a bool and__main__exits non-zero when no/partial user install succeeds, so a silent failure can no longer be reported as success.Changes
copilot/hooks/mdm/setup.py:_fetch_hook_script(gateway_url)— root-only download into an unpredictablemkdtempdir, read into memory with the gateway URL applied, cleaned up infinally._apply_gateway_url(text, gateway_url)— pure string rewrite (replaces the old path-basedrewrite_gateway_url_in_file)._write_file(path, data, mode)—O_NOFOLLOWopen +fchmod(umask-proof), reused for bothunbound.pyandunbound.json.install_hooks_for_user(username, home_dir, script_text)— writes both files via_write_fileinside_run_as_user; no moreshutil.copyfilefrom a staged path.0o711/0o644widening (dead once nothing reads the file post-drop).notify_setup_completeis only called on full success, so the backend isn't told setup completed on a failed run.Test plan
install_hooks_for_user,main()): files actually land + are executable;O_NOFOLLOWrefuses a symlinked path; gateway rewrite vs identity;main()returns False / exits non-zero on no-users, partial-failure, and download-failure, and notifies only on success.26 passed(9 new + 17 existing)./security-review(no High/Critical; net-strengthens the root↔user boundary).Notes
mdm/onboard.py:89passessupports_backfill=Truefor Copilot while the comment at:82-85says Copilot has no backfill — harmless, left as-is.🤖 Generated with Claude Code
Note
Medium Risk
Touches privileged MDM install and orchestrator exit semantics; changes narrow the root→user file boundary but alter production onboarding failure signaling.
Overview
Fixes silent Copilot MDM hook install failures by changing how
unbound.pyreaches each user profile and how setup reports success.Install path: The hook is now downloaded once as root via
_fetch_hook_script, gateway URL applied in memory (_apply_gateway_url), andinstall_hooks_for_userwritesscript_textas the target user with_write_file(O_NOFOLLOW+fchmod) instead ofshutil.copyfilefrom a root-owned staging file the dropped user might not read. Staging-dir permission widening is removed.Success contract:
main()returns a bool on every path;__main__exits non-zero when install is missing, partial, or download fails.notify_setup_completeruns only when all discovered users install successfully.Reviewed by Cursor Bugbot for commit e7b2d85. Bugbot is set up for automated code reviews on this repo. Configure here.
Greptile Summary
This PR replaces Copilot's MDM hook installer's root-owned staging-dir +
shutil.copyfileapproach with an in-memory read (as root) / per-user write (as target UID) flow, matching howclaude-code,codex, andcursoralready behave. It also fixesmain()to return a meaningful bool so__main__exits non-zero on any failure andnotify_setup_completeis only called on full success._fetch_hook_scriptdownloadsunbound.pyonce as root into amkdtempdir, reads it into memory (applying_apply_gateway_url), then wipes the staging dir infinally— eliminating the world-readable root-owned file._write_fileusesO_NOFOLLOW | O_CREAT | O_TRUNCand an explicitfchmodto make writes symlink-safe and umask-proof;install_hooks_for_usernow calls it inside_run_as_user's privilege-dropped fork, replacing the oldshutil.copyfile+os.chmodsequence.main()exit-code contract hardened: all early-out paths returnFalse, the loop checksinstalled_count == len(user_homes), and__main__callssys.exit(1)whenokis falsy.Confidence Score: 4/5
Safe to merge; the refactor correctly moves to in-memory script text + per-user write and the exit-code contract is properly threaded through. One minor write-before-chmod ordering issue in _write_file worth cleaning up but not blocking.
The install flow change is clean and the logic is sound throughout. The one finding — fchmod called before f.write in _write_file, leaving an executable-but-empty file on disk for a brief window — is a real ordering issue but unlikely to bite in practice; it does not affect the correctness of the main bugfix or the exit-code contract.
copilot/hooks/mdm/setup.py — specifically the _write_file helper around the fchmod/write ordering
Important Files Changed
Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant Root as main() [root] participant Fetch as _fetch_hook_script participant Staging as mkdtemp staging dir participant Fork as _run_as_user fork [uid=user] participant Home as ~/.copilot/hooks Root->>Fetch: gateway_url Fetch->>Staging: download_file(SCRIPT_URL) Staging-->>Fetch: unbound.py on disk Fetch->>Fetch: read_text() → apply_gateway_url() Fetch->>Staging: rmtree (finally) Fetch-->>Root: script_text (in memory) loop for each (username, home_dir) Root->>Fork: install_hooks_for_user(username, home_dir, script_text) Fork->>Home: mkdir -p ~/.copilot/hooks Fork->>Home: _write_file(unbound.py, script_text, 0o755) [O_NOFOLLOW + fchmod] Fork->>Home: _write_file(unbound.json, config, 0o644) [O_NOFOLLOW + fchmod] Fork-->>Root: True / None (exit code) end alt all installs succeeded Root->>Root: notify_setup_complete() Root-->>Caller: return True → sys.exit(0) else partial or no installs Root-->>Caller: return False → sys.exit(1) end%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant Root as main() [root] participant Fetch as _fetch_hook_script participant Staging as mkdtemp staging dir participant Fork as _run_as_user fork [uid=user] participant Home as ~/.copilot/hooks Root->>Fetch: gateway_url Fetch->>Staging: download_file(SCRIPT_URL) Staging-->>Fetch: unbound.py on disk Fetch->>Fetch: read_text() → apply_gateway_url() Fetch->>Staging: rmtree (finally) Fetch-->>Root: script_text (in memory) loop for each (username, home_dir) Root->>Fork: install_hooks_for_user(username, home_dir, script_text) Fork->>Home: mkdir -p ~/.copilot/hooks Fork->>Home: _write_file(unbound.py, script_text, 0o755) [O_NOFOLLOW + fchmod] Fork->>Home: _write_file(unbound.json, config, 0o644) [O_NOFOLLOW + fchmod] Fork-->>Root: True / None (exit code) end alt all installs succeeded Root->>Root: notify_setup_complete() Root-->>Caller: return True → sys.exit(0) else partial or no installs Root-->>Caller: return False → sys.exit(1) endReviews (3): Last reviewed commit: "Remove test_install.py from this PR" | Re-trigger Greptile