From 63587a9c036da555bb8f9ae54fa3a325ef84953e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 29 Aug 2026 22:40:20 +0200 Subject: [PATCH] feat(server): establish semantic session permissions (#3981) Keep trusted-client and Hub behavior stable while replacing RPC-name grants with a semantic authorization boundary. This establishes the operation inventory needed for future principal, pairing, and resource-scoped access work. --- CLAUDE.md | 1 + docs/hub.md | 8 +- docs/permissions.md | 59 +++ .../src/server/authorization/index.test.ts | 74 +++ .../server/src/server/authorization/index.ts | 54 +++ .../authorization/operation-permissions.ts | 420 ++++++++++++++++++ packages/server/src/server/session.test.ts | 35 +- packages/server/src/server/session.ts | 29 +- .../session.workspace-git-watch.test.ts | 3 +- ...on.workspace-resolution-invariants.test.ts | 3 +- .../src/server/session.workspaces.test.ts | 19 +- .../snapshot-mutation-ownership.test.ts | 3 +- .../server/src/server/websocket-server.ts | 13 +- .../server/src/server/wire-compat.test.ts | 3 +- 14 files changed, 658 insertions(+), 66 deletions(-) create mode 100644 docs/permissions.md create mode 100644 packages/server/src/server/authorization/index.test.ts create mode 100644 packages/server/src/server/authorization/index.ts create mode 100644 packages/server/src/server/authorization/operation-permissions.ts diff --git a/CLAUDE.md b/CLAUDE.md index ad2a3ff758..b62a53d409 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs | | [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging | | [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules | +| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority | | [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage | | [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render | | [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison | diff --git a/docs/hub.md b/docs/hub.md index de13853218..4dd484e219 100644 --- a/docs/hub.md +++ b/docs/hub.md @@ -24,10 +24,10 @@ receive only `hub.execution.*` authority, so execution credentials cannot manage ## Session grants and execution ownership Trusted clients and the Hub use the same `Session` implementation. The connection boundary supplies -grants: trusted clients receive `*`, while an enrolled Hub connection receives its persisted -`hub.execution.*` grant. One matcher handles exact RPC names and trailing namespace wildcards for -both inbound requests and outbound messages. A denied request returns the ordinary `rpc_error` -shape. +semantic permissions: trusted clients receive the owner permission set, while an enrolled Hub +connection receives `hub.execute`. The current enrollment and persistence format still carries +`hub.execution.*`; one compatibility boundary translates it before the session is created. A denied +request returns the ordinary `rpc_error` shape. See [permissions.md](permissions.md). The Hub connection still has a narrow lifecycle boundary: it has no trusted-client hello/resume, browser, binary, retained-session, or broadcast state. Its outbound execution events include only diff --git a/docs/permissions.md b/docs/permissions.md new file mode 100644 index 0000000000..79ce6edcf8 --- /dev/null +++ b/docs/permissions.md @@ -0,0 +1,59 @@ +# Daemon permissions + +The daemon authorizes principals with semantic permissions. RPC names and protocol namespaces are not authority. + +## Model + +```text +principal -> grants + | + +-- authenticated by a device or service credential + `-- opens a session with equal or narrower authority +``` + +A principal is the durable identity the daemon authorizes. A credential proves that a device or service represents it. Keep them separate so you can rotate credentials, attach more than one device, and revoke a Hub user without inventing daemon user accounts. + +A pairing invitation is neither. It is an expiring, single-use exchange that creates a principal and credential with the permissions selected by its issuer. + +## Permissions + +| Permission | Authority | +| ------------------- | -------------------------------------------------------------------------- | +| `daemon.read` | Daemon status, diagnostics, configuration, and provider information | +| `daemon.manage` | Restart, update, configuration changes, providers, skills, and plugins | +| `tunnel.manage` | Relay, Hub, service tunnel, and public endpoint relationships | +| `access.manage` | Pairing invitations, principals, credentials, grants, and revocation | +| `workspace.read` | Projects, workspaces, agents, timelines, files, diffs, and terminal output | +| `workspace.write` | Prompts, agent control, files, terminals, git operations, and scripts | +| `workspace.manage` | Create, rename, archive, and remove projects and workspaces | +| `automation.manage` | Schedules, heartbeats, and loops | +| `hub.execute` | The Hub-owned execution lifecycle | + +Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce. + +Owner, operator, and viewer are UI presets expanded into explicit permissions. Do not persist them as roles. Adding a permission must not silently widen an existing principal. + +Permissions are additive allows. Missing authority denies the operation. Do not add deny precedence. + +## Resources + +Permissions are daemon-wide today. Future grants may select workspaces or agents, but operation classification remains inside the authorization module: + +```ts +type Grant = { + permission: Permission; + resource: { kind: "daemon" } | { kind: "workspace"; ids: string[] }; +}; +``` + +A delegating principal can grant only authority it already possesses. A session may attenuate its principal's grants but cannot widen them. + +Workspace-scoped grants require every resource-bearing operation and outbound observation to enforce the same workspace boundary. File preview currently accepts any daemon-readable regular file, so it must gain resource enforcement before workspace-specific access ships. + +## Hub + +The Hub authenticates as a service principal. Its locally selected grants decide whether it may execute agents, manage the daemon, manage tunnels, or manage access. + +Hub user and role identifiers remain opaque external subjects. The Hub may create and revoke linked daemon principals when granted `access.manage`; the daemon does not interpret accounts, organizations, or roles. + +The current Hub enrollment protocol still exchanges `hub.execution.*`. The daemon translates that legacy value to `hub.execute` once at the connection boundary. Remove the translation when Hub enrollment exchanges semantic permissions. diff --git a/packages/server/src/server/authorization/index.test.ts b/packages/server/src/server/authorization/index.test.ts new file mode 100644 index 0000000000..4d7f915393 --- /dev/null +++ b/packages/server/src/server/authorization/index.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "vitest"; +import { + SessionInboundMessageSchema, + SessionOutboundMessageSchema, + type SessionInboundMessage, + type SessionOutboundMessage, +} from "../messages.js"; +import { + DAEMON_PERMISSIONS, + OWNER_PERMISSIONS, + SessionAuthorization, + permissionsForLegacyHubScopes, +} from "./index.js"; + +function inboundOperationTypes(): SessionInboundMessage["type"][] { + return SessionInboundMessageSchema.options.map((option) => option.shape.type.value); +} + +function outboundOperationTypes(): SessionOutboundMessage["type"][] { + return SessionOutboundMessageSchema.options.map((option) => option.shape.type.value); +} + +function inboundMessage(type: SessionInboundMessage["type"]): SessionInboundMessage { + return { type } as SessionInboundMessage; +} + +function outboundMessage(type: SessionOutboundMessage["type"]): SessionOutboundMessage { + return { type } as SessionOutboundMessage; +} + +describe("SessionAuthorization", () => { + test("owner authority covers every session operation", () => { + const authorization = new SessionAuthorization(OWNER_PERMISSIONS); + + expect( + inboundOperationTypes().every((type) => authorization.allowsInbound(inboundMessage(type))), + ).toBe(true); + expect( + outboundOperationTypes().every((type) => authorization.allowsOutbound(outboundMessage(type))), + ).toBe(true); + }); + + test("semantic permissions authorize operations instead of RPC namespaces", () => { + const authorization = new SessionAuthorization(["hub.execute"]); + + expect(authorization.allowsInbound(inboundMessage("hub.execution.agent.create.request"))).toBe( + true, + ); + expect(authorization.allowsOutbound(outboundMessage("hub.execution.agent.update"))).toBe(true); + expect(authorization.allowsInbound(inboundMessage("ping"))).toBe(false); + expect( + authorization.allowsInbound(inboundMessage("hub.management.daemon.get_status.request")), + ).toBe(false); + }); + + test("correlated authorization errors can always be emitted", () => { + const authorization = new SessionAuthorization([]); + + expect(authorization.allowsOutbound(outboundMessage("rpc_error"))).toBe(true); + }); + + test("legacy Hub authority is translated at one compatibility boundary", () => { + expect(permissionsForLegacyHubScopes(["hub.execution.*"])).toEqual(["hub.execute"]); + expect(permissionsForLegacyHubScopes(["*"])).toEqual([]); + }); + + test("permission names are semantic", () => { + expect( + DAEMON_PERMISSIONS.every( + (permission) => !permission.includes("*") && !permission.includes("request"), + ), + ).toBe(true); + }); +}); diff --git a/packages/server/src/server/authorization/index.ts b/packages/server/src/server/authorization/index.ts new file mode 100644 index 0000000000..bb0e601bdf --- /dev/null +++ b/packages/server/src/server/authorization/index.ts @@ -0,0 +1,54 @@ +import type { SessionInboundMessage, SessionOutboundMessage } from "../messages.js"; +import { + requiredPermissionForInbound, + requiredPermissionForOutbound, +} from "./operation-permissions.js"; + +export const DAEMON_PERMISSIONS = [ + "daemon.read", + "daemon.manage", + "tunnel.manage", + "access.manage", + "workspace.read", + "workspace.write", + "workspace.manage", + "automation.manage", + "hub.execute", +] as const; + +export type DaemonPermission = (typeof DAEMON_PERMISSIONS)[number]; + +export const OWNER_PERMISSIONS: readonly DaemonPermission[] = DAEMON_PERMISSIONS; + +export class SessionAuthorization { + private permissions: ReadonlySet; + + constructor(permissions: readonly DaemonPermission[]) { + this.permissions = new Set(permissions); + } + + allowsInbound(message: SessionInboundMessage): boolean { + return this.allows(requiredPermissionForInbound(message.type)); + } + + allowsOutbound(message: SessionOutboundMessage): boolean { + return this.allows(requiredPermissionForOutbound(message.type)); + } + + replacePermissions(permissions: readonly DaemonPermission[]): void { + this.permissions = new Set(permissions); + } + + private allows(permission: DaemonPermission | null): boolean { + return permission === null || this.permissions.has(permission); + } +} + +const LEGACY_HUB_EXECUTION_SCOPE = "hub.execution.*"; + +export function permissionsForLegacyHubScopes( + scopes: readonly string[], +): readonly DaemonPermission[] { + // COMPAT(semanticHubPermissions): added in v0.7, remove after Hub enrollment uses permissions. + return scopes.includes(LEGACY_HUB_EXECUTION_SCOPE) ? ["hub.execute"] : []; +} diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts new file mode 100644 index 0000000000..f91e0fe67c --- /dev/null +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -0,0 +1,420 @@ +import type { SessionInboundMessage, SessionOutboundMessage } from "../messages.js"; +import type { DaemonPermission } from "./index.js"; + +type InboundOperation = SessionInboundMessage["type"]; +type OutboundOperation = SessionOutboundMessage["type"]; + +const INBOUND_PERMISSION = { + abort_request: "workspace.write", + "agent.config.apply.request": "workspace.write", + "agent.detach.request": "workspace.write", + "agent.fork_context.request": "workspace.read", + "agent.provider_subagents.list.request": "workspace.read", + "agent.provider_subagents.timeline.get.request": "workspace.read", + "agent.rewind.request": "workspace.write", + "agent.skills.get_status.request": "daemon.read", + "agent.skills.import_legacy_selection.request": "daemon.manage", + "agent.skills.reconcile.request": "daemon.manage", + "agent.skills.save_selection.request": "daemon.manage", + "agent.skills.uninstall.request": "daemon.manage", + "agent.timeline.list_prompts.request": "workspace.read", + "agent.timeline.set_subscription.request": "workspace.read", + agent_permission_response: "workspace.write", + archive_agent_request: "workspace.write", + archive_workspace_request: "workspace.manage", + audio_played: "workspace.write", + branch_suggestions_request: "workspace.read", + "browser.automation.execute.response": "workspace.write", + cancel_agent_request: "workspace.write", + capture_terminal_request: "workspace.read", + "chat/create": "workspace.write", + "chat/delete": "workspace.write", + "chat/inspect": "workspace.read", + "chat/list": "workspace.read", + "chat/post": "workspace.write", + "chat/read": "workspace.read", + "chat/wait": "workspace.read", + "checkout.commits.file_diff.request": "workspace.read", + "checkout.commits.list.request": "workspace.read", + "checkout.discard_changes.request": "workspace.write", + "checkout.forge.get_check_details.request": "workspace.read", + "checkout.forge.set_auto_merge.request": "workspace.write", + "checkout.github.get_check_details.request": "workspace.read", + "checkout.github.set_auto_merge.request": "workspace.write", + "checkout.refresh.request": "workspace.read", + "checkout.rename_branch.request": "workspace.write", + checkout_commit_request: "workspace.write", + checkout_merge_from_base_request: "workspace.write", + checkout_merge_request: "workspace.write", + checkout_pr_create_request: "workspace.write", + checkout_pr_merge_request: "workspace.write", + checkout_pr_status_request: "workspace.read", + checkout_pull_request: "workspace.write", + checkout_push_request: "workspace.write", + checkout_status_request: "workspace.read", + checkout_switch_branch_request: "workspace.write", + clear_agent_attention: "workspace.write", + client_heartbeat: "workspace.read", + close_items_request: "workspace.manage", + create_agent_request: "workspace.write", + create_paseo_worktree_request: "workspace.manage", + create_terminal_request: "workspace.write", + "daemon.config.reload.request": "daemon.manage", + "daemon.get_pairing_offer.request": "access.manage", + "daemon.get_status.request": "daemon.read", + "daemon.update.request": "daemon.manage", + delete_agent_request: "workspace.write", + "diagnostics.request": "daemon.read", + dictation_stream_cancel: "workspace.write", + dictation_stream_chunk: "workspace.write", + dictation_stream_finish: "workspace.write", + dictation_stream_start: "workspace.write", + directory_suggestions_request: "workspace.read", + fetch_agent_history_request: "workspace.read", + fetch_agent_request: "workspace.read", + fetch_agent_timeline_request: "workspace.read", + fetch_agents_request: "workspace.read", + fetch_recent_provider_sessions_request: "workspace.read", + fetch_workspaces_request: "workspace.read", + "file.upload.request": "workspace.write", + file_download_token_request: "workspace.read", + file_explorer_request: "workspace.read", + "forge.search.request": "workspace.read", + "fs.entry.create.request": "workspace.write", + "fs.entry.delete.request": "workspace.write", + "fs.entry.duplicate.request": "workspace.write", + "fs.entry.rename.request": "workspace.write", + "fs.file.subscribe.request": "workspace.read", + "fs.file.unsubscribe.request": "workspace.read", + "fs.file.write.request": "workspace.write", + get_daemon_config_request: "daemon.read", + get_providers_snapshot_request: "daemon.read", + github_search_request: "workspace.read", + "hub.execution.agent.create.request": "hub.execute", + "hub.execution.agent.validate.request": "hub.execute", + "hub.execution.control.request": "hub.execute", + "hub.management.daemon.connect.request": "tunnel.manage", + "hub.management.daemon.disconnect.request": "tunnel.manage", + "hub.management.daemon.get_status.request": "tunnel.manage", + import_agent_request: "workspace.write", + kill_terminal_request: "workspace.write", + list_available_editors_request: "workspace.read", + list_available_providers_request: "daemon.read", + list_commands_request: "daemon.read", + list_provider_features_request: "daemon.read", + list_provider_models_request: "daemon.read", + list_provider_modes_request: "daemon.read", + list_terminals_request: "workspace.read", + "loop/inspect": "automation.manage", + "loop/list": "automation.manage", + "loop/logs": "automation.manage", + "loop/run": "automation.manage", + "loop/stop": "automation.manage", + open_in_editor_request: "workspace.write", + open_project_request: "workspace.manage", + paseo_worktree_archive_request: "workspace.manage", + paseo_worktree_list_request: "workspace.read", + ping: "daemon.read", + "plugin.catalog.get.request": "daemon.manage", + "plugin.directory.inspect.request": "daemon.manage", + "plugin.directory.install.request": "daemon.manage", + "plugin.disable.request": "daemon.manage", + "plugin.enable.request": "daemon.manage", + "plugin.list.request": "daemon.manage", + "plugin.logs.get.request": "daemon.manage", + "plugin.reload.request": "daemon.manage", + "plugin.remove.request": "daemon.manage", + "plugin.rpc.invoke.request": "daemon.manage", + "plugin.source.install.request": "daemon.manage", + "plugin.source.status.request": "daemon.manage", + "plugin.source.update.request": "daemon.manage", + "project.add.request": "workspace.manage", + "project.create_directory.request": "workspace.manage", + "project.github.clone.request": "workspace.manage", + "project.icon.get.request": "workspace.read", + "project.icon.set.request": "workspace.manage", + "project.list.request": "workspace.read", + "project.remove.request": "workspace.manage", + "project.rename.request": "workspace.manage", + project_icon_request: "workspace.read", + "provider.usage.list.request": "daemon.read", + provider_diagnostic_request: "daemon.read", + pull_request_timeline_request: "workspace.read", + "push.unregister.request": "workspace.read", + read_project_config_request: "workspace.read", + refresh_agent_request: "workspace.write", + refresh_providers_snapshot_request: "daemon.read", + register_push_token: "workspace.read", + restart_server_request: "daemon.manage", + resume_agent_request: "workspace.write", + "schedule/create": "automation.manage", + "schedule/delete": "automation.manage", + "schedule/inspect": "automation.manage", + "schedule/list": "automation.manage", + "schedule/logs": "automation.manage", + "schedule/pause": "automation.manage", + "schedule/resume": "automation.manage", + "schedule/run-once": "automation.manage", + "schedule/update": "automation.manage", + send_agent_message_request: "workspace.write", + set_agent_feature_request: "workspace.write", + set_agent_mode_request: "workspace.write", + set_agent_model_request: "workspace.write", + set_agent_thinking_request: "workspace.write", + set_daemon_config_request: "daemon.manage", + set_voice_mode: "workspace.write", + shutdown_server_request: "daemon.manage", + start_workspace_script_request: "workspace.write", + stash_list_request: "workspace.read", + stash_pop_request: "workspace.write", + stash_save_request: "workspace.write", + subscribe_checkout_diff_request: "workspace.read", + subscribe_terminal_request: "workspace.read", + subscribe_terminals_request: "workspace.read", + "terminal.rename.request": "workspace.write", + terminal_input: "workspace.write", + unsubscribe_checkout_diff_request: "workspace.read", + unsubscribe_terminal_request: "workspace.read", + unsubscribe_terminals_request: "workspace.read", + update_agent_request: "workspace.write", + validate_branch_request: "workspace.read", + voice_audio_chunk: "workspace.write", + wait_for_finish_request: "workspace.read", + "workspace.clear_attention.request": "workspace.write", + "workspace.create.request": "workspace.manage", + "workspace.github.search_repositories.request": "workspace.read", + "workspace.label.assignment.set.request": "workspace.manage", + "workspace.label.delete.inspect.request": "workspace.read", + "workspace.label.delete.request": "workspace.manage", + "workspace.label.list.request": "workspace.read", + "workspace.label.update.request": "workspace.manage", + "workspace.pin.set.request": "workspace.manage", + "workspace.recovery.inspect.request": "workspace.read", + "workspace.recovery.restore.request": "workspace.manage", + "workspace.script.list.request": "workspace.read", + "workspace.script.start.request": "workspace.write", + "workspace.script.stop.request": "workspace.write", + "workspace.title.set.request": "workspace.manage", + workspace_setup_status_request: "workspace.read", + write_project_config_request: "workspace.write", +} as const satisfies Record; + +const OUTBOUND_PERMISSION = { + activity_log: "workspace.read", + "agent.config.apply.response": "workspace.write", + "agent.detach.response": "workspace.write", + "agent.fork_context.response": "workspace.read", + "agent.provider_subagents.list.response": "workspace.read", + "agent.provider_subagents.timeline.get.response": "workspace.read", + "agent.provider_subagents.update": "workspace.read", + "agent.rewind.response": "workspace.write", + "agent.skills.get_status.response": "daemon.read", + "agent.skills.import_legacy_selection.response": "daemon.manage", + "agent.skills.reconcile.response": "daemon.manage", + "agent.skills.save_selection.response": "daemon.manage", + "agent.skills.uninstall.response": "daemon.manage", + "agent.timeline.list_prompts.response": "workspace.read", + "agent.timeline.replacement": "workspace.read", + "agent.timeline.set_subscription.response": "workspace.read", + agent_archived: "workspace.read", + agent_attention_required: "workspace.read", + agent_deleted: "workspace.read", + agent_permission_request: "workspace.read", + agent_permission_resolved: "workspace.write", + agent_status: "workspace.read", + agent_stream: "workspace.read", + agent_update: "workspace.read", + archive_workspace_response: "workspace.manage", + artifact: "workspace.read", + assistant_chunk: "workspace.read", + audio_output: "workspace.write", + branch_suggestions_response: "workspace.read", + "browser.automation.execute.request": "workspace.write", + cancel_agent_response: "workspace.write", + capture_terminal_response: "workspace.read", + "chat/create/response": "workspace.write", + "chat/delete/response": "workspace.write", + "chat/inspect/response": "workspace.read", + "chat/list/response": "workspace.read", + "chat/post/response": "workspace.write", + "chat/read/response": "workspace.read", + "chat/wait/response": "workspace.read", + "checkout.commits.file_diff.response": "workspace.read", + "checkout.commits.list.response": "workspace.read", + "checkout.discard_changes.response": "workspace.write", + "checkout.forge.get_check_details.response": "workspace.read", + "checkout.forge.set_auto_merge.response": "workspace.write", + "checkout.github.get_check_details.response": "workspace.read", + "checkout.github.set_auto_merge.response": "workspace.write", + "checkout.refresh.response": "workspace.read", + "checkout.rename_branch.response": "workspace.write", + checkout_commit_response: "workspace.write", + checkout_diff_update: "workspace.read", + checkout_merge_from_base_response: "workspace.write", + checkout_merge_response: "workspace.write", + checkout_pr_create_response: "workspace.write", + checkout_pr_merge_response: "workspace.write", + checkout_pr_status_response: "workspace.read", + checkout_pull_response: "workspace.write", + checkout_push_response: "workspace.write", + checkout_status_response: "workspace.read", + checkout_status_update: "workspace.read", + checkout_switch_branch_response: "workspace.write", + clear_agent_attention_response: "workspace.write", + close_items_response: "workspace.manage", + create_paseo_worktree_response: "workspace.manage", + create_terminal_response: "workspace.write", + "daemon.config.reload.response": "daemon.manage", + "daemon.get_pairing_offer.response": "access.manage", + "daemon.get_status.response": "daemon.read", + "daemon.update.progress": "daemon.manage", + "daemon.update.response": "daemon.manage", + "diagnostics.response": "daemon.read", + dictation_stream_ack: "workspace.write", + dictation_stream_error: "workspace.write", + dictation_stream_final: "workspace.write", + dictation_stream_finish_accepted: "workspace.write", + dictation_stream_partial: "workspace.write", + directory_suggestions_response: "workspace.read", + fetch_agent_history_response: "workspace.read", + fetch_agent_response: "workspace.read", + fetch_agent_timeline_response: "workspace.read", + fetch_agents_response: "workspace.read", + fetch_recent_provider_sessions_response: "workspace.read", + fetch_workspaces_response: "workspace.read", + "file.upload.response": "workspace.write", + file_download_token_response: "workspace.read", + file_explorer_response: "workspace.read", + "forge.search.response": "workspace.read", + "fs.entry.create.response": "workspace.write", + "fs.entry.delete.response": "workspace.write", + "fs.entry.duplicate.response": "workspace.write", + "fs.entry.rename.response": "workspace.write", + "fs.file.subscribe.response": "workspace.read", + "fs.file.unsubscribe.response": "workspace.read", + "fs.file.update": "workspace.read", + "fs.file.write.response": "workspace.write", + get_daemon_config_response: "daemon.read", + get_providers_snapshot_response: "daemon.read", + github_search_response: "workspace.read", + "hub.execution.agent.create.response": "hub.execute", + "hub.execution.agent.stream": "hub.execute", + "hub.execution.agent.update": "hub.execute", + "hub.execution.agent.validate.response": "hub.execute", + "hub.execution.control.response": "hub.execute", + "hub.management.daemon.connect.response": "tunnel.manage", + "hub.management.daemon.disconnect.response": "tunnel.manage", + "hub.management.daemon.get_status.response": "tunnel.manage", + kill_terminal_response: "workspace.write", + list_available_editors_response: "workspace.read", + list_available_providers_response: "daemon.read", + list_commands_response: "daemon.read", + list_provider_features_response: "daemon.read", + list_provider_models_response: "daemon.read", + list_provider_modes_response: "daemon.read", + list_terminals_response: "workspace.read", + "loop/inspect/response": "automation.manage", + "loop/list/response": "automation.manage", + "loop/logs/response": "automation.manage", + "loop/run/response": "automation.manage", + "loop/stop/response": "automation.manage", + open_in_editor_response: "workspace.read", + open_project_response: "workspace.manage", + paseo_worktree_archive_response: "workspace.manage", + paseo_worktree_list_response: "workspace.read", + "plugin.catalog.get.response": "daemon.manage", + "plugin.directory.inspect.response": "daemon.manage", + "plugin.directory.install.response": "daemon.manage", + "plugin.disable.response": "daemon.manage", + "plugin.enable.response": "daemon.manage", + "plugin.list.response": "daemon.manage", + "plugin.logs.get.response": "daemon.manage", + "plugin.reload.response": "daemon.manage", + "plugin.remove.response": "daemon.manage", + "plugin.rpc.invoke.response": "daemon.manage", + "plugin.source.install.response": "daemon.manage", + "plugin.source.status.response": "daemon.manage", + "plugin.source.update.response": "daemon.manage", + pong: "daemon.read", + "project.add.response": "workspace.manage", + "project.create_directory.response": "workspace.manage", + "project.github.clone.response": "workspace.manage", + "project.icon.get.response": "workspace.read", + "project.icon.set.response": "workspace.manage", + "project.list.response": "workspace.read", + "project.remove.response": "workspace.manage", + "project.rename.response": "workspace.manage", + "project.update": "workspace.read", + project_icon_response: "workspace.read", + "provider.usage.list.response": "daemon.read", + provider_diagnostic_response: "daemon.read", + providers_snapshot_update: "daemon.read", + pull_request_timeline_response: "workspace.read", + "push.unregister.response": "workspace.read", + read_project_config_response: "workspace.read", + refresh_providers_snapshot_response: "daemon.read", + rpc_error: null, + "schedule/create/response": "automation.manage", + "schedule/delete/response": "automation.manage", + "schedule/inspect/response": "automation.manage", + "schedule/list/response": "automation.manage", + "schedule/logs/response": "automation.manage", + "schedule/pause/response": "automation.manage", + "schedule/resume/response": "automation.manage", + "schedule/run-once/response": "automation.manage", + "schedule/update/response": "automation.manage", + script_status_update: "workspace.read", + send_agent_message_response: "workspace.write", + set_agent_feature_response: "workspace.write", + set_agent_mode_response: "workspace.write", + set_agent_model_response: "workspace.write", + set_agent_thinking_response: "workspace.write", + set_daemon_config_response: "daemon.manage", + set_voice_mode_response: "workspace.write", + start_workspace_script_response: "workspace.write", + stash_list_response: "workspace.read", + stash_pop_response: "workspace.read", + stash_save_response: "workspace.read", + status: "daemon.read", + subscribe_checkout_diff_response: "workspace.read", + subscribe_terminal_response: "workspace.read", + "terminal.rename.response": "workspace.write", + terminal_attention_required: "workspace.read", + terminal_stream_exit: "workspace.read", + terminals_changed: "workspace.read", + transcription_result: "workspace.write", + update_agent_response: "workspace.write", + validate_branch_response: "workspace.read", + voice_input_state: "workspace.write", + wait_for_finish_response: "workspace.read", + "workspace.clear_attention.response": "workspace.write", + "workspace.create.response": "workspace.manage", + "workspace.github.search_repositories.response": "workspace.read", + "workspace.label.assignment.set.response": "workspace.manage", + "workspace.label.delete.inspect.response": "workspace.read", + "workspace.label.delete.response": "workspace.manage", + "workspace.label.list.response": "workspace.read", + "workspace.label.update": "workspace.read", + "workspace.label.update.response": "workspace.manage", + "workspace.pin.set.response": "workspace.manage", + "workspace.recovery.inspect.response": "workspace.read", + "workspace.recovery.restore.response": "workspace.manage", + "workspace.script.list.response": "workspace.read", + "workspace.script.start.response": "workspace.write", + "workspace.script.stop.response": "workspace.write", + "workspace.title.set.response": "workspace.manage", + workspace_setup_progress: "workspace.read", + workspace_setup_status_response: "workspace.read", + workspace_update: "workspace.read", + write_project_config_response: "workspace.write", +} as const satisfies Record; + +export function requiredPermissionForInbound(operation: InboundOperation): DaemonPermission | null { + return INBOUND_PERMISSION[operation]; +} + +export function requiredPermissionForOutbound( + operation: OutboundOperation, +): DaemonPermission | null { + return OUTBOUND_PERMISSION[operation]; +} diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 54e4a9e1e5..587764dc2d 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -18,7 +18,8 @@ import { FileTransferOpcode, type FileTransferFrame, } from "@getpaseo/protocol/binary-frames/index"; -import { isSessionRpcAllowed, Session } from "./session.js"; +import { Session } from "./session.js"; +import { OWNER_PERMISSIONS, type DaemonPermission } from "./authorization/index.js"; import { DownloadTokenStore } from "./file-download/token-store.js"; import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js"; import type { StoredAgentRecord } from "./agent/agent-storage.js"; @@ -281,7 +282,7 @@ vi.mock("./worktree-bootstrap.js", async (importOriginal) => { }); interface SessionForTestOptions { - scopes?: readonly string[]; + permissions?: readonly DaemonPermission[]; agentManager?: { [K in keyof SessionOptions["agentManager"]]?: unknown }; agentStorage?: { [K in keyof SessionOptions["agentStorage"]]?: unknown }; github?: Partial; @@ -426,7 +427,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { serverId: options.serverId, daemonVersion: options.daemonVersion, daemonRuntimeConfig: options.daemonRuntimeConfig, - scopes: options.scopes ?? ["*"], + permissions: options.permissions ?? OWNER_PERMISSIONS, }; return new Session(sessionOptions); } @@ -721,7 +722,7 @@ describe("workspace label editing", () => { }); }); -describe("session authorization scopes", () => { +describe("session authorization permissions", () => { test("routes named-agent validation through the session source", async () => { const messages: SessionOutboundMessage[] = []; const providers = createProviderSnapshotManagerStub(); @@ -760,10 +761,10 @@ describe("session authorization scopes", () => { }); }); - test("rejects an RPC outside an exact grant with the generic RPC error", async () => { + test("rejects an operation without its semantic permission", async () => { const messages: SessionOutboundMessage[] = []; const session = createSessionForTest({ - scopes: ["hub.execution.agent.create.request"], + permissions: ["hub.execute"], messages, }); @@ -782,32 +783,16 @@ describe("session authorization scopes", () => { ]); }); - test.each([ - ["*", "ping"], - ["hub.execution.*", "hub.execution.agent.create.request"], - ["hub.execution.agent.create.request", "hub.execution.agent.create.request"], - ])("scope %s authorizes %s", (scope, requestType) => { - expect(isSessionRpcAllowed([scope], requestType)).toBe(true); - }); - - test.each([ - ["hub.execution.*", "hub.management.daemon.get_status.request"], - ["hub.execution.agent.create.request", "hub.execution.agent.update"], - ["hub.execution.*", "hub.executions.agent.create.request"], - ])("scope %s rejects %s", (scope, requestType) => { - expect(isSessionRpcAllowed([scope], requestType)).toBe(false); - }); - - test("replaces a session's scopes without reconstructing the session", async () => { + test("replaces a session's permissions without reconstructing the session", async () => { const messages: SessionOutboundMessage[] = []; - const session = createSessionForTest({ scopes: ["hub.execution.*"], messages }); + const session = createSessionForTest({ permissions: ["hub.execute"], messages }); await session.handleMessage({ type: "ping", requestId: "before-scope-change", clientSentAt: 1, }); - session.setScopes(["*"]); + session.setPermissions(["daemon.read"]); await session.handleMessage({ type: "ping", requestId: "after-scope-change", clientSentAt: 2 }); expect(messages).toEqual([ diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 5a8490c27c..8466cf027e 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -244,6 +244,7 @@ import { } from "./worktree-session.js"; import { archiveByScope, type ActiveWorkspaceRef } from "./workspace-archive-service.js"; import { WorkspaceSetupRuntime } from "./workspace-setup-runtime.js"; +import { SessionAuthorization, type DaemonPermission } from "./authorization/index.js"; function resolveWorkspaceSetupRuntime( runtime: WorkspaceSetupRuntime | undefined, @@ -434,7 +435,7 @@ type AgentMcpTransportFactory = () => Promise; export interface SessionOptions { clientId: string; - scopes: readonly string[]; + permissions: readonly DaemonPermission[]; appVersion?: string | null; clientCapabilities?: Record | null; onMessage: (msg: SessionOutboundMessage) => void; @@ -568,18 +569,6 @@ function parseClientCapabilities( return new Set(result); } -export function isSessionRpcAllowed(scopes: readonly string[], rpcName: string): boolean { - return scopes.some((scope) => { - if (scope === "*" || scope === rpcName) { - return true; - } - if (!scope.endsWith(".*")) { - return false; - } - return rpcName.startsWith(scope.slice(0, -1)); - }); -} - function sessionRequestId(message: SessionInboundMessage): string | null { if ("requestId" in message && typeof message.requestId === "string") { return message.requestId; @@ -652,7 +641,7 @@ function workspaceLabelErrorCode(error: unknown): string { export class Session { private readonly clientId: string; - private scopes: readonly string[]; + private readonly authorization: SessionAuthorization; private appVersion: string | null; private clientCapabilities: ReadonlySet; private readonly sessionId: string; @@ -753,7 +742,7 @@ export class Session { constructor(options: SessionOptions) { const { clientId, - scopes, + permissions, appVersion, clientCapabilities, onMessage, @@ -808,7 +797,7 @@ export class Session { getWebSocketRuntimeMetrics, } = options; this.clientId = clientId; - this.scopes = [...scopes]; + this.authorization = new SessionAuthorization(permissions); this.appVersion = appVersion ?? null; this.clientCapabilities = parseClientCapabilities(clientCapabilities); this.sessionId = uuidv4(); @@ -1864,7 +1853,7 @@ export class Session { }, "agent.session.inbound", ); - if (!isSessionRpcAllowed(this.scopes, msg.type)) { + if (!this.authorization.allowsInbound(msg)) { const requestId = sessionRequestId(msg); if (requestId) { this.emit({ @@ -1918,8 +1907,8 @@ export class Session { } } - public setScopes(scopes: readonly string[]): void { - this.scopes = [...scopes]; + public setPermissions(permissions: readonly DaemonPermission[]): void { + this.authorization.replacePermissions(permissions); } private async dispatchInboundMessage(msg: SessionInboundMessage, source?: object): Promise { @@ -7515,7 +7504,7 @@ export class Session { * Emit a message to the client */ private emit(msg: SessionOutboundMessage): void { - if (msg.type !== "rpc_error" && !isSessionRpcAllowed(this.scopes, msg.type)) { + if (!this.authorization.allowsOutbound(msg)) { return; } // JSON.stringify(msg) is only computed when trace is enabled — it runs for diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index f3ae292f39..61dce39c5f 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -4,6 +4,7 @@ import type pino from "pino"; import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js"; import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js"; import { Session, type SessionOptions } from "./session.js"; +import { OWNER_PERMISSIONS } from "./authorization/index.js"; import { asInternals, createStub } from "./test-utils/class-mocks.js"; import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; import { createTestLogger } from "../test-utils/test-logger.js"; @@ -191,7 +192,7 @@ function createSessionForWorkspaceGitWatchTests(options?: { const session = new Session({ clientId: "test-client", - scopes: ["*"], + permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message as { type: string; payload: unknown }), logger: createStub(logger), downloadTokenStore: createStub({}), diff --git a/packages/server/src/server/session.workspace-resolution-invariants.test.ts b/packages/server/src/server/session.workspace-resolution-invariants.test.ts index 911890fdc8..b25912beb6 100644 --- a/packages/server/src/server/session.workspace-resolution-invariants.test.ts +++ b/packages/server/src/server/session.workspace-resolution-invariants.test.ts @@ -9,6 +9,7 @@ import path from "node:path"; import { expect, test, vi } from "vitest"; import { Session, type SessionOptions } from "./session.js"; +import { OWNER_PERMISSIONS } from "./authorization/index.js"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js"; import { asInternals, createStub } from "./test-utils/class-mocks.js"; @@ -88,7 +89,7 @@ function createHarness(input: { const session = new Session({ clientId: "test", - scopes: ["*"], + permissions: OWNER_PERMISSIONS, appVersion: null, onMessage: (m) => emitted.push(m), logger: createStub(logger), diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index d72e0b7993..9e107a0358 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -18,6 +18,7 @@ import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; import { createTestLogger } from "../test-utils/test-logger.js"; import { Session } from "./session.js"; import type { SessionOptions } from "./session.js"; +import { OWNER_PERMISSIONS } from "./authorization/index.js"; import type { AgentUpdatesService } from "./session/agent-updates/agent-updates-service.js"; import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages"; import type { TerminalManager } from "../terminal/terminal-manager.js"; @@ -633,7 +634,7 @@ function createSessionForWorkspaceTests( const session = asTestSession( new Session({ clientId: "test-client", - scopes: ["*"], + permissions: OWNER_PERMISSIONS, appVersion: options.appVersion ?? null, onMessage: options.onMessage ?? vi.fn(), onWorkspaceRecovered: options.onWorkspaceRecovered, @@ -990,7 +991,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist new Session({ clientId: "test-client", serverId: "test-server", - scopes: ["*"], + permissions: OWNER_PERMISSIONS, appVersion: null, onMessage: (message) => emitted.push(message), logger: asSessionLogger(logger), @@ -1142,7 +1143,7 @@ test("create_agent_request launches from an exact subdirectory in a created work const emitted: SessionOutboundMessage[] = []; const session = new Session({ clientId: "test-client", - scopes: ["*"], + permissions: OWNER_PERMISSIONS, appVersion: null, onMessage: (message) => emitted.push(message), logger: asSessionLogger(logger), @@ -1279,7 +1280,7 @@ test("create_agent_request does not title an existing workspace from the agent p const session = asTestSession( new Session({ clientId: "test-client", - scopes: ["*"], + permissions: OWNER_PERMISSIONS, appVersion: null, onMessage: vi.fn(), logger: asSessionLogger(logger), @@ -1548,7 +1549,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" const session = asTestSession( new Session({ clientId: "test-client", - scopes: ["*"], + permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), logger: asSessionLogger(logger), downloadTokenStore: asDownloadTokenStore(), @@ -1913,7 +1914,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy const session = asTestSession( new Session({ clientId: "test-client", - scopes: ["*"], + permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), logger: asSessionLogger(sessionLogger), downloadTokenStore: asDownloadTokenStore(), @@ -2081,7 +2082,7 @@ test("close_items_request archives stored agents that are not currently loaded", const session = asTestSession( new Session({ clientId: "test-client", - scopes: ["*"], + permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), logger: asSessionLogger(sessionLogger), downloadTokenStore: asDownloadTokenStore(), @@ -2240,7 +2241,7 @@ test("close_items_request continues after an archive failure", async () => { const session = asTestSession( new Session({ clientId: "test-client", - scopes: ["*"], + permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), logger: asSessionLogger(sessionLogger), downloadTokenStore: asDownloadTokenStore(), @@ -3501,7 +3502,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto const session = asTestSession( new Session({ clientId: "test-client", - scopes: ["*"], + permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), logger: asSessionLogger(logger), downloadTokenStore: asDownloadTokenStore(), diff --git a/packages/server/src/server/snapshot-mutation-ownership.test.ts b/packages/server/src/server/snapshot-mutation-ownership.test.ts index 126f06678b..b08ca5e6d0 100644 --- a/packages/server/src/server/snapshot-mutation-ownership.test.ts +++ b/packages/server/src/server/snapshot-mutation-ownership.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { Session } from "./session.js"; import type { SessionOptions } from "./session.js"; +import { OWNER_PERMISSIONS } from "./authorization/index.js"; import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; import { asInternals, createStub } from "./test-utils/class-mocks.js"; import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; @@ -99,7 +100,7 @@ describe("snapshot mutation ownership boundary", () => { const session = asInternals( new Session({ clientId: "test-client", - scopes: ["*"], + permissions: OWNER_PERMISSIONS, onMessage, logger: createStub(logger), downloadTokenStore: createStub({}), diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 81c8ff9b5f..008ec4e1f9 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -98,6 +98,11 @@ import { import type { BrowserToolsBroker } from "./browser-tools/broker.js"; import type { DaemonRuntimeConfig } from "./session/daemon/daemon-session.js"; import { DirectorySyncService } from "./directory-sync/index.js"; +import { + OWNER_PERMISSIONS, + permissionsForLegacyHubScopes, + type DaemonPermission, +} from "./authorization/index.js"; import type { WorkspaceLabelService } from "./workspace-labels/index.js"; import { APPLICATION_SOCKET_LEASE_CHECK_INTERVAL_MS, @@ -479,7 +484,7 @@ interface SocketSessionOptions { clientId: string; appVersion: string | null; clientCapabilities: Record | null; - scopes: readonly string[]; + permissions: readonly DaemonPermission[]; connectionLogger: pino.Logger; onMessage: (message: SessionOutboundMessage) => void; onMessageToSource?: (source: object, message: SessionOutboundMessage) => void; @@ -1017,7 +1022,7 @@ export class VoiceAssistantWebSocketServer { clientId: `hub:${options.daemonId}`, appVersion: null, clientCapabilities: null, - scopes: options.scopes, + permissions: permissionsForLegacyHubScopes(options.scopes), connectionLogger, onMessage: (message) => this.sendToClient(ws, wrapSessionMessage(message)), hubExecutionAgents: options.agents, @@ -1339,7 +1344,7 @@ export class VoiceAssistantWebSocketServer { clientId, appVersion, clientCapabilities, - scopes: ["*"], + permissions: OWNER_PERMISSIONS, connectionLogger, onMessage: (msg) => { if (!connection) { @@ -1409,7 +1414,7 @@ export class VoiceAssistantWebSocketServer { clientId: options.clientId, appVersion: options.appVersion, clientCapabilities: options.clientCapabilities, - scopes: options.scopes, + permissions: options.permissions, onMessage: options.onMessage, onMessageToSource: options.onMessageToSource, onBinaryMessage: options.onBinaryMessage, diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index c15f40965f..08cba47149 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -11,6 +11,7 @@ import { type SessionOutboundMessage, } from "@getpaseo/protocol/messages"; import { Session, type SessionOptions } from "./session.js"; +import { OWNER_PERMISSIONS } from "./authorization/index.js"; import { DirectorySyncService } from "./directory-sync/index.js"; import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; import type { AgentTimelineRow } from "./agent/agent-manager.js"; @@ -206,7 +207,7 @@ function createSessionForWireCompatTest(options?: { const session = new Session({ clientId: "wire-compat-client", - scopes: ["*"], + permissions: OWNER_PERMISSIONS, clientCapabilities: options?.clientCapabilities ?? null, onMessage: (message) => messages.push(message), logger: pino({ level: "silent" }),