From 31342ec06c6cc85e3165047cad2843a3b3b88f9b Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:52:59 +0800 Subject: [PATCH] Fix owner-scoped access for Agent endpoints and delete answers Two defects in the #49 access foundation, both reachable as soon as someone has signed in: - The Agent-session endpoints read body.botId/body.ownerBotId, but the browser sends agentId, so agent-session / agent-adopt / agent-archive / agent-rename / remove-agent were denied with bot/forbidden before they ran. The DSH Agent is one per deployment and carries no owner, like add-agent, so those endpoints now follow the same shared-resource rule: free to a signed-in member while alone, blocked with two members. - delete-bot, set-main, remove-agent and delete-room answered with the unfiltered view() (no actor), leaking every owner's Bots, rooms and secret names to a signed-in member; they now pass the actor like the state and update-room answers. --- changelog.d/scoped-delete-views.fixed.md | 1 + changelog.d/signed-in-agent-tools.fixed.md | 1 + core/src/host/authz.js | 12 +++---- core/src/host/channel.js | 8 ++--- core/test/authz.test.js | 24 ++++++++++++++ core/test/room-membership.test.js | 17 ++++++++++ docs/subsystems/core-host.md | 2 +- patches/ds-bot/0005-social-seams.patch | 31 ++++++++++++++++--- patches/ds-bot/files/src/host/authz.js | 12 +++---- patches/ds-bot/files/test/authz.test.js | 24 ++++++++++++++ .../ds-bot/files/test/room-membership.test.js | 17 ++++++++++ 11 files changed, 126 insertions(+), 23 deletions(-) create mode 100644 changelog.d/scoped-delete-views.fixed.md create mode 100644 changelog.d/signed-in-agent-tools.fixed.md diff --git a/changelog.d/scoped-delete-views.fixed.md b/changelog.d/scoped-delete-views.fixed.md new file mode 100644 index 000000000..1b0d57bf5 --- /dev/null +++ b/changelog.d/scoped-delete-views.fixed.md @@ -0,0 +1 @@ +The answers to deleting a Bot, setting a Main Bot, removing the Agent and deleting a group chat now carry only the signed-in member's own scope, like every other team view, instead of the whole team. diff --git a/changelog.d/signed-in-agent-tools.fixed.md b/changelog.d/signed-in-agent-tools.fixed.md new file mode 100644 index 000000000..34c952cc2 --- /dev/null +++ b/changelog.d/signed-in-agent-tools.fixed.md @@ -0,0 +1 @@ +When someone has signed in, the one DSH Agent (and its Sessions: new session, adopt, rename, archive, remove) stays usable to that member while the team is alone, instead of every call being refused before it runs; with more than one member it follows the same shared-resource rule as adding the Agent. diff --git a/core/src/host/authz.js b/core/src/host/authz.js index 13e65372f..298b55ffa 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -31,7 +31,11 @@ const SHARED_BLOCKED = new Set([ 'browser-wait', 'browser-result', 'update-install', 'dsh-update-install', 'diagnostics-clear', 'diagnostics', 'diagnostics-log', 'set-prefs', - 'add-agent', + // The DSH Agent is one per deployment and carries no owner, so it and its Sessions + // (`agent-session` / `agent-adopt` / `agent-archive` / `agent-rename` / `remove-agent`) + // are the same shared resource as `add-agent`: free to a signed-in member while alone, + // blocked once the team has more than one member. + 'add-agent', 'agent-session', 'agent-adopt', 'agent-archive', 'agent-rename', 'remove-agent', ]) // Endpoints whose body names a Bot (or a resource hanging off one). @@ -39,7 +43,6 @@ const BOT_TARGETED = new Set([ 'update-bot', 'delete-bot', 'duplicate-bot', 'set-flags', 'bot-capability', 'memory-view', 'memory-ask', 'memory-forget', 'schedules', 'schedule-create', 'schedule-update', 'schedule-pause', 'schedule-resume', 'schedule-retry', 'schedule-delete', - 'agent-session', 'agent-adopt', 'agent-archive', 'agent-rename', 'remove-agent', 'stop-member-work', 'set-main', 'test-model', ]) @@ -253,11 +256,6 @@ export function install(rt) { if (ep === 'schedule-delete' && typeof body?.id === 'string' && !sessionOwnedBy(actor, body.id) && !sessionOwnedBy(actor, body.sessionId)) return deny('bot/forbidden', 'You do not own that schedule') return requireSession(body?.sessionId) } - if (ep.startsWith('agent-') || ep === 'remove-agent') { - // Agent sessions hang off the deployment's one DSH Agent (no owner record of - // its own), so the body's Bot decides. - return botOwnedBy(actor, body?.botId ?? body?.ownerBotId) ? ALLOW : deny('bot/forbidden', 'You do not own that agent') - } if (ep === 'usage') return body?.botId === undefined ? ALLOW : requireBot(body.botId) if (ep === 'set-main') return requireBot(body?.id) if (ep === 'set-flags') { diff --git a/core/src/host/channel.js b/core/src/host/channel.js index 4a13f2e22..c045cfbe4 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -253,14 +253,14 @@ export function install(rt) { case 'delete-bot': await rt.load() await rt.deleteBot(body.id) - return ok(view()) + return ok(view(actor)) case 'set-main': await rt.load() await rt.setMain(body.id, body.main !== false, { primary: body.primary === true, replace: typeof body.replace === 'string' ? body.replace : undefined, }) - return ok(view()) + return ok(view(actor)) case 'create-room': { await rt.load() const room = await rt.createRoom({ @@ -330,14 +330,14 @@ export function install(rt) { case 'remove-agent': await rt.load() await rt.removeAgent(body.agentId) - return ok(view()) + return ok(view(actor)) case 'set-flags': await rt.load() return ok(await rt.setFlags(body.id, body)) case 'delete-room': await rt.load() await rt.deleteRoom(body.id) - return ok(view()) + return ok(view(actor)) case 'approval-answer': await rt.answerApproval(body.id, body.choice) return ok(true) diff --git a/core/test/authz.test.js b/core/test/authz.test.js index 5d497f1c3..8af99073a 100644 --- a/core/test/authz.test.js +++ b/core/test/authz.test.js @@ -125,6 +125,30 @@ test('no actor when enforced', async (t) => { assert.equal(rt.authorize(null, 'auth-whoami', {}).ok, true) }) +test('the one deployment Agent stays usable to a signed-in member while alone, and blocks with two', async (t) => { + const { rt } = runtime(t, TEAM) + await rt.load() + const session = await redeemOwner(rt) + const actor = await rt.authenticate(session.sessionToken) + assert.equal(rt.authzEnforced(), true) + // The browser sends `agentId`, never a Bot id; the Agent is one per deployment and ownerless, + // so it follows the shared rule (`add-agent`) instead of a per-Bot owner check. + for (const [ep, body] of [ + ['agent-session', { agentId: 'ag', workspaceId: 'w1' }], + ['agent-adopt', { agentId: 'ag', sessionId: 's1' }], + ['agent-archive', { agentId: 'ag', sessionId: 's1' }], + ['agent-rename', { agentId: 'ag', sessionId: 's1', title: 'T' }], + ['remove-agent', { agentId: 'ag' }], + ]) { + const gate = rt.authorize(actor, ep, body) + assert.equal(gate.ok, true, `${ep}: ${gate.code ?? ''} ${gate.message ?? ''}`) + } + // A second member makes the deployment-wide Agent a shared resource, like `add-agent`. + await redeemOwner(rt) + assert.equal(rt.authorize(actor, 'agent-session', { agentId: 'ag' }).code, 'bot/blocked-shared') + assert.equal(rt.authorize(actor, 'remove-agent', { agentId: 'ag' }).code, 'bot/blocked-shared') +}) + test('forged actorId ignored', async (t) => { const { rt } = runtime(t, TEAM) await rt.load() diff --git a/core/test/room-membership.test.js b/core/test/room-membership.test.js index 0d7c40001..2c2f34bd8 100644 --- a/core/test/room-membership.test.js +++ b/core/test/room-membership.test.js @@ -411,6 +411,23 @@ test('channel: view lists a shared room for both humans and hides a private room assert.equal(inject.ok, false) }) +test('channel: delete-bot answers with the actor\'s scoped view, not the whole team', async (t) => { + const { call, rt } = startChannel(t, TEAM) + const inviteA = (await call('auth-invite', {})).value + const alice = (await call('auth-redeem', { token: inviteA.token, displayName: 'Alice' })).value + const inviteB = (await call('auth-invite', {}, alice.sessionToken)).value + const bob = (await call('auth-redeem', { token: inviteB.token, displayName: 'Bob' })).value + rt.state.members[bob.memberId].designatedChiefId = 'chief-b' + rt.state.bots['chief-b'].ownerId = bob.memberId + rt.state.bots['spec-b'].ownerId = bob.memberId + + const deleted = await call('delete-bot', { id: 'spec-b' }, bob.sessionToken) + assert.equal(deleted.ok, true) + assert.equal(deleted.value.bots.some(bot => bot.id === 'spec-b'), false, 'the deleted Bot is gone') + assert.ok(deleted.value.bots.some(bot => bot.id === 'chief-b'), 'Bob keeps his own Bot') + assert.equal(deleted.value.bots.some(bot => bot.id === 'spec-a'), false, "another owner's private Bot stays out of the answer") +}) + test('channel: a legacy single-owner create-room still works', async (t) => { const { call } = startChannel(t, TEAM) const invite = (await call('auth-invite', {})).value diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 31d7fe02a..84299369f 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -67,7 +67,7 @@ external backend or task-specific session mapping; see the ### Team state and access -- The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; an existing backup counts only when it is a readable file holding a parseable version-1 state, and anything else (or any malformed ownership) fails closed (read-only). An older binary refuses a newer version read-only. This is schema foundation only — team mode stays disabled, see [team](team.md). On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md). On top of it, owner-scoped access (#49) adds host module `src/host/authz.js`, installed after member-auth: when any `authSessions` exist, the channel `authorize()` default-denies cross-owner Bot/room/session access, blocks shared non-isolatable resources (secrets, connectors, folder picks, browser bridge, update-install, diagnostics, set-prefs, `add-agent` — the DSH Agent is one per deployment and carries no owner) with `bot/blocked-shared` when more than one member exists, stamps `create-bot` with `actor.memberId`, and filters `view()`/`live()` to the actor; with no sessions the single-human behavior is unchanged. Session targets resolve through the store's owner maps — a Bot id via `botOf`, current chats and group Sessions and earlier parts via `selfOf`/`groupOwners`/`pastOwners` (a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny. `approval-answer` gates on the approval's own Bot or room (resolved by approval id), never on caller-supplied session fields. `flybot/*` plugin endpoints (src/host/hooks.js) reach any signed-in actor for reads; plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in authz.js) keeps per-session entries (`questions`, `exchangeTurns`) the actor owns, and deployment-wide surfaces (agents, secrets, connectors, memory reviews) only while a single member exists — the same rule as the shared writes; rooms the actor belongs to and the Bots in them (#50) stay visible. `create-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` their interval; leave withdraws access; rejoin starts a new interval); local cross-owner rooms accept each side's *granted* Bots (#90: default deny, Chief included); a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and ungranted Bots stay private, and a room with a friend's Bot holds only granted local Bots (a Bot that joined by room code, `viaCode`, only for that room); `view()` labels remotes with an owner badge and shows an invited owner **Shared room from \**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/host/reach.js`, installed after room-membership: `state.grants[botId]` is the list of independent grants (one Bot → one grantee, optionally room-scoped); default deny, Chief included; a Bot whose owner is not a member has none. Grants are set with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `reach()` is the owner policy: a friend's Bot may reach only local Bots granted to that sender, only a granted Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides have matching grants or both are members of the room the message belongs to. It is not a middleware: hooks.js runs it before the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can skip it and a queued message is judged by what is true when it is delivered. Revoking a grant, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (`rt.evictUnshared`). Approval binding (#52) completes the held-send half of this: `state.flybot.approvals` records carry `requestId`, a sha256 `version` over the request content, server-derived `ownerId` and `origin` (`via`/`hop`/audience) — claims in the message (`meta`, text) never confer authority. `flybot/decide` is allowed through authz for any signed-in actor and the per-record check happens in hooks (`decide` requires the sender Bot's owner's session, `bot/forbidden` otherwise, `bot/unauthorized` without a session); `release()` revalidates before dispatch and marks the record `stale` when the request changed, the sender or target Bot is gone (also invalidated eagerly on `deleteBot`), or the Bot changed owners; a demoted Chief's sender-side holds are invalidated eagerly on `setMain` (not inside `staleReason`), and `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. `flybot/approvals` lists only the actor's own holds while enforcement is on; decided stale records keep their attribution in state but appear in no member's list. +- The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; an existing backup counts only when it is a readable file holding a parseable version-1 state, and anything else (or any malformed ownership) fails closed (read-only). An older binary refuses a newer version read-only. This is schema foundation only — team mode stays disabled, see [team](team.md). On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md). On top of it, owner-scoped access (#49) adds host module `src/host/authz.js`, installed after member-auth: when any `authSessions` exist, the channel `authorize()` default-denies cross-owner Bot/room/session access, blocks shared non-isolatable resources (secrets, connectors, folder picks, browser bridge, update-install, diagnostics, set-prefs, and the DSH Agent with its Sessions — `add-agent`, `agent-session`, `agent-adopt`, `agent-archive`, `agent-rename`, `remove-agent`, which name an ownerless `agentId` and are one per deployment) with `bot/blocked-shared` when more than one member exists, stamps `create-bot` with `actor.memberId`, and filters `view()`/`live()` to the actor — every answer that returns a view, `delete-bot`, `set-main`, `remove-agent` and `delete-room` included, passes the actor so it stays scoped; with no sessions the single-human behavior is unchanged. Session targets resolve through the store's owner maps — a Bot id via `botOf`, current chats and group Sessions and earlier parts via `selfOf`/`groupOwners`/`pastOwners` (a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny. `approval-answer` gates on the approval's own Bot or room (resolved by approval id), never on caller-supplied session fields. `flybot/*` plugin endpoints (src/host/hooks.js) reach any signed-in actor for reads; plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in authz.js) keeps per-session entries (`questions`, `exchangeTurns`) the actor owns, and deployment-wide surfaces (agents, secrets, connectors, memory reviews) only while a single member exists — the same rule as the shared writes; rooms the actor belongs to and the Bots in them (#50) stay visible. `create-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` their interval; leave withdraws access; rejoin starts a new interval); local cross-owner rooms accept each side's *granted* Bots (#90: default deny, Chief included); a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and ungranted Bots stay private, and a room with a friend's Bot holds only granted local Bots (a Bot that joined by room code, `viaCode`, only for that room); `view()` labels remotes with an owner badge and shows an invited owner **Shared room from \**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/host/reach.js`, installed after room-membership: `state.grants[botId]` is the list of independent grants (one Bot → one grantee, optionally room-scoped); default deny, Chief included; a Bot whose owner is not a member has none. Grants are set with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `reach()` is the owner policy: a friend's Bot may reach only local Bots granted to that sender, only a granted Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides have matching grants or both are members of the room the message belongs to. It is not a middleware: hooks.js runs it before the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can skip it and a queued message is judged by what is true when it is delivered. Revoking a grant, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (`rt.evictUnshared`). Approval binding (#52) completes the held-send half of this: `state.flybot.approvals` records carry `requestId`, a sha256 `version` over the request content, server-derived `ownerId` and `origin` (`via`/`hop`/audience) — claims in the message (`meta`, text) never confer authority. `flybot/decide` is allowed through authz for any signed-in actor and the per-record check happens in hooks (`decide` requires the sender Bot's owner's session, `bot/forbidden` otherwise, `bot/unauthorized` without a session); `release()` revalidates before dispatch and marks the record `stale` when the request changed, the sender or target Bot is gone (also invalidated eagerly on `deleteBot`), or the Bot changed owners; a demoted Chief's sender-side holds are invalidated eagerly on `setMain` (not inside `staleReason`), and `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. `flybot/approvals` lists only the actor's own holds while enforcement is on; decided stale records keep their attribution in state but appear in no member's list. - Context sharing (#53) is specified in [team](team.md), **Context sharing foundation**: the own-chat brief is share-gated (`share-brief` / `briefShared`), a memory review reads only its owner as the user (`isUserLine`) and labels another human as `peer` (not `bot`), and keeps cross-owner entries out of the team scope (`teamAllowedFrom`), `list_bots` lists only the caller's owner's Bots (a granted child: that child's `createdBy` subtree), Bots granted to the same owner, and Bots in the current room — another owner's folder and instructions stay private — and a `dsh-session:` chip in actor-authored `send` / `room-post` / `answer-question` / `schedule-create` text must resolve for the author (`sessionOwnedBy`; `schedule-create` authz keys off `body.target`). Bot-authored group posts and Bot-to-Bot / own-chat followups drop chips so they cannot tunnel a private chat into another Session. Host modules: `src/host/groups.js`, `src/host/memory.js`, `src/host/tools.js`, `src/host/channel.js`, `src/host/room-membership.js`, `src/host/authz.js`. Team mode stays disabled. diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index f17ad9c63..cd7ae5dbe 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -458,8 +458,20 @@ diff --git a/src/host/channel.js b/src/host/channel.js return ok(bot) } case 'update-bot': -@@ -160,7 +160,13 @@ export function install(rt) { - return ok(view()) +@@ -150,17 +150,23 @@ export function install(rt) { + case 'delete-bot': + await rt.load() + await rt.deleteBot(body.id) +- return ok(view()) ++ return ok(view(actor)) + case 'set-main': + await rt.load() + await rt.setMain(body.id, body.main !== false, { + primary: body.primary === true, + replace: typeof body.replace === 'string' ? body.replace : undefined, + }) +- return ok(view()) ++ return ok(view(actor)) case 'create-room': { await rt.load() - const room = await rt.createRoom({ members: Array.isArray(body.members) ? body.members : [], name: body.name, ...(typeof body.admin === 'string' ? { admin: body.admin } : {}) }) @@ -502,9 +514,20 @@ diff --git a/src/host/channel.js b/src/host/channel.js } case 'folders': case 'folder-pick': -@@ -212,8 +212,10 @@ export function install(rt) { +@@ -203,17 +203,19 @@ export function install(rt) { + case 'remove-agent': + await rt.load() + await rt.removeAgent(body.agentId) +- return ok(view()) ++ return ok(view(actor)) + case 'set-flags': + await rt.load() + return ok(await rt.setFlags(body.id, body)) + case 'delete-room': + await rt.load() await rt.deleteRoom(body.id) - return ok(view()) +- return ok(view()) ++ return ok(view(actor)) case 'approval-answer': - rt.answerApproval(body.id, body.choice) + await rt.answerApproval(body.id, body.choice) diff --git a/patches/ds-bot/files/src/host/authz.js b/patches/ds-bot/files/src/host/authz.js index 13e65372f..298b55ffa 100644 --- a/patches/ds-bot/files/src/host/authz.js +++ b/patches/ds-bot/files/src/host/authz.js @@ -31,7 +31,11 @@ const SHARED_BLOCKED = new Set([ 'browser-wait', 'browser-result', 'update-install', 'dsh-update-install', 'diagnostics-clear', 'diagnostics', 'diagnostics-log', 'set-prefs', - 'add-agent', + // The DSH Agent is one per deployment and carries no owner, so it and its Sessions + // (`agent-session` / `agent-adopt` / `agent-archive` / `agent-rename` / `remove-agent`) + // are the same shared resource as `add-agent`: free to a signed-in member while alone, + // blocked once the team has more than one member. + 'add-agent', 'agent-session', 'agent-adopt', 'agent-archive', 'agent-rename', 'remove-agent', ]) // Endpoints whose body names a Bot (or a resource hanging off one). @@ -39,7 +43,6 @@ const BOT_TARGETED = new Set([ 'update-bot', 'delete-bot', 'duplicate-bot', 'set-flags', 'bot-capability', 'memory-view', 'memory-ask', 'memory-forget', 'schedules', 'schedule-create', 'schedule-update', 'schedule-pause', 'schedule-resume', 'schedule-retry', 'schedule-delete', - 'agent-session', 'agent-adopt', 'agent-archive', 'agent-rename', 'remove-agent', 'stop-member-work', 'set-main', 'test-model', ]) @@ -253,11 +256,6 @@ export function install(rt) { if (ep === 'schedule-delete' && typeof body?.id === 'string' && !sessionOwnedBy(actor, body.id) && !sessionOwnedBy(actor, body.sessionId)) return deny('bot/forbidden', 'You do not own that schedule') return requireSession(body?.sessionId) } - if (ep.startsWith('agent-') || ep === 'remove-agent') { - // Agent sessions hang off the deployment's one DSH Agent (no owner record of - // its own), so the body's Bot decides. - return botOwnedBy(actor, body?.botId ?? body?.ownerBotId) ? ALLOW : deny('bot/forbidden', 'You do not own that agent') - } if (ep === 'usage') return body?.botId === undefined ? ALLOW : requireBot(body.botId) if (ep === 'set-main') return requireBot(body?.id) if (ep === 'set-flags') { diff --git a/patches/ds-bot/files/test/authz.test.js b/patches/ds-bot/files/test/authz.test.js index 5d497f1c3..8af99073a 100644 --- a/patches/ds-bot/files/test/authz.test.js +++ b/patches/ds-bot/files/test/authz.test.js @@ -125,6 +125,30 @@ test('no actor when enforced', async (t) => { assert.equal(rt.authorize(null, 'auth-whoami', {}).ok, true) }) +test('the one deployment Agent stays usable to a signed-in member while alone, and blocks with two', async (t) => { + const { rt } = runtime(t, TEAM) + await rt.load() + const session = await redeemOwner(rt) + const actor = await rt.authenticate(session.sessionToken) + assert.equal(rt.authzEnforced(), true) + // The browser sends `agentId`, never a Bot id; the Agent is one per deployment and ownerless, + // so it follows the shared rule (`add-agent`) instead of a per-Bot owner check. + for (const [ep, body] of [ + ['agent-session', { agentId: 'ag', workspaceId: 'w1' }], + ['agent-adopt', { agentId: 'ag', sessionId: 's1' }], + ['agent-archive', { agentId: 'ag', sessionId: 's1' }], + ['agent-rename', { agentId: 'ag', sessionId: 's1', title: 'T' }], + ['remove-agent', { agentId: 'ag' }], + ]) { + const gate = rt.authorize(actor, ep, body) + assert.equal(gate.ok, true, `${ep}: ${gate.code ?? ''} ${gate.message ?? ''}`) + } + // A second member makes the deployment-wide Agent a shared resource, like `add-agent`. + await redeemOwner(rt) + assert.equal(rt.authorize(actor, 'agent-session', { agentId: 'ag' }).code, 'bot/blocked-shared') + assert.equal(rt.authorize(actor, 'remove-agent', { agentId: 'ag' }).code, 'bot/blocked-shared') +}) + test('forged actorId ignored', async (t) => { const { rt } = runtime(t, TEAM) await rt.load() diff --git a/patches/ds-bot/files/test/room-membership.test.js b/patches/ds-bot/files/test/room-membership.test.js index 0d7c40001..2c2f34bd8 100644 --- a/patches/ds-bot/files/test/room-membership.test.js +++ b/patches/ds-bot/files/test/room-membership.test.js @@ -411,6 +411,23 @@ test('channel: view lists a shared room for both humans and hides a private room assert.equal(inject.ok, false) }) +test('channel: delete-bot answers with the actor\'s scoped view, not the whole team', async (t) => { + const { call, rt } = startChannel(t, TEAM) + const inviteA = (await call('auth-invite', {})).value + const alice = (await call('auth-redeem', { token: inviteA.token, displayName: 'Alice' })).value + const inviteB = (await call('auth-invite', {}, alice.sessionToken)).value + const bob = (await call('auth-redeem', { token: inviteB.token, displayName: 'Bob' })).value + rt.state.members[bob.memberId].designatedChiefId = 'chief-b' + rt.state.bots['chief-b'].ownerId = bob.memberId + rt.state.bots['spec-b'].ownerId = bob.memberId + + const deleted = await call('delete-bot', { id: 'spec-b' }, bob.sessionToken) + assert.equal(deleted.ok, true) + assert.equal(deleted.value.bots.some(bot => bot.id === 'spec-b'), false, 'the deleted Bot is gone') + assert.ok(deleted.value.bots.some(bot => bot.id === 'chief-b'), 'Bob keeps his own Bot') + assert.equal(deleted.value.bots.some(bot => bot.id === 'spec-a'), false, "another owner's private Bot stays out of the answer") +}) + test('channel: a legacy single-owner create-room still works', async (t) => { const { call } = startChannel(t, TEAM) const invite = (await call('auth-invite', {})).value