diff --git a/src/backend/services/share/ShareService.test.ts b/src/backend/services/share/ShareService.test.ts
index 759278c24..5a6089f01 100644
--- a/src/backend/services/share/ShareService.test.ts
+++ b/src/backend/services/share/ShareService.test.ts
@@ -806,7 +806,35 @@ describe('ShareService', () => {
recipient: { email: third.email },
mode: 'manage',
}),
- ).rejects.toMatchObject({ statusCode: 403 });
+ ).rejects.toMatchObject({
+ statusCode: 403,
+ legacyCode: 'cannot_delegate_manage',
+ });
+
+ // What they can do is unchanged.
+ await expect(
+ share(delegate.actor, {
+ uid: file.uuid,
+ recipient: { email: third.email },
+ mode: 'write',
+ }),
+ ).resolves.toMatchObject({ mode: 'write' });
+ });
+
+ it('tells a stranger nothing when they ask to grant `manage`', async () => {
+ const owner = await makeUser();
+ const stranger = await makeUser();
+ const third = await makeUser();
+ const file = await makeFile(owner.user);
+
+ // No access at all, so the refusal must not confirm the file exists.
+ await expect(
+ share(stranger.actor, {
+ uid: file.uuid,
+ recipient: { email: third.email },
+ mode: 'manage',
+ }),
+ ).rejects.not.toMatchObject({ legacyCode: 'cannot_delegate_manage' });
});
it('leaves a delegate alone when their authority survives another issuer', async () => {
diff --git a/src/backend/services/share/ShareService.ts b/src/backend/services/share/ShareService.ts
index 9e6ce17da..6327ae06d 100644
--- a/src/backend/services/share/ShareService.ts
+++ b/src/backend/services/share/ShareService.ts
@@ -1720,6 +1720,22 @@ export class ShareService extends PuterService {
// given, rather than everything its user owns.
if (allowed && (await this.#hasOwnReach(actor, entry, mode))) return;
+ // Only for someone who can already share here, so it leaks nothing.
+ if (mode === MANAGE_PERM_PREFIX) {
+ const canDelegateAccess =
+ await this.services.permission.canManagePermission(
+ userRelatedActor(actor),
+ entryPermissionForMode(entry.uuid, 'write'),
+ );
+ if (canDelegateAccess) {
+ throw new HttpError(
+ 403,
+ 'Only the owner can grant edit & share access',
+ { legacyCode: 'cannot_delegate_manage' },
+ );
+ }
+ }
+
const safe = await this.services.acl.getSafeAclError(
actor,
this.#descriptorFor(entry),
diff --git a/src/gui/src/UI/Dashboard/UIShareModal.js b/src/gui/src/UI/Dashboard/UIShareModal.js
index 65e4217b8..9e84a55b2 100644
--- a/src/gui/src/UI/Dashboard/UIShareModal.js
+++ b/src/gui/src/UI/Dashboard/UIShareModal.js
@@ -19,7 +19,7 @@
import path from '../../lib/path.js';
import item_icon from '../../helpers/item_icon.js';
-import { owner_of_path } from '../../helpers/path_owner.js';
+import { is_owned_by_me, owner_of_path } from '../../helpers/path_owner.js';
import { invalidate_shared_roots } from '../../helpers/shared_access.js';
import { icons } from '../../helpers/actionIcons.js';
import { mode_label, options_for } from '../../helpers/share_modes.js';
@@ -115,6 +115,8 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
}));
const target_paths = targets.map((item) => item.path);
const total = targets.length;
+ // Strictest item decides: one borrowed item withholds it for the rest.
+ const allow_manage = target_paths.every((p) => is_owned_by_me(p));
const is_multi = total > 1;
// Nothing to share: an empty selection is a caller's mistake, not a dialog.
if ( total === 0 ) return { close: () => {} };
@@ -159,7 +161,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
-
+