This is an automated email from the ASF dual-hosted git repository.

CRZbulabula pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iotdb-mcp-server.git


The following commit(s) were added to refs/heads/main by this push:
     new 4550cff  fix: restrict agent permission (#20)
4550cff is described below

commit 4550cffb51cfb5ea3a98a81000ac5ce075e61ee0
Author: Zeyu Zhang <[email protected]>
AuthorDate: Thu Sep 17 15:47:21 2026 +0800

    fix: restrict agent permission (#20)
---
 README-zh.md                                    |   7 +
 README.md                                       |   9 +
 docs/iotdb-threat-model-proposal.md             |  95 ++++++
 docs/session-policy-security.md                 | 111 +++++++
 pyproject.toml                                  |   1 +
 src/iotdb_mcp_server/policy_approval.py         | 171 ++++++++++
 src/iotdb_mcp_server/runtime_policy.py          | 288 +++++++++++++----
 src/iotdb_mcp_server/server.py                  |   2 +
 src/iotdb_mcp_server/services/README.md         |  38 ++-
 src/iotdb_mcp_server/services/database.py       |   2 +-
 src/iotdb_mcp_server/services/metadata.py       |   2 +-
 src/iotdb_mcp_server/services/model.py          |   2 +-
 src/iotdb_mcp_server/services/runtime_policy.py |  27 +-
 src/iotdb_mcp_server/services/sql_driver.py     |   5 +-
 src/iotdb_mcp_server/services/table.py          |   2 +-
 src/iotdb_mcp_server/services/timeseries.py     |   2 +-
 src/iotdb_mcp_server/services/ttl.py            |   2 +-
 src/iotdb_mcp_server/services/write.py          |   2 +-
 tests/test_runtime_policy.py                    | 414 +++++++++++++++++++++++-
 19 files changed, 1076 insertions(+), 106 deletions(-)

diff --git a/README-zh.md b/README-zh.md
index 112d2f5..fc98fb4 100644
--- a/README-zh.md
+++ b/README-zh.md
@@ -27,6 +27,13 @@ IoTDB MCP 权限默认是提示层。服务器为 SQL 操作返回所需权限
 只有需要 MCP server 自身硬阻断时,才设置
 `TIMESEEK_MCP_PERMISSION_ENFORCEMENT=strict`。
 
+Session 策略受启动时冻结的部署权限上限约束(进程环境变量 > MCP 配置 > 默认值)。
+收紧权限立即生效;在上限内扩大权限默认需要独立的管理员审批,审批前保持原策略。
+部署者可设置 `IOTDB_SESSION_POLICY_APPROVAL_MODE=allow`,允许上限内免审批调整。
+普通工具不能修改强制检查开关、SQL 分类扩展项或突破部署上限;reset 和 replace
+也遵循相同规则。配置与审批步骤见[Session 权限管理](docs/session-policy-security.md)。
+审批目录和管理员命令必须处于 agent 的文件系统及 shell 权限之外。
+
 ### 工具
 
 服务器为 IoTDB 的树模型(Tree Model)和表模型(Table Model)提供了不同的工具。您可以通过设置 
"IOTDB_SQL_DIALECT" 配置为 "tree" 或 "table" 来选择使用哪种模型。
diff --git a/README.md b/README.md
index 622e785..1b2a133 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,15 @@ environment variables such as `IOTDB_SQL_DRIVER_MODE=full` 
and
 `TIMESEEK_MCP_PERMISSION_ENFORCEMENT=strict` only when the MCP server itself
 should hard-block disallowed operations.
 
+Session policy changes are bounded by a deployment policy frozen at startup
+(process environment > MCP configuration > defaults). Narrowing applies 
immediately;
+widening within that ceiling requires out-of-band administrator approval by 
default.
+`IOTDB_SESSION_POLICY_APPROVAL_MODE=allow` explicitly permits in-ceiling 
widening
+without approval. Neither setting allows tools to change enforcement switches,
+SQL classification prefixes, or the deployment ceiling. Reset and replace 
follow
+the same rules. See [session policy 
administration](docs/session-policy-security.md)
+for configuration, approval commands, and the required host isolation.
+
 ### Tools
 
 The server offers different tools for IoTDB Tree Model and Table Model. You 
can choose between them by setting the "IOTDB_SQL_DIALECT" configuration to 
either "tree" or "table".
diff --git a/docs/iotdb-threat-model-proposal.md 
b/docs/iotdb-threat-model-proposal.md
new file mode 100644
index 0000000..c467969
--- /dev/null
+++ b/docs/iotdb-threat-model-proposal.md
@@ -0,0 +1,95 @@
+# IoTDB 威胁模型补充建议:MCP Server
+
+本文件是提交 PMC 审阅的建议,尚不是已批准的安全承诺。
+按本地 IoTDB 主仓库 `THREAT_MODEL.md` 的 v0 章节组织;合入前需与目标版本核对。
+当前 MCP 的运行约束见 [Session 权限管理](session-policy-security.md)。
+
+## §2:明确组件与参与者
+
+在组件表中显式增加 `apache/iotdb-mcp-server`,由 PMC 确认纳入主模型还是维护单独模型并互相引用。
+不要因其位于独立仓库,就默认套用客户端 SDK 的范围排除。
+
+建议组件表条目:
+
+| Family | Entry point | Touches outside process | In model? |
+| --- | --- | --- | --- |
+| IoTDB MCP Server | MCP tools over stdio | IoTDB session RPC, local 
configuration, result files, policy approval files | In the MCP-specific 
boundary described below; database-side RBAC remains independently enforced |
+
+明确四种参与者:部署管理员、负责交互与沙箱的宿主、调用 MCP 工具的 agent/MCP 客户端、
+数据库账号。宿主可以管理 agent 的执行权限,但 agent 不因此自动获得管理员身份。
+数据库凭据由 MCP 使用时,数据库账号权限也不等于 agent 对 MCP 管理策略的权限。
+
+## §3、§4、§7、§11a:修正 root/admin 排除规则
+
+现有模型把“需要 root/admin 的操作”视为可信输入。对 MCP 应补充例外:
+
+> An MCP client does not become a trusted deployment administrator merely 
because
+> the MCP server uses a database account with administrative privileges. An MCP
+> client bypassing an operator-enforced MCP policy is an in-scope adversary 
even
+> when the underlying database would authorize the SQL for the server's 
account.
+
+在威胁主体中增加:已连接且能调用普通 MCP 工具的恶意客户端、被提示注入影响的 agent、
+超出用户意图自行执行操作的 agent。攻击者可选择任意工具名、参数、调用顺序和并发请求,
+也可自行填写危险操作确认布尔值。不能假设工具调用来自自然语言用户的真实授权。
+
+## §4:增加两条边界
+
+1. **agent → MCP 数据操作与 session 策略工具**:不可信输入;部署策略上限在服务启动时冻结。
+   显式越权请求不可修改策略;收紧可直接生效;上限内扩大按部署者选定的审批模式执行。
+2. **管理员 → 配置与审批通道**:独立的可信管理操作;修改上限、enforcement 和 SQL 分类规则
+   需要受保护的配置及重启。普通 MCP 工具不能批准自己的请求。
+
+数据操作还要跨越 **MCP → IoTDB RBAC** 边界。MCP 限制与数据库授权应同时满足,
+MCP 的部署上限不能扩大数据库账号本身的权限。
+
+## §5、§5a、§6:部署假设与输入分类
+
+- stdio 仅说明传输方式,不说明客户端可信;一个进程是一个策略域,不提供多租户隔离。
+- 管理配置、审批目录和服务代码必须不在 agent 可读写或可替换的权限范围内;
+  受信管理端可以访问它们。不得将审批目录放在可由查询导出等工具写入的位置。
+- 同用户不受限 shell 能运行管理员命令或伪造审批文件。0700 不是同 UID 的安全隔离;
+  若需要约束该 agent,宿主必须提供额外隔离。管理员自动批准服务也属于可信计算基。
+- 审批模式 `require`(默认)和 `allow`(管理员显式选择)必须列为安全配置变体。
+  require 无可用通道时禁止扩大,不可自动降级为 allow。
+- 明确 `advisory` 不提供 SQL 权限强制执行保证;`strict` 下才承诺相关执行检查。
+  当前默认 advisory 应清楚记录,PMC 可另行决定未来版本是否切换默认 strict。
+- `confirm_destructive=true` 是攻击者可控输入,不等于人类身份、签名或独立审批凭据。
+- 审批请求内容与决定是不同信任级别的输入。审批需绑定确切变更及有效期,防止替换与重放。
+
+## §8:建议承诺的 MCP 安全属性
+
+建议 PMC 明确承诺并逐个核验调用入口:
+
+1. **部署上限不可由工具扩大**:set、preset、replace、reset、工具别名、并发与重启恢复
+   均不得绕过。enforcement 和 SQL 分类规则不能由普通工具改写。
+2. **先审批后生效**:require 模式下,未批准、拒绝、过期、不匹配或已消费的审批均不能扩大权限。
+   对混合权限变更,拒绝或待批时不得部分生效。
+3. **审批绑定**:批准仅用于特定运行实例、策略修订和具体变更,不可挪用到另一提权请求。
+4. **严格执行一致性**:strict 模式的各 SQL/DDL/DML 工具必须按其声明的权限规则检查。
+   主模型应说明通用 SQL driver 与专用工具开关之间的关系,避免部署者误认为关闭单个入口
+   等于关闭所有等价 SQL。实现完整入口审计后再扩大此项承诺。
+5. **凭据保密与资源生命周期**:建议分别定义快照/日志不泄露认证凭据、结果文件路径约束、
+   查询与连接池资源释放的保证及边界,并以独立测试核验;本次策略修改不代表已完成这些审计。
+
+## §9、§10、§11:非保证与部署者职责
+
+- 不防御已控制 MCP 进程、服务代码、管理配置或审批文件的攻击者;但这种控制不能从
+  “能够调用 MCP 工具”或“服务器使用 root 数据库凭据”直接推定。
+- 不把自然语言“用户已同意”、普通确认布尔值或 agent full permission 当成人工审批证明。
+- 不宣称 advisory 模式强制只读,也不宣称 session 策略收紧会撤销已经开始执行的 SQL。
+- 部署者负责 strict 配置、IoTDB 最小权限账号、管理通道隔离、审批目录保护及实例隔离。
+  如果显式选择 allow,仅表示同意部署上限内自动调整,不表示同意修改部署上限或关闭 strict。
+
+## §12、§13:模型变更与分流示例
+
+| 场景 | 建议分流 |
+| --- | --- |
+| 普通 MCP 客户端能关闭已启用的 strict 或突破部署上限 | VALID:权限边界绕过 |
+| require 模式下可伪造、替换或重放审批而扩大权限 | VALID:审批边界绕过 |
+| 使用部署者明确开启的 allow 在部署上限内扩大 session 权限 | BY-DESIGN,前提是配置契约清楚 |
+| 仅证明显式 advisory 不强制执行 SQL 权限 | 按已声明非保证判断;误导文档另行评估 |
+| 已掌握管理员 shell 后编辑配置重启 | OUT-OF-MODEL: trusted-input,须证明已有管理权限 |
+| 服务端持有高权限数据库账号,而 agent 仅有普通 MCP 工具权限 | 不得仅凭账号级别判为 trusted-input |
+
+新增 HTTP/共享服务传输、多客户端共享实例、在线管理 API、宿主自动审批集成,或修改默认
+enforcement/审批模式时,应触发模型复审。最终范围和承诺由 PMC 确认,并随发布版本绑定。
diff --git a/docs/session-policy-security.md b/docs/session-policy-security.md
new file mode 100644
index 0000000..a631682
--- /dev/null
+++ b/docs/session-policy-security.md
@@ -0,0 +1,111 @@
+# Session 权限管理
+
+## 权限边界
+
+服务启动时冻结部署策略:进程环境变量优先,其次为启动时发现的 MCP 配置,最后为默认值。
+运行期间修改环境变量或 `.mcp.json` 不会改变该策略。修改部署策略后须由管理员重启服务。
+部署上限不是启动 session 的只读默认值;需要“部署允许写、session 暂时只读”时,
+先配置可写部署上限,再通过 session 工具收紧。
+
+策略比较规则:
+
+| 类型 | 收紧 | 扩大 |
+| --- | --- | --- |
+| `IOTDB_ENABLE_*` | true → false | false → true |
+| `*_ALLOWED_USERS` | 删除用户,`*` → 指定集合 | 添加用户,指定集合 → `*` |
+| `IOTDB_SQL_DRIVER_MODE` | full → ddl → readonly | readonly → ddl → full |
+| `*CONFIRM*` | false → true | true → false |
+
+空用户白名单表示拒绝所有用户。`readonly` 预设也关闭模型管理。
+预设与部署上限取交集,例如部署只允许 alice 时,`full` 不会把用户白名单改成 `*`。
+显式 `policy` 中任何一项超过部署上限,整个请求都会被拒绝;不会应用其余项。
+混合收紧和扩大的请求作为整体审批,在批准之前不应用其中任何一项。
+
+以下内容仅由管理员配置,普通 MCP 工具不能设置或 reset:
+
+- `IOTDB_STRICT_PERMISSION_ENFORCEMENT`
+- `TIMESEEK_MCP_PERMISSION_ENFORCEMENT`
+- `IOTDB_SQL_DRIVER_EXTRA_READONLY_PREFIXES`
+- `IOTDB_SQL_DRIVER_EXTRA_DDL_PREFIXES`
+- `IOTDB_SQL_DRIVER_EXTRA_FULL_PREFIXES`
+- 审批模式和审批目录
+
+SQL 分类扩展项可能把写语句划为只读,因此也不能交给 agent 修改。
+关闭严格检查、扩大部署上限或修改 SQL 分类规则的管理员通道是:
+在受保护的宿主配置中修改相应项,然后重启 MCP。session 审批命令不具备这些能力。
+
+## 上限内提权审批
+
+`IOTDB_SESSION_POLICY_APPROVAL_MODE` 由管理员在启动时设置:
+
+- `require`(默认):收紧立即生效;扩大需由独立管理员通道批准。
+- `allow`:允许上限内扩大权限,无需逐次审批。仍不能越过部署上限或修改管理员专属项。
+
+`set_iotdb_session_policy`、`replace=true`、`reset_iotdb_session_policy` 均遵循此规则。
+重置只读 session 通常会恢复更宽权限,所以也需要审批。
+未配置审批目录时,扩大请求返回 `approval_unavailable`、`applied=false`,原权限保持不变。
+无变化的请求不需要审批。
+
+管理员在 agent 沙箱之外创建目录,并在 MCP 启动环境中配置:
+
+```sh
+mkdir -m 700 /absolute/protected/iotdb-policy-approvals
+export 
IOTDB_SESSION_POLICY_APPROVAL_DIR=/absolute/protected/iotdb-policy-approvals
+export IOTDB_SESSION_POLICY_APPROVAL_MODE=require
+export TIMESEEK_MCP_PERMISSION_ENFORCEMENT=strict
+```
+
+目录需由服务运行用户拥有;POSIX 上禁止 group/other 权限。管理员命令在可信终端中
+以能访问该目录的服务用户身份运行。Windows 部署需用 ACL 和宿主沙箱落实等效隔离。
+不要把目录设在 agent 可写工作区、SQL 结果导出目录或其他工具可写路径中。
+
+1. agent 请求扩大 session 权限。返回 `status=approval_required`、`applied=false`、
+   `request_id`、变更前后值和过期时间;此时策略尚未改变。
+2. 管理员从可信界面查看请求:
+
+   ```sh
+   iotdb-policy-admin show REQUEST_ID --directory 
/absolute/protected/iotdb-policy-approvals
+   ```
+
+3. 核对具体变更后,从独立管理终端批准或拒绝:
+
+   ```sh
+   iotdb-policy-admin approve REQUEST_ID --directory 
/absolute/protected/iotdb-policy-approvals
+   # 或:iotdb-policy-admin deny REQUEST_ID --directory 
/absolute/protected/iotdb-policy-approvals
+   ```
+
+4. agent 重试原始 MCP 调用。服务校验对应审批后才应用变更,返回 `applied=true`。
+
+源码运行可用 `PYTHONPATH=src python3 -m iotdb_mcp_server.policy_approval` 替代命令名。
+管理命令不会注册为 MCP tool;MCP 调用没有 `approved` 或 `confirm` 提权参数。
+批准绑定具体变更、部署指纹、策略修订号和运行实例;有效期 5 分钟,仅可消费一次。
+策略发生变化、服务重启或请求过期后,旧审批不能用于新的变更。最多保留 32 个待审批请求;
+在新申请时清理本实例已过期或旧修订请求。重启遗留文件不会被新实例采纳,可由管理员清理。
+审批文件是授权凭据,不是不可篡改审计日志;生产环境可由管理平台另行记录审批者身份。
+
+## 与 agent full permission 的关系
+
+宿主的 full permission 不会自动改变服务端审批模式。人工审批流程应由宿主在独立权限域
+执行,不应把上述审批命令直接交给 agent 自动运行。
+
+**同一 OS 用户下,若 agent 具有未受限制的 shell/文件访问,0700 不能隔离它与 MCP。**
+部署者必须通过沙箱、容器、独立服务身份或可信管理平台,阻止 agent 读写审批目录、
+修改部署配置、替换服务代码或自行重启为更宽权限。若这些能力已授权给 agent,
+它拥有管理员通道的能力,服务端不能再保证人工审批。普通工具自动批准不能代替此授权。
+
+## 保证范围与兼容性
+
+- 默认 SQL 检查仍为 `advisory`;要让只读、工具开关和用户白名单成为硬限制,管理员必须启用
+  `TIMESEEK_MCP_PERMISSION_ENFORCEMENT=strict` 或 
`IOTDB_STRICT_PERMISSION_ENFORCEMENT=true`。
+  在 advisory 模式下,session 配置收紧不会提供 SQL 执行隔离保证。
+- SQL 执行工具的 `confirm_destructive` 等布尔值仍是调用者声明,不是可验证的人工审批。
+  本变更的独立审批针对 session 权限扩大,不是每条危险 SQL 的审批服务。
+- 通用 SQL driver 的模式/白名单与专用 DDL/DML 工具开关分别控制各自入口。
+  部署只读时应同时设置 driver 为 readonly 并关闭相关专用写工具;可用 readonly 预设收紧
+  session,但其部署上限仍以管理员配置为准。IoTDB 账号权限继续独立生效,建议最小授权。
+- 策略是单个 stdio MCP 进程级别的,不是多租户或多目标独立授权域。
+  不应把此进程在不同信任级别的客户端间共享;正在执行的 SQL 不承诺被策略收紧立即取消。
+- 相较旧版,配置不再热加载,进程环境变量优先,非法策略值会使初始化失败;
+  reset/replace 可能返回待审批而非立即完成。调用方应检查 `payload.applied` 和 `payload.status`。
+
+IoTDB 主仓库的威胁模型补充草案见 
[iotdb-threat-model-proposal.md](iotdb-threat-model-proposal.md)。
diff --git a/pyproject.toml b/pyproject.toml
index ca73270..65d3c8a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -34,3 +34,4 @@ dev-dependencies = [
 [project.scripts]
 iotdb-mcp-server = "iotdb_mcp_server:main"
 iotdb-target-cli = "iotdb_mcp_server.target_cli:main"
+iotdb-policy-admin = "iotdb_mcp_server.policy_approval:main"
diff --git a/src/iotdb_mcp_server/policy_approval.py 
b/src/iotdb_mcp_server/policy_approval.py
new file mode 100644
index 0000000..0f08fe9
--- /dev/null
+++ b/src/iotdb_mcp_server/policy_approval.py
@@ -0,0 +1,171 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Out-of-band policy approvals. Never register administrative functions as 
tools.
+
+The directory and this CLI must be outside the agent's filesystem/shell 
authority.
+Filesystem permissions alone do not isolate an unsandboxed same-UID agent.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+from pathlib import Path
+import re
+import tempfile
+import time
+from typing import Any
+import uuid
+
+_APPROVAL_TTL_SECONDS = 300
+_MAX_PENDING = 32
+
+
+def _digest(value: dict[str, Any]) -> str:
+    return hashlib.sha256(json.dumps(value, 
sort_keys=True).encode()).hexdigest()
+
+
+def _check_directory(directory: Path) -> None:
+    if not directory.is_absolute() or not directory.is_dir() or 
directory.is_symlink():
+        raise ValueError(
+            "Approval directory must be an existing, absolute, non-symlink 
directory."
+        )
+    if os.name == "posix":
+        info = directory.stat()
+        if info.st_uid != os.getuid() or info.st_mode & 0o077:
+            raise PermissionError(
+                "Approval directory must be owned by the service user with 
mode 0700."
+            )
+
+
+def _write_json(path: Path, data: dict[str, Any]) -> None:
+    fd, name = tempfile.mkstemp(prefix=".policy-", dir=path.parent)
+    try:
+        with os.fdopen(fd, "w", encoding="utf-8") as stream:
+            json.dump(data, stream, indent=2, sort_keys=True)
+            stream.write("\n")
+        os.replace(name, path)
+    finally:
+        Path(name).unlink(missing_ok=True)
+
+
+class PolicyApprovalStore:
+    """Bind one decision to an exact proposal, revision, and running 
process."""
+
+    def __init__(self, directory: Path):
+        _check_directory(directory)
+        self.directory = directory
+        self._pending: dict[str, dict[str, Any]] = {}
+
+    def _remove(self, key: str) -> None:
+        request = self._pending.pop(key)
+        for suffix in ("request", "decision"):
+            (self.directory / f"{request['request_id']}.{suffix}.json").unlink(
+                missing_ok=True
+            )
+
+    def authorize(self, proposal: dict[str, Any]) -> dict[str, Any]:
+        _check_directory(self.directory)
+        now = time.time()
+        for key, request in list(self._pending.items()):
+            if (
+                request["expires_at"] <= now
+                or request["revision"] != proposal["revision"]
+            ):
+                self._remove(key)
+        key = _digest(proposal)
+        if key not in self._pending:
+            if len(self._pending) >= _MAX_PENDING:
+                raise PermissionError(
+                    "Too many pending policy approvals. Wait for requests to 
expire."
+                )
+            request = {
+                **proposal,
+                "request_id": uuid.uuid4().hex,
+                "created_at": now,
+                "expires_at": now + _APPROVAL_TTL_SECONDS,
+            }
+            _write_json(
+                self.directory / f"{request['request_id']}.request.json", 
request
+            )
+            self._pending[key] = request
+        request = self._pending[key]
+        result = {
+            "status": "approval_required",
+            "request_id": request["request_id"],
+            "expires_at": request["expires_at"],
+            "requested_changes": request["changes"],
+        }
+        decision_path = self.directory / 
f"{request['request_id']}.decision.json"
+        try:
+            decision = json.loads(decision_path.read_text(encoding="utf-8"))
+        except FileNotFoundError:
+            return result
+        # The in-memory request, not the file shown to the administrator, is
+        # authoritative. Tampering with that file cannot approve another 
change.
+        if not isinstance(decision, dict) or decision.get("request_digest") != 
_digest(
+            request
+        ):
+            raise PermissionError("Invalid administrator decision; policy 
unchanged.")
+        if decision.get("decision") == "approve":
+            if time.time() >= request["expires_at"]:
+                self._remove(key)
+                return self.authorize(proposal)
+            self._remove(key)
+            result["status"] = "approved"
+        else:
+            result["status"] = "approval_rejected"
+        return result
+
+
+def main(argv: list[str] | None = None) -> int:
+    """Run only from an operator-controlled terminal or trusted management 
UI."""
+    parser = argparse.ArgumentParser(
+        description="Review and decide an MCP session policy request."
+    )
+    parser.add_argument("action", choices=("show", "approve", "deny"))
+    parser.add_argument("request_id")
+    parser.add_argument("--directory", required=True, type=Path)
+    args = parser.parse_args(argv)
+    _check_directory(args.directory)
+    if not re.fullmatch(r"[0-9a-f]{32}", args.request_id):
+        parser.error("Invalid request ID")
+    request = json.loads(
+        (args.directory / 
f"{args.request_id}.request.json").read_text(encoding="utf-8")
+    )
+    if request["request_id"] != args.request_id or request["expires_at"] <= 
time.time():
+        parser.error("Request is invalid or expired")
+    print(json.dumps(request, indent=2, sort_keys=True))
+    if args.action != "show":
+        _write_json(
+            args.directory / f"{args.request_id}.decision.json",
+            {
+                "request_digest": _digest(request),
+                "decision": args.action,
+            },
+        )
+        print(
+            f"Decision recorded: {args.action}. Retry the original MCP request 
to apply it."
+        )
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/src/iotdb_mcp_server/runtime_policy.py 
b/src/iotdb_mcp_server/runtime_policy.py
index 4b3f418..b591a77 100644
--- a/src/iotdb_mcp_server/runtime_policy.py
+++ b/src/iotdb_mcp_server/runtime_policy.py
@@ -19,6 +19,7 @@
 from __future__ import annotations
 
 from collections.abc import Mapping
+import hashlib
 import json
 import os
 from pathlib import Path
@@ -26,13 +27,23 @@ import re
 from threading import RLock
 from typing import Any
 
+from iotdb_mcp_server.policy_approval import PolicyApprovalStore
+
 
 _DEFAULT_SERVER_NAME = "iotdb"
 _SHELL_DEFAULT_RE = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\}$")
-_SENSITIVE_KEY_RE = re.compile(r"(PASSWORD|SECRET|TOKEN|API_KEY|ACCESS_KEY)", 
re.IGNORECASE)
+_SENSITIVE_KEY_RE = re.compile(
+    r"(PASSWORD|SECRET|TOKEN|API_KEY|ACCESS_KEY)", re.IGNORECASE
+)
 _WITHHELD_ENV_KEYS = frozenset({"TIMESEEK_IOTDB_TARGETS_JSON"})
 _SESSION_POLICY_LOCK = RLock()
 _SESSION_POLICY: dict[str, str] = {}
+_DEPLOYMENT_POLICY: dict[str, str] | None = None
+_DEPLOYMENT_ENV: dict[str, str] = {}
+_DEPLOYMENT_CONFIG_PATH = ""
+_APPROVAL_MODE = "require"
+_APPROVAL_STORE: PolicyApprovalStore | None = None
+_POLICY_REVISION = 0
 
 _ENFORCEMENT_POLICY_KEYS = frozenset(
     {
@@ -75,6 +86,19 @@ _POLICY_KEYS = frozenset(
     }
 )
 
+# Prefix changes can reclassify writes as reads, so they are 
administrator-only.
+_ADMIN_POLICY_KEYS = _ENFORCEMENT_POLICY_KEYS | frozenset(
+    key for key in _POLICY_KEYS if "_EXTRA_" in key
+)
+_BOOL_POLICY_KEYS = frozenset(
+    key
+    for key in _POLICY_KEYS
+    if key.startswith("IOTDB_ENABLE_")
+    or "CONFIRM" in key
+    or key == "IOTDB_STRICT_PERMISSION_ENFORCEMENT"
+)
+_MODE_RANK = {"readonly": 0, "ddl": 1, "full": 2}
+
 _FULL_PERMISSION_DEFAULTS = {
     "IOTDB_ENABLE_METADATA_QUERY": "true",
     "IOTDB_METADATA_ALLOWED_USERS": "*",
@@ -121,6 +145,7 @@ _PRESET_POLICIES = {
         "IOTDB_ENABLE_TIMESERIES_DDL": "false",
         "IOTDB_ENABLE_TTL_SQL": "false",
         "IOTDB_ENABLE_WRITE_DML": "false",
+        "IOTDB_ENABLE_MODEL_MANAGEMENT": "false",
         "IOTDB_SQL_DRIVER_MODE": "readonly",
     },
 }
@@ -197,16 +222,74 @@ def _load_mcp_env() -> tuple[dict[str, str], str]:
         server = data.get("mcpServers", {}).get(server_name, {})
         env = server.get("env", {})
         if isinstance(env, dict):
-            return {str(key): _expand_config_value(value) for key, value in 
env.items()}, str(path)
+            return {
+                str(key): _expand_config_value(value) for key, value in 
env.items()
+            }, str(path)
     return {}, ""
 
 
-def dynamic_getenv(name: str, default: str | None = None) -> str | None:
+def _normalize_policy_value(key: str, value: Any) -> str:
+    text = _coerce_policy_value(value).strip()
+    if key in _BOOL_POLICY_KEYS:
+        if text.lower() in {"1", "true", "yes", "on"}:
+            return "true"
+        if text.lower() in {"0", "false", "no", "off"}:
+            return "false"
+        raise ValueError(f"{key} must be a boolean.")
+    if key == "IOTDB_SQL_DRIVER_MODE":
+        if text.lower() not in _MODE_RANK:
+            raise ValueError(f"{key} must be readonly, ddl, or full.")
+        return text.lower()
+    if key == "TIMESEEK_MCP_PERMISSION_ENFORCEMENT":
+        if text.lower() not in {"advisory", "strict"}:
+            raise ValueError(f"{key} must be advisory or strict.")
+        return text.lower()
+    items = {item.strip() for item in text.split(",") if item.strip()}
+    if key.endswith("_ALLOWED_USERS"):
+        return "*" if "*" in items else ",".join(sorted(items))
+    return ",".join(sorted(item.upper() for item in items))
+
+
+def initialize_runtime_policy() -> None:
+    """Freeze operator policy once, before tools are registered. No MCP reload 
API."""
+    global _DEPLOYMENT_POLICY, _DEPLOYMENT_ENV, _DEPLOYMENT_CONFIG_PATH
+    global _APPROVAL_MODE, _APPROVAL_STORE
     with _SESSION_POLICY_LOCK:
-        value = _SESSION_POLICY.get(name)
-    if value not in (None, ""):
-        return value
+        if _DEPLOYMENT_POLICY is not None:
+            return
+        env, path = _load_mcp_env()
+        # Explicit process environment wins over config discovery, including 
empty
+        # allowlists. Malformed policy fails startup rather than enabling 
access.
+        source = {**env, **os.environ}
+        defaults = {**_FULL_PERMISSION_DEFAULTS, **_ENFORCEMENT_DEFAULTS}
+        deployment = {
+            key: _normalize_policy_value(key, source.get(key, 
defaults.get(key, "")))
+            for key in _POLICY_KEYS
+        }
+        mode = (
+            source.get("IOTDB_SESSION_POLICY_APPROVAL_MODE", 
"require").strip().lower()
+        )
+        if mode not in {"require", "allow"}:
+            raise ValueError(
+                "IOTDB_SESSION_POLICY_APPROVAL_MODE must be require or allow."
+            )
+        directory = source.get("IOTDB_SESSION_POLICY_APPROVAL_DIR", "")
+        store = PolicyApprovalStore(Path(directory).expanduser()) if directory 
else None
+        _DEPLOYMENT_ENV = env
+        _DEPLOYMENT_CONFIG_PATH = path
+        _APPROVAL_MODE = mode
+        _APPROVAL_STORE = store
+        _DEPLOYMENT_POLICY = deployment
+
 
+def dynamic_getenv(name: str, default: str | None = None) -> str | None:
+    if name in _POLICY_KEYS:
+        initialize_runtime_policy()
+        with _SESSION_POLICY_LOCK:
+            assert _DEPLOYMENT_POLICY is not None
+            return _SESSION_POLICY.get(name, _DEPLOYMENT_POLICY[name])
+
+    # Non-policy operational settings retain their existing lookup behavior.
     env, _ = _load_mcp_env()
     value = env.get(name)
     if value not in (None, ""):
@@ -237,7 +320,14 @@ def dynamic_env_bool(name: str, default: bool) -> bool:
 def permission_enforcement_mode() -> str:
     if dynamic_env_bool("IOTDB_STRICT_PERMISSION_ENFORCEMENT", False):
         return "strict"
-    mode = (dynamic_getenv("TIMESEEK_MCP_PERMISSION_ENFORCEMENT", "advisory") 
or "advisory").strip().lower()
+    mode = (
+        (
+            dynamic_getenv("TIMESEEK_MCP_PERMISSION_ENFORCEMENT", "advisory")
+            or "advisory"
+        )
+        .strip()
+        .lower()
+    )
     if mode not in {"advisory", "strict"}:
         return "advisory"
     return mode
@@ -249,18 +339,15 @@ def strict_permission_enforcement() -> bool:
 
 def _redact_env(env: dict[str, str]) -> dict[str, str]:
     return {
-        key: "***"
-        if key in _WITHHELD_ENV_KEYS or _SENSITIVE_KEY_RE.search(key)
-        else value
+        key: (
+            "***"
+            if key in _WITHHELD_ENV_KEYS or _SENSITIVE_KEY_RE.search(key)
+            else value
+        )
         for key, value in env.items()
     }
 
 
-def _session_policy_copy() -> dict[str, str]:
-    with _SESSION_POLICY_LOCK:
-        return dict(_SESSION_POLICY)
-
-
 def _coerce_policy_value(value: Any) -> str:
     if isinstance(value, bool):
         return "true" if value else "false"
@@ -275,6 +362,13 @@ def _validate_policy_keys(policy: Mapping[str, Any]) -> 
None:
             + ", ".join(unknown)
             + ". Use get_iotdb_session_policy to inspect supported keys."
         )
+    admin = sorted(str(key) for key in policy if str(key) in 
_ADMIN_POLICY_KEYS)
+    if admin:
+        raise PermissionError(
+            "Administrator-only policy key(s): "
+            + ", ".join(admin)
+            + ". Change protected deployment configuration and restart the 
server."
+        )
 
 
 def session_policy_for_preset(preset: str) -> dict[str, str]:
@@ -282,11 +376,86 @@ def session_policy_for_preset(preset: str) -> dict[str, 
str]:
     try:
         return dict(_PRESET_POLICIES[normalized])
     except KeyError as exc:
-        raise ValueError("Unsupported policy preset. Expected one of: full, 
ddl, readonly.") from exc
+        raise ValueError(
+            "Unsupported policy preset. Expected one of: full, ddl, readonly."
+        ) from exc
 
 
 def supported_policy_keys() -> list[str]:
-    return sorted(_POLICY_KEYS)
+    return sorted(_POLICY_KEYS - _ADMIN_POLICY_KEYS)
+
+
+def _no_broader(key: str, candidate: str, limit: str) -> bool:
+    if candidate == limit:
+        return True
+    if key.startswith("IOTDB_ENABLE_"):
+        return candidate == "false"
+    if "CONFIRM" in key:
+        return candidate == "true"
+    if key == "IOTDB_SQL_DRIVER_MODE":
+        return _MODE_RANK[candidate] <= _MODE_RANK[limit]
+    if key.endswith("_ALLOWED_USERS"):
+        candidate_users = {item for item in candidate.split(",") if item}
+        limit_users = {item for item in limit.split(",") if item}
+        return "*" in limit_users or candidate_users <= limit_users
+    return False
+
+
+def _apply_session_candidate(candidate: dict[str, str]) -> dict[str, Any]:
+    """Validate, authorize, and commit under one lock, including 
reset/replace."""
+    global _POLICY_REVISION
+    assert _DEPLOYMENT_POLICY is not None
+    before = {**_DEPLOYMENT_POLICY, **_SESSION_POLICY}
+    after = {**_DEPLOYMENT_POLICY, **candidate}
+    above_ceiling = [
+        key
+        for key in after
+        if not _no_broader(key, after[key], _DEPLOYMENT_POLICY[key])
+    ]
+    if above_ceiling:
+        raise PermissionError(
+            "Deployment permission ceiling exceeded: "
+            + ", ".join(sorted(above_ceiling))
+            + ". Administrator must change protected configuration and 
restart."
+        )
+    changes = {
+        key: {"before": before[key], "after": after[key]}
+        for key in sorted(after)
+        if before[key] != after[key]
+    }
+    widened = [key for key in changes if not _no_broader(key, after[key], 
before[key])]
+    if widened and _APPROVAL_MODE == "require":
+        if _APPROVAL_STORE is None:
+            return {
+                **dynamic_policy_snapshot(),
+                "status": "approval_unavailable",
+                "applied": False,
+                "requested_changes": changes,
+                "message": "No administrator approval directory configured; 
policy unchanged.",
+            }
+        fingerprint = hashlib.sha256(
+            json.dumps(_DEPLOYMENT_POLICY, sort_keys=True).encode()
+        ).hexdigest()
+        approval = _APPROVAL_STORE.authorize(
+            {
+                "revision": _POLICY_REVISION,
+                "deployment_fingerprint": fingerprint,
+                "changes": changes,
+                "widened_keys": widened,
+            }
+        )
+        if approval["status"] != "approved":
+            return {
+                **dynamic_policy_snapshot(),
+                **approval,
+                "applied": False,
+                "message": "Policy unchanged. Administrator approval is 
required before retrying.",
+            }
+    _SESSION_POLICY.clear()
+    _SESSION_POLICY.update(candidate)
+    if changes:
+        _POLICY_REVISION += 1
+    return {**dynamic_policy_snapshot(), "status": "applied", "applied": True}
 
 
 def set_session_policy(
@@ -295,59 +464,56 @@ def set_session_policy(
     preset: str | None = None,
     replace: bool = False,
 ) -> dict[str, Any]:
-    updates: dict[str, str] = {}
-    if preset:
-        updates.update(session_policy_for_preset(preset))
-    if policy:
-        _validate_policy_keys(policy)
-        updates.update({str(key): _coerce_policy_value(value) for key, value 
in policy.items()})
-
+    initialize_runtime_policy()
     with _SESSION_POLICY_LOCK:
-        preserved_enforcement = (
-            {
-                key: value
-                for key, value in _SESSION_POLICY.items()
-                if key in _ENFORCEMENT_POLICY_KEYS
-            }
-            if replace and preset
-            else {}
-        )
-        if replace:
-            _SESSION_POLICY.clear()
-            _SESSION_POLICY.update(preserved_enforcement)
-        _SESSION_POLICY.update(updates)
-
-    return dynamic_policy_snapshot()
+        assert _DEPLOYMENT_POLICY is not None
+        updates: dict[str, str] = {}
+        if preset:
+            # Presets are permission caps, intersected with operator 
restrictions.
+            for key, value in session_policy_for_preset(preset).items():
+                ceiling = _DEPLOYMENT_POLICY[key]
+                updates[key] = value if _no_broader(key, value, ceiling) else 
ceiling
+        if policy:
+            _validate_policy_keys(policy)
+            updates.update(
+                {
+                    str(key): _normalize_policy_value(str(key), value)
+                    for key, value in policy.items()
+                }
+            )
+        candidate = {} if replace else dict(_SESSION_POLICY)
+        candidate.update(updates)
+        return _apply_session_candidate(candidate)
 
 
 def reset_session_policy(keys: list[str] | None = None) -> dict[str, Any]:
+    initialize_runtime_policy()
     if keys is not None:
         _validate_policy_keys({key: "" for key in keys})
 
     with _SESSION_POLICY_LOCK:
-        if keys is None:
-            _SESSION_POLICY.clear()
-        else:
+        candidate = {} if keys is None else dict(_SESSION_POLICY)
+        if keys is not None:
             for key in keys:
-                _SESSION_POLICY.pop(key, None)
-
-    return dynamic_policy_snapshot()
+                candidate.pop(key, None)
+        return _apply_session_candidate(candidate)
 
 
 def dynamic_policy_snapshot() -> dict[str, Any]:
-    env, path = _load_mcp_env()
-    session_policy = _session_policy_copy()
-    effective: dict[str, str] = {}
-    for key in supported_policy_keys():
-        value = dynamic_getenv(key, "")
-        if value not in (None, ""):
-            effective[key] = value
-    return {
-        "mcp_config_path": path,
-        "session_policy": _redact_env(session_policy),
-        "mcp_env": _redact_env(env),
-        "defaults": {**_FULL_PERMISSION_DEFAULTS, **_ENFORCEMENT_DEFAULTS},
-        "effective_policy": _redact_env(effective),
-        "supported_keys": supported_policy_keys(),
-        "presets": sorted(_PRESET_POLICIES),
-    }
+    initialize_runtime_policy()
+    with _SESSION_POLICY_LOCK:
+        assert _DEPLOYMENT_POLICY is not None
+        return {
+            "mcp_config_path": _DEPLOYMENT_CONFIG_PATH,
+            "session_policy": dict(_SESSION_POLICY),
+            "mcp_env": _redact_env(_DEPLOYMENT_ENV),
+            "defaults": {**_FULL_PERMISSION_DEFAULTS, **_ENFORCEMENT_DEFAULTS},
+            "deployment_policy": dict(_DEPLOYMENT_POLICY),
+            "effective_policy": {**_DEPLOYMENT_POLICY, **_SESSION_POLICY},
+            "supported_keys": supported_policy_keys(),
+            "administrator_only_keys": sorted(_ADMIN_POLICY_KEYS),
+            "approval_mode": _APPROVAL_MODE,
+            "approval_available": _APPROVAL_STORE is not None,
+            "policy_revision": _POLICY_REVISION,
+            "presets": sorted(_PRESET_POLICIES),
+        }
diff --git a/src/iotdb_mcp_server/server.py b/src/iotdb_mcp_server/server.py
index b0935e9..f86dd85 100644
--- a/src/iotdb_mcp_server/server.py
+++ b/src/iotdb_mcp_server/server.py
@@ -21,6 +21,7 @@ import logging
 from fastmcp import FastMCP
 
 from iotdb_mcp_server.config import Config
+from iotdb_mcp_server.runtime_policy import initialize_runtime_policy
 from iotdb_mcp_server.services.database import register_database_tools
 from iotdb_mcp_server.services.explain import register_explain_tools
 from iotdb_mcp_server.services.metadata import register_metadata_tools
@@ -44,6 +45,7 @@ logging.basicConfig(
 logger = logging.getLogger("iotdb_mcp_server")
 
 config = Config.from_env_arguments()
+initialize_runtime_policy()
 
 logger.info(
     "IoTDB Config: %s",
diff --git a/src/iotdb_mcp_server/services/README.md 
b/src/iotdb_mcp_server/services/README.md
index 27d1dad..ce0d2e7 100644
--- a/src/iotdb_mcp_server/services/README.md
+++ b/src/iotdb_mcp_server/services/README.md
@@ -107,11 +107,15 @@ Features:
   - `ddl`: advisory signal for read and DDL-capable workflows
   - `readonly`: advisory signal for read-only workflows
   - In `strict` mode only, these presets become hard execution gates
-- Effective policy priority:
-  - session overlay set by MCP tool
-  - dynamically read `.mcp.json`
-  - process environment
-  - built-in full-permission defaults
+- Deployment ceiling is frozen at server startup: process environment, then
+  `.mcp.json`, then defaults. Policy files are not hot-reloaded.
+- Every session value must be no broader than that ceiling. Presets are 
intersected
+  with it; explicit above-ceiling requests are rejected atomically.
+- Narrowing applies immediately. Widening (including reset/replace) requires
+  out-of-band approval by default. The operator may explicitly configure
+  `IOTDB_SESSION_POLICY_APPROVAL_MODE=allow` for in-ceiling changes only.
+- Enforcement switches and SQL classification extensions are 
administrator-only.
+- See [session policy 
administration](../../../docs/session-policy-security.md).
 
 ### `metadata.py`
 
@@ -284,18 +288,18 @@ Parser check:
 
 ## Security and Permission Model
 
-IoTDB MCP permissions default to host-agent approval flow. The MCP server 
reports
-required permission and risk; Codex, Claude Code, or OpenCode owns user 
approval
-through its normal question/approval mechanism. This keeps MCP policy aligned
-with the host agent system instead of turning `.mcp.json` into a hard blocker.
+SQL execution permissions remain advisory by default. The MCP server reports
+required permission and risk, but a tool-supplied confirmation boolean does not
+prove human approval. For a service-enforced SQL permission boundary, enable 
strict
+mode. Session policy mutation authorization is enforced in both modes.
 
 Default mode is `advisory`. In advisory mode:
 
 - `IOTDB_SQL_DRIVER_MODE` is a prompt-layer signal, not an execution blocker.
 - `IOTDB_ENABLE_*` and `*_ALLOWED_USERS` are prompt-layer/session settings, not
   hard gates.
-- Destructive operations still require explicit tool confirmation flags, such
-  as `confirm_destructive=true`, after the host agent has obtained approval.
+- Destructive operations still require tool confirmation flags, such as
+  `confirm_destructive=true`. These are caller assertions, not verified 
approvals.
 
 Strict hard gating is available only when explicitly enabled:
 
@@ -305,7 +309,7 @@ TIMESEEK_MCP_PERMISSION_ENFORCEMENT=strict
 IOTDB_STRICT_PERMISSION_ENFORCEMENT=true
 ```
 
-Policy can be changed at runtime without restarting the MCP server:
+Session policy can be narrowed at runtime without restarting the MCP server:
 
 ```json
 {
@@ -314,7 +318,7 @@ Policy can be changed at runtime without restarting the MCP 
server:
 }
 ```
 
-or:
+Widening within the startup deployment ceiling can be requested with:
 
 ```json
 {
@@ -325,6 +329,14 @@ or:
 }
 ```
 
+The second example may return `applied=false` with `status=approval_required`,
+`approval_rejected`, or `approval_unavailable`. It must not be treated as an 
applied
+change. An administrator reviews and decides the exact request through
+`iotdb-policy-admin`, outside the agent's authority, then the client retries.
+Changing the deployment ceiling, either enforcement switch, or SQL 
classification
+prefixes requires administrator-controlled configuration and a server restart.
+See [the complete workflow](../../../docs/session-policy-security.md).
+
 ### Per-module advisory keys
 
 - `database.py`
diff --git a/src/iotdb_mcp_server/services/database.py 
b/src/iotdb_mcp_server/services/database.py
index 5578c1e..4297dbe 100644
--- a/src/iotdb_mcp_server/services/database.py
+++ b/src/iotdb_mcp_server/services/database.py
@@ -64,7 +64,7 @@ def _assert_database_ddl_permission(
                 "Set IOTDB_ENABLE_DATABASE_DDL=true to enable."
             )
 
-        allowed_users = 
_csv_set(dynamic_getenv("IOTDB_DATABASE_DDL_ALLOWED_USERS", "root") or "root")
+        allowed_users = 
_csv_set(dynamic_getenv("IOTDB_DATABASE_DDL_ALLOWED_USERS", "root") or "")
         if "*" not in allowed_users and config.user not in allowed_users:
             raise PermissionError(
                 f"Current MCP user '{config.user}' is not allowed by 
IOTDB_DATABASE_DDL_ALLOWED_USERS."
diff --git a/src/iotdb_mcp_server/services/metadata.py 
b/src/iotdb_mcp_server/services/metadata.py
index b7a8398..7341bd4 100644
--- a/src/iotdb_mcp_server/services/metadata.py
+++ b/src/iotdb_mcp_server/services/metadata.py
@@ -59,7 +59,7 @@ def _assert_metadata_permission(config: Config) -> None:
             )
 
         allowed_users = _csv_set(
-            dynamic_getenv("IOTDB_METADATA_ALLOWED_USERS", "*") or "*"
+            dynamic_getenv("IOTDB_METADATA_ALLOWED_USERS", "*") or ""
         )
         if "*" not in allowed_users and config.user not in allowed_users:
             raise PermissionError(
diff --git a/src/iotdb_mcp_server/services/model.py 
b/src/iotdb_mcp_server/services/model.py
index 577c172..48a38dc 100644
--- a/src/iotdb_mcp_server/services/model.py
+++ b/src/iotdb_mcp_server/services/model.py
@@ -69,7 +69,7 @@ def _assert_model_permission(
             )
 
         allowed_users = _csv_set(
-            dynamic_getenv("IOTDB_MODEL_ALLOWED_USERS", "root") or "root"
+            dynamic_getenv("IOTDB_MODEL_ALLOWED_USERS", "root") or ""
         )
         if "*" not in allowed_users and config.user not in allowed_users:
             raise PermissionError(
diff --git a/src/iotdb_mcp_server/services/runtime_policy.py 
b/src/iotdb_mcp_server/services/runtime_policy.py
index c768389..32015be 100644
--- a/src/iotdb_mcp_server/services/runtime_policy.py
+++ b/src/iotdb_mcp_server/services/runtime_policy.py
@@ -48,13 +48,18 @@ def register_runtime_policy_tools(mcp, config: Config, 
logger: logging.Logger) -
         replace: bool = False,
     ) -> list[TextContent]:
         """
-        Set session-scoped IoTDB MCP permissions without restarting the MCP 
server.
+        Request session permissions bounded by the immutable deployment policy.
 
-        preset accepts: full, ddl, readonly. Explicit policy values override 
preset values.
+        Narrowing applies immediately. Widening may return approval_required 
with
+        no changes applied; an administrator must approve out of band before 
retry.
+        There is no agent-supplied approval flag. Presets (full, ddl, 
readonly) are
+        intersected with the deployment ceiling. Explicit values cannot exceed 
it.
+        Enforcement switches and SQL classification prefixes are 
administrator-only.
         """
         snapshot = set_session_policy(policy=policy, preset=preset, 
replace=replace)
         logger.info(
-            "Updated IoTDB session policy preset=%s replace=%s keys=%s",
+            "IoTDB session policy status=%s preset=%s replace=%s keys=%s",
+            snapshot["status"],
             preset,
             replace,
             sorted((policy or {}).keys()),
@@ -62,16 +67,22 @@ def register_runtime_policy_tools(mcp, config: Config, 
logger: logging.Logger) -
         return payload_response(
             "set_iotdb_session_policy",
             snapshot,
-            message="IoTDB session policy updated.",
+            message="IoTDB session policy: " + snapshot["status"] + ".",
         )
 
     @mcp.tool()
-    async def reset_iotdb_session_policy(keys: list[str] | None = None) -> 
list[TextContent]:
-        """Clear all or selected session-scoped IoTDB MCP permission 
overrides."""
+    async def reset_iotdb_session_policy(
+        keys: list[str] | None = None,
+    ) -> list[TextContent]:
+        """Reset overrides; any resulting widening requires the same approval 
as set."""
         snapshot = reset_session_policy(keys=keys)
-        logger.info("Reset IoTDB session policy keys=%s", keys or "*")
+        logger.info(
+            "IoTDB session policy reset status=%s keys=%s",
+            snapshot["status"],
+            keys or "*",
+        )
         return payload_response(
             "reset_iotdb_session_policy",
             snapshot,
-            message="IoTDB session policy reset.",
+            message="IoTDB session policy reset: " + snapshot["status"] + ".",
         )
diff --git a/src/iotdb_mcp_server/services/sql_driver.py 
b/src/iotdb_mcp_server/services/sql_driver.py
index 055c3e5..5636d52 100644
--- a/src/iotdb_mcp_server/services/sql_driver.py
+++ b/src/iotdb_mcp_server/services/sql_driver.py
@@ -527,7 +527,8 @@ def _sql_permission_payload(
                 "Call sql_execute with confirm_destructive=true when the "
                 "confirmation_parameter is confirm_destructive."
             ),
-            "do_not_use_environment_as_primary_gate": True,
+            "deployment_policy_is_immutable": True,
+            "strict_policy_cannot_be_bypassed_by_confirmation": True,
         },
     }
 
@@ -547,7 +548,7 @@ def _assert_sql_driver_permission(
             )
 
         allowed_users = _csv_set(
-            dynamic_getenv("IOTDB_SQL_DRIVER_ALLOWED_USERS", "root") or "root"
+            dynamic_getenv("IOTDB_SQL_DRIVER_ALLOWED_USERS", "root") or ""
         )
         if "*" not in allowed_users and config.user not in allowed_users:
             raise PermissionError(
diff --git a/src/iotdb_mcp_server/services/table.py 
b/src/iotdb_mcp_server/services/table.py
index 958a05f..2eb8fd2 100644
--- a/src/iotdb_mcp_server/services/table.py
+++ b/src/iotdb_mcp_server/services/table.py
@@ -50,7 +50,7 @@ def _assert_table_ddl_permission(
                 "Set IOTDB_ENABLE_TABLE_DDL=true to enable."
             )
 
-        allowed_users = 
_csv_set(dynamic_getenv("IOTDB_TABLE_DDL_ALLOWED_USERS", "root") or "root")
+        allowed_users = 
_csv_set(dynamic_getenv("IOTDB_TABLE_DDL_ALLOWED_USERS", "root") or "")
         if "*" not in allowed_users and config.user not in allowed_users:
             raise PermissionError(
                 f"Current MCP user '{config.user}' is not allowed by 
IOTDB_TABLE_DDL_ALLOWED_USERS."
diff --git a/src/iotdb_mcp_server/services/timeseries.py 
b/src/iotdb_mcp_server/services/timeseries.py
index 89b034e..6678af1 100644
--- a/src/iotdb_mcp_server/services/timeseries.py
+++ b/src/iotdb_mcp_server/services/timeseries.py
@@ -52,7 +52,7 @@ def _assert_timeseries_ddl_permission(
                 "Set IOTDB_ENABLE_TIMESERIES_DDL=true to enable."
             )
 
-        allowed_users = 
_csv_set(dynamic_getenv("IOTDB_TIMESERIES_DDL_ALLOWED_USERS", "root") or "root")
+        allowed_users = 
_csv_set(dynamic_getenv("IOTDB_TIMESERIES_DDL_ALLOWED_USERS", "root") or "")
         if "*" not in allowed_users and config.user not in allowed_users:
             raise PermissionError(
                 f"Current MCP user '{config.user}' is not allowed by 
IOTDB_TIMESERIES_DDL_ALLOWED_USERS."
diff --git a/src/iotdb_mcp_server/services/ttl.py 
b/src/iotdb_mcp_server/services/ttl.py
index b22786e..43fea71 100644
--- a/src/iotdb_mcp_server/services/ttl.py
+++ b/src/iotdb_mcp_server/services/ttl.py
@@ -55,7 +55,7 @@ def _assert_ttl_permission(
                 "Set IOTDB_ENABLE_TTL_SQL=true to enable."
             )
 
-        allowed_users = _csv_set(dynamic_getenv("IOTDB_TTL_ALLOWED_USERS", 
"root") or "root")
+        allowed_users = _csv_set(dynamic_getenv("IOTDB_TTL_ALLOWED_USERS", 
"root") or "")
         if "*" not in allowed_users and config.user not in allowed_users:
             raise PermissionError(
                 f"Current MCP user '{config.user}' is not allowed by 
IOTDB_TTL_ALLOWED_USERS."
diff --git a/src/iotdb_mcp_server/services/write.py 
b/src/iotdb_mcp_server/services/write.py
index 5250112..4396473 100644
--- a/src/iotdb_mcp_server/services/write.py
+++ b/src/iotdb_mcp_server/services/write.py
@@ -52,7 +52,7 @@ def _assert_write_permission(
                 "Set IOTDB_ENABLE_WRITE_DML=true to enable."
             )
 
-        allowed_users = _csv_set(dynamic_getenv("IOTDB_WRITE_ALLOWED_USERS", 
"root") or "root")
+        allowed_users = _csv_set(dynamic_getenv("IOTDB_WRITE_ALLOWED_USERS", 
"root") or "")
         if "*" not in allowed_users and config.user not in allowed_users:
             raise PermissionError(
                 f"Current MCP user '{config.user}' is not allowed by 
IOTDB_WRITE_ALLOWED_USERS."
diff --git a/tests/test_runtime_policy.py b/tests/test_runtime_policy.py
index df1e213..0ea7676 100644
--- a/tests/test_runtime_policy.py
+++ b/tests/test_runtime_policy.py
@@ -1,8 +1,16 @@
 from __future__ import annotations
 
+import asyncio
+from concurrent.futures import ThreadPoolExecutor
+import contextlib
+import io
+import json
+import logging
 import os
+import subprocess
 import sys
 import unittest
+import tempfile
 from pathlib import Path
 from unittest.mock import patch
 
@@ -14,12 +22,61 @@ from iotdb_mcp_server.runtime_policy import (  # noqa: E402
     reset_session_policy,
     set_session_policy,
     strict_permission_enforcement,
+    dynamic_getenv,
+    initialize_runtime_policy,
 )
+from iotdb_mcp_server import runtime_policy as runtime  # noqa: E402
+from iotdb_mcp_server.policy_approval import main as administer  # noqa: E402
+from iotdb_mcp_server.config import Config  # noqa: E402
+from iotdb_mcp_server.services.sql_driver import (
+    _assert_sql_driver_permission,
+)  # noqa: E402
+from iotdb_mcp_server.services.write import _assert_write_permission  # noqa: 
E402
+from iotdb_mcp_server.services.runtime_policy import (
+    register_runtime_policy_tools,
+)  # noqa: E402
 
 
 class RuntimePolicyTest(unittest.TestCase):
-    def tearDown(self) -> None:
-        reset_session_policy()
+    def setUp(self) -> None:
+        self.enterContext(patch.dict(os.environ, {}, clear=True))
+        self.enterContext(patch.object(runtime, "_load_mcp_env", 
return_value=({}, "")))
+        for name, value in {
+            "_DEPLOYMENT_POLICY": None,
+            "_DEPLOYMENT_ENV": {},
+            "_DEPLOYMENT_CONFIG_PATH": "",
+            "_SESSION_POLICY": {},
+            "_POLICY_REVISION": 0,
+            "_APPROVAL_STORE": None,
+            "_APPROVAL_MODE": "require",
+        }.items():
+            self.enterContext(patch.object(runtime, name, value))
+
+    def approval_directory(self) -> Path:
+        directory = Path(self.enterContext(tempfile.TemporaryDirectory()))
+        os.environ["IOTDB_SESSION_POLICY_APPROVAL_DIR"] = str(directory)
+        return directory
+
+    def decide(self, directory: Path, request: dict, action: str = "approve") 
-> None:
+        with contextlib.redirect_stdout(io.StringIO()):
+            self.assertEqual(
+                administer(
+                    [action, request["request_id"], "--directory", 
str(directory)]
+                ),
+                0,
+            )
+
+    def config(self) -> Config:
+        return Config(
+            host="127.0.0.1",
+            port=6667,
+            user="root",
+            password="test",
+            database="test",
+            sql_dialect="tree",
+            timezone="UTC",
+            export_path="/tmp",
+        )
 
     def test_targets_json_is_fully_withheld_from_policy_snapshot(self) -> None:
         mcp_env = {
@@ -61,22 +118,349 @@ class RuntimePolicyTest(unittest.TestCase):
                 snapshot["session_policy"],
             )
 
-    def test_replacing_with_preset_preserves_session_enforcement(self) -> None:
-        with (
-            patch(
-                "iotdb_mcp_server.runtime_policy._load_mcp_env",
-                return_value=({}, ""),
-            ),
-            patch.dict(os.environ, {}, clear=True),
+    def test_both_enforcement_switches_and_classification_are_admin_only(self) 
-> None:
+        os.environ["IOTDB_STRICT_PERMISSION_ENFORCEMENT"] = "true"
+        for key, value in {
+            "IOTDB_STRICT_PERMISSION_ENFORCEMENT": False,
+            "TIMESEEK_MCP_PERMISSION_ENFORCEMENT": "advisory",
+            "IOTDB_SQL_DRIVER_EXTRA_READONLY_PREFIXES": "INSERT INTO",
+            "IOTDB_SQL_DRIVER_EXTRA_DDL_PREFIXES": "DELETE FROM",
+            "IOTDB_SQL_DRIVER_EXTRA_FULL_PREFIXES": "GRANT",
+        }.items():
+            with self.subTest(key=key):
+                with self.assertRaises(PermissionError):
+                    set_session_policy({key: value}, replace=True)
+                with self.assertRaises(PermissionError):
+                    reset_session_policy([key])
+        self.assertTrue(strict_permission_enforcement())
+        self.assertEqual(dynamic_policy_snapshot()["session_policy"], {})
+
+    def test_environment_ceiling_is_frozen_and_takes_precedence_over_config(
+        self,
+    ) -> None:
+        os.environ.update(
+            {
+                "IOTDB_STRICT_PERMISSION_ENFORCEMENT": "true",
+                "IOTDB_SQL_DRIVER_MODE": "readonly",
+                "IOTDB_ENABLE_WRITE_DML": "false",
+            }
+        )
+        runtime._load_mcp_env.return_value = (
+            {
+                "IOTDB_SQL_DRIVER_MODE": "full",
+                "IOTDB_ENABLE_WRITE_DML": "true",
+            },
+            "/tmp/.mcp.json",
+        )
+        initialize_runtime_policy()
+        os.environ["IOTDB_SQL_DRIVER_MODE"] = "full"
+        os.environ["IOTDB_STRICT_PERMISSION_ENFORCEMENT"] = "false"
+        runtime._load_mcp_env.return_value = ({"IOTDB_SQL_DRIVER_MODE": 
"full"}, "")
+        with self.assertRaisesRegex(PermissionError, "ceiling"):
+            set_session_policy({"IOTDB_SQL_DRIVER_MODE": "full"})
+        snapshot = set_session_policy(preset="full")
+        self.assertEqual(
+            snapshot["effective_policy"]["IOTDB_SQL_DRIVER_MODE"], "readonly"
+        )
+        self.assertEqual(dynamic_getenv("IOTDB_ENABLE_WRITE_DML"), "false")
+        self.assertTrue(strict_permission_enforcement())
+        with self.assertRaises(PermissionError):
+            _assert_sql_driver_permission(
+                self.config(), "ddl", "readonly", True, "DROP DATABASE"
+            )
+        with self.assertRaises(PermissionError):
+            _assert_write_permission(self.config(), "INSERT")
+
+    def test_preset_intersects_allowlists_and_confirmation_ceiling(self) -> 
None:
+        os.environ["IOTDB_WRITE_ALLOWED_USERS"] = "alice"
+        os.environ["IOTDB_REQUIRE_DELETE_CONFIRM"] = "true"
+        result = set_session_policy(preset="readonly")
+        self.assertTrue(result["applied"])
+        self.assertEqual(dynamic_getenv("IOTDB_WRITE_ALLOWED_USERS"), "alice")
+        self.assertEqual(dynamic_getenv("IOTDB_ENABLE_MODEL_MANAGEMENT"), 
"false")
+        for policy in (
+            {"IOTDB_WRITE_ALLOWED_USERS": "*"},
+            {"IOTDB_REQUIRE_DELETE_CONFIRM": False},
         ):
-            set_session_policy({"TIMESEEK_MCP_PERMISSION_ENFORCEMENT": 
"strict"})
-            snapshot = set_session_policy(preset="readonly", replace=True)
+            with self.assertRaises(PermissionError):
+                set_session_policy(policy)
 
-            self.assertTrue(strict_permission_enforcement())
-            self.assertEqual(
-                
snapshot["session_policy"]["TIMESEEK_MCP_PERMISSION_ENFORCEMENT"],
-                "strict",
+    def test_narrowing_applies_and_widening_fails_closed_without_channel(self) 
-> None:
+        result = set_session_policy({"IOTDB_SQL_DRIVER_MODE": "readonly"})
+        self.assertTrue(result["applied"])
+        for operation in (
+            lambda: set_session_policy({"IOTDB_SQL_DRIVER_MODE": "full"}),
+            lambda: set_session_policy(preset="full", replace=True),
+            lambda: set_session_policy(replace=True),
+            lambda: reset_session_policy(),
+            lambda: reset_session_policy(["IOTDB_SQL_DRIVER_MODE"]),
+        ):
+            with self.subTest(operation=operation):
+                result = operation()
+                self.assertFalse(result["applied"])
+                self.assertEqual(result["status"], "approval_unavailable")
+                self.assertEqual(dynamic_getenv("IOTDB_SQL_DRIVER_MODE"), 
"readonly")
+
+    def test_approval_is_exact_one_time_and_precedes_mutation(self) -> None:
+        directory = self.approval_directory()
+        set_session_policy(preset="readonly")
+        result = set_session_policy(preset="full")
+        self.assertEqual(result["status"], "approval_required")
+        self.assertFalse(result["applied"])
+        self.assertEqual(dynamic_getenv("IOTDB_ENABLE_WRITE_DML"), "false")
+        self.decide(directory, result)
+        approved = set_session_policy(preset="full")
+        self.assertTrue(approved["applied"])
+        self.assertEqual(dynamic_getenv("IOTDB_ENABLE_WRITE_DML"), "true")
+        self.assertEqual(list(directory.iterdir()), [])
+        set_session_policy(preset="readonly")
+        replay = set_session_policy(preset="full")
+        self.assertFalse(replay["applied"])
+        self.assertNotEqual(result["request_id"], replay["request_id"])
+
+    def test_reset_and_replace_require_approval_before_restoring_permissions(
+        self,
+    ) -> None:
+        directory = self.approval_directory()
+        for operation in (
+            reset_session_policy,
+            lambda: set_session_policy(replace=True),
+        ):
+            with self.subTest(operation=operation):
+                set_session_policy({"IOTDB_ENABLE_WRITE_DML": False})
+                result = operation()
+                self.assertFalse(result["applied"])
+                self.decide(directory, result)
+                self.assertTrue(operation()["applied"])
+                self.assertEqual(dynamic_getenv("IOTDB_ENABLE_WRITE_DML"), 
"true")
+
+    def test_allow_is_operator_only_and_never_bypasses_ceiling(self) -> None:
+        os.environ.update(
+            {
+                "IOTDB_SESSION_POLICY_APPROVAL_MODE": "allow",
+                "IOTDB_ENABLE_DATABASE_DDL": "false",
+            }
+        )
+        set_session_policy(preset="readonly")
+        self.assertTrue(set_session_policy(preset="full")["applied"])
+        with self.assertRaises(PermissionError):
+            set_session_policy({"IOTDB_ENABLE_DATABASE_DDL": True})
+        for key in (
+            "IOTDB_SESSION_POLICY_APPROVAL_MODE",
+            "IOTDB_SESSION_POLICY_APPROVAL_DIR",
+            "approved",
+        ):
+            with self.assertRaises(ValueError):
+                set_session_policy({key: "allow"})
+
+    def test_approval_mode_is_frozen(self) -> None:
+        set_session_policy(preset="readonly")
+        os.environ["IOTDB_SESSION_POLICY_APPROVAL_MODE"] = "allow"
+        self.assertFalse(reset_session_policy()["applied"])
+
+    def test_rejection_keeps_original_policy(self) -> None:
+        directory = self.approval_directory()
+        set_session_policy(preset="readonly")
+        request = reset_session_policy()
+        self.decide(directory, request, "deny")
+        result = reset_session_policy()
+        self.assertEqual(result["status"], "approval_rejected")
+        self.assertFalse(result["applied"])
+        self.assertEqual(dynamic_getenv("IOTDB_SQL_DRIVER_MODE"), "readonly")
+
+    def test_expired_approval_cannot_apply(self) -> None:
+        directory = self.approval_directory()
+        set_session_policy(preset="readonly")
+        request = reset_session_policy()
+        self.decide(directory, request)
+        with patch(
+            "iotdb_mcp_server.policy_approval.time.time",
+            return_value=request["expires_at"] + 1,
+        ):
+            result = reset_session_policy()
+        self.assertFalse(result["applied"])
+        self.assertNotEqual(result["request_id"], request["request_id"])
+
+    def test_changed_revision_invalidates_pending_approval(self) -> None:
+        directory = self.approval_directory()
+        set_session_policy({"IOTDB_ENABLE_WRITE_DML": False})
+        request = reset_session_policy()
+        self.decide(directory, request)
+        set_session_policy({"IOTDB_ENABLE_DATABASE_DDL": False})
+        result = reset_session_policy()
+        self.assertFalse(result["applied"])
+        self.assertNotEqual(result["request_id"], request["request_id"])
+
+    def test_different_proposal_cannot_reuse_approval(self) -> None:
+        directory = self.approval_directory()
+        set_session_policy(preset="readonly")
+        request = set_session_policy({"IOTDB_ENABLE_WRITE_DML": True})
+        self.decide(directory, request)
+        result = set_session_policy(preset="full")
+        self.assertFalse(result["applied"])
+        self.assertNotEqual(result["request_id"], request["request_id"])
+
+    def test_tampered_request_cannot_approve_original_proposal(self) -> None:
+        directory = self.approval_directory()
+        set_session_policy(preset="readonly")
+        request = reset_session_policy()
+        path = directory / f"{request['request_id']}.request.json"
+        data = json.loads(path.read_text())
+        data["changes"] = {}
+        path.write_text(json.dumps(data))
+        self.decide(directory, request)
+        with self.assertRaises(PermissionError):
+            reset_session_policy()
+        self.assertEqual(dynamic_getenv("IOTDB_SQL_DRIVER_MODE"), "readonly")
+
+    def test_atomic_rejection_and_invalid_values(self) -> None:
+        os.environ["IOTDB_ENABLE_DATABASE_DDL"] = "false"
+        with self.assertRaises(PermissionError):
+            set_session_policy(
+                {"IOTDB_ENABLE_WRITE_DML": False, "IOTDB_ENABLE_DATABASE_DDL": 
True}
             )
+        self.assertEqual(dynamic_policy_snapshot()["session_policy"], {})
+        for policy in (
+            {"IOTDB_ENABLE_WRITE_DML": "maybe"},
+            {"IOTDB_SQL_DRIVER_MODE": "unrestricted"},
+        ):
+            with self.assertRaises(ValueError):
+                set_session_policy(policy)
+        self.assertEqual(dynamic_policy_snapshot()["session_policy"], {})
+
+    def test_empty_allowlist_denies_root_and_restoring_it_requires_approval(
+        self,
+    ) -> None:
+        os.environ["IOTDB_STRICT_PERMISSION_ENFORCEMENT"] = "true"
+        set_session_policy({"IOTDB_WRITE_ALLOWED_USERS": ""})
+        self.assertEqual(dynamic_getenv("IOTDB_WRITE_ALLOWED_USERS", "root"), 
"")
+        with self.assertRaises(PermissionError):
+            _assert_write_permission(self.config(), "INSERT")
+        self.assertFalse(reset_session_policy()["applied"])
+
+    def test_confirmation_and_allowlist_widening_need_approval(self) -> None:
+        os.environ["IOTDB_REQUIRE_DELETE_CONFIRM"] = "false"
+        for narrow, wider in (
+            (
+                {"IOTDB_REQUIRE_DELETE_CONFIRM": True},
+                {"IOTDB_REQUIRE_DELETE_CONFIRM": False},
+            ),
+            (
+                {"IOTDB_WRITE_ALLOWED_USERS": "alice"},
+                {"IOTDB_WRITE_ALLOWED_USERS": "alice,bob"},
+            ),
+            ({"IOTDB_SQL_DRIVER_MODE": "readonly"}, {"IOTDB_SQL_DRIVER_MODE": 
"ddl"}),
+        ):
+            self.assertTrue(set_session_policy(narrow)["applied"])
+            self.assertFalse(set_session_policy(wider)["applied"])
+
+    def test_invalid_deployment_fails_initialization(self) -> None:
+        os.environ["IOTDB_ENABLE_WRITE_DML"] = "maybe"
+        with self.assertRaises(ValueError):
+            initialize_runtime_policy()
+        self.assertIsNone(runtime._DEPLOYMENT_POLICY)
+
+    def test_restart_invalidates_approval(self) -> None:
+        directory = self.approval_directory()
+        set_session_policy(preset="readonly")
+        request = reset_session_policy()
+        self.decide(directory, request)
+        runtime._APPROVAL_STORE = runtime.PolicyApprovalStore(directory)
+        result = reset_session_policy()
+        self.assertFalse(result["applied"])
+        self.assertNotEqual(result["request_id"], request["request_id"])
+
+    def test_concurrent_retries_consume_approval_once(self) -> None:
+        directory = self.approval_directory()
+        set_session_policy(preset="readonly")
+        request = reset_session_policy()
+        self.decide(directory, request)
+        with ThreadPoolExecutor(max_workers=2) as executor:
+            results = list(executor.map(lambda _: reset_session_policy(), 
range(2)))
+        self.assertTrue(all(result["applied"] for result in results))
+        self.assertEqual(dynamic_policy_snapshot()["policy_revision"], 2)
+        self.assertEqual(list(directory.iterdir()), [])
+
+    def test_pending_approval_count_is_bounded(self) -> None:
+        self.approval_directory()
+        set_session_policy({"IOTDB_WRITE_ALLOWED_USERS": "alice"})
+        with patch("iotdb_mcp_server.policy_approval._MAX_PENDING", 2):
+            for user in ("bob", "carol"):
+                self.assertFalse(
+                    set_session_policy({"IOTDB_WRITE_ALLOWED_USERS": 
f"alice,{user}"})[
+                        "applied"
+                    ]
+                )
+            with self.assertRaisesRegex(PermissionError, "Too many"):
+                set_session_policy({"IOTDB_WRITE_ALLOWED_USERS": "*"})
+
+    def test_approval_directory_and_cli_request_id_are_validated(self) -> None:
+        directory = self.approval_directory()
+        if os.name == "posix":
+            directory.chmod(0o777)
+            try:
+                with self.assertRaises(PermissionError):
+                    initialize_runtime_policy()
+            finally:
+                directory.chmod(0o700)
+        with contextlib.redirect_stderr(io.StringIO()), 
self.assertRaises(SystemExit):
+            administer(["approve", "../invalid", "--directory", 
str(directory)])
+
+    def test_real_mcp_tool_approval_flow(self) -> None:
+        from fastmcp import Client, FastMCP
+
+        directory = self.approval_directory()
+        mcp = FastMCP("policy-test")
+        register_runtime_policy_tools(mcp, self.config(), 
logging.getLogger(__name__))
+
+        async def exercise():
+            async with Client(mcp) as client:
+                tools = await client.list_tools()
+                self.assertEqual(
+                    {tool.name for tool in tools},
+                    {
+                        "get_iotdb_session_policy",
+                        "set_iotdb_session_policy",
+                        "reset_iotdb_session_policy",
+                    },
+                )
+                setter = next(
+                    tool for tool in tools if tool.name == 
"set_iotdb_session_policy"
+                )
+                self.assertEqual(
+                    set(setter.inputSchema["properties"]),
+                    {"policy", "preset", "replace"},
+                )
+                await client.call_tool(
+                    "set_iotdb_session_policy", {"preset": "readonly"}
+                )
+                response = await 
client.call_tool("reset_iotdb_session_policy", {})
+                pending = json.loads(response.content[0].text)["payload"]
+                self.assertFalse(pending["applied"])
+                # Exercise the independent administrative process, not a tool 
or
+                # an in-process callback that an agent could supply itself.
+                decision = subprocess.run(
+                    [
+                        sys.executable,
+                        "-m",
+                        "iotdb_mcp_server.policy_approval",
+                        "approve",
+                        pending["request_id"],
+                        "--directory",
+                        str(directory),
+                    ],
+                    env={**os.environ, "PYTHONPATH": str(ROOT / "src")},
+                    capture_output=True,
+                    text=True,
+                    timeout=10,
+                )
+                self.assertEqual(decision.returncode, 0, decision.stderr)
+                response = await 
client.call_tool("reset_iotdb_session_policy", {})
+                self.assertTrue(
+                    json.loads(response.content[0].text)["payload"]["applied"]
+                )
+
+        asyncio.run(exercise())
 
 
 if __name__ == "__main__":

Reply via email to