Skip to content

fix(agent): stop identities when a directory changes owner - #2178

Draft
Benoît Cortier (CBenoit) wants to merge 3 commits into
masterfrom
claude/agent-foreign-dir-midrun-046b3f
Draft

Benoît Cortier (CBenoit) wants to merge 3 commits into
masterfrom
claude/agent-foreign-dir-midrun-046b3f

Conversation

@CBenoit

Copy link
Copy Markdown
Member

An identity directory owned by another account stops all Agent Identity processing until it is fixed, because the account that created it can keep a handle that still changes it after a reset. Start-up already refused such a directory, but while the agent ran, a directory that changed owner only made each state change fail: runners kept renewing and checking in, and their agent channel streams stayed open.

The pending-directory poll now provisions the identity directories, as every state change does before it writes, then checks the stored identities, without taking the state lock. A directory whose owner the agent doesn't accept stops every runner and its streams, as an untrusted authority directory or identity.json already does, and the agent resumes without a restart once the owner is fixed. A directory the agent owns with wrong permissions is still repaired and stops nothing; the poll now resets such permissions on each interval, which every state change already did.

Issue: DGW-478
Security: yes
Co-Authored-By: Claude Opus 5.5 [email protected]

🤖 Generated with Claude Code

@CBenoit

Copy link
Copy Markdown
Member Author

Evidence and notes

Lines changed (git diff --numstat b43cbd68e e71f43664):

Area Added Removed
Production (agent-identity state.rs, private_file.rs; devolutions-agent identity poll) 45 21
Tests (devolutions-agent/src/identity/mod.rs test module) 132 9
Other 0 0

How it works: the poll calls the new Store::check_state, which runs provision_directories (the same Existing::RepairAccess provisioning start-up and every state change run) and then check_stored_identities. A ForeignIdentityDirectory in the error chain returns the existing Stopped::Untrusted, so run_identity drops every runner and its streams and goes back to start-up, which refuses until the owner is fixed. runner.rs is unchanged.

Tests at e71f43664:

Platform agent-identity devolutions-agent lib devolutions-agent main Owner test
Windows, not elevated 227 passed 201 passed 6 passed skipped (5 cases, needs elevation)
Linux (WSL), normal user 210 passed 135 passed 6 passed skipped (needs root)
Linux (WSL), root, identity::tests only — 12 passed — ran: identity, authorities, keys, rejected, pending

The owner test (an_identity_directory_given_to_another_account_after_start_up_stops_identity_processing) failed on the code before the fix (pending poll kept running) and passes after it, as root on Linux. The CI runners run it only where they are root or elevated; the Windows icacls /setowner path has not run locally.

cargo fmt --check and cargo clippy -p agent-identity -p devolutions-agent --all-targets -- -D warnings are clean on Windows and Linux.

Decisions and side effects worth a look:

  • The poll now resets wrong permissions on the identity directories each interval (default 5 s). Every state change already did this; crates/agent-identity/INTENT.md §Local state names start-up, so the reset is now periodic rather than also per write.
  • The check takes no state lock, so an outside handle on <data-dir>\identity (Explorer, a backup) can't stall the poll. On Windows, while a runner holds the state lock, the poll's provisioning can fail with a sharing violation; that is an ordinary error, retried at the next poll and logged as a warning at most once a minute.
  • At start-up a foreign directory is still logged through the generic "state is unavailable" error, whose text names the directory.
  • The start-up half of the test asserts only that start-up is refused: for identity and keys, the device-key directory check refuses before provisioning, so the error type differs by case.

Note

LLM-assisted content (no human feedback).

@CBenoit

Copy link
Copy Markdown
Member Author

UNTESTED properties

No new UNTESTED: comment. The change relies on one that already exists:

  • devolutions-agent/src/identity/mod.rs, run_identity: dropping the runners aborts every runner task and, with it, its agent channel streams. Tests check that the pending poll returns Stopped::Untrusted, not that a live runner stops.

Note

LLM-assisted content (no human feedback).

Base automatically changed from claude/sec-agent-findings-0f32bb to master October 30, 2026 16:45
…es owner mid-run

An identity directory owned by another account must stop all identity
processing until it is fixed, because the account that created it can
keep a handle that still changes it after a permission reset. Start-up
already refused such a directory, but the pending-directory poll only
checked authority directories and identity.json, so a directory that
changed owner while the agent ran only made each state change fail
while every runner and agent channel stream kept going.

The poll now runs what every state change runs before it writes: it
takes and releases the state lock, whose provisioning refuses an
identity directory with an owner the agent does not accept, then checks
the stored identities. Such a refusal stops every identity and its
streams, as an untrusted authority directory does, and start-up retries
until the directory is fixed, so processing resumes without a restart.
Other errors still only make the poll retry.

Issue: DGW-478
Security: yes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The pending-directory poll checks the identity state on every interval
with nothing to write. Taking the state lock for that check made it
wait on any outside handle: on Windows the lock is an unshared handle
on <data-dir>\identity, so Explorer, a shell whose working directory
is that folder, or a backup or antivirus scan made the poll sleep for
30 s on a runtime worker each cycle, and on Unix flock waits without
bound.

The check now provisions the identity directories and checks the
stored identities without locking. Provisioning is what the lock runs
first, so a directory owned by another account is refused exactly as
before, while a sharing violation is an ordinary error the poll
retries.

The test that gives an identity directory to another account now also
covers the authority directories, and gives the directory back by SID,
because icacls cannot map the name of an Entra ID or Microsoft
account.

Issue: DGW-478
Security: yes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
@CBenoit
Benoît Cortier (CBenoit) force-pushed the claude/agent-foreign-dir-midrun-046b3f branch from e71f436 to dc8c68e Compare November 13, 2026 09:23

This branch has not been deployed

No deployments
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