ssh-licco 2.2.1__tar.gz → 2.3.0__tar.gz
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.
- {ssh_licco-2.2.1/ssh_licco.egg-info → ssh_licco-2.3.0}/PKG-INFO +3 -3
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/README.md +2 -2
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/pyproject.toml +1 -1
- {ssh_licco-2.2.1 → ssh_licco-2.3.0/ssh_licco.egg-info}/PKG-INFO +3 -3
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_licco.egg-info/SOURCES.txt +0 -2
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/__init__.py +2 -2
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/handlers/__init__.py +0 -4
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/handlers/execute.py +0 -9
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/handlers/schemas.py +0 -61
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/handlers/utils.py +0 -48
- ssh_licco-2.2.1/ssh_mcp/approval.py +0 -220
- ssh_licco-2.2.1/ssh_mcp/handlers/approval.py +0 -131
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/LICENSE +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/setup.cfg +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_licco.egg-info/dependency_links.txt +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_licco.egg-info/entry_points.txt +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_licco.egg-info/requires.txt +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_licco.egg-info/top_level.txt +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/__main__.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/audit_logger.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/batch_executor.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/cli.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/clients/__init__.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/clients/additional_clients.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/clients/factory.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/clients/interface.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/clients/paramiko_client.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/config_manager.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/connection_config.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/connection_pool.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/exceptions.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/executor.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/handlers/connect.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/handlers/context.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/handlers/docker.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/handlers/file_transfer.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/handlers/host.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/handlers/key.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/handlers/process.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/handlers/session.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/key_manager.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/logging_config.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/runtime_guard.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/secret_provider.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/security.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/server.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/service.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/session_manager.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/tunnel.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/ssh_mcp/watchdog.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/tests/test_config_manager.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/tests/test_key_manager.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/tests/test_security.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/tests/test_server.py +0 -0
- {ssh_licco-2.2.1 → ssh_licco-2.3.0}/tests/test_session_manager.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ssh-licco
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: SSH Model Context Protocol Server - Enable SSH functionality for AI models
|
|
5
5
|
Author-email: SSH LICCO Team <contact@ssh-licco.dev>
|
|
6
6
|
License: MIT
|
|
@@ -267,7 +267,7 @@ SSH 跳板模式部署的生产必做加固,补齐跳板机单点风险:
|
|
|
267
267
|
| 3. 双层命令拦截 | `SSH_REMOTE_GUARD=true` + 远端 ForceCommand | 跳板机侧禁元字符 + 远端二次白名单校验 |
|
|
268
268
|
| 4. 灾难性命令硬拦截(v2.2.0 新增) | 默认开启,不可关闭 | rm -rf 绝对路径、mkfs、raw-disk dd、fork-bomb、chmod -R 777/000 /、`> /dev/sd*` 等在 MCP 网关层被**无条件**拒绝;任何安全级别、`confirm_dangerous=true` 等均无法绕过 |
|
|
269
269
|
|
|
270
|
-
> **关于 v2.1.0 引入的"高危操作审批"项**:v2.1.0 曾提供 `SSH_APPROVAL_GATE=true` + 3 个审批工具作为第 4 项加固。v2.2.0 起该项被**硬拦截取代**——审批流程依赖 AI 自报命令,存在闭环风险;硬拦截更直接,灾难性命令在 MCP 网关层就被拒绝,运维侧不需要再走"先申请再审批"流程。`SSH_APPROVAL_GATE` 环境变量与 `ssh_mcp/approval.py`
|
|
270
|
+
> **关于 v2.1.0 引入的"高危操作审批"项**:v2.1.0 曾提供 `SSH_APPROVAL_GATE=true` + 3 个审批工具作为第 4 项加固。v2.2.0 起该项被**硬拦截取代**——审批流程依赖 AI 自报命令,存在闭环风险;硬拦截更直接,灾难性命令在 MCP 网关层就被拒绝,运维侧不需要再走"先申请再审批"流程。`SSH_APPROVAL_GATE` 环境变量与 `ssh_mcp/approval.py` / `ssh_mcp/handlers/approval.py` 代码已在 v2.2.0 删除。
|
|
271
271
|
|
|
272
272
|
完整方案见 [docs/SECURITY_HARDENING.md](docs/SECURITY_HARDENING.md)。
|
|
273
273
|
|
|
@@ -287,7 +287,7 @@ SSH 跳板模式部署的生产必做加固,补齐跳板机单点风险:
|
|
|
287
287
|
| `ssh_session` | screen/tmux 会话 | 持久化远程会话管理(create/send/capture/list/kill),SSH 断开后进程依然存活 |
|
|
288
288
|
| `ssh_process` | 进程管理 | 启动/停止/查询远程进程,SSH 端口转发(tunnel_open/tunnel_close/tunnel_list) |
|
|
289
289
|
|
|
290
|
-
> **关于 v2.1.0 引入的 3 个审批工具**(`ssh_request_approval` / `ssh_approve_command` / `ssh_list_approvals`):已从 MCP `list_tools()`
|
|
290
|
+
> **关于 v2.1.0 引入的 3 个审批工具**(`ssh_request_approval` / `ssh_approve_command` / `ssh_list_approvals`):已从 MCP `list_tools()` 移除,**代码已在 v2.2.0 删除**(`ssh_mcp/approval.py`、`ssh_mcp/handlers/approval.py`)。审批流程依赖 AI 自报命令、运维侧背书,存在闭环风险;v2.2.0 的硬拦截更直接——灾难性命令在 MCP 网关层就被拒绝,运维侧不需要再走"先申请再审批"流程。
|
|
291
291
|
|
|
292
292
|
### 📖 详细文档
|
|
293
293
|
|
|
@@ -234,7 +234,7 @@ SSH 跳板模式部署的生产必做加固,补齐跳板机单点风险:
|
|
|
234
234
|
| 3. 双层命令拦截 | `SSH_REMOTE_GUARD=true` + 远端 ForceCommand | 跳板机侧禁元字符 + 远端二次白名单校验 |
|
|
235
235
|
| 4. 灾难性命令硬拦截(v2.2.0 新增) | 默认开启,不可关闭 | rm -rf 绝对路径、mkfs、raw-disk dd、fork-bomb、chmod -R 777/000 /、`> /dev/sd*` 等在 MCP 网关层被**无条件**拒绝;任何安全级别、`confirm_dangerous=true` 等均无法绕过 |
|
|
236
236
|
|
|
237
|
-
> **关于 v2.1.0 引入的"高危操作审批"项**:v2.1.0 曾提供 `SSH_APPROVAL_GATE=true` + 3 个审批工具作为第 4 项加固。v2.2.0 起该项被**硬拦截取代**——审批流程依赖 AI 自报命令,存在闭环风险;硬拦截更直接,灾难性命令在 MCP 网关层就被拒绝,运维侧不需要再走"先申请再审批"流程。`SSH_APPROVAL_GATE` 环境变量与 `ssh_mcp/approval.py`
|
|
237
|
+
> **关于 v2.1.0 引入的"高危操作审批"项**:v2.1.0 曾提供 `SSH_APPROVAL_GATE=true` + 3 个审批工具作为第 4 项加固。v2.2.0 起该项被**硬拦截取代**——审批流程依赖 AI 自报命令,存在闭环风险;硬拦截更直接,灾难性命令在 MCP 网关层就被拒绝,运维侧不需要再走"先申请再审批"流程。`SSH_APPROVAL_GATE` 环境变量与 `ssh_mcp/approval.py` / `ssh_mcp/handlers/approval.py` 代码已在 v2.2.0 删除。
|
|
238
238
|
|
|
239
239
|
完整方案见 [docs/SECURITY_HARDENING.md](docs/SECURITY_HARDENING.md)。
|
|
240
240
|
|
|
@@ -254,7 +254,7 @@ SSH 跳板模式部署的生产必做加固,补齐跳板机单点风险:
|
|
|
254
254
|
| `ssh_session` | screen/tmux 会话 | 持久化远程会话管理(create/send/capture/list/kill),SSH 断开后进程依然存活 |
|
|
255
255
|
| `ssh_process` | 进程管理 | 启动/停止/查询远程进程,SSH 端口转发(tunnel_open/tunnel_close/tunnel_list) |
|
|
256
256
|
|
|
257
|
-
> **关于 v2.1.0 引入的 3 个审批工具**(`ssh_request_approval` / `ssh_approve_command` / `ssh_list_approvals`):已从 MCP `list_tools()`
|
|
257
|
+
> **关于 v2.1.0 引入的 3 个审批工具**(`ssh_request_approval` / `ssh_approve_command` / `ssh_list_approvals`):已从 MCP `list_tools()` 移除,**代码已在 v2.2.0 删除**(`ssh_mcp/approval.py`、`ssh_mcp/handlers/approval.py`)。审批流程依赖 AI 自报命令、运维侧背书,存在闭环风险;v2.2.0 的硬拦截更直接——灾难性命令在 MCP 网关层就被拒绝,运维侧不需要再走"先申请再审批"流程。
|
|
258
258
|
|
|
259
259
|
### 📖 详细文档
|
|
260
260
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ssh-licco
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: SSH Model Context Protocol Server - Enable SSH functionality for AI models
|
|
5
5
|
Author-email: SSH LICCO Team <contact@ssh-licco.dev>
|
|
6
6
|
License: MIT
|
|
@@ -267,7 +267,7 @@ SSH 跳板模式部署的生产必做加固,补齐跳板机单点风险:
|
|
|
267
267
|
| 3. 双层命令拦截 | `SSH_REMOTE_GUARD=true` + 远端 ForceCommand | 跳板机侧禁元字符 + 远端二次白名单校验 |
|
|
268
268
|
| 4. 灾难性命令硬拦截(v2.2.0 新增) | 默认开启,不可关闭 | rm -rf 绝对路径、mkfs、raw-disk dd、fork-bomb、chmod -R 777/000 /、`> /dev/sd*` 等在 MCP 网关层被**无条件**拒绝;任何安全级别、`confirm_dangerous=true` 等均无法绕过 |
|
|
269
269
|
|
|
270
|
-
> **关于 v2.1.0 引入的"高危操作审批"项**:v2.1.0 曾提供 `SSH_APPROVAL_GATE=true` + 3 个审批工具作为第 4 项加固。v2.2.0 起该项被**硬拦截取代**——审批流程依赖 AI 自报命令,存在闭环风险;硬拦截更直接,灾难性命令在 MCP 网关层就被拒绝,运维侧不需要再走"先申请再审批"流程。`SSH_APPROVAL_GATE` 环境变量与 `ssh_mcp/approval.py`
|
|
270
|
+
> **关于 v2.1.0 引入的"高危操作审批"项**:v2.1.0 曾提供 `SSH_APPROVAL_GATE=true` + 3 个审批工具作为第 4 项加固。v2.2.0 起该项被**硬拦截取代**——审批流程依赖 AI 自报命令,存在闭环风险;硬拦截更直接,灾难性命令在 MCP 网关层就被拒绝,运维侧不需要再走"先申请再审批"流程。`SSH_APPROVAL_GATE` 环境变量与 `ssh_mcp/approval.py` / `ssh_mcp/handlers/approval.py` 代码已在 v2.2.0 删除。
|
|
271
271
|
|
|
272
272
|
完整方案见 [docs/SECURITY_HARDENING.md](docs/SECURITY_HARDENING.md)。
|
|
273
273
|
|
|
@@ -287,7 +287,7 @@ SSH 跳板模式部署的生产必做加固,补齐跳板机单点风险:
|
|
|
287
287
|
| `ssh_session` | screen/tmux 会话 | 持久化远程会话管理(create/send/capture/list/kill),SSH 断开后进程依然存活 |
|
|
288
288
|
| `ssh_process` | 进程管理 | 启动/停止/查询远程进程,SSH 端口转发(tunnel_open/tunnel_close/tunnel_list) |
|
|
289
289
|
|
|
290
|
-
> **关于 v2.1.0 引入的 3 个审批工具**(`ssh_request_approval` / `ssh_approve_command` / `ssh_list_approvals`):已从 MCP `list_tools()`
|
|
290
|
+
> **关于 v2.1.0 引入的 3 个审批工具**(`ssh_request_approval` / `ssh_approve_command` / `ssh_list_approvals`):已从 MCP `list_tools()` 移除,**代码已在 v2.2.0 删除**(`ssh_mcp/approval.py`、`ssh_mcp/handlers/approval.py`)。审批流程依赖 AI 自报命令、运维侧背书,存在闭环风险;v2.2.0 的硬拦截更直接——灾难性命令在 MCP 网关层就被拒绝,运维侧不需要再走"先申请再审批"流程。
|
|
291
291
|
|
|
292
292
|
### 📖 详细文档
|
|
293
293
|
|
|
@@ -9,7 +9,6 @@ ssh_licco.egg-info/requires.txt
|
|
|
9
9
|
ssh_licco.egg-info/top_level.txt
|
|
10
10
|
ssh_mcp/__init__.py
|
|
11
11
|
ssh_mcp/__main__.py
|
|
12
|
-
ssh_mcp/approval.py
|
|
13
12
|
ssh_mcp/audit_logger.py
|
|
14
13
|
ssh_mcp/batch_executor.py
|
|
15
14
|
ssh_mcp/cli.py
|
|
@@ -34,7 +33,6 @@ ssh_mcp/clients/factory.py
|
|
|
34
33
|
ssh_mcp/clients/interface.py
|
|
35
34
|
ssh_mcp/clients/paramiko_client.py
|
|
36
35
|
ssh_mcp/handlers/__init__.py
|
|
37
|
-
ssh_mcp/handlers/approval.py
|
|
38
36
|
ssh_mcp/handlers/connect.py
|
|
39
37
|
ssh_mcp/handlers/context.py
|
|
40
38
|
ssh_mcp/handlers/docker.py
|
|
@@ -7,7 +7,6 @@ from collections.abc import Awaitable, Callable
|
|
|
7
7
|
from mcp.types import TextContent
|
|
8
8
|
|
|
9
9
|
from . import schemas
|
|
10
|
-
from .approval import handle_approve_command, handle_list_approvals, handle_request_approval
|
|
11
10
|
from .connect import handle_connect, handle_disconnect
|
|
12
11
|
from .context import HandlerContext
|
|
13
12
|
from .docker import handle_docker
|
|
@@ -30,9 +29,6 @@ HANDLERS: dict[str, Handler] = {
|
|
|
30
29
|
"ssh_generate_key": handle_generate_key,
|
|
31
30
|
"ssh_session": handle_session,
|
|
32
31
|
"ssh_process": handle_process,
|
|
33
|
-
"ssh_request_approval": handle_request_approval,
|
|
34
|
-
"ssh_approve_command": handle_approve_command,
|
|
35
|
-
"ssh_list_approvals": handle_list_approvals,
|
|
36
32
|
}
|
|
37
33
|
|
|
38
34
|
__all__ = ["HANDLERS", "schemas"]
|
|
@@ -13,7 +13,6 @@ from ..security import SecurityError, command_validator
|
|
|
13
13
|
from .connect import handle_connect
|
|
14
14
|
from .context import HandlerContext
|
|
15
15
|
from .utils import (
|
|
16
|
-
check_approval_gate,
|
|
17
16
|
diagnose_exit_code,
|
|
18
17
|
diagnose_startup_failure,
|
|
19
18
|
ensure_session,
|
|
@@ -55,14 +54,6 @@ async def handle_execute(ctx: HandlerContext, args: dict) -> list[TextContent]:
|
|
|
55
54
|
]
|
|
56
55
|
command = normalized
|
|
57
56
|
|
|
58
|
-
# 加固点 4:高危操作审批门禁
|
|
59
|
-
# 当 SSH_APPROVAL_GATE=true 且命令被判定为高危时,必须携带有效 approval_id 才能执行。
|
|
60
|
-
approval_id = args.get("approval_id")
|
|
61
|
-
approval_err = check_approval_gate(command, approval_id)
|
|
62
|
-
if approval_err:
|
|
63
|
-
ctx.logger.warning(f"Command blocked by approval gate: {command}")
|
|
64
|
-
return [TextContent(type="text", text=approval_err)]
|
|
65
|
-
|
|
66
57
|
# Resolve session via session_id / name / host / env fallback
|
|
67
58
|
session_id = await ensure_session(ctx, args, handle_connect)
|
|
68
59
|
if not session_id:
|
|
@@ -137,10 +137,6 @@ TOOLS: dict[str, Tool] = {
|
|
|
137
137
|
"default": False,
|
|
138
138
|
"description": "Bypass security validation for known-dangerous commands (e.g. rm -rf /path). Use with caution — only for operations you explicitly intend to perform.",
|
|
139
139
|
},
|
|
140
|
-
"approval_id": {
|
|
141
|
-
"type": "string",
|
|
142
|
-
"description": "加固点 4:高危操作审批 ID。当 SSH_APPROVAL_GATE=true 且命令被判定为高危时,必须先调用 ssh_request_approval 获取 approval_id,再带上此参数执行。否则高危命令会被拒绝。",
|
|
143
|
-
},
|
|
144
140
|
"remote_guard": {
|
|
145
141
|
"type": "boolean",
|
|
146
142
|
"default": False,
|
|
@@ -496,61 +492,4 @@ TOOLS: dict[str, Tool] = {
|
|
|
496
492
|
"required": ["session_id", "action"],
|
|
497
493
|
},
|
|
498
494
|
),
|
|
499
|
-
"ssh_request_approval": Tool(
|
|
500
|
-
name="ssh_request_approval",
|
|
501
|
-
description="加固点 4:高危操作审批 — AI 提交审批申请。当 SSH_APPROVAL_GATE=true 时,rm -rf、reboot、iptables 等高危命令必须先调用本工具申请审批,获得 approval_id 后再调用 ssh_execute 携带 approval_id 执行。AI 不能直接下发高危命令。",
|
|
502
|
-
inputSchema={
|
|
503
|
-
"type": "object",
|
|
504
|
-
"properties": {
|
|
505
|
-
"command": {
|
|
506
|
-
"type": "string",
|
|
507
|
-
"description": "需要审批的高危命令(必须与后续 ssh_execute 的 command 完全一致,否则校验失败)。",
|
|
508
|
-
},
|
|
509
|
-
"reason": {
|
|
510
|
-
"type": "string",
|
|
511
|
-
"description": "申请理由:为什么需要执行此高危命令、预期影响、回滚方案。",
|
|
512
|
-
},
|
|
513
|
-
},
|
|
514
|
-
"required": ["command", "reason"],
|
|
515
|
-
},
|
|
516
|
-
),
|
|
517
|
-
"ssh_approve_command": Tool(
|
|
518
|
-
name="ssh_approve_command",
|
|
519
|
-
description="加固点 4:高危操作审批 — 运维人员人工审批 AI 提交的申请。返回审批结果。审批通过后 AI 可用获得的 approval_id 执行命令(一次性,用后即焚)。",
|
|
520
|
-
inputSchema={
|
|
521
|
-
"type": "object",
|
|
522
|
-
"properties": {
|
|
523
|
-
"approval_id": {
|
|
524
|
-
"type": "string",
|
|
525
|
-
"description": "待审批的 approval_id(由 ssh_request_approval 返回)。",
|
|
526
|
-
},
|
|
527
|
-
"decision": {
|
|
528
|
-
"type": "string",
|
|
529
|
-
"enum": ["approved", "rejected"],
|
|
530
|
-
"description": "审批决定:approved=同意执行,rejected=拒绝。",
|
|
531
|
-
},
|
|
532
|
-
"reviewer": {
|
|
533
|
-
"type": "string",
|
|
534
|
-
"description": "审批人标识(运维人员姓名/账号),用于审计。",
|
|
535
|
-
},
|
|
536
|
-
"comment": {"type": "string", "description": "审批意见(可选)。"},
|
|
537
|
-
},
|
|
538
|
-
"required": ["approval_id", "decision", "reviewer"],
|
|
539
|
-
},
|
|
540
|
-
),
|
|
541
|
-
"ssh_list_approvals": Tool(
|
|
542
|
-
name="ssh_list_approvals",
|
|
543
|
-
description="加固点 4:高危操作审批 — 列出审批记录。运维人员查看待处理队列或历史记录。action=pending 只看待审批,action=all 查看全部(最近 100 条)。",
|
|
544
|
-
inputSchema={
|
|
545
|
-
"type": "object",
|
|
546
|
-
"properties": {
|
|
547
|
-
"action": {
|
|
548
|
-
"type": "string",
|
|
549
|
-
"enum": ["pending", "all"],
|
|
550
|
-
"default": "pending",
|
|
551
|
-
"description": "pending=仅待审批,all=全部记录(最近 100 条)。",
|
|
552
|
-
}
|
|
553
|
-
},
|
|
554
|
-
},
|
|
555
|
-
),
|
|
556
495
|
}
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import os
|
|
6
5
|
import re
|
|
7
6
|
import shlex
|
|
8
7
|
from collections.abc import Awaitable, Callable
|
|
@@ -116,53 +115,6 @@ def normalize_command_for_remote_guard(command: str) -> tuple[str, str | None]:
|
|
|
116
115
|
return command, None
|
|
117
116
|
|
|
118
117
|
|
|
119
|
-
def check_approval_gate(command: str, approval_id: str | None) -> str | None:
|
|
120
|
-
"""加固点 4:高危操作审批门禁。
|
|
121
|
-
|
|
122
|
-
当 SSH_APPROVAL_GATE=true 时,高危命令(CRITICAL/HIGH 风险)必须携带有效
|
|
123
|
-
approval_id(由 ssh_request_approval 工具申请、人工 approve 后获得)才能执行。
|
|
124
|
-
|
|
125
|
-
返回 None 表示放行;返回字符串表示拒绝原因(作为 MCP 返回文本)。
|
|
126
|
-
"""
|
|
127
|
-
if os.getenv("SSH_APPROVAL_GATE", "false").lower() != "true":
|
|
128
|
-
return None # 审批门禁未启用
|
|
129
|
-
|
|
130
|
-
from ..security import RiskLevel, command_validator
|
|
131
|
-
|
|
132
|
-
risk = command_validator.assess_risk_level(command)
|
|
133
|
-
|
|
134
|
-
# 仅 CRITICAL / HIGH 风险需要审批;MEDIUM/LOW/SAFE 直接放行
|
|
135
|
-
if risk not in (RiskLevel.CRITICAL, RiskLevel.HIGH):
|
|
136
|
-
return None
|
|
137
|
-
|
|
138
|
-
if not approval_id:
|
|
139
|
-
return (
|
|
140
|
-
f"❌ 高危操作审批门禁拦截\n\n"
|
|
141
|
-
f"Command: {command}\n"
|
|
142
|
-
f"Risk: {risk.value}\n\n"
|
|
143
|
-
f"此命令被判定为「{risk.value}」风险,启用了人工审批门禁(SSH_APPROVAL_GATE=true)。\n"
|
|
144
|
-
f"AI 不能直接下发此类高危运维命令,必须先申请审批:\n\n"
|
|
145
|
-
f" 1. 调用 ssh_request_approval 工具,提交 command 与 reason;\n"
|
|
146
|
-
f" 2. 等待人工审批通过,获得 approval_id;\n"
|
|
147
|
-
f" 3. 用 ssh_execute 携带 approval_id 重新执行。\n\n"
|
|
148
|
-
f"高危命令类别:rm -rf、reboot/shutdown、iptables/防火墙修改、mkfs、dd 覆盘等。"
|
|
149
|
-
)
|
|
150
|
-
|
|
151
|
-
# 校验 approval_id 有效性
|
|
152
|
-
from ..approval import ApprovalGate
|
|
153
|
-
|
|
154
|
-
gate = ApprovalGate.instance()
|
|
155
|
-
ok, reason = gate.verify(approval_id, command)
|
|
156
|
-
if not ok:
|
|
157
|
-
return (
|
|
158
|
-
f"❌ 审批校验失败\n\n"
|
|
159
|
-
f"approval_id: {approval_id}\n"
|
|
160
|
-
f"Reason: {reason}\n\n"
|
|
161
|
-
f"请重新申请审批(ssh_request_approval)或确认 approval_id 是否匹配当前命令。"
|
|
162
|
-
)
|
|
163
|
-
return None
|
|
164
|
-
|
|
165
|
-
|
|
166
118
|
def diagnose_exit_code(exit_code: int, log_tail: str, stderr: str) -> str:
|
|
167
119
|
"""根据退出码和日志输出诊断命令失败原因"""
|
|
168
120
|
combined = (log_tail + " " + stderr).lower()
|
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
"""SSH-LICCO 高危操作审批门禁 — 加固点 4
|
|
2
|
-
|
|
3
|
-
生产跳板机场景下,AI 不能直接一键下发高危运维命令(rm、重启服务、修改防火墙等)。
|
|
4
|
-
本模块实现审批工作流:
|
|
5
|
-
|
|
6
|
-
AI 调用 ssh_execute(高危命令)
|
|
7
|
-
│
|
|
8
|
-
│ 被审批门禁拦截(_check_approval_gate)
|
|
9
|
-
▼
|
|
10
|
-
AI 调用 ssh_request_approval(command, reason)
|
|
11
|
-
│ → 生成 approval_id,写入待审批队列(持久化 JSON)
|
|
12
|
-
│ → 返回 approval_id 与提示「等待人工审批」
|
|
13
|
-
▼
|
|
14
|
-
运维人员人工审批
|
|
15
|
-
│ 调用 ssh_approve_command(approval_id, decision, reviewer)
|
|
16
|
-
│ → approval 状态变更为 approved / rejected
|
|
17
|
-
▼
|
|
18
|
-
AI 再次调用 ssh_execute(command, approval_id)
|
|
19
|
-
│ → 审批门禁校验 approval_id 有效且 approved、命令匹配
|
|
20
|
-
▼
|
|
21
|
-
执行命令(一次性,用后即焚)
|
|
22
|
-
|
|
23
|
-
设计要点:
|
|
24
|
-
- 审批记录持久化到 JSON 文件(SSH_APPROVAL_STORE,默认 ~/.ssh_licco/approvals.json),
|
|
25
|
-
进程重启后审批状态不丢失。
|
|
26
|
-
- approval_id 是 UUID,不可猜测。
|
|
27
|
-
- 一次性消费:approved 的 approval_id 校验通过后立即标记为 consumed,防止复用。
|
|
28
|
-
- 命令必须严格匹配(shlex 规范化后比对),防止「申请 rm -rf /tmp/a,执行 rm -rf /」。
|
|
29
|
-
- 审批有 TTL(默认 1 小时),超时自动失效。
|
|
30
|
-
- 文件读写加锁,支持并发请求。
|
|
31
|
-
|
|
32
|
-
启用方式:
|
|
33
|
-
SSH_APPROVAL_GATE=true # 开启审批门禁
|
|
34
|
-
SSH_APPROVAL_STORE=/path/to/approvals.json # 审批记录存储路径
|
|
35
|
-
SSH_APPROVAL_TTL=3600 # 审批有效期(秒),默认 3600
|
|
36
|
-
"""
|
|
37
|
-
|
|
38
|
-
from __future__ import annotations
|
|
39
|
-
|
|
40
|
-
import json
|
|
41
|
-
import os
|
|
42
|
-
import shlex
|
|
43
|
-
import threading
|
|
44
|
-
import time
|
|
45
|
-
import uuid
|
|
46
|
-
from dataclasses import asdict, dataclass
|
|
47
|
-
from pathlib import Path
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
@dataclass
|
|
51
|
-
class ApprovalRecord:
|
|
52
|
-
approval_id: str
|
|
53
|
-
command: str # 原始命令
|
|
54
|
-
command_norm: str # shlex 规范化后的命令(用于比对)
|
|
55
|
-
reason: str # 申请理由
|
|
56
|
-
status: str # pending | approved | rejected | consumed | expired
|
|
57
|
-
requested_at: float # 申请时间戳
|
|
58
|
-
decided_at: float | None = None # 审批决定时间
|
|
59
|
-
reviewer: str | None = None # 审批人
|
|
60
|
-
consumed_at: float | None = None # 消费(执行)时间
|
|
61
|
-
ttl: int = 3600 # 有效期(秒)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
class ApprovalGate:
|
|
65
|
-
"""审批门禁单例。"""
|
|
66
|
-
|
|
67
|
-
_instance: ApprovalGate | None = None
|
|
68
|
-
_lock = threading.Lock()
|
|
69
|
-
|
|
70
|
-
def __init__(self):
|
|
71
|
-
store_path = os.getenv(
|
|
72
|
-
"SSH_APPROVAL_STORE",
|
|
73
|
-
str(Path.home() / ".ssh_licco" / "approvals.json"),
|
|
74
|
-
)
|
|
75
|
-
self._store_path = Path(store_path)
|
|
76
|
-
self._store_path.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
-
self._ttl = int(os.getenv("SSH_APPROVAL_TTL", "3600"))
|
|
78
|
-
self._file_lock = threading.Lock()
|
|
79
|
-
self._records: dict[str, ApprovalRecord] = {}
|
|
80
|
-
self._load()
|
|
81
|
-
|
|
82
|
-
@classmethod
|
|
83
|
-
def instance(cls) -> ApprovalGate:
|
|
84
|
-
with cls._lock:
|
|
85
|
-
if cls._instance is None:
|
|
86
|
-
cls._instance = cls()
|
|
87
|
-
return cls._instance
|
|
88
|
-
|
|
89
|
-
# ── 持久化 ──
|
|
90
|
-
def _load(self) -> None:
|
|
91
|
-
if not self._store_path.exists():
|
|
92
|
-
self._records = {}
|
|
93
|
-
return
|
|
94
|
-
try:
|
|
95
|
-
with open(self._store_path, encoding="utf-8") as f:
|
|
96
|
-
data = json.load(f)
|
|
97
|
-
self._records = {k: ApprovalRecord(**v) for k, v in data.items()}
|
|
98
|
-
except Exception:
|
|
99
|
-
self._records = {}
|
|
100
|
-
|
|
101
|
-
def _save(self) -> None:
|
|
102
|
-
data = {k: asdict(v) for k, v in self._records.items()}
|
|
103
|
-
tmp = self._store_path.with_suffix(".tmp")
|
|
104
|
-
with open(tmp, "w", encoding="utf-8") as f:
|
|
105
|
-
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
106
|
-
tmp.replace(self._store_path) # 原子替换
|
|
107
|
-
|
|
108
|
-
@staticmethod
|
|
109
|
-
def _normalize(command: str) -> str:
|
|
110
|
-
"""shlex 规范化命令,用于申请与执行时的命令比对。"""
|
|
111
|
-
try:
|
|
112
|
-
parts = shlex.split(command)
|
|
113
|
-
return " ".join(parts)
|
|
114
|
-
except ValueError:
|
|
115
|
-
return command.strip()
|
|
116
|
-
|
|
117
|
-
# ── 公开 API ──
|
|
118
|
-
def request(self, command: str, reason: str = "") -> ApprovalRecord:
|
|
119
|
-
"""AI 提交审批申请。返回 ApprovalRecord(status=pending)。"""
|
|
120
|
-
with self._file_lock:
|
|
121
|
-
self._purge_expired_locked()
|
|
122
|
-
rec = ApprovalRecord(
|
|
123
|
-
approval_id=uuid.uuid4().hex,
|
|
124
|
-
command=command,
|
|
125
|
-
command_norm=self._normalize(command),
|
|
126
|
-
reason=reason,
|
|
127
|
-
status="pending",
|
|
128
|
-
requested_at=time.time(),
|
|
129
|
-
ttl=self._ttl,
|
|
130
|
-
)
|
|
131
|
-
self._records[rec.approval_id] = rec
|
|
132
|
-
self._save()
|
|
133
|
-
return rec
|
|
134
|
-
|
|
135
|
-
def approve(
|
|
136
|
-
self, approval_id: str, reviewer: str, decision: str = "approved"
|
|
137
|
-
) -> ApprovalRecord:
|
|
138
|
-
"""运维人员审批。decision = approved | rejected。"""
|
|
139
|
-
with self._file_lock:
|
|
140
|
-
self._purge_expired_locked()
|
|
141
|
-
rec = self._records.get(approval_id)
|
|
142
|
-
if rec is None:
|
|
143
|
-
raise KeyError(f"approval_id 不存在:{approval_id}")
|
|
144
|
-
if rec.status == "expired":
|
|
145
|
-
raise ValueError("审批已过期,请重新申请")
|
|
146
|
-
if rec.status in ("approved", "rejected", "consumed"):
|
|
147
|
-
raise ValueError(f"审批已处理(status={rec.status}),不可重复操作")
|
|
148
|
-
if decision not in ("approved", "rejected"):
|
|
149
|
-
raise ValueError("decision 必须是 approved 或 rejected")
|
|
150
|
-
rec.status = decision
|
|
151
|
-
rec.decided_at = time.time()
|
|
152
|
-
rec.reviewer = reviewer
|
|
153
|
-
self._save()
|
|
154
|
-
return rec
|
|
155
|
-
|
|
156
|
-
def verify(self, approval_id: str, command: str) -> tuple[bool, str]:
|
|
157
|
-
"""执行前校验 approval_id。返回 (是否通过, 原因)。"""
|
|
158
|
-
with self._file_lock:
|
|
159
|
-
self._purge_expired_locked()
|
|
160
|
-
rec = self._records.get(approval_id)
|
|
161
|
-
if rec is None:
|
|
162
|
-
return False, f"approval_id 不存在:{approval_id}"
|
|
163
|
-
if rec.status == "expired":
|
|
164
|
-
return False, "审批已过期,请重新申请"
|
|
165
|
-
if rec.status == "pending":
|
|
166
|
-
return False, "审批仍在 pending,尚未被人工审批"
|
|
167
|
-
if rec.status == "rejected":
|
|
168
|
-
return False, f"审批已被拒绝(reviewer={rec.reviewer})"
|
|
169
|
-
if rec.status == "consumed":
|
|
170
|
-
return False, "此 approval_id 已被使用过(一次性消费),请重新申请"
|
|
171
|
-
if rec.status != "approved":
|
|
172
|
-
return False, f"审批状态异常:{rec.status}"
|
|
173
|
-
|
|
174
|
-
# 命令必须严格匹配(规范化后比对)
|
|
175
|
-
cmd_norm = self._normalize(command)
|
|
176
|
-
if cmd_norm != rec.command_norm:
|
|
177
|
-
return False, (
|
|
178
|
-
f"命令与审批申请不匹配。\n"
|
|
179
|
-
f" 申请命令:{rec.command}\n"
|
|
180
|
-
f" 执行命令:{command}\n"
|
|
181
|
-
f"拒绝执行,防止「申请 A 命令、执行 B 命令」绕过审批。"
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
# 校验通过,标记为已消费(一次性)
|
|
185
|
-
rec.status = "consumed"
|
|
186
|
-
rec.consumed_at = time.time()
|
|
187
|
-
self._save()
|
|
188
|
-
return True, f"审批校验通过(reviewer={rec.reviewer}),已消费。"
|
|
189
|
-
|
|
190
|
-
def list_pending(self) -> list[ApprovalRecord]:
|
|
191
|
-
"""列出所有 pending 审批(供运维人员查看待处理队列)。"""
|
|
192
|
-
with self._file_lock:
|
|
193
|
-
self._purge_expired_locked()
|
|
194
|
-
return [r for r in self._records.values() if r.status == "pending"]
|
|
195
|
-
|
|
196
|
-
def list_all(self, limit: int = 100) -> list[ApprovalRecord]:
|
|
197
|
-
"""列出所有审批记录(最近的在前)。"""
|
|
198
|
-
with self._file_lock:
|
|
199
|
-
self._purge_expired_locked()
|
|
200
|
-
recs = sorted(
|
|
201
|
-
self._records.values(),
|
|
202
|
-
key=lambda r: r.requested_at,
|
|
203
|
-
reverse=True,
|
|
204
|
-
)
|
|
205
|
-
return recs[:limit]
|
|
206
|
-
|
|
207
|
-
def _purge_expired_locked(self) -> None:
|
|
208
|
-
"""清理过期审批(pending 超过 TTL 标记为 expired)。"""
|
|
209
|
-
now = time.time()
|
|
210
|
-
changed = False
|
|
211
|
-
for rec in self._records.values():
|
|
212
|
-
if rec.status == "pending" and (now - rec.requested_at) > rec.ttl:
|
|
213
|
-
rec.status = "expired"
|
|
214
|
-
changed = True
|
|
215
|
-
# approved 但未消费的也按 TTL 失效(从 decided_at 起算)
|
|
216
|
-
if rec.status == "approved" and rec.decided_at and (now - rec.decided_at) > rec.ttl:
|
|
217
|
-
rec.status = "expired"
|
|
218
|
-
changed = True
|
|
219
|
-
if changed:
|
|
220
|
-
self._save()
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
"""Handlers for approval workflow tools."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import time
|
|
6
|
-
|
|
7
|
-
from mcp.types import TextContent
|
|
8
|
-
|
|
9
|
-
from .context import HandlerContext
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def _fmt_ts(ts: float | None) -> str:
|
|
13
|
-
"""Format a Unix timestamp to a human-readable string."""
|
|
14
|
-
if ts is None:
|
|
15
|
-
return "N/A"
|
|
16
|
-
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts))
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
async def handle_request_approval(ctx: HandlerContext, args: dict) -> list[TextContent]:
|
|
20
|
-
"""加固点 4:AI 提交高危命令审批申请。"""
|
|
21
|
-
from ..approval import ApprovalGate
|
|
22
|
-
from ..security import command_validator
|
|
23
|
-
|
|
24
|
-
command = args.get("command", "").strip()
|
|
25
|
-
reason = args.get("reason", "").strip()
|
|
26
|
-
if not command:
|
|
27
|
-
return [TextContent(type="text", text="command 不能为空")]
|
|
28
|
-
if not reason:
|
|
29
|
-
return [TextContent(type="text", text="reason 不能为空:必须说明为什么需要执行此高危命令")]
|
|
30
|
-
|
|
31
|
-
risk = command_validator.assess_risk_level(command)
|
|
32
|
-
gate = ApprovalGate.instance()
|
|
33
|
-
rec = gate.request(command, reason)
|
|
34
|
-
|
|
35
|
-
return [
|
|
36
|
-
TextContent(
|
|
37
|
-
type="text",
|
|
38
|
-
text=(
|
|
39
|
-
f"✅ 审批申请已提交\n\n"
|
|
40
|
-
f"approval_id: {rec.approval_id}\n"
|
|
41
|
-
f"command: {command}\n"
|
|
42
|
-
f"risk: {risk.value}\n"
|
|
43
|
-
f"reason: {reason}\n"
|
|
44
|
-
f"requested_at: {_fmt_ts(rec.requested_at)}\n"
|
|
45
|
-
f"TTL: {rec.ttl}s(超时自动失效)\n\n"
|
|
46
|
-
f"⏳ 等待运维人员审批。\n"
|
|
47
|
-
" 审批人请调用 ssh_approve_command("
|
|
48
|
-
f"approval_id='{rec.approval_id}', "
|
|
49
|
-
"decision='approved'|'rejected', reviewer='...')\n"
|
|
50
|
-
" 审批通过后,调用 ssh_execute("
|
|
51
|
-
f"command=..., approval_id='{rec.approval_id}') 执行。\n"
|
|
52
|
-
" 注意:approval_id 一次性消费,命令必须与此申请完全一致。"
|
|
53
|
-
),
|
|
54
|
-
)
|
|
55
|
-
]
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
async def handle_approve_command(ctx: HandlerContext, args: dict) -> list[TextContent]:
|
|
59
|
-
"""加固点 4:运维人员审批 AI 提交的申请。"""
|
|
60
|
-
from ..approval import ApprovalGate
|
|
61
|
-
|
|
62
|
-
approval_id = args.get("approval_id", "").strip()
|
|
63
|
-
decision = args.get("decision", "").strip()
|
|
64
|
-
reviewer = args.get("reviewer", "").strip()
|
|
65
|
-
comment = args.get("comment", "")
|
|
66
|
-
|
|
67
|
-
if not approval_id:
|
|
68
|
-
return [TextContent(type="text", text="approval_id 不能为空")]
|
|
69
|
-
if decision not in ("approved", "rejected"):
|
|
70
|
-
return [TextContent(type="text", text="decision 必须是 approved 或 rejected")]
|
|
71
|
-
if not reviewer:
|
|
72
|
-
return [TextContent(type="text", text="reviewer 不能为空:必须填写审批人标识")]
|
|
73
|
-
|
|
74
|
-
gate = ApprovalGate.instance()
|
|
75
|
-
try:
|
|
76
|
-
rec = gate.approve(approval_id, reviewer, decision)
|
|
77
|
-
except (KeyError, ValueError) as e:
|
|
78
|
-
return [TextContent(type="text", text=f"审批失败:{e}")]
|
|
79
|
-
|
|
80
|
-
status_emoji = "✅" if rec.status == "approved" else "❌"
|
|
81
|
-
return [
|
|
82
|
-
TextContent(
|
|
83
|
-
type="text",
|
|
84
|
-
text=(
|
|
85
|
-
f"{status_emoji} 审批已完成\n\n"
|
|
86
|
-
f"approval_id: {rec.approval_id}\n"
|
|
87
|
-
f"command: {rec.command}\n"
|
|
88
|
-
f"status: {rec.status}\n"
|
|
89
|
-
f"reviewer: {rec.reviewer}\n"
|
|
90
|
-
f"decided_at: {_fmt_ts(rec.decided_at)}\n"
|
|
91
|
-
)
|
|
92
|
-
+ (f"comment: {comment}\n" if comment else "")
|
|
93
|
-
+ (
|
|
94
|
-
"\n👉 审批通过,AI 可执行:ssh_execute("
|
|
95
|
-
f"command='{rec.command}', approval_id='{rec.approval_id}')"
|
|
96
|
-
if rec.status == "approved"
|
|
97
|
-
else "\n🚫 审批拒绝,AI 不可执行此命令。"
|
|
98
|
-
),
|
|
99
|
-
)
|
|
100
|
-
]
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
async def handle_list_approvals(ctx: HandlerContext, args: dict) -> list[TextContent]:
|
|
104
|
-
"""加固点 4:列出审批记录。"""
|
|
105
|
-
from ..approval import ApprovalGate
|
|
106
|
-
|
|
107
|
-
action = args.get("action", "pending")
|
|
108
|
-
gate = ApprovalGate.instance()
|
|
109
|
-
recs = gate.list_pending() if action == "pending" else gate.list_all()
|
|
110
|
-
|
|
111
|
-
if not recs:
|
|
112
|
-
return [
|
|
113
|
-
TextContent(type="text", text=f"没有{('待审批' if action == 'pending' else '')}记录。")
|
|
114
|
-
]
|
|
115
|
-
|
|
116
|
-
lines = [f"📋 审批记录({action},共 {len(recs)} 条)\n" + "=" * 60]
|
|
117
|
-
for r in recs:
|
|
118
|
-
lines.append(
|
|
119
|
-
f"\napproval_id: {r.approval_id}\n"
|
|
120
|
-
f" command: {r.command}\n"
|
|
121
|
-
f" status: {r.status}\n"
|
|
122
|
-
f" risk_reason: {r.reason}\n"
|
|
123
|
-
f" requested_at: {_fmt_ts(r.requested_at)}\n"
|
|
124
|
-
+ (
|
|
125
|
-
f" reviewer: {r.reviewer}\n decided_at: {_fmt_ts(r.decided_at)}\n"
|
|
126
|
-
if r.decided_at
|
|
127
|
-
else ""
|
|
128
|
-
)
|
|
129
|
-
+ (f" consumed_at: {_fmt_ts(r.consumed_at)}\n" if r.consumed_at else "")
|
|
130
|
-
)
|
|
131
|
-
return [TextContent(type="text", text="\n".join(lines))]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|