@hyzyn/dsh-docker 0.9.6 → 0.11.0

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/lib/audit.d.ts ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * 能力使用审计(capability-use audit):把「授权了」和「用了」接成闭环。
3
+ *
4
+ * ## 它解决什么
5
+ *
6
+ * 授权链已有完整审计(kit `elevation.ts` 的 begin / grant / expire / revoke / load 五行),但
7
+ * **授权之后能力被用来干了什么**此前没有台账(README 已知限制自认的一条)。本模块给每一次
8
+ * **过了能力闸**的变更 / exec 操作落一行结构化审计行,两条入口都覆盖:
9
+ * - `source=http`(面板路由,`/action`、`/images/remove` 等 8 条);
10
+ * - `source=tool`(agent 工具 `docker_action` 等 5 个,走 DockerApi、不经 HTTP 路由,不会重复计数)。
11
+ *
12
+ * 行格式(对齐 kit elevation 审计行的 key=value 风格):
13
+ *
14
+ * [dsh-docker] capability-use: capability=allowMutations source=http action=container.remove target=web ref=shop-web-1 ok=true durationMs=123
15
+ *
16
+ * `capability` 刻意与能力开关同名(`allowMutations` / `allowExec`):审计行必须能和 elevation 的
17
+ * 授权行对上号,这是本模块的全部意义。
18
+ *
19
+ * ## 「使用 = 过了闸」的单一语义
20
+ *
21
+ * - **403 拒绝不记**:请求被闸挡回,能力没有被使用(也没有 docker 侧的任何后果);
22
+ * 宿主侧授权状态的变化已由 elevation 五行覆盖。tier-gate 的 ask / deny 同样不记:
23
+ * ask 只是询问、没有执行(不是使用),deny 的放行与否由宿主的 approval 日志兜着。
24
+ * - **docker 报错也算一次使用**:`ok=false` + `detail=` 截断后的错误文案——「试过删一个容器
25
+ * 失败了」与「删成功了」同样是审计要回答的问题。错误按原路径 rethrow,不改调用方的错误处理。
26
+ *
27
+ * ## 脱敏与截断(硬规矩,helper 统一做,不靠调用点自觉)
28
+ *
29
+ * 日志会落盘(宿主的启动失败报告会带上最近日志),所以:
30
+ * - exec 的 `command` 记进 `detail` 时**截断到 200 字符**(含 `…` 截断信号,与全仓「截断要有
31
+ * 信号」同一取向)、**控制字符转义成 `\n` 等字面量**——保证「一行就是一次事件」,命令里的
32
+ * 换行伪造不出第二行审计;错误文案同样处理。
33
+ * - 刻意**不转义空格**:`detail` 是自由文本区(永远在行尾,解析器按字段序取前段不受它干扰),
34
+ * `ref` 过了 assertRef / assertImageRef / assertName 白名单(无空格),`target` 是用户自己
35
+ * 配置的目标名——可读性优先于机器可解析。
36
+ * - 与 elevation「nonce 绝不进日志」是同一类取舍的正面:这条**代价**(exec 命令截断 200 字符
37
+ * 进日志)已写进插件 README 的已知限制。
38
+ *
39
+ * ## 出口为什么是 console 而不是 ctx.logger(docker D162)
40
+ *
41
+ * 真机验收(2026-10-07,DSH 0.2.1-alpha.1)实测:插件经 `ctx.logger.info` 打的行**既不出现在
42
+ * 宿主 stdout、也找不到落盘文件**——能看到的 `[dsh-docker]` 行全部是 `console.log` 调用点
43
+ * (mounted / config applied / agent tools registered / tier-gate;tier-gate 的日志出口默认就是
44
+ * console,见 kit `tier-gate.ts`)。审计行走 ctx.logger 等于在真宿主上无处可查,而「出问题时翻
45
+ * 得到」正是审计存在的意义。所以出口固定走 `console.log`(前缀里已带 `[dsh-docker]`),
46
+ * **不做参数**:出口是本模块的取舍,不是调用方的选择。kit elevation 的授权行(begin / grant /
47
+ * expire / revoke / load)走的仍是 ctx.logger,同一问题记在根 ROADMAP 待办——修好后两类行同通道。
48
+ *
49
+ * ## 为什么在 docker 包、不在 kit
50
+ *
51
+ * 按 conventions 的 L0/L1 边界判据(改一个包就能做完 → L1):目前只有 docker 一个插件需要使用
52
+ * 审计。**晋升条件**:当第二个插件(tty)也要接同一套「capability-use」行时,把本模块上提
53
+ * `@hyzyn/dsh-kit`,并把日志前缀做成参数(对齐 elevation 的 `logPrefix`)——在那之前不要提前抽象。
54
+ */
55
+ /** 审计行挂的能力开关名:与授权行(elevation load/grant)对得上号。 */
56
+ export type CapabilityName = 'allowMutations' | 'allowExec';
57
+ /** 使用入口:面板路由(http)|agent 工具(tool)。 */
58
+ export type CapabilityUseSource = 'http' | 'tool';
59
+ /**
60
+ * action 的**固定词表**:`<资源>.<动作>`(exec 自成一格)。新增变更操作时先来这里加词,
61
+ * 再接调用点——审计行的消费方(人 + 日志检索)靠词表稳定。
62
+ */
63
+ export type CapabilityUseAction = 'container.start' | 'container.stop' | 'container.restart' | 'container.remove' | 'image.remove' | 'image.prune' | 'image.pull' | 'network.remove' | 'network.prune' | 'volume.remove' | 'volume.prune' | 'exec';
64
+ /** 一次使用的基本字段(`audited` 的入参)。 */
65
+ export interface CapabilityUseFields {
66
+ capability: CapabilityName;
67
+ source: CapabilityUseSource;
68
+ action: CapabilityUseAction;
69
+ /** 目标名(docker 目标的配置名)。 */
70
+ target: string;
71
+ /** 容器名/ID、镜像引用、网络/卷名;prune 这类无目标的操作省略。 */
72
+ ref?: string;
73
+ }
74
+ /** 一条完整审计行的字段(含结果侧)。 */
75
+ export interface CapabilityUseLine extends CapabilityUseFields {
76
+ /** 操作本身成败:docker 报错也算一次使用(ok=false + detail)。 */
77
+ ok?: boolean;
78
+ /** 命令耗时(`audited` 计时;start 行没有)。 */
79
+ durationMs?: number;
80
+ /** 失败原因 / exec 退出码等补充(截断 + 转义)。 */
81
+ detail?: string;
82
+ /** 长流(pull 进度流)专用:开流 `start`、收尾 `end`——中途宿主挂掉时至少 start 行还在。 */
83
+ event?: 'start' | 'end';
84
+ /** end 行的收尾原因(与 SSE end 帧的 reason 同源)。 */
85
+ reason?: string;
86
+ /** end 行的 docker 退出码(被中止时为 null,省略)。 */
87
+ code?: number | null;
88
+ }
89
+ /** 字符串值统一截断上限(exec 命令、错误文案;硬规矩见文件头)。 */
90
+ export declare const AUDIT_DETAIL_MAX_CHARS = 200;
91
+ /**
92
+ * 统一的截断 + 转义:先按**原始字符数**截断(补 `…` 信号),再逐字符转义控制字符。
93
+ * 单次遍历、不存在二次转义;空格刻意保留(取舍见文件头「脱敏与截断」)。
94
+ */
95
+ export declare function sanitizeAuditValue(text: string, maxChars?: number): string;
96
+ /** 把一条使用审计行打到宿主 stdout(出口取舍见文件头「出口为什么是 console」,docker D162)。 */
97
+ export declare function auditCapabilityUse(fields: CapabilityUseLine): void;
98
+ /**
99
+ * 包一层变更 / exec 调用:计时 → try/catch → 审计(成功与失败都记)→ 失败原样 rethrow。
100
+ *
101
+ * 只该放在**能力闸判定之后**(403 分支之后)——「使用 = 过了闸」。`detailOf` 可选:exec / pull
102
+ * 这类「api 成功返回但带退出码」的操作用它组 detail——exec 记 `code=… cmd=…`(**命令会进日志**,
103
+ * 这是写进 README 的代价),pull 只记退出码;整段 detail 的 200 截断与控制字符转义由 emit 时的
104
+ * `sanitizeAuditValue` 统一做(单一出口,不在调用点各写一遍)。失败路径的 detail 是错误文案。
105
+ */
106
+ export declare function audited<T>(fields: CapabilityUseFields, run: () => Promise<T>, detailOf?: (result: T) => string | undefined): Promise<T>;
package/lib/audit.js ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * 能力使用审计(capability-use audit):把「授权了」和「用了」接成闭环。
3
+ *
4
+ * ## 它解决什么
5
+ *
6
+ * 授权链已有完整审计(kit `elevation.ts` 的 begin / grant / expire / revoke / load 五行),但
7
+ * **授权之后能力被用来干了什么**此前没有台账(README 已知限制自认的一条)。本模块给每一次
8
+ * **过了能力闸**的变更 / exec 操作落一行结构化审计行,两条入口都覆盖:
9
+ * - `source=http`(面板路由,`/action`、`/images/remove` 等 8 条);
10
+ * - `source=tool`(agent 工具 `docker_action` 等 5 个,走 DockerApi、不经 HTTP 路由,不会重复计数)。
11
+ *
12
+ * 行格式(对齐 kit elevation 审计行的 key=value 风格):
13
+ *
14
+ * [dsh-docker] capability-use: capability=allowMutations source=http action=container.remove target=web ref=shop-web-1 ok=true durationMs=123
15
+ *
16
+ * `capability` 刻意与能力开关同名(`allowMutations` / `allowExec`):审计行必须能和 elevation 的
17
+ * 授权行对上号,这是本模块的全部意义。
18
+ *
19
+ * ## 「使用 = 过了闸」的单一语义
20
+ *
21
+ * - **403 拒绝不记**:请求被闸挡回,能力没有被使用(也没有 docker 侧的任何后果);
22
+ * 宿主侧授权状态的变化已由 elevation 五行覆盖。tier-gate 的 ask / deny 同样不记:
23
+ * ask 只是询问、没有执行(不是使用),deny 的放行与否由宿主的 approval 日志兜着。
24
+ * - **docker 报错也算一次使用**:`ok=false` + `detail=` 截断后的错误文案——「试过删一个容器
25
+ * 失败了」与「删成功了」同样是审计要回答的问题。错误按原路径 rethrow,不改调用方的错误处理。
26
+ *
27
+ * ## 脱敏与截断(硬规矩,helper 统一做,不靠调用点自觉)
28
+ *
29
+ * 日志会落盘(宿主的启动失败报告会带上最近日志),所以:
30
+ * - exec 的 `command` 记进 `detail` 时**截断到 200 字符**(含 `…` 截断信号,与全仓「截断要有
31
+ * 信号」同一取向)、**控制字符转义成 `\n` 等字面量**——保证「一行就是一次事件」,命令里的
32
+ * 换行伪造不出第二行审计;错误文案同样处理。
33
+ * - 刻意**不转义空格**:`detail` 是自由文本区(永远在行尾,解析器按字段序取前段不受它干扰),
34
+ * `ref` 过了 assertRef / assertImageRef / assertName 白名单(无空格),`target` 是用户自己
35
+ * 配置的目标名——可读性优先于机器可解析。
36
+ * - 与 elevation「nonce 绝不进日志」是同一类取舍的正面:这条**代价**(exec 命令截断 200 字符
37
+ * 进日志)已写进插件 README 的已知限制。
38
+ *
39
+ * ## 出口为什么是 console 而不是 ctx.logger(docker D162)
40
+ *
41
+ * 真机验收(2026-10-07,DSH 0.2.1-alpha.1)实测:插件经 `ctx.logger.info` 打的行**既不出现在
42
+ * 宿主 stdout、也找不到落盘文件**——能看到的 `[dsh-docker]` 行全部是 `console.log` 调用点
43
+ * (mounted / config applied / agent tools registered / tier-gate;tier-gate 的日志出口默认就是
44
+ * console,见 kit `tier-gate.ts`)。审计行走 ctx.logger 等于在真宿主上无处可查,而「出问题时翻
45
+ * 得到」正是审计存在的意义。所以出口固定走 `console.log`(前缀里已带 `[dsh-docker]`),
46
+ * **不做参数**:出口是本模块的取舍,不是调用方的选择。kit elevation 的授权行(begin / grant /
47
+ * expire / revoke / load)走的仍是 ctx.logger,同一问题记在根 ROADMAP 待办——修好后两类行同通道。
48
+ *
49
+ * ## 为什么在 docker 包、不在 kit
50
+ *
51
+ * 按 conventions 的 L0/L1 边界判据(改一个包就能做完 → L1):目前只有 docker 一个插件需要使用
52
+ * 审计。**晋升条件**:当第二个插件(tty)也要接同一套「capability-use」行时,把本模块上提
53
+ * `@hyzyn/dsh-kit`,并把日志前缀做成参数(对齐 elevation 的 `logPrefix`)——在那之前不要提前抽象。
54
+ */
55
+ /** 字符串值统一截断上限(exec 命令、错误文案;硬规矩见文件头)。 */
56
+ export const AUDIT_DETAIL_MAX_CHARS = 200;
57
+ /**
58
+ * 统一的截断 + 转义:先按**原始字符数**截断(补 `…` 信号),再逐字符转义控制字符。
59
+ * 单次遍历、不存在二次转义;空格刻意保留(取舍见文件头「脱敏与截断」)。
60
+ */
61
+ export function sanitizeAuditValue(text, maxChars = AUDIT_DETAIL_MAX_CHARS) {
62
+ let value = text;
63
+ let truncated = false;
64
+ if (value.length > maxChars) {
65
+ value = value.slice(0, maxChars);
66
+ truncated = true;
67
+ }
68
+ let out = '';
69
+ for (const char of value) {
70
+ if (char === '\\')
71
+ out += '\\\\';
72
+ else if (char === '\n')
73
+ out += '\\n';
74
+ else if (char === '\r')
75
+ out += '\\r';
76
+ else if (char === '\t')
77
+ out += '\\t';
78
+ else {
79
+ const code = char.codePointAt(0) ?? 0;
80
+ // 其余 C0/C1 控制字符(含 DEL):十六进制字面量,保住「一行一次事件」
81
+ out += code < 0x20 || code === 0x7f ? `\\x${code.toString(16).padStart(2, '0')}` : char;
82
+ }
83
+ }
84
+ return truncated ? out + '…' : out;
85
+ }
86
+ /** 把一条使用审计行打到宿主 stdout(出口取舍见文件头「出口为什么是 console」,docker D162)。 */
87
+ export function auditCapabilityUse(fields) {
88
+ const parts = [
89
+ `capability=${fields.capability}`,
90
+ `source=${fields.source}`,
91
+ `action=${fields.action}`,
92
+ `target=${sanitizeAuditValue(fields.target)}`,
93
+ ];
94
+ if (fields.ref !== undefined && fields.ref !== '')
95
+ parts.push(`ref=${sanitizeAuditValue(fields.ref)}`);
96
+ if (fields.event !== undefined)
97
+ parts.push(`event=${fields.event}`);
98
+ if (fields.ok !== undefined)
99
+ parts.push(`ok=${fields.ok ? 'true' : 'false'}`);
100
+ if (fields.durationMs !== undefined)
101
+ parts.push(`durationMs=${String(Math.max(0, Math.round(fields.durationMs)))}`);
102
+ if (fields.reason !== undefined)
103
+ parts.push(`reason=${sanitizeAuditValue(fields.reason)}`);
104
+ if (fields.code !== undefined && fields.code !== null)
105
+ parts.push(`code=${String(fields.code)}`);
106
+ if (fields.detail !== undefined)
107
+ parts.push(`detail=${sanitizeAuditValue(fields.detail)}`);
108
+ console.log(`[dsh-docker] capability-use: ${parts.join(' ')}`);
109
+ }
110
+ /**
111
+ * 包一层变更 / exec 调用:计时 → try/catch → 审计(成功与失败都记)→ 失败原样 rethrow。
112
+ *
113
+ * 只该放在**能力闸判定之后**(403 分支之后)——「使用 = 过了闸」。`detailOf` 可选:exec / pull
114
+ * 这类「api 成功返回但带退出码」的操作用它组 detail——exec 记 `code=… cmd=…`(**命令会进日志**,
115
+ * 这是写进 README 的代价),pull 只记退出码;整段 detail 的 200 截断与控制字符转义由 emit 时的
116
+ * `sanitizeAuditValue` 统一做(单一出口,不在调用点各写一遍)。失败路径的 detail 是错误文案。
117
+ */
118
+ export async function audited(fields, run, detailOf) {
119
+ const startedAt = Date.now();
120
+ try {
121
+ const result = await run();
122
+ auditCapabilityUse({
123
+ ...fields,
124
+ ok: true,
125
+ durationMs: Date.now() - startedAt,
126
+ detail: detailOf?.(result),
127
+ });
128
+ return result;
129
+ }
130
+ catch (error) {
131
+ auditCapabilityUse({
132
+ ...fields,
133
+ ok: false,
134
+ durationMs: Date.now() - startedAt,
135
+ detail: error instanceof Error ? error.message : String(error),
136
+ });
137
+ throw error;
138
+ }
139
+ }
140
+ //# sourceMappingURL=audit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit.js","sourceRoot":"","sources":["../src/audit.ts"],"names":[],"mappings":"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"}
package/lib/index.d.ts CHANGED
@@ -20,9 +20,13 @@ import type { Context } from '@deepseek-ai/cordis';
20
20
  import z from '@deepseek-ai/schemastery';
21
21
  import { DockerApi, assertBin, assertImageRef, parseImageHistoryJson, parseImageHistoryText, parseImageInspectJson, parseInspectJson, parsePsJson, parseStatsJson } from './docker.js';
22
22
  import type { DockerTarget, ResolvedTarget } from './docker.js';
23
+ import type { TierClass } from '@hyzyn/dsh-kit';
23
24
  import type { HostKeyRecord, SshSpec } from './ssh-exec.js';
24
25
  export type { HostKeyRecord } from './ssh-exec.js';
25
26
  export type { ContainerSummary, ContainerDetail, ContainerStats, ContainerEvent, ImageSummary, NetworkSummary, NetworkDetail, VolumeSummary, VolumeDetail, DockerTarget } from './docker.js';
27
+ export declare const DOCKER_TIER_CLASS: Record<string, TierClass>;
28
+ /** 本插件 agent 工具的名单前缀(档位闸只听这些名字)。 */
29
+ export declare const DOCKER_TIER_PREFIXES: readonly ["docker_"];
26
30
  export interface Config {
27
31
  /** 关闭整个插件。默认开。 */
28
32
  enabled?: boolean;
package/lib/index.js CHANGED
@@ -3,9 +3,40 @@ import { delimiter, basename, join } from 'node:path';
3
3
  import z from '@deepseek-ai/schemastery';
4
4
  import { definePlugin, hasSameOriginProof, isLoopbackRequestStrict, originProofHint, plainConfig, readSettingsEntry, settingsEntryScope, suppressAutoSettingsPage } from '@hyzyn/dsh-kit';
5
5
  import { defineTool } from '@deepseek-ai/dsh-tools';
6
+ import { auditCapabilityUse, audited } from './audit.js';
6
7
  import { DockerApi, assertBin, assertImageRef, assertName, assertRef, assertSince, createRunner, parseImageHistoryJson, parseImageHistoryText, parseContainerEvent, parseEventsJson, parseImageInspectJson, parseInspectJson, parsePsJson, parseStatsJson, suggestContainerNames, } from './docker.js';
7
8
  import { RemoteExec, sanitizeJumpSpec, sanitizeProxyCommand, setCredentialResolver, sshTarget } from './ssh-exec.js';
8
- import { auditLoadedGrants, bindCapabilitySources, capabilityDeniedMessage, capabilityGrantAt, capabilityGranted, capabilityGrantVia, capabilityPaths, createElevationManager, dshHome, sharedGrantStore, } from '@hyzyn/dsh-kit';
9
+ import { auditLoadedGrants, attachTierGate, bindCapabilitySources, capabilityDeniedMessage, capabilityGrantAt, capabilityGranted, capabilityGrantVia, capabilityPaths, createElevationManager, dshHome, sharedGrantStore, } from '@hyzyn/dsh-kit';
10
+ /*
11
+ * 档位闸的分类表(docs/permission-tier-plan.md §2.1):`read` 纯读 / 固定二进制探测
12
+ * (docker_connect_local 不在能力开关里,见 connectLocal 的注释);`write` 容器与镜像的变更
13
+ * (docker_image_pull 是拉取非破坏,但改本地状态,且本就注册在 allowMutations 组里);
14
+ * `exec` 容器内一次性命令。名单必须与 refreshTools 里 add() 注册的工集一一对应——对账由
15
+ * test/tier-gate.test.ts 从 add() 调用处现算(漏分类 → 红)。与 allowMutations / allowExec
16
+ * 的关系是**双层并存**:静态开关管注册(宿主级、持久授权),档位闸管已注册调用的放行
17
+ * (逐调用、会话级),谁也不取代谁。
18
+ */
19
+ export const DOCKER_TIER_CLASS = {
20
+ docker_targets: 'read',
21
+ docker_connect_local: 'read',
22
+ docker_ps: 'read',
23
+ docker_attention: 'read',
24
+ docker_inspect: 'read',
25
+ docker_logs: 'read',
26
+ docker_stats: 'read',
27
+ docker_events: 'read',
28
+ docker_images: 'read',
29
+ docker_image_inspect: 'read',
30
+ docker_networks: 'read',
31
+ docker_volumes: 'read',
32
+ docker_action: 'write',
33
+ docker_image_remove: 'write',
34
+ docker_image_prune: 'write',
35
+ docker_image_pull: 'write',
36
+ docker_exec: 'exec',
37
+ };
38
+ /** 本插件 agent 工具的名单前缀(档位闸只听这些名字)。 */
39
+ export const DOCKER_TIER_PREFIXES = ['docker_'];
9
40
  const TARGET_SCHEMA = z.object({
10
41
  name: z.string().required(),
11
42
  kind: z.union([z.const('local'), z.const('ssh')]).default('local'),
@@ -141,7 +172,17 @@ function guardTargetFailures(api) {
141
172
  function isTargetFailure(error) {
142
173
  return error !== null && typeof error === 'object' && error[TARGET_FAILURE] === true;
143
174
  }
144
- const DOCKER_GUIDANCE = '本机已安装 dsh-docker 插件(Docker 容器面板):Web GUI 侧边栏「容器」入口可查看各目标(本机 / SSH 主机)上的容器列表(含 Compose 项目视图、事件「活动」条)、状态、端口、日志(含实时跟随)与资源占用(含实时跟随 + 迷你趋势图),以及镜像列表与镜像详情(层 / 大小 / 构建历史、拉取进度流)、网络与卷(列表 + 详情;删除 / 清理同样在开关之后);目标在 插件配置 → Docker 容器面板 里维护(SSH 目标可直接引用 tty 终端面板的连接簿条目)。**默认只读**:启动/停止/重启/删除容器、删除镜像 / 清理 dangling / 拉取镜像、docker exec,都需要用户在设置里显式打开「允许变更操作」「允许 exec」后才有对应工具与按钮。agent 侧配套只读工具 docker_targets(列目标;没有任何目标时先调 docker_connect_local 一键加上本机目标)、docker_connect_local(把宿主所在机器加成本机目标并选中,复用已有的本机目标、不重复创建;顺带探测 daemon,失败会说明是 daemon 未启动 / CLI 缺失 / socket 无权访问)、docker_ps(列容器,含 compose 项目与服务;**target 传 `*` 可一次列出所有目标**;端口已按 IPv4/IPv6 双栈归并,`ports` 为空时看 `net`——host 网络容器的端口即宿主机端口)、docker_attention(**需关注汇总**:unhealthy / 反复重启 / OOM / 非零退出 / 僵死,同样支持 `*` 跨目标)、docker_inspect(容器详情)、docker_logs(日志快照)、docker_stats(CPU/内存/IO 快照)、docker_images(镜像列表)、docker_image_inspect(镜像详情 + 构建历史)、docker_events(容器事件快照,见面板容器列表的「活动」条)、docker_networks(网络列表)、docker_volumes(卷列表);排障推荐顺序:不确定从哪台/哪个容器看起时先 docker_attention(可 `*` 跨目标)→ docker_ps → docker_logs → docker_inspect → docker_stats → docker_events,镜像排查用 docker_images → docker_image_inspect。docker_action(容器生命周期)、docker_image_remove(删镜像)、docker_image_prune(清理 dangling)、docker_image_pull(拉取镜像)、docker_exec 仅在用户打开对应开关后可用,执行前须确认目标,破坏性操作(容器 remove / 镜像删除与清理)要向用户复述后果。网络 / 卷的删除与 prune 目前只提供面板按钮(HTTP 端点),没有对应的 agent 工具——不要在 agent 侧绕过面板做这些变更。docker socket 等价于目标主机的 root 权限,不要在用户未明确要求时执行变更操作。';
175
+ /**
176
+ * agent 公告:本插件的能力面与两条 exec 通道的边界。
177
+ *
178
+ * ⚠️ **「允许 exec」只管本插件的一次性通道**(D163)。卡片「终端」按钮跑的是
179
+ * `docker exec -it` 交互式 shell——它经 tty 的 `ttyTerminal` 承载、**不检查 `allowExec`**
180
+ * (`client-src/index.js` 的 `openExec` 里没有任何该判断,只读模式下也照常可用),所以这句话
181
+ * 一旦退回「…docker exec,都需要…才有对应工具与按钮」的笼统说法,就是在**声称一条不成立的
182
+ * 安全性质**(2026-10-07 用户正是照着旧措辞问「这算是漏洞吗」)。守卫:
183
+ * `scripts/test/exec-terminal-scope.test.ts`(含「退回旧措辞必须变红」的反例)。
184
+ */
185
+ const DOCKER_GUIDANCE = '本机已安装 dsh-docker 插件(Docker 容器面板):Web GUI 侧边栏「容器」入口可查看各目标(本机 / SSH 主机)上的容器列表(含 Compose 项目视图、事件「活动」条)、状态、端口、日志(含实时跟随)与资源占用(含实时跟随 + 迷你趋势图),以及镜像列表与镜像详情(层 / 大小 / 构建历史、拉取进度流)、网络与卷(列表 + 详情;删除 / 清理同样在开关之后);目标在 插件配置 → Docker 容器面板 里维护(SSH 目标可直接引用 tty 终端面板的连接簿条目)。**默认只读**:启动/停止/重启/删除容器、删除镜像 / 清理 dangling / 拉取镜像、**一次性** docker exec(docker_exec 工具与容器详情抽屉「概览」页签的一次性命令框),都需要用户在设置里显式打开「允许变更操作」「允许 exec」后才有对应工具与按钮;「允许 exec」**只管这条一次性通道**——卡片上的「终端」按钮跑的是 `docker exec -it` 交互式 shell,由 tty 终端面板承载、**不受它门控**(只读模式下也能用),别把它当成被关着。agent 侧配套只读工具 docker_targets(列目标;没有任何目标时先调 docker_connect_local 一键加上本机目标)、docker_connect_local(把宿主所在机器加成本机目标并选中,复用已有的本机目标、不重复创建;顺带探测 daemon,失败会说明是 daemon 未启动 / CLI 缺失 / socket 无权访问)、docker_ps(列容器,含 compose 项目与服务;**target 传 `*` 可一次列出所有目标**;端口已按 IPv4/IPv6 双栈归并,`ports` 为空时看 `net`——host 网络容器的端口即宿主机端口)、docker_attention(**需关注汇总**:unhealthy / 反复重启 / OOM / 非零退出 / 僵死,同样支持 `*` 跨目标)、docker_inspect(容器详情)、docker_logs(日志快照)、docker_stats(CPU/内存/IO 快照)、docker_images(镜像列表)、docker_image_inspect(镜像详情 + 构建历史)、docker_events(容器事件快照,见面板容器列表的「活动」条)、docker_networks(网络列表)、docker_volumes(卷列表);排障推荐顺序:不确定从哪台/哪个容器看起时先 docker_attention(可 `*` 跨目标)→ docker_ps → docker_logs → docker_inspect → docker_stats → docker_events,镜像排查用 docker_images → docker_image_inspect。docker_action(容器生命周期)、docker_image_remove(删镜像)、docker_image_prune(清理 dangling)、docker_image_pull(拉取镜像)、docker_exec 仅在用户打开对应开关后可用,执行前须确认目标,破坏性操作(容器 remove / 镜像删除与清理)要向用户复述后果。网络 / 卷的删除与 prune 目前只提供面板按钮(HTTP 端点),没有对应的 agent 工具——不要在 agent 侧绕过面板做这些变更。docker socket 等价于目标主机的 root 权限,不要在用户未明确要求时执行变更操作。';
145
186
  /**
146
187
  * SSE 帧封装:data 一律 `JSON.stringify` 成**单行**——换行 / 引号被转义,
147
188
  * 多字节字符也不会被 SSE 的 `\n` 行边界截断(客户端 JSON.parse 还原)。
@@ -762,7 +803,7 @@ const plugin = definePlugin({
762
803
  * 刻意放在 `enabled` 判定**之前**:授权是宿主级的、与插件这次是否启用无关;插件这次是禁用态时
763
804
  * 反而更该说一句(「盘上有授权,但插件没开」与「点了没反应」是两回事)。
764
805
  */
765
- auditLoadedGrants(grantStore, [CAP_MUTATIONS.env, CAP_EXEC.env], { info: (msg) => ctx.logger.info(msg), warn: (msg) => ctx.logger.warn(msg) }, '[dsh-docker]');
806
+ auditLoadedGrants(grantStore, [CAP_MUTATIONS.env, CAP_EXEC.env], '[dsh-docker]');
766
807
  // volatile 字段解析后是 `{ get() }` 引用,先还原成纯数据(见 @hyzyn/dsh-kit 的 plainConfig)。
767
808
  const config = plainConfig((rawConfig ?? {}));
768
809
  /*
@@ -2642,6 +2683,7 @@ const plugin = definePlugin({
2642
2683
  throw new Error(picked.error ?? '无效的 target');
2643
2684
  if (typeof input.id !== 'string')
2644
2685
  throw new Error('id 必填');
2686
+ const id = input.id;
2645
2687
  const action = input.action;
2646
2688
  if (action !== 'start' && action !== 'stop' && action !== 'restart' && action !== 'remove') {
2647
2689
  throw new Error('action 必须是 start / stop / restart / remove');
@@ -2649,7 +2691,7 @@ const plugin = definePlugin({
2649
2691
  const { api } = apiFor(picked.name);
2650
2692
  if (api === undefined)
2651
2693
  throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
2652
- const result = await api.action({ action, id: input.id });
2694
+ const result = await audited({ capability: 'allowMutations', source: 'tool', action: `container.${action}`, target: picked.name, ref: id }, () => api.action({ action, id }));
2653
2695
  return { target: picked.name, id: result.id, action: result.action, message: result.message };
2654
2696
  },
2655
2697
  }));
@@ -2684,10 +2726,11 @@ const plugin = definePlugin({
2684
2726
  throw new Error(picked.error ?? '无效的 target');
2685
2727
  if (typeof input.ref !== 'string' || input.ref.trim() === '')
2686
2728
  throw new Error('ref 必填');
2729
+ const ref = input.ref;
2687
2730
  const { api } = apiFor(picked.name);
2688
2731
  if (api === undefined)
2689
2732
  throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
2690
- const result = await api.imageRemove(input.ref);
2733
+ const result = await audited({ capability: 'allowMutations', source: 'tool', action: 'image.remove', target: picked.name, ref }, () => api.imageRemove(ref));
2691
2734
  return { target: picked.name, ref: result.ref, message: result.message };
2692
2735
  },
2693
2736
  }));
@@ -2719,7 +2762,7 @@ const plugin = definePlugin({
2719
2762
  const { api } = apiFor(picked.name);
2720
2763
  if (api === undefined)
2721
2764
  throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
2722
- const result = await api.imagePrune();
2765
+ const result = await audited({ capability: 'allowMutations', source: 'tool', action: 'image.prune', target: picked.name }, () => api.imagePrune());
2723
2766
  return { target: picked.name, message: result.message };
2724
2767
  },
2725
2768
  }));
@@ -2758,11 +2801,12 @@ const plugin = definePlugin({
2758
2801
  throw new Error(picked.error ?? '无效的 target');
2759
2802
  if (typeof input.ref !== 'string' || input.ref.trim() === '')
2760
2803
  throw new Error('ref 必填');
2804
+ const ref = input.ref;
2761
2805
  const { api } = apiFor(picked.name);
2762
2806
  if (api === undefined)
2763
2807
  throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
2764
2808
  const timeoutSec = typeof input.timeoutSec === 'number' && Number.isInteger(input.timeoutSec) ? input.timeoutSec : 600;
2765
- const result = await api.pull(input.ref, timeoutSec * 1000);
2809
+ const result = await audited({ capability: 'allowMutations', source: 'tool', action: 'image.pull', target: picked.name, ref }, () => api.pull(ref, timeoutSec * 1000), (row) => row.code === null ? undefined : `code=${String(row.code)}`);
2766
2810
  return {
2767
2811
  target: picked.name,
2768
2812
  ref: result.ref,
@@ -2819,11 +2863,13 @@ const plugin = definePlugin({
2819
2863
  throw new Error('id 必填');
2820
2864
  if (typeof input.command !== 'string' || input.command.trim() === '')
2821
2865
  throw new Error('command 必填');
2866
+ const id = input.id;
2867
+ const command = input.command;
2822
2868
  const timeoutSec = typeof input.timeoutSec === 'number' && Number.isInteger(input.timeoutSec) ? input.timeoutSec : live.execTimeoutSec;
2823
2869
  const { api } = apiFor(picked.name);
2824
2870
  if (api === undefined)
2825
2871
  throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
2826
- const result = await api.exec(input.id, input.command, timeoutSec * 1000);
2872
+ const result = await audited({ capability: 'allowExec', source: 'tool', action: 'exec', target: picked.name, ref: id }, () => api.exec(id, command, timeoutSec * 1000), (row) => `code=${String(row.code)} cmd=${row.command}`);
2827
2873
  return {
2828
2874
  target: picked.name,
2829
2875
  id: result.id,
@@ -2835,6 +2881,13 @@ const plugin = definePlugin({
2835
2881
  },
2836
2882
  }));
2837
2883
  }
2884
+ // 档位闸与工具同生命周期:refreshTools 幂等重建时随 toolDisposers 一起撤下/重挂
2885
+ // (禁用热生效时工具与闸一起撤;机制见 kit 的 tier-gate.ts,方案见 docs/permission-tier-plan.md)。
2886
+ toolDisposers.push(attachTierGate(ctx, {
2887
+ pkg: 'docker',
2888
+ prefixes: DOCKER_TIER_PREFIXES,
2889
+ classify: (tool) => DOCKER_TIER_CLASS[tool],
2890
+ }));
2838
2891
  if (failures.length > 0) {
2839
2892
  console.warn(`[dsh-docker] ${String(failures.length)} 个 agent 工具注册失败(下次配置变更会重试):${failures.join(';')}`);
2840
2893
  }
@@ -3197,6 +3250,8 @@ const plugin = definePlugin({
3197
3250
  writeJson(res, 400, { error: built.error ?? '无法构造执行通道' });
3198
3251
  return;
3199
3252
  }
3253
+ // run 回调里 TS 不保留 picked.name 的属性窄化,先落局部 const
3254
+ const targetName = picked.name;
3200
3255
  await openSseStream(res, {
3201
3256
  reason: 'pull-exit',
3202
3257
  // 同日志流:逐层进度是文本尾部流,积压时丢最旧而不是把整条流掐掉(D153)
@@ -3207,13 +3262,26 @@ const plugin = definePlugin({
3207
3262
  const coalescer = createSseCoalescer({
3208
3263
  emit: (channel, text) => sendEvent('line', channel === 'd' ? { d: text } : { e: text }),
3209
3264
  });
3265
+ /*
3266
+ * 使用审计拆成 start / end 两行(capability-use 是逐次事件,不是逐行日志):
3267
+ * 开流记 start(宿主中途挂掉时至少「有人拉过这个镜像」还在),收尾记 end——
3268
+ * 被中止时 runner 以 code=null 落定,end 行省略 code;抛错则 ok=false + detail。
3269
+ */
3270
+ const useFields = { capability: 'allowMutations', source: 'http', action: 'image.pull', target: targetName, ref: safeRef };
3271
+ const startedAt = Date.now();
3272
+ auditCapabilityUse({ ...useFields, event: 'start' });
3210
3273
  try {
3211
3274
  const result = await api.pullStream(safeRef, {
3212
3275
  onStdout: (chunk) => coalescer.push('d', chunk),
3213
3276
  onStderr: (chunk) => coalescer.push('e', chunk),
3214
3277
  }, signal);
3278
+ auditCapabilityUse({ ...useFields, event: 'end', ok: true, durationMs: Date.now() - startedAt, reason: 'pull-exit', ...(result.code === null ? {} : { code: result.code }) });
3215
3279
  return result.code;
3216
3280
  }
3281
+ catch (error) {
3282
+ auditCapabilityUse({ ...useFields, event: 'end', ok: false, durationMs: Date.now() - startedAt, detail: error instanceof Error ? error.message : String(error) });
3283
+ throw error;
3284
+ }
3217
3285
  finally {
3218
3286
  coalescer.flush();
3219
3287
  coalescer.dispose();
@@ -3648,7 +3716,10 @@ const plugin = definePlugin({
3648
3716
  writeJson(res, 400, { error: 'ref 必填' });
3649
3717
  return;
3650
3718
  }
3651
- writeJson(res, 200, { ok: true, result: await api.imageRemove(body.ref) });
3719
+ // 能力闸之后包审计(「使用 = 过了闸」;403 不记)——下同;
3720
+ // ref 先落局部 const:回调里 TS 不保留属性窄化
3721
+ const ref = body.ref;
3722
+ writeJson(res, 200, { ok: true, result: await audited({ capability: 'allowMutations', source: 'http', action: 'image.remove', target: picked.name, ref }, () => api.imageRemove(ref)) });
3652
3723
  return;
3653
3724
  }
3654
3725
  case '/images/prune': {
@@ -3656,7 +3727,7 @@ const plugin = definePlugin({
3656
3727
  writeJson(res, 403, { error: mutationsOffMessage() });
3657
3728
  return;
3658
3729
  }
3659
- writeJson(res, 200, { ok: true, result: await api.imagePrune() });
3730
+ writeJson(res, 200, { ok: true, result: await audited({ capability: 'allowMutations', source: 'http', action: 'image.prune', target: picked.name }, () => api.imagePrune()) });
3660
3731
  return;
3661
3732
  }
3662
3733
  case '/networks': {
@@ -3680,7 +3751,8 @@ const plugin = definePlugin({
3680
3751
  writeJson(res, 400, { error: 'name 必填' });
3681
3752
  return;
3682
3753
  }
3683
- writeJson(res, 200, { ok: true, result: await api.networkRemove(body.name) });
3754
+ const name = body.name;
3755
+ writeJson(res, 200, { ok: true, result: await audited({ capability: 'allowMutations', source: 'http', action: 'network.remove', target: picked.name, ref: name }, () => api.networkRemove(name)) });
3684
3756
  return;
3685
3757
  }
3686
3758
  case '/networks/prune': {
@@ -3688,7 +3760,7 @@ const plugin = definePlugin({
3688
3760
  writeJson(res, 403, { error: mutationsOffMessage() });
3689
3761
  return;
3690
3762
  }
3691
- writeJson(res, 200, { ok: true, result: await api.networkPrune() });
3763
+ writeJson(res, 200, { ok: true, result: await audited({ capability: 'allowMutations', source: 'http', action: 'network.prune', target: picked.name }, () => api.networkPrune()) });
3692
3764
  return;
3693
3765
  }
3694
3766
  case '/volumes': {
@@ -3712,7 +3784,8 @@ const plugin = definePlugin({
3712
3784
  writeJson(res, 400, { error: 'name 必填' });
3713
3785
  return;
3714
3786
  }
3715
- writeJson(res, 200, { ok: true, result: await api.volumeRemove(body.name) });
3787
+ const name = body.name;
3788
+ writeJson(res, 200, { ok: true, result: await audited({ capability: 'allowMutations', source: 'http', action: 'volume.remove', target: picked.name, ref: name }, () => api.volumeRemove(name)) });
3716
3789
  return;
3717
3790
  }
3718
3791
  case '/volumes/prune': {
@@ -3720,7 +3793,7 @@ const plugin = definePlugin({
3720
3793
  writeJson(res, 403, { error: mutationsOffMessage() });
3721
3794
  return;
3722
3795
  }
3723
- writeJson(res, 200, { ok: true, result: await api.volumePrune() });
3796
+ writeJson(res, 200, { ok: true, result: await audited({ capability: 'allowMutations', source: 'http', action: 'volume.prune', target: picked.name }, () => api.volumePrune()) });
3724
3797
  return;
3725
3798
  }
3726
3799
  case '/action': {
@@ -3737,7 +3810,8 @@ const plugin = definePlugin({
3737
3810
  writeJson(res, 400, { error: 'action 必须是 start / stop / restart / remove' });
3738
3811
  return;
3739
3812
  }
3740
- writeJson(res, 200, { ok: true, result: await api.action({ action, id: body.id }) });
3813
+ const id = body.id;
3814
+ writeJson(res, 200, { ok: true, result: await audited({ capability: 'allowMutations', source: 'http', action: `container.${action}`, target: picked.name, ref: id }, () => api.action({ action, id })) });
3741
3815
  return;
3742
3816
  }
3743
3817
  case '/exec': {
@@ -3750,7 +3824,11 @@ const plugin = definePlugin({
3750
3824
  return;
3751
3825
  }
3752
3826
  const timeoutSec = typeof body.timeoutSec === 'number' && Number.isInteger(body.timeoutSec) ? body.timeoutSec : live.execTimeoutSec;
3753
- writeJson(res, 200, { ok: true, result: await api.exec(body.id, body.command, timeoutSec * 1000) });
3827
+ const id = body.id;
3828
+ const command = body.command;
3829
+ // exec 的退出码是命令自己的结果(非 0 不是 docker 报错):ok 只随 api 成败;
3830
+ // 命令随行进日志(这是要写进 README 的代价)——截断与控制字符转义由 helper 统一做
3831
+ writeJson(res, 200, { ok: true, result: await audited({ capability: 'allowExec', source: 'http', action: 'exec', target: picked.name, ref: id }, () => api.exec(id, command, timeoutSec * 1000), (row) => `code=${String(row.code)} cmd=${row.command}`) });
3754
3832
  return;
3755
3833
  }
3756
3834
  default: {