@hyzyn/dsh-docker 0.9.6 → 0.10.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":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AAqDH,wCAAwC;AACxC,MAAM,CAAC,MAAM,sBAAsB,GAAG,GAAG,CAAA;AAEzC;;;GAGG;AACH,MAAM,UAAU,kBAAkB,CAAC,IAAY,EAAE,WAAmB,sBAAsB;IACxF,IAAI,KAAK,GAAG,IAAI,CAAA;IAChB,IAAI,SAAS,GAAG,KAAK,CAAA;IACrB,IAAI,KAAK,CAAC,MAAM,GAAG,QAAQ,EAAE,CAAC;QAC5B,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAA;QAChC,SAAS,GAAG,IAAI,CAAA;IAClB,CAAC;IACD,IAAI,GAAG,GAAG,EAAE,CAAA;IACZ,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,IAAI,IAAI,KAAK,IAAI;YAAE,GAAG,IAAI,MAAM,CAAA;aAC3B,IAAI,IAAI,KAAK,IAAI;YAAE,GAAG,IAAI,KAAK,CAAA;aAC/B,IAAI,IAAI,KAAK,IAAI;YAAE,GAAG,IAAI,KAAK,CAAA;aAC/B,IAAI,IAAI,KAAK,IAAI;YAAE,GAAG,IAAI,KAAK,CAAA;aAC/B,CAAC;YACJ,MAAM,IAAI,GAAG,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,IAAI,CAAC,CAAA;YACrC,0CAA0C;YAC1C,GAAG,IAAI,IAAI,GAAG,IAAI,IAAI,IAAI,KAAK,IAAI,CAAC,CAAC,CAAC,MAAM,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAA;QACzF,CAAC;IACH,CAAC;IACD,OAAO,SAAS,CAAC,CAAC,CAAC,GAAG,GAAG,GAAG,CAAC,CAAC,CAAC,GAAG,CAAA;AACpC,CAAC;AAED,iEAAiE;AACjE,MAAM,UAAU,kBAAkB,CAAC,MAAyB;IAC1D,MAAM,KAAK,GAAG;QACZ,cAAc,MAAM,CAAC,UAAU,EAAE;QACjC,UAAU,MAAM,CAAC,MAAM,EAAE;QACzB,UAAU,MAAM,CAAC,MAAM,EAAE;QACzB,UAAU,kBAAkB,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE;KAC9C,CAAA;IACD,IAAI,MAAM,CAAC,GAAG,KAAK,SAAS,IAAI,MAAM,CAAC,GAAG,KAAK,EAAE;QAAE,KAAK,CAAC,IAAI,CAAC,OAAO,kBAAkB,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;IACtG,IAAI,MAAM,CAAC,KAAK,KAAK,SAAS;QAAE,KAAK,CAAC,IAAI,CAAC,SAAS,MAAM,CAAC,KAAK,EAAE,CAAC,CAAA;IACnE,IAAI,MAAM,CAAC,EAAE,KAAK,SAAS;QAAE,KAAK,CAAC,IAAI,CAAC,MAAM,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC,CAAA;IAC7E,IAAI,MAAM,CAAC,UAAU,KAAK,SAAS;QAAE,KAAK,CAAC,IAAI,CAAC,cAAc,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,CAAC,CAAA;IACnH,IAAI,MAAM,CAAC,MAAM,KAAK,SAAS;QAAE,KAAK,CAAC,IAAI,CAAC,UAAU,kBAAkB,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;IAC1F,IAAI,MAAM,CAAC,IAAI,KAAK,SAAS,IAAI,MAAM,CAAC,IAAI,KAAK,IAAI;QAAE,KAAK,CAAC,IAAI,CAAC,QAAQ,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;IAChG,IAAI,MAAM,CAAC,MAAM,KAAK,SAAS;QAAE,KAAK,CAAC,IAAI,CAAC,UAAU,kBAAkB,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;IAC1F,OAAO,CAAC,GAAG,CAAC,gCAAgC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;AAChE,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,OAAO,CAC3B,MAA2B,EAC3B,GAAqB,EACrB,QAA4C;IAE5C,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,CAAA;IAC5B,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,MAAM,GAAG,EAAE,CAAA;QAC1B,kBAAkB,CAAC;YACjB,GAAG,MAAM;YACT,EAAE,EAAE,IAAI;YACR,UAAU,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,SAAS;YAClC,MAAM,EAAE,QAAQ,EAAE,CAAC,MAAM,CAAC;SAC3B,CAAC,CAAA;QACF,OAAO,MAAM,CAAA;IACf,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,kBAAkB,CAAC;YACjB,GAAG,MAAM;YACT,EAAE,EAAE,KAAK;YACT,UAAU,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,SAAS;YAClC,MAAM,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC;SAC/D,CAAC,CAAA;QACF,MAAM,KAAK,CAAA;IACb,CAAC;AACH,CAAC"}
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'),
@@ -762,7 +793,7 @@ const plugin = definePlugin({
762
793
  * 刻意放在 `enabled` 判定**之前**:授权是宿主级的、与插件这次是否启用无关;插件这次是禁用态时
763
794
  * 反而更该说一句(「盘上有授权,但插件没开」与「点了没反应」是两回事)。
764
795
  */
765
- auditLoadedGrants(grantStore, [CAP_MUTATIONS.env, CAP_EXEC.env], { info: (msg) => ctx.logger.info(msg), warn: (msg) => ctx.logger.warn(msg) }, '[dsh-docker]');
796
+ auditLoadedGrants(grantStore, [CAP_MUTATIONS.env, CAP_EXEC.env], '[dsh-docker]');
766
797
  // volatile 字段解析后是 `{ get() }` 引用,先还原成纯数据(见 @hyzyn/dsh-kit 的 plainConfig)。
767
798
  const config = plainConfig((rawConfig ?? {}));
768
799
  /*
@@ -2642,6 +2673,7 @@ const plugin = definePlugin({
2642
2673
  throw new Error(picked.error ?? '无效的 target');
2643
2674
  if (typeof input.id !== 'string')
2644
2675
  throw new Error('id 必填');
2676
+ const id = input.id;
2645
2677
  const action = input.action;
2646
2678
  if (action !== 'start' && action !== 'stop' && action !== 'restart' && action !== 'remove') {
2647
2679
  throw new Error('action 必须是 start / stop / restart / remove');
@@ -2649,7 +2681,7 @@ const plugin = definePlugin({
2649
2681
  const { api } = apiFor(picked.name);
2650
2682
  if (api === undefined)
2651
2683
  throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
2652
- const result = await api.action({ action, id: input.id });
2684
+ const result = await audited({ capability: 'allowMutations', source: 'tool', action: `container.${action}`, target: picked.name, ref: id }, () => api.action({ action, id }));
2653
2685
  return { target: picked.name, id: result.id, action: result.action, message: result.message };
2654
2686
  },
2655
2687
  }));
@@ -2684,10 +2716,11 @@ const plugin = definePlugin({
2684
2716
  throw new Error(picked.error ?? '无效的 target');
2685
2717
  if (typeof input.ref !== 'string' || input.ref.trim() === '')
2686
2718
  throw new Error('ref 必填');
2719
+ const ref = input.ref;
2687
2720
  const { api } = apiFor(picked.name);
2688
2721
  if (api === undefined)
2689
2722
  throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
2690
- const result = await api.imageRemove(input.ref);
2723
+ const result = await audited({ capability: 'allowMutations', source: 'tool', action: 'image.remove', target: picked.name, ref }, () => api.imageRemove(ref));
2691
2724
  return { target: picked.name, ref: result.ref, message: result.message };
2692
2725
  },
2693
2726
  }));
@@ -2719,7 +2752,7 @@ const plugin = definePlugin({
2719
2752
  const { api } = apiFor(picked.name);
2720
2753
  if (api === undefined)
2721
2754
  throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
2722
- const result = await api.imagePrune();
2755
+ const result = await audited({ capability: 'allowMutations', source: 'tool', action: 'image.prune', target: picked.name }, () => api.imagePrune());
2723
2756
  return { target: picked.name, message: result.message };
2724
2757
  },
2725
2758
  }));
@@ -2758,11 +2791,12 @@ const plugin = definePlugin({
2758
2791
  throw new Error(picked.error ?? '无效的 target');
2759
2792
  if (typeof input.ref !== 'string' || input.ref.trim() === '')
2760
2793
  throw new Error('ref 必填');
2794
+ const ref = input.ref;
2761
2795
  const { api } = apiFor(picked.name);
2762
2796
  if (api === undefined)
2763
2797
  throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
2764
2798
  const timeoutSec = typeof input.timeoutSec === 'number' && Number.isInteger(input.timeoutSec) ? input.timeoutSec : 600;
2765
- const result = await api.pull(input.ref, timeoutSec * 1000);
2799
+ 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
2800
  return {
2767
2801
  target: picked.name,
2768
2802
  ref: result.ref,
@@ -2819,11 +2853,13 @@ const plugin = definePlugin({
2819
2853
  throw new Error('id 必填');
2820
2854
  if (typeof input.command !== 'string' || input.command.trim() === '')
2821
2855
  throw new Error('command 必填');
2856
+ const id = input.id;
2857
+ const command = input.command;
2822
2858
  const timeoutSec = typeof input.timeoutSec === 'number' && Number.isInteger(input.timeoutSec) ? input.timeoutSec : live.execTimeoutSec;
2823
2859
  const { api } = apiFor(picked.name);
2824
2860
  if (api === undefined)
2825
2861
  throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
2826
- const result = await api.exec(input.id, input.command, timeoutSec * 1000);
2862
+ 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
2863
  return {
2828
2864
  target: picked.name,
2829
2865
  id: result.id,
@@ -2835,6 +2871,13 @@ const plugin = definePlugin({
2835
2871
  },
2836
2872
  }));
2837
2873
  }
2874
+ // 档位闸与工具同生命周期:refreshTools 幂等重建时随 toolDisposers 一起撤下/重挂
2875
+ // (禁用热生效时工具与闸一起撤;机制见 kit 的 tier-gate.ts,方案见 docs/permission-tier-plan.md)。
2876
+ toolDisposers.push(attachTierGate(ctx, {
2877
+ pkg: 'docker',
2878
+ prefixes: DOCKER_TIER_PREFIXES,
2879
+ classify: (tool) => DOCKER_TIER_CLASS[tool],
2880
+ }));
2838
2881
  if (failures.length > 0) {
2839
2882
  console.warn(`[dsh-docker] ${String(failures.length)} 个 agent 工具注册失败(下次配置变更会重试):${failures.join(';')}`);
2840
2883
  }
@@ -3197,6 +3240,8 @@ const plugin = definePlugin({
3197
3240
  writeJson(res, 400, { error: built.error ?? '无法构造执行通道' });
3198
3241
  return;
3199
3242
  }
3243
+ // run 回调里 TS 不保留 picked.name 的属性窄化,先落局部 const
3244
+ const targetName = picked.name;
3200
3245
  await openSseStream(res, {
3201
3246
  reason: 'pull-exit',
3202
3247
  // 同日志流:逐层进度是文本尾部流,积压时丢最旧而不是把整条流掐掉(D153)
@@ -3207,13 +3252,26 @@ const plugin = definePlugin({
3207
3252
  const coalescer = createSseCoalescer({
3208
3253
  emit: (channel, text) => sendEvent('line', channel === 'd' ? { d: text } : { e: text }),
3209
3254
  });
3255
+ /*
3256
+ * 使用审计拆成 start / end 两行(capability-use 是逐次事件,不是逐行日志):
3257
+ * 开流记 start(宿主中途挂掉时至少「有人拉过这个镜像」还在),收尾记 end——
3258
+ * 被中止时 runner 以 code=null 落定,end 行省略 code;抛错则 ok=false + detail。
3259
+ */
3260
+ const useFields = { capability: 'allowMutations', source: 'http', action: 'image.pull', target: targetName, ref: safeRef };
3261
+ const startedAt = Date.now();
3262
+ auditCapabilityUse({ ...useFields, event: 'start' });
3210
3263
  try {
3211
3264
  const result = await api.pullStream(safeRef, {
3212
3265
  onStdout: (chunk) => coalescer.push('d', chunk),
3213
3266
  onStderr: (chunk) => coalescer.push('e', chunk),
3214
3267
  }, signal);
3268
+ auditCapabilityUse({ ...useFields, event: 'end', ok: true, durationMs: Date.now() - startedAt, reason: 'pull-exit', ...(result.code === null ? {} : { code: result.code }) });
3215
3269
  return result.code;
3216
3270
  }
3271
+ catch (error) {
3272
+ auditCapabilityUse({ ...useFields, event: 'end', ok: false, durationMs: Date.now() - startedAt, detail: error instanceof Error ? error.message : String(error) });
3273
+ throw error;
3274
+ }
3217
3275
  finally {
3218
3276
  coalescer.flush();
3219
3277
  coalescer.dispose();
@@ -3648,7 +3706,10 @@ const plugin = definePlugin({
3648
3706
  writeJson(res, 400, { error: 'ref 必填' });
3649
3707
  return;
3650
3708
  }
3651
- writeJson(res, 200, { ok: true, result: await api.imageRemove(body.ref) });
3709
+ // 能力闸之后包审计(「使用 = 过了闸」;403 不记)——下同;
3710
+ // ref 先落局部 const:回调里 TS 不保留属性窄化
3711
+ const ref = body.ref;
3712
+ writeJson(res, 200, { ok: true, result: await audited({ capability: 'allowMutations', source: 'http', action: 'image.remove', target: picked.name, ref }, () => api.imageRemove(ref)) });
3652
3713
  return;
3653
3714
  }
3654
3715
  case '/images/prune': {
@@ -3656,7 +3717,7 @@ const plugin = definePlugin({
3656
3717
  writeJson(res, 403, { error: mutationsOffMessage() });
3657
3718
  return;
3658
3719
  }
3659
- writeJson(res, 200, { ok: true, result: await api.imagePrune() });
3720
+ writeJson(res, 200, { ok: true, result: await audited({ capability: 'allowMutations', source: 'http', action: 'image.prune', target: picked.name }, () => api.imagePrune()) });
3660
3721
  return;
3661
3722
  }
3662
3723
  case '/networks': {
@@ -3680,7 +3741,8 @@ const plugin = definePlugin({
3680
3741
  writeJson(res, 400, { error: 'name 必填' });
3681
3742
  return;
3682
3743
  }
3683
- writeJson(res, 200, { ok: true, result: await api.networkRemove(body.name) });
3744
+ const name = body.name;
3745
+ writeJson(res, 200, { ok: true, result: await audited({ capability: 'allowMutations', source: 'http', action: 'network.remove', target: picked.name, ref: name }, () => api.networkRemove(name)) });
3684
3746
  return;
3685
3747
  }
3686
3748
  case '/networks/prune': {
@@ -3688,7 +3750,7 @@ const plugin = definePlugin({
3688
3750
  writeJson(res, 403, { error: mutationsOffMessage() });
3689
3751
  return;
3690
3752
  }
3691
- writeJson(res, 200, { ok: true, result: await api.networkPrune() });
3753
+ writeJson(res, 200, { ok: true, result: await audited({ capability: 'allowMutations', source: 'http', action: 'network.prune', target: picked.name }, () => api.networkPrune()) });
3692
3754
  return;
3693
3755
  }
3694
3756
  case '/volumes': {
@@ -3712,7 +3774,8 @@ const plugin = definePlugin({
3712
3774
  writeJson(res, 400, { error: 'name 必填' });
3713
3775
  return;
3714
3776
  }
3715
- writeJson(res, 200, { ok: true, result: await api.volumeRemove(body.name) });
3777
+ const name = body.name;
3778
+ writeJson(res, 200, { ok: true, result: await audited({ capability: 'allowMutations', source: 'http', action: 'volume.remove', target: picked.name, ref: name }, () => api.volumeRemove(name)) });
3716
3779
  return;
3717
3780
  }
3718
3781
  case '/volumes/prune': {
@@ -3720,7 +3783,7 @@ const plugin = definePlugin({
3720
3783
  writeJson(res, 403, { error: mutationsOffMessage() });
3721
3784
  return;
3722
3785
  }
3723
- writeJson(res, 200, { ok: true, result: await api.volumePrune() });
3786
+ writeJson(res, 200, { ok: true, result: await audited({ capability: 'allowMutations', source: 'http', action: 'volume.prune', target: picked.name }, () => api.volumePrune()) });
3724
3787
  return;
3725
3788
  }
3726
3789
  case '/action': {
@@ -3737,7 +3800,8 @@ const plugin = definePlugin({
3737
3800
  writeJson(res, 400, { error: 'action 必须是 start / stop / restart / remove' });
3738
3801
  return;
3739
3802
  }
3740
- writeJson(res, 200, { ok: true, result: await api.action({ action, id: body.id }) });
3803
+ const id = body.id;
3804
+ 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
3805
  return;
3742
3806
  }
3743
3807
  case '/exec': {
@@ -3750,7 +3814,11 @@ const plugin = definePlugin({
3750
3814
  return;
3751
3815
  }
3752
3816
  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) });
3817
+ const id = body.id;
3818
+ const command = body.command;
3819
+ // exec 的退出码是命令自己的结果(非 0 不是 docker 报错):ok 只随 api 成败;
3820
+ // 命令随行进日志(这是要写进 README 的代价)——截断与控制字符转义由 helper 统一做
3821
+ 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
3822
  return;
3755
3823
  }
3756
3824
  default: {