@addozhang/dsh-discord 0.5.0-rc.2 → 0.5.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,6 +23,7 @@ No extra process: the adapter is a DSH plugin that mounts straight onto your `ds
23
23
  - **Approvals & questions** — DSH asks become buttons, select menus, and a free-text modal. Ownership is enforced (the asker — or the thread owner on later turns — clicks), expiry sweeps fail closed, and settled controls grey out in place.
24
24
  - **Session control** — `/steer`, `/stop`, and `/queue list|remove` with turn-ownership checks; `/project bind|list|info` and `/session resume` for guild↔workspace binding and history; `/guild forget` for operator cleanup.
25
25
  - **Model selection** — `/model show` reads the session's live model directory; `/model select` walks an interactive provider → model → reasoning cascade, or applies a typed `provider/model` directly. Open to any authorized member by default; restrictable to Host operators.
26
+ - **Permission switching** — `/permission show` / `set` read and switch the session's permission preset (bundled sandbox + approval tiers) through the Host's own `/permission` command path, inheriting its journal audit pair; `danger-full-access` requires a button confirmation.
26
27
  - **Settings card, bilingual out of the box** — token onboarding and connect/disconnect (stored in the Host credential service, never in settings or logs), guild allowlist, auto-archive, and language. Every Discord-visible string ships in Chinese and English; the bot follows the DSH language preference or a pinned choice.
27
28
  - **Hardened by design** — deny-first authorization inside an explicit guild allowlist. Mentions are suppressed twice: `allowed_mentions` on every request, plus byte-level neutralization of the wire body. DSH submission is at-most-once with unknown-preserving reconciliation — an ambiguous delivery is never blindly resent. Bindings survive restarts, and the READY sweep rebuilds deleted category/control channels while treating a deleted workspace channel as user intent (the mapping retires; the workspace stays bindable).
28
29
 
@@ -70,6 +71,7 @@ All keys live in the `dsh-discord` settings namespace and can be set either from
70
71
  | `deniedUserIds` / `deniedRoleIds` | `[]` | Deny entries; they win over every grant above. |
71
72
  | `hostOperatorUserIds` | `[]` | Host operators (`/guild forget`; `/model select` when `modelSelectOperatorOnly` is enabled). |
72
73
  | `modelSelectOperatorOnly` | `false` | Restrict `/model select` to Host operators (`settings.yaml` only; the card does not expose it). Default `false`: any authorized member may switch, and the switch still updates the Host default. |
74
+ | `permissionSelectOperatorOnly` | `true` | Restrict `/permission set` to Host operators (`settings.yaml` only). Default `true` — stricter than `/model` because `danger-full-access` disables both sandbox and approval; loosen to let any authorized member switch. |
73
75
  | `defaultVerbosity` | `essential-tools` | Tool-activity row granularity: `text-only`, `essential-tools`, or `full-tools`. |
74
76
  | `language` | `auto` | Bot-visible copy language: `auto` follows the DSH language preference (non-Chinese renders English), or pin `zh`/`en`. |
75
77
  | `streamUpdateIntervalMs` | `800` | Coalescing budget for stream edits (250–10000). |
@@ -110,10 +112,11 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
110
112
  | `/model show` / `select` | session thread | show the live model directory; `select` without arguments walks the interactive provider → model → reasoning cascade (any authorized member by default) |
111
113
  | `/session resume` | project channel | pick one of this workspace's past sessions (autocomplete: title and age, newest first) and resume it into a new thread of this channel; blank, already-bound, subagent, and archived sessions are never offered. The thread renders the session's history — answers and tool summaries plus the user's inputs as quoted echo lines (plugin/system injections are never echoed). After an adapter restart, threads catch up only what they missed: a persisted render watermark suppresses already-delivered history, and bound sessions re-subscribe on reconnect so turns prompted from the web UI still land in their thread |
112
114
  | `/guild forget` | any channel | operator-only removal of adapter records |
115
+ | `/permission show` / `set` | session thread | read or switch the session's permission preset (view only / workspace edit / full access — bundled sandbox + approval tiers, catalog read live from the Host). `set danger-full-access` asks for a button confirmation first; presets autocomplete; Host operators only by default (`permissionSelectOperatorOnly` can loosen it). Every switch — from Discord or the web UI — renders one system line in the thread |
113
116
 
114
117
  ## Design notes
115
118
 
116
- - The adapter is a function/namespace plugin (`inject: ['sessionController', 'workspaceController', 'sessionQuery', 'webServer', 'credentials', 'settings', 'storageDomain', 'connection']`) that mounts the Discord Gateway, command surface, stream renderer, and the settings card onto the DSH web profile.
119
+ - The adapter is a function/namespace plugin (`inject: ['sessionController', 'workspaceController', 'sessionQuery', 'webServer', 'credentials', 'settings', 'storageDomain', 'connection', 'commands', 'permissionPresets']`) that mounts the Discord Gateway, command surface, stream renderer, and the settings card onto the DSH web profile.
117
120
  - The settings card is the first-run onboarding surface: the token entry writes the credential service's `DSH_DISCORD_BOT_TOKEN` ref over the plugin management channel, then triggers the start chain. Disconnect keeps the credential; an empty reconnect uses it.
118
121
  - The publish workflow authenticates to npm via trusted publishing (OIDC) — no publish token is stored anywhere.
119
122
  - The adapter start chain is generation-counted, so Connect/Disconnect races with the initial boot yield exactly one gateway.
package/README.zh.md CHANGED
@@ -23,6 +23,7 @@
23
23
  - **审批与提问** — DSH 的 ask 渲染为按钮、下拉菜单与自由文本弹窗。所有权强制校验(提问者——或后续 Turn 的线程属主——才能点击),超时清扫 fail-closed,结算后的控件原地置灰。
24
24
  - **会话控制** — `/steer`、`/stop`、`/queue list|remove` 带运行所有权校验;`/project bind|list|info` 与 `/session resume` 管理 Guild↔工作区绑定与历史会话;`/guild forget` 供操作员清理。
25
25
  - **模型切换** — `/model show` 读取会话的实时模型目录;`/model select` 走交互式 provider → 模型 → 推理强度级联,也可直接填写 `provider/model` 应用。默认对所有授权成员开放,可收紧为仅 Host 操作员。
26
+ - **权限切换** — `/permission show` / `set` 读写会话权限预设(沙箱 + 审批捆绑档位),走宿主自己的 `/permission` 命令路径,journal 自动记审计;`danger-full-access` 需按钮二次确认。
26
27
  - **设置卡片,开箱双语** — Token 引导与连接/断开(存入 Host 凭据服务,绝不写入设置或日志)、服务器白名单、自动归档与语言。所有 Discord 可见文案提供中英双语;Bot 默认跟随 DSH 语言偏好,也可从卡片固定。
27
28
  - **安全设计** — 显式服务器白名单内的 deny-first 授权。提及抑制双保险:每条请求携带 `allowed_mentions`,外加 wire body 的字节级提及中和。DSH 提交至多一次,并做保留 unknown 的对账——结果不明的投递绝不盲目重发。绑定重启后持久;READY 扫描会重建被删除的 category/控制频道,而把被删除的工作区频道视为用户意图(解除映射,workspace 保持可重新绑定)。
28
29
 
@@ -70,6 +71,7 @@ dsh plugin --profile web rm @addozhang/dsh-discord # 卸载;先执行 /guild
70
71
  | `deniedUserIds` / `deniedRoleIds` | `[]` | 拒绝名单;优先级高于上述一切授权。 |
71
72
  | `hostOperatorUserIds` | `[]` | Host 操作员(`/guild forget`;启用 `modelSelectOperatorOnly` 后也包括 `/model select`)。 |
72
73
  | `modelSelectOperatorOnly` | `false` | 将 `/model select` 限制为 Host 操作员(仅 `settings.yaml`,卡片不展示)。默认 `false`:任何授权成员均可切换,且切换仍会更新 Host 默认。 |
74
+ | `permissionSelectOperatorOnly` | `true` | 将 `/permission set` 限制为 Host 操作员(仅 `settings.yaml`)。默认 `true`——比 `/model` 更严,因为「完全权限」会同时解除沙箱与审批;可放开为任何授权成员可切换。 |
73
75
  | `defaultVerbosity` | `essential-tools` | 工具活动行粒度:`text-only`、`essential-tools`、`full-tools`。 |
74
76
  | `language` | `auto` | Bot 可见文案语言:`auto` 跟随 DSH 语言偏好(非中文渲染英文),或固定 `zh`/`en`。 |
75
77
  | `streamUpdateIntervalMs` | `800` | 流式编辑合并间隔(250–10000)。 |
@@ -110,11 +112,12 @@ dsh-discord:
110
112
  | `/model show` / `select` | 会话线程 | 查看实时模型目录;`select` 不带参数时走交互式 provider → 模型 → 推理强度级联(默认对所有授权成员开放) |
111
113
  | `/session resume` | 项目频道 | 自动补全选择本工作区的历史会话(显示标题与时间,最新优先),恢复为当前频道的新线程;空白、已挂线程、subagent、已归档的会话不会出现。线程会渲染会话历史——回答与工具摘要之外,用户输入以引用行回显(插件/系统注入永不回显)。适配器重启后线程只补齐错过部分:持久化的渲染水位抑制已投递过的历史,已绑定会话在重连时自动重订阅,因此从 web UI 发起的回合也会落进对应线程 |
112
114
  | `/guild forget` | 任意频道 | 仅操作员:移除适配器记录 |
115
+ | `/permission show` / `set` | 会话线程 | 查看或切换会话权限预设(仅可查看 / 工作区内修改 / 完全权限——沙箱与审批的捆绑档位,目录取自宿主实时配置)。`set danger-full-access` 先要求按钮确认;预设自动补全;默认仅 Host 操作员可切换(`permissionSelectOperatorOnly` 可放开)。任何入口(含 web UI)的切换都会在线程渲染一行系统消息 |
113
116
 
114
117
 
115
118
  ## 设计说明
116
119
 
117
- - 适配器为 function/namespace 插件(`inject: ['sessionController', 'workspaceController', 'sessionQuery', 'webServer', 'credentials', 'settings', 'storageDomain', 'connection']`),将 Discord Gateway、命令面、流式渲染器与设置卡片挂载到 DSH web profile。
120
+ - 适配器为 function/namespace 插件(`inject: ['sessionController', 'workspaceController', 'sessionQuery', 'webServer', 'credentials', 'settings', 'storageDomain', 'connection', 'commands', 'permissionPresets']`),将 Discord Gateway、命令面、流式渲染器与设置卡片挂载到 DSH web profile。
118
121
  - 设置卡片是首次使用的引导面:Token 输入通过插件管理通道写入凭据服务的 `DSH_DISCORD_BOT_TOKEN` 引用,然后触发启动链。断开连接保留凭据;留空重连直接使用已存 Token。
119
122
  - 发布工作流通过 npm trusted publishing (OIDC) 认证——任何地方都不保存发布凭证。
120
123
  - 适配器启动链带代际计数,Connect/Disconnect 与初始启动竞争时只会产生一个 Gateway。
package/cordis.patch.yml CHANGED
@@ -11,4 +11,4 @@
11
11
  - insert:
12
12
  - id: dsh-discord
13
13
  name: '@addozhang/dsh-discord'
14
- inject: [sessionController, workspaceController, sessionQuery, webServer, credentials, settings, storageDomain, connection]
14
+ inject: [sessionController, workspaceController, sessionQuery, webServer, credentials, settings, storageDomain, connection, commands, permissionPresets]
@@ -44,6 +44,12 @@ export const MILESTONE_ONE_COMMANDS = [
44
44
  grouped('guild', 'Guild-scoped adapter operations', [
45
45
  { name: 'forget' },
46
46
  ]),
47
+ grouped('permission', 'Show or switch the session permission preset', [
48
+ { name: 'show' },
49
+ // The preset autocompletes from the live Host catalog (16.61);
50
+ // danger-full-access goes through a button confirmation first.
51
+ { name: 'set', options: [{ name: 'preset', required: true, autocomplete: true }] },
52
+ ]),
47
53
  ];
48
54
  const DISCORD_APPLICATION_COMMAND = 1;
49
55
  const DISCORD_SUBCOMMAND = 1;
@@ -19,6 +19,7 @@
19
19
  import type { ProjectListPort } from '../features/project-list.js';
20
20
  import type { WorkspaceResolver } from '../features/project-bind.js';
21
21
  import type { DshModelPort } from '../features/model-control.js';
22
+ import type { DshPermissionPort } from '../features/permission-control.js';
22
23
  /** The workspace rows the catalog port needs (subset of WorkspaceView). */
23
24
  export interface WorkspaceCatalogEntry {
24
25
  workspaceId: string;
@@ -93,6 +94,18 @@ export interface DshSessionControllerFace {
93
94
  }, signal: AbortSignal): AsyncIterable<unknown>;
94
95
  /** Host-wide live state stream (queue/jobs/projection frames over one baseline). */
95
96
  control(signal: AbortSignal): AsyncIterable<unknown>;
97
+ /**
98
+ * Resolve or resume one Session's live Agent (0.1.6 probe-verified shape
99
+ * `{agent} | {error}`); the agent object stays opaque — it exists only to
100
+ * feed `commands.execute` for the Host-native `/permission` command path.
101
+ */
102
+ resolveAgent(sessionId: string): Promise<{
103
+ agent?: unknown;
104
+ error?: {
105
+ code?: string;
106
+ message?: string;
107
+ };
108
+ }>;
96
109
  }
97
110
  /**
98
111
  * Narrow slice of the 0.1.6 `workspaceController` cordis service. The
@@ -118,6 +131,30 @@ export interface DshSessionQueryFace {
118
131
  };
119
132
  } & Partial<AsyncDisposable>>;
120
133
  }
134
+ /**
135
+ * Narrow slice of the `commands` cordis service (dsh-commands). `execute`
136
+ * runs one registered slash command against an exact agent WITHOUT sending
137
+ * it to the model, logging the command/run + command/done journal pair; the
138
+ * signal is owned by the caller. Signatures 2026-09-19 real-host verified.
139
+ */
140
+ export interface DshCommandsFace {
141
+ execute(agent: unknown, line: string, submittedAttachments: readonly unknown[], signal: AbortSignal): Promise<{
142
+ commandId: string;
143
+ result?: {
144
+ kind?: unknown;
145
+ text?: unknown;
146
+ };
147
+ } | undefined>;
148
+ }
149
+ /**
150
+ * Narrow slice of the `permissionPresets` cordis service
151
+ * (dsh-permission-presets): the deployment's configured preset table.
152
+ */
153
+ export interface DshPermissionPresetsFace {
154
+ catalog(): Promise<{
155
+ options?: unknown;
156
+ }>;
157
+ }
121
158
  /** The host-generation model catalog (`session/modelCatalog`, host-wide). */
122
159
  export interface ModelCatalogWireShape {
123
160
  default: ModelSelectionShape;
@@ -134,6 +171,8 @@ export interface DshHostFace {
134
171
  session: DshSessionControllerFace;
135
172
  workspace: DshWorkspaceControllerFace;
136
173
  sessionQuery: DshSessionQueryFace;
174
+ commands: DshCommandsFace;
175
+ permissionPresets: DshPermissionPresetsFace;
137
176
  }
138
177
  /**
139
178
  * Resolve the 0.1.6 controller services off the Cordis context. Throws one
@@ -416,3 +455,37 @@ export declare function selectSessionModel(dsh: DshHostFace, request: {
416
455
  * reasons about.
417
456
  */
418
457
  export declare function createModelPort(dsh: DshHostFace, options?: ApiProxyFaceOptions): DshModelPort;
458
+ /** One normalized preset row off the catalog's `options` array. */
459
+ export interface PermissionCatalogEntry {
460
+ value: string;
461
+ name?: string;
462
+ }
463
+ export type PermissionCatalogOutcome = {
464
+ outcome: 'completed';
465
+ entries: PermissionCatalogEntry[];
466
+ } | {
467
+ outcome: 'failed';
468
+ };
469
+ /** Read the deployment's preset table (`permissionPresets.catalog`). */
470
+ export declare function permissionCatalog(dsh: DshHostFace, options?: ApiProxyFaceOptions): Promise<PermissionCatalogOutcome>;
471
+ export type SwitchSessionPermissionOutcome = {
472
+ outcome: 'completed';
473
+ preset: string;
474
+ } | {
475
+ outcome: 'rejected';
476
+ reason: string;
477
+ } | {
478
+ outcome: 'unknown';
479
+ };
480
+ /**
481
+ * Switch the session's preset through the Host's OWN `/permission` command
482
+ * path (resolveAgent → commands.execute) — the same entry the web UI uses,
483
+ * inheriting its journal audit pair and admission checks. An execute throw
484
+ * is unknown (the command may have run): callers never retry blindly.
485
+ */
486
+ export declare function switchSessionPermission(dsh: DshHostFace, request: {
487
+ sessionId: string;
488
+ preset: string;
489
+ }, options?: ApiProxyFaceOptions): Promise<SwitchSessionPermissionOutcome>;
490
+ /** The /permission surface over the controller, command, and preset services. */
491
+ export declare function createPermissionPort(dsh: DshHostFace, options?: ApiProxyFaceOptions): DshPermissionPort;
@@ -24,7 +24,7 @@ import { parseWorkspaceReference } from '../policy/disclosure.js';
24
24
  */
25
25
  export function resolveHostFace(ctx) {
26
26
  const missing = [];
27
- for (const name of ['sessionController', 'workspaceController', 'sessionQuery']) {
27
+ for (const name of ['sessionController', 'workspaceController', 'sessionQuery', 'commands', 'permissionPresets']) {
28
28
  if (ctx.get(name) === undefined || ctx.get(name) === null)
29
29
  missing.push(name);
30
30
  }
@@ -35,6 +35,8 @@ export function resolveHostFace(ctx) {
35
35
  session: ctx.get('sessionController'),
36
36
  workspace: ctx.get('workspaceController'),
37
37
  sessionQuery: ctx.get('sessionQuery'),
38
+ commands: ctx.get('commands'),
39
+ permissionPresets: ctx.get('permissionPresets'),
38
40
  };
39
41
  }
40
42
  /** Raised when the Host did not answer within the bounded window. */
@@ -596,3 +598,124 @@ export function createModelPort(dsh, options = {}) {
596
598
  selectModel: request => selectSessionModel(dsh, request, options),
597
599
  };
598
600
  }
601
+ /** Read the deployment's preset table (`permissionPresets.catalog`). */
602
+ export async function permissionCatalog(dsh, options = {}) {
603
+ const log = options.log;
604
+ try {
605
+ const catalog = await withRpcTimeout(dsh.permissionPresets.catalog(), options.timeoutMs ?? CATALOG_TIMEOUT_MS);
606
+ if (!isRecord(catalog) || !Array.isArray(catalog['options'])) {
607
+ log?.('discord_permission_catalog_malformed', {});
608
+ return { outcome: 'failed' };
609
+ }
610
+ const entries = [];
611
+ for (const row of catalog['options']) {
612
+ if (!isRecord(row) || typeof row['value'] !== 'string')
613
+ continue;
614
+ entries.push({
615
+ value: row['value'],
616
+ ...(typeof row['name'] === 'string' ? { name: row['name'] } : {}),
617
+ });
618
+ }
619
+ return { outcome: 'completed', entries };
620
+ }
621
+ catch (cause) {
622
+ if (cause instanceof RpcTimeoutError) {
623
+ log?.('discord_permission_catalog_timeout', {});
624
+ return { outcome: 'failed' };
625
+ }
626
+ log?.('discord_permission_catalog_failed', { cause: String(cause) });
627
+ return { outcome: 'failed' };
628
+ }
629
+ }
630
+ /**
631
+ * Read the session's current preset off the `permissions` projection view
632
+ * (`{currentValue}`, stateVersion 2). Any read failure resolves failed —
633
+ * show degrades to "current unknown", never a hard failure.
634
+ */
635
+ async function readSessionPermission(dsh, sessionId, options) {
636
+ const log = options.log;
637
+ let observation;
638
+ try {
639
+ observation = await withRpcTimeout(dsh.sessionQuery.observeSession(sessionId), options.timeoutMs ?? CATALOG_TIMEOUT_MS);
640
+ }
641
+ catch {
642
+ return undefined;
643
+ }
644
+ try {
645
+ const projections = isRecord(observation) && isRecord(observation['projections'])
646
+ ? observation['projections']
647
+ : undefined;
648
+ const values = projections !== undefined && isRecord(projections['values'])
649
+ ? projections['values']
650
+ : undefined;
651
+ const permission = values !== undefined ? values['permissions'] : undefined;
652
+ const current = isRecord(permission) ? permission['currentValue'] : undefined;
653
+ return typeof current === 'string' ? current : undefined;
654
+ }
655
+ finally {
656
+ const dispose = observation?.[Symbol.asyncDispose]
657
+ ?? observation?.dispose;
658
+ if (typeof dispose === 'function') {
659
+ try {
660
+ void dispose.call(observation);
661
+ }
662
+ catch (cause) {
663
+ log?.('discord_session_permission_dispose_threw', { cause: String(cause) });
664
+ }
665
+ }
666
+ }
667
+ }
668
+ /**
669
+ * Switch the session's preset through the Host's OWN `/permission` command
670
+ * path (resolveAgent → commands.execute) — the same entry the web UI uses,
671
+ * inheriting its journal audit pair and admission checks. An execute throw
672
+ * is unknown (the command may have run): callers never retry blindly.
673
+ */
674
+ export async function switchSessionPermission(dsh, request, options = {}) {
675
+ const log = options.log;
676
+ const controller = new AbortController();
677
+ try {
678
+ const resolved = await withRpcTimeout(dsh.session.resolveAgent(request.sessionId), options.timeoutMs ?? PROMPT_TIMEOUT_MS);
679
+ if (!isRecord(resolved) || resolved['agent'] === undefined) {
680
+ const message = isRecord(resolved['error']) && typeof resolved['error']['message'] === 'string'
681
+ ? resolved['error']['message']
682
+ : 'session unavailable';
683
+ log?.('discord_permission_agent_unresolved', { sessionId: request.sessionId });
684
+ return { outcome: 'rejected', reason: message };
685
+ }
686
+ const agent = resolved['agent'];
687
+ const execution = await withRpcTimeout(dsh.commands.execute(agent, `/permission ${request.preset}`, [], controller.signal), options.timeoutMs ?? PROMPT_TIMEOUT_MS);
688
+ if (execution === undefined) {
689
+ // Unmatched command: this Host has no /permission registered.
690
+ log?.('discord_permission_command_missing', { sessionId: request.sessionId });
691
+ return { outcome: 'rejected', reason: 'the Host has no /permission command' };
692
+ }
693
+ const result = isRecord(execution) && isRecord(execution['result']) ? execution['result'] : undefined;
694
+ const kind = result !== undefined ? result['kind'] : undefined;
695
+ const text = result !== undefined && typeof result['text'] === 'string' ? result['text'] : '';
696
+ if (kind === 'success') {
697
+ return { outcome: 'completed', preset: request.preset };
698
+ }
699
+ log?.('discord_permission_command_error', { sessionId: request.sessionId, text });
700
+ return { outcome: 'rejected', reason: text === '' ? 'the Host rejected the switch' : text };
701
+ }
702
+ catch (cause) {
703
+ if (cause instanceof RpcTimeoutError) {
704
+ log?.('discord_permission_switch_timeout', { sessionId: request.sessionId });
705
+ return { outcome: 'unknown' };
706
+ }
707
+ log?.('discord_permission_switch_threw', { sessionId: request.sessionId, cause: String(cause) });
708
+ return { outcome: 'unknown' };
709
+ }
710
+ }
711
+ /** The /permission surface over the controller, command, and preset services. */
712
+ export function createPermissionPort(dsh, options = {}) {
713
+ return {
714
+ catalog: () => permissionCatalog(dsh, options),
715
+ current: async (sessionId) => {
716
+ const preset = await readSessionPermission(dsh, sessionId, options);
717
+ return preset === undefined ? { outcome: 'failed' } : { outcome: 'completed', preset };
718
+ },
719
+ set: (sessionId, preset) => switchSessionPermission(dsh, { sessionId, preset }, options),
720
+ };
721
+ }
@@ -15,6 +15,7 @@ import type { CancelOutcome, PromptOutcome, QueueRemoveOutcome, WorkspaceDetailO
15
15
  import type { TurnTracker } from './turn-ownership.js';
16
16
  import { type DshApprovalRespondPort } from './approval-routing.js';
17
17
  import { type DshModelPort } from './model-control.js';
18
+ import { type DshPermissionPort } from './permission-control.js';
18
19
  import type { QuestionInteractionOutcome } from './question-routing.js';
19
20
  import type { ApprovalStore } from './approval-store.js';
20
21
  import type { ChannelBinding } from '../state/records.js';
@@ -132,6 +133,13 @@ export interface InteractionRouterDeps {
132
133
  * deployments flip this so any authorized member can switch (16.42).
133
134
  */
134
135
  modelSelectOperatorOnly: () => boolean;
136
+ /**
137
+ * Whether /permission set stays Host-operator-only (default true — the
138
+ * danger-full-access preset strips sandbox AND approval; 16.60).
139
+ */
140
+ permissionSelectOperatorOnly: () => boolean;
141
+ /** The live permission-preset catalog + guarded switch (/permission surface). */
142
+ permission: DshPermissionPort;
135
143
  log: (event: string, detail?: unknown) => void;
136
144
  warn: (event: string, detail?: unknown) => void;
137
145
  }
@@ -14,6 +14,8 @@ import { planSteer } from './steer-control.js';
14
14
  import { planStop } from './stop-control.js';
15
15
  import { handleApprovalClick } from './approval-routing.js';
16
16
  import { applyModelSelection } from './model-control.js';
17
+ import { applyPermissionPreset, requiresConfirmation, showPermission, } from './permission-control.js';
18
+ import { permissionPresetLabel } from '../i18n.js';
17
19
  /** Discord interactive-component id for string select menus. */
18
20
  const SELECT_MENU = 3;
19
21
  /** `provider/model` — or `provider/model (effort)` when an effort is set. */
@@ -40,7 +42,32 @@ function renderModelApply(copy, result) {
40
42
  : copy.modelNotInCatalog;
41
43
  }
42
44
  }
45
+ /** The ephemeral outcome line for a /permission switch attempt. */
46
+ function renderPermissionApply(copy, result) {
47
+ switch (result.outcome) {
48
+ case 'applied': return copy.permissionApplied(permissionPresetLabel(result.preset, copy));
49
+ case 'rejected': return copy.permissionRejected(result.reason);
50
+ case 'unknown': return copy.permissionUnknown;
51
+ case 'refused':
52
+ return result.reason === 'not-host-operator' ? copy.permissionSelectOperatorOnly : copy.permissionNotInCatalog;
53
+ }
54
+ }
55
+ /** Buttons live while the registry entry does; entries sweep past one extra TTL. */
56
+ const PERMISSION_PROMPT_SWEEP_MS = 20 * 60 * 1000;
43
57
  export function createInteractionRouter(deps) {
58
+ /**
59
+ * Posted /permission confirmation prompts by custom_id. In-memory only —
60
+ * a restart forgets both halves together (the registry is memory too),
61
+ * so a resolved-but-unretired prompt is impossible to click anyway.
62
+ */
63
+ const permissionPrompts = new Map();
64
+ /** Drop prompt records past their sweep window (never-clicked prompts). */
65
+ function sweepPermissionPrompts(nowMs) {
66
+ for (const [customId, record] of permissionPrompts) {
67
+ if (nowMs - record.createdAtMs > PERMISSION_PROMPT_SWEEP_MS)
68
+ permissionPrompts.delete(customId);
69
+ }
70
+ }
44
71
  /** Authorize one interaction against the live policy table. */
45
72
  function authorize(event) {
46
73
  return evaluateAuthorization(deps.policy(), {
@@ -52,7 +79,7 @@ export function createInteractionRouter(deps) {
52
79
  });
53
80
  }
54
81
  async function routeAutocomplete(event, interactionToken) {
55
- if (event.commandName !== 'project' && event.commandName !== 'session')
82
+ if (event.commandName !== 'project' && event.commandName !== 'session' && event.commandName !== 'permission')
56
83
  return;
57
84
  // Autocomplete has no deferred-ack form: whatever happens below, Discord
58
85
  // must receive exactly one type-8 answer, even if only empty choices.
@@ -98,6 +125,27 @@ export function createInteractionRouter(deps) {
98
125
  deps.warn('discord_autocomplete_catalog_failed', String(cause));
99
126
  }
100
127
  }
128
+ else if (decision.allowed && event.commandName === 'permission') {
129
+ // /permission set: live catalog entries, labeled in the current
130
+ // language; the raw preset key is the value (custom deployments
131
+ // define their own tables, so nothing is hardcoded).
132
+ try {
133
+ const catalog = await deps.permission.catalog();
134
+ if (catalog.outcome === 'completed') {
135
+ const lowered = query.trim().toLowerCase();
136
+ choices.push(...catalog.entries
137
+ .filter(entry => entry.value !== lowered)
138
+ .filter(entry => lowered === ''
139
+ || entry.value.toLowerCase().includes(lowered)
140
+ || permissionPresetLabel(entry.value, deps.copy).toLowerCase().includes(lowered))
141
+ .slice(0, 25)
142
+ .map(entry => ({ name: permissionPresetLabel(entry.value, deps.copy), value: entry.value })));
143
+ }
144
+ }
145
+ catch (cause) {
146
+ deps.warn('discord_autocomplete_permission_failed', String(cause));
147
+ }
148
+ }
101
149
  const rest = await deps.rest();
102
150
  if (rest === undefined) {
103
151
  deps.warn('discord_autocomplete_failed', 'missing-token');
@@ -135,7 +183,8 @@ export function createInteractionRouter(deps) {
135
183
  }
136
184
  // Deferred-ack followups must never fail silently: the REST client
137
185
  // resolves (never rejects) 4xx outcomes, so a void'ed call would drop
138
- // the failure without a trace.
186
+ // the failure without a trace. Returns the created message id so
187
+ // callers can retire controls on the posted message later.
139
188
  const followUp = async (content, components) => {
140
189
  const posted = await rest.request('POST', `/webhooks/${deps.applicationId()}/${interactionToken}`, {
141
190
  content,
@@ -144,7 +193,10 @@ export function createInteractionRouter(deps) {
144
193
  });
145
194
  if (posted.outcome !== 'completed') {
146
195
  deps.log('discord_followup_failed', posted.outcome === 'rejected' ? `HTTP ${String(posted.status)}` : posted.reason);
196
+ return undefined;
147
197
  }
198
+ const messageId = posted.body?.['id'];
199
+ return typeof messageId === 'string' ? messageId : undefined;
148
200
  };
149
201
  const buttonRow = (confirmId, cancelId) => [{
150
202
  type: 1,
@@ -505,6 +557,94 @@ export function createInteractionRouter(deps) {
505
557
  await followUp(content);
506
558
  return;
507
559
  }
560
+ if (event.commandName === 'permission') {
561
+ // /permission show|set (16.59–16.61): the bound Session's preset —
562
+ // the Host bundle of sandbox mode + approval policy. The switch
563
+ // rides the Host's own /permission command path.
564
+ const options = event.data['options'];
565
+ const subcommand = Array.isArray(options) ? options[0] : undefined;
566
+ const subName = subcommand?.name;
567
+ const sessionId = deps.sessionForThread(event.guildId, event.channelId);
568
+ if (sessionId === undefined) {
569
+ await followUp(deps.copy.permissionNeedsThread);
570
+ return;
571
+ }
572
+ if (subName === 'show') {
573
+ const view = await showPermission(deps.permission, { sessionId });
574
+ if (view.outcome === 'failed') {
575
+ await followUp(deps.copy.permissionShowUnavailable);
576
+ return;
577
+ }
578
+ const current = view.current === undefined
579
+ ? deps.copy.permissionShowUnknownCurrent
580
+ : permissionPresetLabel(view.current, deps.copy);
581
+ const entries = view.entries.map(preset => permissionPresetLabel(preset, deps.copy)).join(' · ');
582
+ await followUp(deps.copy.permissionShowHeader(current, entries));
583
+ return;
584
+ }
585
+ if (subName === 'set') {
586
+ // Host-operator authority by default — danger-full-access strips
587
+ // sandbox AND approval at once (16.60); deployments may loosen.
588
+ const requireOperator = deps.permissionSelectOperatorOnly();
589
+ const decision = authorize(event);
590
+ if (!decision.allowed || (requireOperator && decision.level !== 'host-operator')) {
591
+ await followUp(deps.copy.permissionSelectOperatorOnly);
592
+ return;
593
+ }
594
+ const wireOptions = Array.isArray(subcommand?.options) ? subcommand.options : [];
595
+ const typedPreset = wireOptions.find(option => option.name === 'preset')?.value;
596
+ if (typeof typedPreset !== 'string' || typedPreset === '') {
597
+ await followUp(deps.copy.permissionNotInCatalog);
598
+ return;
599
+ }
600
+ if (requiresConfirmation(typedPreset)) {
601
+ // The dangerous preset(s) go through an explicit risk
602
+ // confirmation — the web UI shows the same gate (16.61).
603
+ const nowMs = Date.now();
604
+ const expiresAtMs = nowMs + 15 * 60 * 1000;
605
+ const base = { kind: 'permission-confirm', sessionId, preset: typedPreset, guildId: event.guildId, actorId: event.actorId, expiresAtMs };
606
+ const confirmId = deps.registry.register({ ...base, action: 'confirm' });
607
+ const cancelId = deps.registry.register({ ...base, action: 'cancel' });
608
+ const label = permissionPresetLabel(typedPreset, deps.copy);
609
+ const content = `${deps.copy.permissionConfirmHeader(label)}\n${deps.copy.permissionConfirmBody}`;
610
+ const promptMessageId = await followUp(content, [{
611
+ type: 1,
612
+ components: [
613
+ { type: 2, style: 3, label: deps.copy.permissionConfirmButton, custom_id: confirmId },
614
+ { type: 2, style: 4, label: deps.copy.permissionConfirmCancelButton, custom_id: cancelId },
615
+ ],
616
+ }]);
617
+ // Remember the posted prompt so a settle can gray the buttons
618
+ // out — an unsettled-looking live control invites second clicks.
619
+ sweepPermissionPrompts(nowMs);
620
+ if (promptMessageId !== undefined) {
621
+ const record = {
622
+ messageId: promptMessageId,
623
+ interactionToken,
624
+ content,
625
+ confirmCustomId: confirmId,
626
+ cancelCustomId: cancelId,
627
+ confirmLabel: deps.copy.permissionConfirmButton,
628
+ cancelLabel: deps.copy.permissionConfirmCancelButton,
629
+ createdAtMs: nowMs,
630
+ };
631
+ permissionPrompts.set(confirmId, record);
632
+ permissionPrompts.set(cancelId, record);
633
+ }
634
+ return;
635
+ }
636
+ const result = await applyPermissionPreset(deps.permission, {
637
+ decision,
638
+ sessionId,
639
+ preset: typedPreset,
640
+ requireHostOperator: requireOperator,
641
+ });
642
+ await followUp(renderPermissionApply(deps.copy, result));
643
+ return;
644
+ }
645
+ await followUp(deps.copy.unknownSubcommand);
646
+ return;
647
+ }
508
648
  if (event.commandName === 'session') {
509
649
  // /session resume: cold-adopt an existing DSH session into a new
510
650
  // thread of the bound project channel (16.44). /session new is
@@ -664,6 +804,92 @@ export function createInteractionRouter(deps) {
664
804
  if (posted.outcome !== 'completed')
665
805
  deps.log('discord_followup_failed', posted.outcome);
666
806
  }
807
+ /**
808
+ * The danger-full-access risk confirmation (16.61): ack, then re-authorize
809
+ * the clicker (deny-first — the initiating command's grant proves nothing
810
+ * at click time), settle as confirm-then-switch or cancel.
811
+ */
812
+ async function routePermissionConfirmComponent(event, interactionToken) {
813
+ const customId = event.data['custom_id'];
814
+ if (typeof customId !== 'string')
815
+ return;
816
+ const resolved = deps.registry.resolve(customId, Date.now());
817
+ const context = resolved.found ? resolved.context : undefined;
818
+ if (context?.['kind'] !== 'permission-confirm')
819
+ return;
820
+ const rest = await deps.rest();
821
+ if (rest === undefined) {
822
+ deps.log('discord_ack_failed', 'missing-token');
823
+ return;
824
+ }
825
+ const clicked = await rest.request('POST', `/interactions/${event.interactionId}/${interactionToken}/callback`, { type: 6 });
826
+ if (clicked.outcome !== 'completed') {
827
+ deps.log('discord_ack_failed', clicked.outcome);
828
+ return;
829
+ }
830
+ const owner = context['actorId'];
831
+ const sessionId = context['sessionId'];
832
+ const preset = context['preset'];
833
+ if (owner !== event.actorId || typeof sessionId !== 'string' || typeof preset !== 'string') {
834
+ const denied = await rest.request('POST', `/webhooks/${deps.applicationId()}/${interactionToken}`, { content: deps.copy.confirmNotOwner, flags: OUTBOUND_EPHEMERAL_FLAGS });
835
+ if (denied.outcome !== 'completed')
836
+ deps.log('discord_followup_failed', denied.outcome);
837
+ return;
838
+ }
839
+ const respond = async (content) => {
840
+ const posted = await rest.request('POST', `/webhooks/${deps.applicationId()}/${interactionToken}`, { content, flags: OUTBOUND_EPHEMERAL_FLAGS });
841
+ if (posted.outcome !== 'completed')
842
+ deps.log('discord_followup_failed', posted.outcome);
843
+ };
844
+ /**
845
+ * Retire the posted prompt: both buttons gray out so the settled
846
+ * control never looks live again. The prompt was posted by the SLASH
847
+ * interaction's webhook, so only that token can edit it; a late click
848
+ * past the webhook token's lifetime fails the edit loudly but the
849
+ * outcome followup still reaches the clicker (the registry TTL already
850
+ * bounds double-clicks well inside the window).
851
+ */
852
+ const retirePrompt = async () => {
853
+ const record = permissionPrompts.get(customId);
854
+ permissionPrompts.delete(record?.confirmCustomId ?? customId);
855
+ permissionPrompts.delete(record?.cancelCustomId ?? customId);
856
+ if (record === undefined)
857
+ return;
858
+ const patched = await rest.request('PATCH', `/webhooks/${deps.applicationId()}/${record.interactionToken}/messages/${record.messageId}`, {
859
+ content: record.content,
860
+ flags: OUTBOUND_EPHEMERAL_FLAGS,
861
+ components: [{
862
+ type: 1,
863
+ components: [
864
+ { type: 2, style: 3, label: record.confirmLabel, custom_id: record.confirmCustomId, disabled: true },
865
+ { type: 2, style: 4, label: record.cancelLabel, custom_id: record.cancelCustomId, disabled: true },
866
+ ],
867
+ }],
868
+ });
869
+ if (patched.outcome !== 'completed') {
870
+ deps.warn('discord_permission_prompt_retire_failed', patched.outcome === 'rejected' ? `HTTP ${String(patched.status)}` : patched.reason);
871
+ }
872
+ };
873
+ if (context['action'] !== 'confirm') {
874
+ await retirePrompt();
875
+ await respond(deps.copy.permissionCancelled);
876
+ return;
877
+ }
878
+ // Re-run the full authorization + catalog validation at click time; the
879
+ // settings flag may have flipped since the prompt was posted.
880
+ const decision = authorize(event);
881
+ await retirePrompt();
882
+ const result = await applyPermissionPreset(deps.permission, {
883
+ decision,
884
+ sessionId,
885
+ preset,
886
+ requireHostOperator: deps.permissionSelectOperatorOnly(),
887
+ });
888
+ deps.log('discord_permission_confirm', { outcome: result.outcome, preset });
889
+ await respond(result.outcome === 'applied'
890
+ ? `${deps.copy.permissionConfirmed(permissionPresetLabel(preset, deps.copy))}\n${renderPermissionApply(deps.copy, result)}`
891
+ : renderPermissionApply(deps.copy, result));
892
+ }
667
893
  /** One cascade stage: ack, re-authorize, then advance or apply (16.35). */
668
894
  async function routeModelComponent(event, interactionToken) {
669
895
  const customId = event.data['custom_id'];
@@ -900,6 +1126,10 @@ export function createInteractionRouter(deps) {
900
1126
  await routeModelComponent(event, interactionToken);
901
1127
  return;
902
1128
  }
1129
+ if (bindContext?.['kind'] === 'permission-confirm') {
1130
+ await routePermissionConfirmComponent(event, interactionToken);
1131
+ return;
1132
+ }
903
1133
  // A not-found custom_id means the control was never registered or has
904
1134
  // been retired by its TTL (approval/question registry entries stay
905
1135
  // resolvable until expiry; claimed-vs-resolved state lives in the
@@ -0,0 +1,88 @@
1
+ /**
2
+ * `/permission show` and the guarded `/permission set` (design.md §7, tasks
3
+ * 16.59–16.61). A permission preset is the Host's bundle of sandbox mode +
4
+ * approval policy; the switch goes through the Host's own `/permission`
5
+ * command path (the same entry the web UI uses), so the journal records the
6
+ * command/run + command/done audit pair and the Host runs its own admission
7
+ * (unknown preset → error text). The adapter validates against the live
8
+ * catalog first — deployments configure their own preset tables.
9
+ *
10
+ * The default gate is stricter than /model's: `danger-full-access` disables
11
+ * sandbox AND approval at once, so only the explicit Host-operator allowlist
12
+ * may switch (`permissionSelectOperatorOnly: true`); single-user deployments
13
+ * may loosen it, and a member-level decision still never authorizes.
14
+ */
15
+ import type { AccessDecision } from '../policy/authorization.js';
16
+ export interface DshPermissionPort {
17
+ /** The deployment's live preset catalog (read from `permissionPresets`). */
18
+ catalog(): Promise<{
19
+ outcome: 'completed';
20
+ entries: ReadonlyArray<{
21
+ value: string;
22
+ name?: string;
23
+ }>;
24
+ } | {
25
+ outcome: 'failed';
26
+ } | {
27
+ outcome: 'unknown';
28
+ }>;
29
+ /** The session's current preset off the `permissions` projection view. */
30
+ current(sessionId: string): Promise<{
31
+ outcome: 'completed';
32
+ preset: string;
33
+ } | {
34
+ outcome: 'failed';
35
+ } | {
36
+ outcome: 'unknown';
37
+ }>;
38
+ /** Switch through the Host `/permission` command path. */
39
+ set(sessionId: string, preset: string): Promise<{
40
+ outcome: 'completed';
41
+ preset: string;
42
+ } | {
43
+ outcome: 'rejected';
44
+ reason: string;
45
+ } | {
46
+ outcome: 'unknown';
47
+ }>;
48
+ }
49
+ export type PermissionShowResult = {
50
+ outcome: 'ok';
51
+ current: string | undefined;
52
+ entries: ReadonlyArray<string>;
53
+ } | {
54
+ outcome: 'failed';
55
+ };
56
+ /** `/permission show`: the current preset plus the switchable catalog. */
57
+ export declare function showPermission(port: DshPermissionPort, request: {
58
+ sessionId: string;
59
+ }): Promise<PermissionShowResult>;
60
+ export type PermissionApplyResult = {
61
+ outcome: 'applied';
62
+ preset: string;
63
+ } | {
64
+ outcome: 'rejected';
65
+ reason: string;
66
+ } | {
67
+ outcome: 'unknown';
68
+ } | {
69
+ outcome: 'refused';
70
+ reason: 'not-host-operator' | 'preset-not-in-catalog';
71
+ };
72
+ /**
73
+ * The presets that strip BOTH confinement layers at once; switching to one
74
+ * requires an explicit risk confirmation (the web UI shows the same gate).
75
+ */
76
+ export declare const CONFIRM_REQUIRED_PRESETS: readonly string[];
77
+ export declare function requiresConfirmation(preset: string): boolean;
78
+ export declare function applyPermissionPreset(port: DshPermissionPort, request: {
79
+ decision: AccessDecision;
80
+ sessionId: string;
81
+ preset: string;
82
+ /**
83
+ * Keep the Host-operator restriction (default, stricter than /model).
84
+ * Single-user deployments may drop it so administrators of the
85
+ * allowlisted guild can switch (16.60); members are denied either way.
86
+ */
87
+ requireHostOperator?: boolean | undefined;
88
+ }): Promise<PermissionApplyResult>;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * `/permission show` and the guarded `/permission set` (design.md §7, tasks
3
+ * 16.59–16.61). A permission preset is the Host's bundle of sandbox mode +
4
+ * approval policy; the switch goes through the Host's own `/permission`
5
+ * command path (the same entry the web UI uses), so the journal records the
6
+ * command/run + command/done audit pair and the Host runs its own admission
7
+ * (unknown preset → error text). The adapter validates against the live
8
+ * catalog first — deployments configure their own preset tables.
9
+ *
10
+ * The default gate is stricter than /model's: `danger-full-access` disables
11
+ * sandbox AND approval at once, so only the explicit Host-operator allowlist
12
+ * may switch (`permissionSelectOperatorOnly: true`); single-user deployments
13
+ * may loosen it, and a member-level decision still never authorizes.
14
+ */
15
+ /** `/permission show`: the current preset plus the switchable catalog. */
16
+ export async function showPermission(port, request) {
17
+ const [catalog, current] = await Promise.all([port.catalog(), port.current(request.sessionId)]);
18
+ if (catalog.outcome !== 'completed')
19
+ return { outcome: 'failed' };
20
+ return {
21
+ outcome: 'ok',
22
+ ...(current.outcome === 'completed' ? { current: current.preset } : { current: undefined }),
23
+ entries: catalog.entries.map(entry => entry.value),
24
+ };
25
+ }
26
+ /**
27
+ * The presets that strip BOTH confinement layers at once; switching to one
28
+ * requires an explicit risk confirmation (the web UI shows the same gate).
29
+ */
30
+ export const CONFIRM_REQUIRED_PRESETS = ['danger-full-access'];
31
+ export function requiresConfirmation(preset) {
32
+ return CONFIRM_REQUIRED_PRESETS.includes(preset);
33
+ }
34
+ export async function applyPermissionPreset(port, request) {
35
+ const requireOperator = request.requireHostOperator ?? true;
36
+ if (requireOperator) {
37
+ // Host-operator authority ONLY: a Guild administrator is not enough —
38
+ // the switch can strip sandbox and approval from the Session.
39
+ if (!request.decision.allowed || request.decision.level !== 'host-operator') {
40
+ return { outcome: 'refused', reason: 'not-host-operator' };
41
+ }
42
+ }
43
+ if (!request.decision.allowed) {
44
+ return { outcome: 'refused', reason: 'not-host-operator' };
45
+ }
46
+ const catalog = await port.catalog();
47
+ if (catalog.outcome !== 'completed') {
48
+ return { outcome: 'refused', reason: 'preset-not-in-catalog' };
49
+ }
50
+ if (!catalog.entries.some(entry => entry.value === request.preset)) {
51
+ return { outcome: 'refused', reason: 'preset-not-in-catalog' };
52
+ }
53
+ const applied = await port.set(request.sessionId, request.preset);
54
+ if (applied.outcome === 'completed')
55
+ return { outcome: 'applied', preset: applied.preset };
56
+ if (applied.outcome === 'rejected')
57
+ return { outcome: 'rejected', reason: applied.reason };
58
+ return { outcome: 'unknown' };
59
+ }
package/lib/i18n.d.ts CHANGED
@@ -118,6 +118,26 @@ declare const zh: {
118
118
  modelNotInCatalog: string;
119
119
  modelInvalidReasoning: string;
120
120
  modelTypedParseFailed: string;
121
+ permissionNeedsThread: string;
122
+ permissionShowUnavailable: string;
123
+ permissionShowHeader: (current: string, entries: string) => string;
124
+ permissionShowUnknownCurrent: string;
125
+ permissionPresetReadOnly: string;
126
+ permissionPresetWorkspaceWrite: string;
127
+ permissionPresetFullAccess: string;
128
+ permissionApplied: (label: string) => string;
129
+ permissionRejected: (reason: string) => string;
130
+ permissionUnknown: string;
131
+ permissionSelectOperatorOnly: string;
132
+ permissionNotInCatalog: string;
133
+ permissionConfirmHeader: (label: string) => string;
134
+ permissionConfirmBody: string;
135
+ permissionConfirmButton: string;
136
+ permissionConfirmCancelButton: string;
137
+ permissionConfirmed: (label: string) => string;
138
+ permissionCancelled: string;
139
+ permissionConfirmExpired: string;
140
+ permissionSystemLine: (label: string) => string;
121
141
  sessionResumeNeedsBoundChannel: string;
122
142
  sessionResumeStarted: (threadId: string) => string;
123
143
  sessionResumeAlreadyBound: (threadId: string) => string;
@@ -136,4 +156,10 @@ declare const zh: {
136
156
  export type CopyTable = typeof zh;
137
157
  /** Discord-visible copy for the configured language. */
138
158
  export declare function createCopy(language: Language): CopyTable;
159
+ /**
160
+ * Localize one permission-preset key for display. The dsh-base canonical
161
+ * three map to Discord copy; deployment-custom keys fall back to the raw
162
+ * host key (never invented labels).
163
+ */
164
+ export declare function permissionPresetLabel(preset: string, copy: CopyTable): string;
139
165
  export {};
package/lib/i18n.js CHANGED
@@ -129,6 +129,27 @@ const zh = {
129
129
  modelNotInCatalog: '⚠️ 该 provider/模型不在当前会话的目录中。',
130
130
  modelInvalidReasoning: '⚠️ 该推理强度对此模型无效。',
131
131
  modelTypedParseFailed: '⚠️ 模型需按 `provider/model` 格式填写,或留空进入交互式选择。',
132
+ // ── /permission show / set(16.59–16.61)─────────────────────────────
133
+ permissionNeedsThread: '⚠️ /permission 需要在已绑定 Session 的任务线程中使用(先在项目频道 @ 机器人)。',
134
+ permissionShowUnavailable: '⚠️ 权限目录暂时不可用,请稍后重试。',
135
+ permissionShowHeader: (current, entries) => `**当前权限模式:** ${current}\n**可切换:** ${entries}`,
136
+ permissionShowUnknownCurrent: '未知(投影暂不可读)',
137
+ permissionPresetReadOnly: '仅可查看',
138
+ permissionPresetWorkspaceWrite: '工作区内修改',
139
+ permissionPresetFullAccess: '完全权限',
140
+ permissionApplied: (label) => `✅ 权限模式已切换:${label}(沙箱与审批策略随预设生效)。`,
141
+ permissionRejected: (reason) => `⚠️ DSH 拒绝了此次切换:${reason}`,
142
+ permissionUnknown: '⚠️ 切换结果未知(命令可能已执行),请用 /permission show 确认。',
143
+ permissionSelectOperatorOnly: '⚠️ 只有 Host 操作员可以切换权限模式(完全权限会同时解除沙箱与审批)。',
144
+ permissionNotInCatalog: '⚠️ 该预设不在当前部署的权限目录中。',
145
+ permissionConfirmHeader: (label) => `⚠️ 确认启用「${label}」?`,
146
+ permissionConfirmBody: '启用后本会话将减少确认步骤,可直接执行敏感操作、文件修改或外部命令。仅建议在你信任后续任务时使用。',
147
+ permissionConfirmButton: '我已了解风险,启用',
148
+ permissionConfirmCancelButton: '取消',
149
+ permissionConfirmed: (label) => `✅ 已确认,正在切换到「${label}」……`,
150
+ permissionCancelled: '已取消,权限模式未变更。',
151
+ permissionConfirmExpired: '⚠️ 该确认已过期,请重新运行 /permission set。',
152
+ permissionSystemLine: (label) => `🔧 权限模式 → ${label}`,
132
153
  // ── /session resume ──────────────────────────────────────────────────
133
154
  sessionResumeNeedsBoundChannel: '⚠️ /session resume 需要在已绑定工作区的项目频道中使用。',
134
155
  sessionResumeStarted: (threadId) => `✅ 会话已恢复到 <#${threadId}>——历史在 Web 界面查看,线程内直接续聊。`,
@@ -261,6 +282,27 @@ const en = {
261
282
  modelNotInCatalog: "⚠️ That provider/model is not in this session's catalog.",
262
283
  modelInvalidReasoning: '⚠️ That reasoning effort is not valid for this model.',
263
284
  modelTypedParseFailed: '⚠️ The model must be `provider/model`, or left empty for the interactive cascade.',
285
+ // ── /permission show / set (16.59–16.61) ─────────────────────────────
286
+ permissionNeedsThread: '⚠️ /permission needs a task Thread bound to a Session (mention the bot in a project channel first).',
287
+ permissionShowUnavailable: '⚠️ The permission catalog is temporarily unavailable; try again shortly.',
288
+ permissionShowHeader: (current, entries) => `**Current permission mode:** ${current}\n**Switchable:** ${entries}`,
289
+ permissionShowUnknownCurrent: 'unknown (projection unreadable)',
290
+ permissionPresetReadOnly: 'View only',
291
+ permissionPresetWorkspaceWrite: 'Workspace edit',
292
+ permissionPresetFullAccess: 'Full access',
293
+ permissionApplied: (label) => `✅ Permission mode switched: ${label} (sandbox and approval follow the preset).`,
294
+ permissionRejected: (reason) => `⚠️ DSH rejected this switch: ${reason}`,
295
+ permissionUnknown: '⚠️ The switch outcome is unknown (the command may have run); confirm with /permission show.',
296
+ permissionSelectOperatorOnly: '⚠️ Only Host operators can switch the permission mode (full access disables both sandbox and approval).',
297
+ permissionNotInCatalog: "⚠️ That preset is not in this deployment's permission catalog.",
298
+ permissionConfirmHeader: (label) => `⚠️ Enable "${label}"?`,
299
+ permissionConfirmBody: 'After enabling, this Session will require fewer confirmations and can directly perform sensitive operations, file changes, or external commands. Only recommended when you trust the upcoming work.',
300
+ permissionConfirmButton: 'I understand the risk, enable',
301
+ permissionConfirmCancelButton: 'Cancel',
302
+ permissionConfirmed: (label) => `✅ Confirmed — switching to "${label}"…`,
303
+ permissionCancelled: 'Cancelled; the permission mode is unchanged.',
304
+ permissionConfirmExpired: '⚠️ That confirmation expired; run /permission set again.',
305
+ permissionSystemLine: (label) => `🔧 Permission mode → ${label}`,
264
306
  // ── /session resume ──────────────────────────────────────────────────
265
307
  sessionResumeNeedsBoundChannel: '⚠️ /session resume must run in a bound project channel.',
266
308
  sessionResumeStarted: threadId => `✅ Session resumed into <#${threadId}> — full history lives in the web UI; continue in the thread.`,
@@ -282,3 +324,17 @@ const en = {
282
324
  export function createCopy(language) {
283
325
  return language === 'en' ? en : zh;
284
326
  }
327
+ /**
328
+ * Localize one permission-preset key for display. The dsh-base canonical
329
+ * three map to Discord copy; deployment-custom keys fall back to the raw
330
+ * host key (never invented labels).
331
+ */
332
+ export function permissionPresetLabel(preset, copy) {
333
+ if (preset === 'read-only')
334
+ return copy.permissionPresetReadOnly;
335
+ if (preset === 'workspace-write')
336
+ return copy.permissionPresetWorkspaceWrite;
337
+ if (preset === 'danger-full-access')
338
+ return copy.permissionPresetFullAccess;
339
+ return preset;
340
+ }
package/lib/index.js CHANGED
@@ -9,14 +9,14 @@ import { createRestThreadPort } from './discord/thread-port.js';
9
9
  import { createComponentRegistry } from './discord/components.js';
10
10
  import { buildCommandRegistrations } from './discord/commands.js';
11
11
  import { startDiscordAdapter } from './compose.js';
12
- import { createModelPort, createWorkspaceCatalogPort, createWorkspaceResolver, readWorkspaceDetail, promptSession, createSessionViaProxy, cancelSessionViaProxy, steerSession, removeQueueItemViaProxy, resolveHostFace } from './dsh/host-face.js';
12
+ import { createModelPort, createPermissionPort, createWorkspaceCatalogPort, createWorkspaceResolver, readWorkspaceDetail, promptSession, createSessionViaProxy, cancelSessionViaProxy, steerSession, removeQueueItemViaProxy, resolveHostFace } from './dsh/host-face.js';
13
13
  import { createApprovalStore } from './features/approval-store.js';
14
14
  import { createAskWiring } from './features/ask-wiring.js';
15
15
  import { sweepExpiredApprovals } from './features/approval-expiry.js';
16
16
  import { sweepExpiredQuestions } from './features/question-expiry.js';
17
17
  import { createQuestionStore } from './features/question-store.js';
18
18
  import { handleSelectInput, handleModalSubmit } from './features/question-routing.js';
19
- import { createCopy } from './i18n.js';
19
+ import { createCopy, permissionPresetLabel } from './i18n.js';
20
20
  import { createInteractionRouter } from './features/interaction-router.js';
21
21
  import { channelBindingKey, parseChannelBindingKey, threadBindingKey, parseThreadBindingKey, discordDomainSpec, CHANNEL_BINDINGS_TABLE, THREAD_BINDINGS_TABLE, INTENTS_TABLE } from './state/domain.js';
22
22
  import { planBindingReconciliation } from './features/reconcile-bindings.js';
@@ -855,6 +855,8 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
855
855
  rpcLog('discord_reconcile_channel_retired', { channelId, reason: 'discord-deleted' });
856
856
  },
857
857
  model: createModelPort(dsh, { log: rpcLog }),
858
+ permission: createPermissionPort(dsh, { log: rpcLog }),
859
+ permissionSelectOperatorOnly: () => current.permissionSelectOperatorOnly,
858
860
  modelSelectOperatorOnly: () => current.modelSelectOperatorOnly,
859
861
  resumeCandidates: createResumeCandidatesPort({
860
862
  listSessions: () => listSessionSummaries(dsh, { log: rpcLog }),
@@ -1154,6 +1156,7 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
1154
1156
  nonText: copy.userInputNonText,
1155
1157
  truncated: copy.userInputTruncated,
1156
1158
  }),
1159
+ permissionLineCopy: () => ({ line: presetKey => copy.permissionSystemLine(permissionPresetLabel(presetKey, copy)) }),
1157
1160
  onQueueSnapshot: (sessionId, items) => { queueSnapshots.set(sessionId, items); },
1158
1161
  onTurnEnded: (sessionId, info) => {
1159
1162
  const turn = turnTracker.active(sessionId);
package/lib/settings.d.ts CHANGED
@@ -30,6 +30,14 @@ export interface DiscordSettings {
30
30
  * re-tighten to the operator allowlist.
31
31
  */
32
32
  modelSelectOperatorOnly: boolean;
33
+ /**
34
+ * Restrict /permission set to the explicit Host-operator allowlist.
35
+ * Defaults to true — stricter than /model because the danger-full-access
36
+ * preset disables BOTH sandbox and approval (16.60); single-user
37
+ * deployments may loosen it so any authorized member can switch, mirroring
38
+ * the loosened /model select vocabulary.
39
+ */
40
+ permissionSelectOperatorOnly: boolean;
33
41
  }
34
42
  /** The archive durations Discord's API accepts (minutes). */
35
43
  export type ThreadAutoArchiveMinutes = 60 | 1440 | 4320 | 10080;
package/lib/settings.js CHANGED
@@ -20,6 +20,7 @@ export const DEFAULT_DISCORD_SETTINGS = Object.freeze({
20
20
  questionTimeoutMs: 30 * 60_000,
21
21
  threadAutoArchiveMinutes: 1440,
22
22
  modelSelectOperatorOnly: false,
23
+ permissionSelectOperatorOnly: true,
23
24
  });
24
25
  const discordIdList = z.array(z.string()).default([]);
25
26
  export const DiscordSettingsSchema = z.object({
@@ -42,6 +43,7 @@ export const DiscordSettingsSchema = z.object({
42
43
  approvalTimeoutMs: z.number().step(1).min(30_000).max(86_400_000).default(600_000),
43
44
  questionTimeoutMs: z.number().step(1).min(30_000).max(86_400_000).default(1_800_000),
44
45
  modelSelectOperatorOnly: z.boolean().default(false),
46
+ permissionSelectOperatorOnly: z.boolean().default(true),
45
47
  });
46
48
  const DISCORD_SNOWFLAKE = /^\d{17,20}$/u;
47
49
  const ID_FIELDS = [
package/lib/startup.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * with a single actionable diagnostic naming every gap.
7
7
  */
8
8
  /** The exact Host service roster the adapter requires (design decision 1). */
9
- export declare const REQUIRED_HOST_SERVICES: readonly ["sessionController", "workspaceController", "sessionQuery", "webServer", "credentials", "settings", "storageDomain", "connection"];
9
+ export declare const REQUIRED_HOST_SERVICES: readonly ["sessionController", "workspaceController", "sessionQuery", "webServer", "credentials", "settings", "storageDomain", "connection", "commands", "permissionPresets"];
10
10
  export type HostServiceName = (typeof REQUIRED_HOST_SERVICES)[number];
11
11
  /**
12
12
  * Validate every required Host capability through `resolveService`, which
package/lib/startup.js CHANGED
@@ -15,6 +15,10 @@ export const REQUIRED_HOST_SERVICES = [
15
15
  'settings',
16
16
  'storageDomain',
17
17
  'connection',
18
+ // The /permission surface (16.59): the Host command runtime and the
19
+ // permission-preset catalog (both ship in dsh-base).
20
+ 'commands',
21
+ 'permissionPresets',
18
22
  ];
19
23
  /**
20
24
  * Minimum contract members per service, confirmed against the 0.1.6
@@ -23,7 +27,7 @@ export const REQUIRED_HOST_SERVICES = [
23
27
  * the startup probe should have refused; refuse rather than probe at runtime.
24
28
  */
25
29
  const REQUIRED_CONTRACT_MEMBERS = {
26
- sessionController: ['prompt', 'create', 'list', 'cancel', 'updateQueue', 'selectModel', 'modelCatalog', 'follow', 'control'],
30
+ sessionController: ['prompt', 'create', 'list', 'cancel', 'updateQueue', 'selectModel', 'modelCatalog', 'follow', 'control', 'resolveAgent'],
27
31
  workspaceController: ['follow'],
28
32
  sessionQuery: ['observeSession'],
29
33
  webServer: [],
@@ -31,6 +35,8 @@ const REQUIRED_CONTRACT_MEMBERS = {
31
35
  settings: ['installSection'],
32
36
  storageDomain: ['open'],
33
37
  connection: ['rpc'],
38
+ commands: ['execute'],
39
+ permissionPresets: ['catalog'],
34
40
  };
35
41
  /** Actionable remediation hint appended to every activation failure. */
36
42
  const REMEDIATION = 'install @addozhang/dsh-discord into a dsh web profile providing the 0.1.6 host controller services';
@@ -120,6 +120,13 @@ export interface LiveRenderDeps {
120
120
  nonText: string;
121
121
  truncated: string;
122
122
  };
123
+ /**
124
+ * Localized permission system-line copy, resolved live (language can
125
+ * change). Unset: permission/preset records never render (16.62).
126
+ */
127
+ permissionLineCopy?: () => {
128
+ line: (presetLabel: string) => string;
129
+ };
123
130
  /** Turn ownership release on turn/end; `info` carries the consumed watermark. */
124
131
  onTurnEnded?: (sessionId: string, info?: {
125
132
  threadId: string;
@@ -344,6 +344,27 @@ export function startLiveRender(deps) {
344
344
  echoUserInput(threadId, runtime, data, event);
345
345
  return;
346
346
  }
347
+ case 'permission/preset': {
348
+ // Durable preset intent (16.62): one audit line per switch, from
349
+ // ANY surface (web UI or Discord). The knob events that follow
350
+ // (sandbox/mode, approval/policy) stay unrendered — the preset
351
+ // table already implies them.
352
+ const copy = deps.permissionLineCopy?.();
353
+ if (copy === undefined)
354
+ return;
355
+ const preset = data['preset'];
356
+ if (typeof preset !== 'string' || preset === '')
357
+ return;
358
+ const payload = buildOutboundMessage({ kind: 'tool', content: copy.line(preset) });
359
+ void deps.delivery.send({ channelId: threadId, content: payload.content }).then(sent => {
360
+ if (sent.outcome !== 'completed') {
361
+ deps.log?.('discord_live_permission_line_failed', { threadId, outcome: sent.outcome });
362
+ }
363
+ }).catch((cause) => {
364
+ deps.log?.('discord_live_permission_line_threw', { threadId, cause: String(cause) });
365
+ });
366
+ return;
367
+ }
347
368
  case 'turn/start': {
348
369
  if (typeof turnId !== 'string')
349
370
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@addozhang/dsh-discord",
3
- "version": "0.5.0-rc.2",
3
+ "version": "0.5.0-rc.3",
4
4
  "description": "Discord adapter for DeepSeek Harness · DeepSeek Harness 的 Discord 适配器",
5
5
  "license": "MIT",
6
6
  "type": "module",