Skip to content

fix(agent-installer): stop granting LOCAL SERVICE write access to the data folder - #2173

Merged
Benoît Cortier (CBenoit) merged 7 commits into
masterfrom
claude/agent-installer-no-local-service-4232af
Oct 28, 2026
Merged

Benoît Cortier (CBenoit) merged 7 commits into
masterfrom
claude/agent-installer-no-local-service-4232af

Conversation

@CBenoit

@CBenoit Benoît Cortier (CBenoit) commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

The Agent installer gave LOCAL SERVICE write access to %ProgramData%\Devolutions\Agent, inherited by every file and folder below it, so any service running as that account could add entries or change files there. No Agent component runs as LOCAL SERVICE or needs that access.

The installer now:

  • gives LOCAL SERVICE no entry on the folder, so only SYSTEM and administrators have any right to change what the agent stores there;
  • on install, upgrade and repair of an existing folder, after resetting its owner and DACL on the handle it checked, removes every LOCAL SERVICE allow entry, explicit or inherited, from the DACL of each file and folder below it, including the pedm subfolders earlier installers created before locking pedm;
  • leaves every other part of those DACLs as it was, and doesn't write a DACL that has no such entry, so the agent's identity folders and files keep exactly the protected DACL the agent provisions;
  • never follows a link during that walk: a link loses LOCAL SERVICE from its own DACL, so that account can't retarget it, and what it points to is unchanged;
  • removes those entries from a file with several hard links too, since removing them never widens any account's access, wherever the file's other names are;
  • skips entries it can't open by their listed name, and fails the run if a folder is swapped while it walks;
  • refuses install, upgrade and repair, with an error in the installer's language naming the entry and its owner, when a file or folder below the folder is owned by an account other than SYSTEM, Administrators, TrustedInstaller or a member of Administrators, because an owner can always change its entry's permissions; the check runs before the upgrade removes the installed product, and again during the walk for entries added since;
  • never changes an owner below the folder, so a deployment never silently takes over an entry another account created;
  • names the entry when the walk can't open one, and waits up to 2 seconds for a folder another process holds without sharing, as the running agent briefly does with its identity folder.

Issue: DGW-478
Security: yes

🤖 Generated with Claude Code

The Agent data folder gave LOCAL SERVICE write access, so any service
running as that account could change what the agent stores there.
Only SYSTEM and administrators may.

The installer's descriptor for the folder drops the LOCAL SERVICE
entry. When the folder already exists, the installer resets its owner
and DACL on a handle that holds it, then removes every LOCAL SERVICE
allow entry, explicit or inherited, from the DACLs of the files and
folders below. Each DACL keeps its other entries, their order and its
control flags, and a DACL without such an entry is not written, so the
agent's identity folders and files keep exactly the protected DACL it
provisions.

The walk never follows a link: reparse points are neither followed nor
changed, files with more than one hard link are skipped, each directory
is held while the entries below it are processed, and a directory
replaced during the walk fails it.

Issue: DGW-478
Security: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An entry listed below the Agent data folder that can't be opened by the
name it was listed under, because it was deleted after the listing or
because a Win32 path can't reach its name, such as one ending in a dot,
no longer fails the install. Such a name is never created by the
installer or the agent, so only an account that could add entries made
it, and that account owns it; failing would let one such name block
every later upgrade. Every other failure to open an entry still fails
the walk.

The documentation now states that removing LOCAL SERVICE from the DACLs
below the folder leaves owners unchanged, so an entry LOCAL SERVICE
created there keeps it as its owner.

Issue: DGW-478
Security: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…folder

A reparse point below the Agent data folder, such as a junction or a
symbolic link, is still never followed, but its own DACL now loses
LOCAL SERVICE allow entries like any other entry. Write access on a
link lets an account retarget or remove it, which would redirect what
the agent writes through it. The link is opened itself, never its
target, and a directory link opened again for its security must be the
entry first opened, so what it points to is unchanged.

A test now checks that a file held open without sharing still loses
LOCAL SERVICE, since the agent may hold its files open while the
installer runs.

Issue: DGW-478
Security: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The walk below the Agent data folder no longer skips a file with more
than one hard link: it removes LOCAL SERVICE allow entries from it like
any other file. Skipping it let an account that could write a file keep
that access by adding a hard link to it, and replacing a file copies its
kept entries onto the replacement. A file's security descriptor is
shared by all its names, some possibly outside the folder, which is
harmless, because removing LOCAL SERVICE allow entries never widens any
account's access.

Issue: DGW-478
Security: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CBenoit

Benoît Cortier (CBenoit) commented Oct 28, 2026 •

Copy link
Copy Markdown
Member Author

Evidence

No Agent component needs LOCAL SERVICE write access to %ProgramData%\Devolutions\Agent:

  • The Agent service runs as LocalSystem (package/AgentWindowsManaged/Program.cs, no account set).
  • PEDM, the tunnel, PowerShell Universal, the package broker and the updater run in the service process or as SYSTEM children.
  • devolutions-session runs as the logged-on user and writes under %APPDATA%.
  • private-fs grants SYSTEM, Administrators and OWNER RIGHTS only.
  • The updater rejects S-1-5-19 as a service account (service_account.rs), and the package broker treats LOCAL SERVICE as untrusted (policy_security.rs).
  • The LOCAL SERVICE entry came in with the Agent bootstrap (feat(agent): Devolutions agent bootstrap #875, feat(agent): Devolutions Gateway service updater #889), copied from the Gateway SDDL, with no stated consumer.

Install sequence: I built the MSI locally with MSBuild and read InstallExecuteSequence:

  • checkProgramDataDirectory runs at 1503, before RemoveExistingProducts at 1504.
  • Next come createProgramDataDirectory at 3702, the PEDM create and permission actions at 3704 and 3706, and setProgramDataDirectoryPermissions at 3708.
  • All of these run before InstallFiles at 4000, enrollAgentIdentity at 5887 and StartServices at 5900.
  • All are deferred and not impersonated (type 3073), and none runs when REMOVE="ALL".

Tests: package/AgentWindowsManaged.Tests, unelevated, at f8eae07: 184 passed, 0 failed (baseline 161).

Lines changed: 1141 added, 64 removed.

Area Added / removed
Installer actions 536 / 54
Localized strings and generated files 19 / 0
Tests and helpers 583 / 7
Broker doc comment 3 / 3

Intent audit of package/AgentWindowsManaged against INTENT.md §Data folder and crates/agent-identity/INTENT.md §Local state:

  • Everything conforms, including the two §Data folder statements this PR adds: the removal of LOCAL SERVICE below Agent, hard-linked files included, and the refusal of untrusted owners below it.
  • The walk writes no DACL that has no LOCAL SERVICE allow entry, so the agent's identity folders and files keep exactly the DACL the agent provisions. A binary descriptor comparison test checks this.

Deviations

  • The walk differs from the Gateway installer's (fix(installer): refuse an untrusted data folder #2172). The Gateway walk re-inherits from the data folder and unprotects files. This one only removes LOCAL SERVICE allow entries and leaves the rest of each DACL as it is, because the agent refuses identity folders and files whose protected DACL differs from the one it provisions.
  • Doc comment change outside the installer. In crates/now-package-broker/src/policy_store/windows.rs, a comment no longer says the installer grants LOCAL SERVICE write access.

Open questions for the maintainer

None. The owner refusal and the hard-link behaviour are now stated in package/AgentWindowsManaged/INTENT.md §Data folder, as approved by the maintainer.

Decisions and declined review findings

  • Behaviour:
    • The walk removes only LOCAL SERVICE allow entries.
    • Any failure in the walk fails the install, including a sharing violation or access denied on an entry.
    • Entries the walk can't reach by their listed name are skipped and logged: deleted ones, and names ending in a dot or space.
    • Other unreachable names (device names, invalid characters, over-long paths) fail the run.
  • Owners below Agent (maintainer's decision): an entry owned by an account other than SYSTEM, Administrators, TrustedInstaller or a member of Administrators is refused, never re-owned.
    • checkProgramDataDirectory (1503, before RemoveExistingProducts) walks the tree read-only.
    • The walk in createProgramDataDirectory checks again, because the old DACL lets LOCAL SERVICE add entries until Agent is reset. A refusal there leaves the entries already walked without LOCAL SERVICE, which only tightens them.
    • No writer in the repository leaves an untrusted owner: the agent and the installer create entries as SYSTEM with explicit owners, and the Gateway rewrites update.json in place.
  • The running agent's lock: the agent holds identity without sharing for a moment around each state write. Hold retries a sharing violation every 25 ms for up to 2 s; a longer hold still fails the run, which can be repeated.
  • Walk failures name the entry: any failure to open an entry other than not-found fails the run with an exception naming the path.
  • Known limits, not handled:
    • An entry unreachable by its listed name (a name ending in a dot or space) is skipped, so its owner isn't checked.
    • A file pending deletion (deleted while another process still has it open) fails the run as access denied for that moment; a repeat run succeeds.
    • A sharing violation on Devolutions or Agent themselves, past the retry, is reported as unreadable security (unchanged from before).
  • A hard link's other name under an outside folder: if that folder passes LOCAL SERVICE down by inheritance, a later propagation from it can put the entry back on the shared descriptor until the next install. Noted, not handled.
  • Declined test changes: oracle re-citations, and logging a file's hard-link count.
  • Test pruners: no mutation testing was run, so test-pruner was skipped. dependency-pruner was skipped because no manifest changed.

UNTESTED properties

Introduced:

  • DataFolders.cs: the handle on Agent holds it until the walk below returns.
  • SecureDirectory.cs, the walk:
    • each directory handle stays open until the entries below it are done;
    • a directory swapped between its two opens fails the walk.
  • SecureDirectory.cs, OpenUnlessNotFound:
    • a directory that vanishes between its first open and its hold is skipped;
    • any other failure fails the walk; tests cover a sharing violation, not access denied.
  • DataFolders.cs:
    • the actions pass no trust decision, so IsTrusted decides in Check (before RemoveExistingProducts) and in Create;
    • Check's handle on Agent stays open until the walk below returns.
  • SecureDirectory.cs: a file symbolic link loses LOCAL SERVICE from its own DACL, not from its target's.

Still accurate, unchanged:

  • CustomActions.cs: the refusal message, the cleanup on failure, and that a missing pedm folder doesn't fail the run.
  • DataFolders.cs:
    • the refusal of a link or of an untrusted owner;
    • the handle on Devolutions;
    • setting the owner to SYSTEM.
  • SecureDirectory.cs: trust through Administrators membership, and the handles held while the pedm folders are set. Membership now also decides owners below Agent: when Authz can't resolve groups, only direct members of the local Administrators group are trusted, so an entry owned by a domain administrator trusted only through a nested group may be refused on a machine that can't reach its domain.

Notes

  • A junction inside Agent loses LOCAL SERVICE from its own DACL, and its target is left unchanged.
  • The updater's update.json grants the Gateway service SID, not LOCAL SERVICE, so this change doesn't affect it.

Note

LLM-assisted content (no human feedback).

…trusted owner

Earlier installers let LOCAL SERVICE add entries below the Agent data
folder, and an entry it created keeps it as the owner, which can always
change the entry's DACL whatever the installer sets.

Install, upgrade and repair now refuse, with an error in the installer's
language naming the entry and its owner, when a file or folder below
Agent has an owner other than SYSTEM, Administrators, TrustedInstaller
or a member of Administrators.
The data folder check walks the entries without changing anything and
without following links, before the upgrade removes the installed
product.
The walk that removes LOCAL SERVICE checks each owner again before it
lists a directory or changes a DACL, because the earlier DACL still lets
LOCAL SERVICE add entries until the folder is reset.
The installer never changes owners below Agent, so a deployment never
silently takes over an entry another account created.

Issue: DGW-478
Security: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…data folder walk

The data folder check now walks the entries below Agent before the
upgrade removes the installed product, while the old agent still runs.
The agent holds its identity folder without sharing for a moment around
each state write, so holding a folder retries a sharing violation for
about 2 seconds before it fails the run.

A failure to open or hold an entry below Agent, other than one that no
longer exists, now names the entry, because the installer log is all an
administrator has to find it.

Issue: DGW-478
Security: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rules

The data folder intent now states that install, upgrade and repair
remove the access earlier installers gave LOCAL SERVICE below the
folder, hard-linked files included, and refuse an entry below it whose
owner isn't trusted rather than take ownership of it.

Issue: DGW-478
Security: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CBenoit
Benoît Cortier (CBenoit) merged commit 7a16818 into master Oct 28, 2026
53 checks passed
@CBenoit
Benoît Cortier (CBenoit) deleted the claude/agent-installer-no-local-service-4232af branch October 28, 2026 17:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant