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.
This commit is contained in:
Mohamed Boudra
2026-08-29 22:40:20 +02:00
committed by GitHub
parent a88aa49f4c
commit 63587a9c03
14 changed files with 658 additions and 66 deletions
+1
View File
@@ -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 |
+4 -4
View File
@@ -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
+59
View File
@@ -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.
@@ -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);
});
});
@@ -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<DaemonPermission>;
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"] : [];
}
@@ -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<InboundOperation, DaemonPermission | null>;
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<OutboundOperation, DaemonPermission | null>;
export function requiredPermissionForInbound(operation: InboundOperation): DaemonPermission | null {
return INBOUND_PERMISSION[operation];
}
export function requiredPermissionForOutbound(
operation: OutboundOperation,
): DaemonPermission | null {
return OUTBOUND_PERMISSION[operation];
}
+10 -25
View File
@@ -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<ForgeService & GitHubService>;
@@ -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([
+9 -20
View File
@@ -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<unknown>;
export interface SessionOptions {
clientId: string;
scopes: readonly string[];
permissions: readonly DaemonPermission[];
appVersion?: string | null;
clientCapabilities?: Record<string, unknown> | 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<ClientCapability>;
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<void> {
@@ -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
@@ -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<pino.Logger>(logger),
downloadTokenStore: createStub<SessionOptions["downloadTokenStore"]>({}),
@@ -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<SessionOptions["logger"]>(logger),
@@ -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(),
@@ -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<SessionInternals>(
new Session({
clientId: "test-client",
scopes: ["*"],
permissions: OWNER_PERMISSIONS,
onMessage,
logger: createStub<SessionOptions["logger"]>(logger),
downloadTokenStore: createStub<SessionOptions["downloadTokenStore"]>({}),
@@ -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<string, unknown> | 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,
@@ -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" }),