From 340f01e95cff968073e4625df3b495f8e75f77ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Thu, 30 Jul 2026 23:03:32 +0800 Subject: [PATCH] refactor(cmdcore): align leaf safety with schema contract --- AGENTS.md | 6 +- docs/cmdcore-domain-model.md | 89 ++---- docs/command-framework-architecture.md | 73 ++--- docs/command-framework-comparison.md | 21 +- docs/flag-help-schema-homology.md | 50 ++-- docs/rfc-command-framework-convergence.md | 16 +- internal/cli/runtime_schema.go | 7 +- internal/cmdcore/cmdcore.go | 274 +++++++----------- internal/cmdcore/cmdcore_test.go | 101 ++++--- internal/cmdcore/schema_decl.go | 25 +- internal/cmdcore/schema_decl_test.go | 67 +++-- internal/helpers/devapp.go | 123 ++++---- internal/helpers/devapp_leaf_test.go | 2 +- .../helpers/devapp_safety_homology_test.go | 10 +- internal/helpers/devapp_test.go | 6 +- internal/helpers/leaf.go | 52 +--- internal/helpers/leaf_constraints_test.go | 74 +++-- internal/helpers/leaf_policy_test.go | 7 +- internal/shortcut/adapter.go | 30 +- internal/shortcut/adapter_test.go | 10 +- .../check-runtime-confirmation-truth.sh | 22 +- scripts/policy/check-schema-catalog.sh | 5 +- 22 files changed, 510 insertions(+), 560 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5daef9a5..71062b1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,10 +25,10 @@ changes must not rewrite it mechanically. - Framework definition: `docs/rfc-command-framework-convergence.md` **§5.0** - Today: `helpers.LeafSpec` → `cmdcore.CommandSpec` (+ `Schema`) → `cmdcore.NewCommand` -- **Declare = final Schema source**: `Flags` / `Constraints` / `Risk` / `ConstParams` / `Schema` (ToolSpec groups) -- Light runtime write: only when `Schema` is set → convert once → `RegisterRuntimeContractFinal` (map store, no JSON/deep-clone); `Risk` alone stays `dws.schema.risk`. Assembly **pass-throughs** Final. +- **Declare = final Schema source**: `Flags` / `Constraints` / `Safety` / `ConstParams` / `Schema` (ToolSpec groups) +- `Safety` uses the existing `cli.SafetySpec` directly. Its `confirmation` drives the runtime gate; `effect` / `risk` / `idempotency` are published unchanged. When `Schema` is set, convert once → `RegisterRuntimeContractFinal` (map store, no JSON/deep-clone); assembly **pass-throughs** Final. - **Execute** = hooks (`Validate` / `Call` / `RunE` / `PostMount`) — not a second surface authority -- Declaration path has **no reviewed parallel fields**; migration-only `runtime_gate` annotate until Safety/`Risk` is declared +- Declaration path has **no reviewed parallel fields**; migration-only `runtime_gate` annotate until `Safety` is declared ## flag / help / schema homology diff --git a/docs/cmdcore-domain-model.md b/docs/cmdcore-domain-model.md index aaac967d..d94b1a87 100644 --- a/docs/cmdcore-domain-model.md +++ b/docs/cmdcore-domain-model.md @@ -27,25 +27,19 @@ │ └────────────────────────────────────────┘ │ │ │ │ ┌─── 安全模型 ──────────────────────────────────────────────────┐ │ +│ │ Safety cli.SafetySpec │ │ +│ │ ├─ Effect (read / write / destructive) │ │ +│ │ ├─ Risk (low / medium / high) │ │ +│ │ ├─ Confirmation (not_required / user_required) ──▶ 运行时门 │ │ +│ │ └─ Idempotency (idempotent / retryable / …) │ │ │ │ │ │ -│ │ Risk (运行时) Safety (Schema) │ │ -│ │ ┌──────────────┐ ┌───────────────┐ │ │ -│ │ │ read │─────▶│ read │ low/idempotent │ │ -│ │ │ write │─────▶│ write │ medium/unknown │ │ -│ │ │ high-risk- │─────▶│ destructive │ high/unknown │ │ -│ │ │ write │ │ │ │ │ -│ │ └──────────────┘ │ high-write │ high/unknown │ │ -│ │ │ └───────────────┘ │ │ -│ │ │ SafetyDefault() ▲ │ │ -│ │ └────────────────────┘ (Safety 为空时的推导) │ │ -│ │ │ │ -│ │ ConfirmFirst: bool (确认门顺序) │ │ +│ │ 四字段彼此独立;同一值同时供运行时与 Schema 使用 │ │ +│ │ ConfirmFirst: bool (只控制确认门顺序) │ │ │ └───────────────────────────────────────────────────────────────┘ │ │ │ │ ┌─── Schema 声明 (Agent 可见的元数据) ──────────────────────────┐ │ │ │ SchemaDecl │ │ │ │ ├─ Title / Description │ │ -│ │ ├─ SafetyDecl {Effect, Risk, Confirmation, Idempotency} │ │ │ │ ├─ DryRunDecl {PreviewKind, RemoteReads} │ │ │ │ ├─ InterfaceDecl {Mode, Availability, Reason, Ref} │ │ │ │ ├─ SelectionDecl {AgentSummary, UseWhen, AvoidWhen, │ │ @@ -74,11 +68,12 @@ CommandSpec ──── NewCommand() ────▶ cobra.Command ──── │ │ │ 构建时检查: 注册产物: 执行上下文: • validateDispatchDecl • Flags + Aliases • Str(name) - • validateSchemaDecl • Annotations (Schema) • Int(name) - • RegisterFlags • Long (约束 help) • Bool(name) - • ValidateConstraintDecls • RunE (管线) • StrSlice(name) - • embedContractIntoSchema • Changed(name) - • AnnotateConstraints • DryRun() / Yes() + • validateSafetySpec • Annotations (Schema) • Int(name) + • validateSchemaDecl • Long (约束 help) • Bool(name) + • RegisterFlags • RunE (管线) • StrSlice(name) + • ValidateConstraintDecls • Changed(name) + • embedContractIntoSchema • DryRun() / Yes() + • AnnotateConstraints • PostMount ``` @@ -89,59 +84,33 @@ CommandSpec ──── NewCommand() ────▶ cobra.Command ──── | **CommandSpec** | struct | 一个命令的完整契约(声明 + 执行) | | **FlagSpec** | struct | 一个参数的注册、回退链、绑定规则 | | **Constraint** | struct | 参数间的关系约束 | -| **Risk** | enum | 运行时确认行为(是否提示用户) | -| **Safety** | enum | Schema 安全元数据等级(Agent 决策依据) | +| **Safety** | cli.SafetySpec | 运行时与 Schema 共用的安全契约 | | **SchemaDecl** | struct | Agent 可见的完整工具规格声明 | | **SelectionDecl** | struct | Agent 选择该工具的语义指引 | | **InterfaceDecl** | struct | 工具的接口模式与可用性 | | **DryRunDecl** | struct | dry-run 能力声明 | -| **SafetyDecl** | struct | Safety 枚举展开后的细粒度覆盖 | | **IdentityDecl** | struct | 工具在注册表中的身份标识 | | **PositionalDecl** | struct | 有序位置参数声明 | | **Ctx** | struct | 执行上下文(类型安全的 flag 读取) | | **NewCommand** | func | 统一构建器(声明 → cobra.Command) | -## Risk 枚举 +## SafetySpec -控制**运行时确认行为**——是否在派发前提示用户确认。 +`CommandSpec.Safety` 直接复用 `cli.SafetySpec`,没有 cmdcore 自定义 Risk/Safety 枚举,也没有 `SafetyDecl` 覆盖层: -| 值 | 含义 | 运行时行为 | -|----|------|-----------| -| `""` / `read` | 只读操作 | 不提示 | -| `write` | 变更状态 | 未加 `--yes` 时提示确认 | -| `high-risk-write` | 破坏性/不可逆 | 未加 `--yes` 时提示确认 | - -## Safety 枚举 - -控制 **Schema 元数据等级**——告诉 Agent 操作对系统的影响程度。 - -| 值 | effect | risk | confirmation | idempotency | -|----|--------|------|--------------|-------------| -| `read` | read | low | not_required | idempotent | -| `write` | write | medium | user_required | unknown | -| `high-write` | write | high | user_required | unknown | -| `destructive` | destructive | high | user_required | unknown | - -## Safety 优先级链 - -三层解析,先命中先生效: - -``` -SafetyDecl 显式字段 > Safety 枚举 > Risk.SafetyDefault() - (最高: 精确覆盖) (中: 等级快捷) (最低: 默认推导) +```go +Safety: cli.SafetySpec{ + Effect: "write", + Risk: "high", + Confirmation: "user_required", + Idempotency: "unknown", +} ``` -- **SafetyDecl 显式字段**:`SchemaDecl.Safety` 中非空字段直接使用 -- **Safety 枚举**:`CommandSpec.Safety` 非空时按表展开填充空字段 -- **Risk.SafetyDefault()**:Safety 为空时从 Risk 推导 - -推导映射(`Risk.SafetyDefault()`): - -``` -read → SafetyRead -write → SafetyWrite -high-risk-write → SafetyDestructive -``` +- `Confirmation == "user_required"` 时执行确认;`--yes` 和 `--dry-run` 可跳过交互。 +- `Effect`、`Risk`、`Idempotency` 不参与确认决策,也不会改写 `Confirmation`。 +- 任意一个字段非空时,四个字段必须全部显式声明;构建时拒绝部分声明。 +- 完全空值仅作为历史只读默认,最终发布为 `read/low/not_required/idempotent`。 ## FlagSpec 有效值回退链 @@ -226,7 +195,7 @@ DryRunDecl{ |--------|----------|-------------| | **Invoke** | 单步 MCP/后端调用 | 框架完成 required→constraint→validate→buildArgs→confirm,传入装配好的 toolArgs | | **Orchestrate** | 多步编排 | 框架完成 required→constraint→validate→confirm,传入 Ctx 自行组装调用 | -| **RunE** | 逃生舱 | 框架只注册 flag 和 Schema,执行完全自定义 | +| **RunE** | 逃生舱 | 框架仍执行 Safety 确认,具体业务执行完全自定义 | ## 设计不变量 @@ -234,5 +203,5 @@ DryRunDecl{ 2. **声明面绝不调用后端**——cmdcore 是 dispatch-agnostic 3. **执行面绝不发明 CLI 表面**——业务 flag 必须在 Flags 声明 4. **构建时拦截 > 运行时报错**——声明错误 panic 在注册阶段 -5. **Safety 和 Risk 独立但可组合**——运行时行为与 Schema 元数据各管一摊 +5. **SafetySpec 是单一事实源**——Confirmation 驱动运行时,其余字段原样进入 Schema 6. **声明即 review**——代码中的 Schema 经 code review 后直接投影,不依赖外部 hint 文件 diff --git a/docs/command-framework-architecture.md b/docs/command-framework-architecture.md index 3e724cff..40b75673 100644 --- a/docs/command-framework-architecture.md +++ b/docs/command-framework-architecture.md @@ -10,10 +10,10 @@ 命令框架将 CLI 命令的**声明**与**执行**分离: -- **声明面** — 数据字段描述命令是什么(flag、约束、风险等级、Schema 元数据) +- **声明面** — 数据字段描述命令是什么(flag、约束、SafetySpec、Schema 元数据) - **执行面** — 钩子函数描述命令做什么(校验、派发、编排) -框架负责:flag 注册、有效值回退链、required/约束校验、Risk 写确认、toolArgs 装配、Agent Runtime Schema 投影。 +框架负责:flag 注册、有效值回退链、required/约束校验、SafetySpec 确认、toolArgs 装配、Agent Runtime Schema 投影。 ## 核心类型 @@ -30,8 +30,7 @@ type CommandSpec struct { Example string Flags []FlagSpec Constraints []Constraint - Risk Risk // 运行时确认行为 - Safety Safety // Schema 元数据等级 + Safety cli.SafetySpec // 运行时与 Schema 的单一安全来源 ConfirmFirst bool // 确认门先于参数校验 ConstParams map[string]any Schema SchemaDecl // 完整 ToolSpec 载荷 @@ -47,32 +46,29 @@ type CommandSpec struct { } ``` -### Risk 与 Safety(独立枚举) +### SafetySpec(单一安全来源) -两个正交维度,组合使用: +`CommandSpec.Safety` 直接使用现有 Agent Runtime Schema 的 `cli.SafetySpec`: -| 枚举 | 职责 | 取值 | -|------|------|------| -| **Risk** | 运行时确认行为(是否弹 yes/no) | `read` / `write` / `high-risk-write` | -| **Safety** | Schema 元数据等级(Agent 看到的影响描述) | `read` / `write` / `high-write` / `destructive` | +| 字段 | 职责 | +|------|------| +| `Effect` | 操作影响:read / write / destructive | +| `Risk` | 风险等级:low / medium / high | +| `Confirmation` | 是否需要用户确认:not_required / user_required | +| `Idempotency` | 幂等性:idempotent / retryable / non_idempotent / unknown | -Safety 为空时,通过 `Risk.SafetyDefault()` 方法推导: - -| Risk | → Safety 默认 | Schema 展开 | -|------|--------------|-------------| -| (空) / `read` | `read` | effect:read, risk:low, confirmation:not_required, idempotency:idempotent | -| `write` | `write` | effect:write, risk:medium, confirmation:user_required, idempotency:unknown | -| `high-risk-write` | `destructive` | effect:destructive, risk:high, confirmation:user_required, idempotency:unknown | - -显式设置 Safety 可以覆盖默认推导: +四个字段彼此独立。框架只读取 `Confirmation` 决定运行时确认,其余字段原样发布到 Schema,不从一个字段机械推导另一个。非空 SafetySpec 必须一次声明完整: ```go -// create:运行时 high-risk 确认,但 Schema 标记为 high-write(不可逆但非破坏性) -Risk: LeafRiskHighWrite, -Safety: LeafSafetyHighWrite, // write/high 而非 destructive/high +Safety: cli.SafetySpec{ + Effect: "write", + Risk: "high", + Confirmation: "user_required", + Idempotency: "unknown", +}, ``` -优先级链:`explicit SafetyDecl fields > Safety enum > Risk.SafetyDefault()` +完全空值保留历史只读默认 `read/low/not_required/idempotent`;不存在 Risk/Safety 枚举或覆盖优先级链。 ### FlagSpec @@ -123,19 +119,20 @@ flag 解析按以下顺序取值(先命中先生效): `cmdcore.NewCommand(spec)` 执行以下构建时检查(失败则 panic): 1. **validateDispatchDecl** — 恰好一个执行体(Invoke/Orchestrate/RunE) -2. **validateSchemaDecl** — Schema 声明完整性(Description、AgentSummary、UseWhen、AvoidWhen、Examples、Safety、Interface) -3. **RegisterFlags** — flag + alias 注册到 cobra -4. **ValidateConstraintDecls** — 约束引用的 flag 必须存在 -5. **embedContractIntoSchema** — 投影到 dws.schema.* annotations -6. **AnnotateConstraints** — 约束渲染到 --help -7. **PostMount** — 调用方的挂载收尾钩子 +2. **validateSafetySpec** — 非空 SafetySpec 的四个独立字段必须完整 +3. **validateSchemaDecl** — Schema 声明完整性(Description、AgentSummary、UseWhen、AvoidWhen、Examples、Interface) +4. **RegisterFlags** — flag + alias 注册到 cobra +5. **ValidateConstraintDecls** — 约束引用的 flag 必须存在 +6. **embedContractIntoSchema** — 投影到 dws.schema.* annotations +7. **AnnotateConstraints** — 约束渲染到 --help +8. **PostMount** — 调用方的挂载收尾钩子 ## 运行时流程 生成的 `RunE` 按以下顺序执行: ``` -[ConfirmFirst? → ConfirmRisk] ← devapp 遗留语义:先确认后校验 +[ConfirmFirst? → ConfirmSafety] ← 可选:先确认后校验 │ ▼ ValidateRequired ← 有效值回退链校验 @@ -153,7 +150,7 @@ BuildArgs ← flag → toolArgs 装配 ConstParams 合并 │ ▼ -[!ConfirmFirst? → ConfirmRisk] ← 默认顺序:校验后确认 +[!ConfirmFirst? → ConfirmSafety] ← 默认顺序:校验后确认 │ ▼ Invoke(ctx, toolArgs) ← 单步派发 @@ -170,8 +167,10 @@ func newDevAppCreateCommand(runner executor.Runner) *cobra.Command { Use: "create", Short: "创建开放平台企业内部应用", Tool: devAppCreateTool, - Risk: LeafRiskHighWrite, - Safety: LeafSafetyHighWrite, + Safety: cli.SafetySpec{ + Effect: "write", Risk: "high", + Confirmation: "user_required", Idempotency: "unknown", + }, ConfirmFirst: true, Flags: []LeafFlag{ {Name: "name", Usage: "应用名称 (必填)", Bind: "name", @@ -208,6 +207,8 @@ spec := FromShortcut(Shortcut{ }) ``` +Shortcut 本次仍保留自身的 `Risk`。未接入 live mount 的 adapter 只在边界将它展开成完整 `cli.SafetySpec`;cmdcore/Leaf 不再保留该枚举。 + ## 文件结构 | 文件 | 职责 | @@ -230,8 +231,8 @@ spec := FromShortcut(Shortcut{ ## 设计原则 -1. **声明 vs 执行分离** — Flags/Constraints/Risk/Safety/Schema 是声明;Invoke/Validate/PostMount 是执行 +1. **声明 vs 执行分离** — Flags/Constraints/Safety/Schema 是声明;Invoke/Validate/PostMount 是执行 2. **单一数据源** — 一份声明驱动 --help、Schema、catalog、runtime 校验 -3. **框架推导 > 手写** — Safety 从 Risk 推导、Example 从 Schema.Selection.Examples 继承 +3. **安全字段不互推** — Confirmation 单独驱动确认,Effect/Risk/Idempotency 原样发布 4. **构建时拦截 > 运行时报错** — 声明不完整在命令注册时 panic,不等到用户触发 -5. **向后兼容** — type alias 让现有 LeafSpec 调用方零改动 +5. **边界兼容** — Shortcut 暂由 adapter 转换,Leaf 直接声明 SafetySpec diff --git a/docs/command-framework-comparison.md b/docs/command-framework-comparison.md index db46cf87..d4798e02 100644 --- a/docs/command-framework-comparison.md +++ b/docs/command-framework-comparison.md @@ -20,18 +20,17 @@ ``` CommandSpec (声明) → NewCommand() → cobra.Command │ - ├── Risk enum (运行时确认) - ├── Safety enum (Schema 元数据) + ├── cli.SafetySpec (运行时 + Schema 单一安全来源) ├── FlagSpec[] (参数 + 回退链 + 绑定) ├── Constraint[] (互斥/至少一个) - ├── SchemaDecl (Agent Selection/Safety/DryRun/Interface) + ├── SchemaDecl (Agent Selection/DryRun/Interface) │ └── Invoke / Orchestrate / RunE (执行) ``` **核心特点**: - 声明与执行严格分离 -- Risk(运行时)和 Safety(Schema)独立枚举 +- SafetySpec 四个独立字段直接对齐 Agent Runtime Schema - 有效值回退链:flag → alias → env → default - 框架统一校验、装配、确认、投影 - Schema 从代码声明直接投影,无外部 hint 文件 @@ -82,7 +81,7 @@ API Discovery → 代码生成 → surface command |------|-------------|----------|-----| | 参数别名 + 环境变量回退 | ✅ FlagSpec.Aliases + EnvVar | ❌ 无 | ❌ 无 | | 声明式约束 (互斥/至少一个) | ✅ Constraint[] | ❌ 只有 Validate hook | ✅ argparse group | -| Risk/Safety 分离 | ✅ 两个独立枚举 | ❌ 只有 Risk | ❌ 无 | +| 安全契约 | ✅ SafetySpec(effect/risk/confirmation/idempotency) | Risk | 无 | | Schema 投影 (Agent metadata) | ✅ SchemaDecl 内建 | ⚠️ 运行时 introspection | ❌ 外挂 | | 参数绑定 (flag name → API key) | ✅ FlagSpec.Bind | ❌ 手写 | ✅ 自动映射 | | ConstParams (固定载荷) | ✅ | ❌ 手写在 Execute | ✅ 隐式 | @@ -104,7 +103,7 @@ API Discovery → 代码生成 → surface command |------|-------------|----------|-----| | 工具发现 | `dws schema --all` (静态 catalog) | `--print-schema` (运行时) | API Discovery | | 选择指引 | SelectionDecl (UseWhen/AvoidWhen) | Description + Tips | 无 | -| 安全声明 | Safety enum + SafetyDecl | Risk string | 无 | +| 安全声明 | cli.SafetySpec 直接声明 | Risk string | 无 | | dry-run 能力声明 | DryRunDecl (reviewed) | DryRun hook 存在性 | 无 | | 接口模式 | InterfaceDecl (local/pinned/composite) | 隐式 (全部 REST) | 隐式 (全部 REST) | @@ -128,7 +127,7 @@ GWS: API Discovery JSON → 代码生成 → surface command | 选择 | 理由 | 对比 | |------|------|------| | 框架装配参数 | 消除 N 个命令各写一份 toolArgs 装配 | lark-cli 每个 Execute 手动取 flag 值 | -| Risk + Safety 分离 | 运行时行为与 Schema 元数据独立演进 | lark-cli 只有 Risk 一个维度 | +| SafetySpec 单一来源 | confirmation 驱动运行时,其余字段原样发布且互不推导 | lark-cli 只有 Risk 一个维度 | | 声明式约束 | 构建时校验合法性 + 投影到 Schema + 渲染帮助 | lark-cli 约束隐藏在 Validate 逻辑里 | | Schema 构建时投影 | 离线 catalog 支持 Agent 批量发现 | lark-cli 需要逐个命令 introspection | | 有效值回退链 | flag → alias → env 统一语义 | lark-cli 别名是独立 Flag 手动关联 | @@ -170,8 +169,10 @@ NewLeafCommand(LeafSpec{ Use: "create", Short: "创建应用", Tool: "create_dev_app", - Risk: LeafRiskHighWrite, - Safety: LeafSafetyHighWrite, + Safety: cli.SafetySpec{ + Effect: "write", Risk: "high", + Confirmation: "user_required", Idempotency: "unknown", + }, ConfirmFirst: true, Flags: []LeafFlag{ {Name: "name", Usage: "应用名称", Bind: "name", @@ -228,7 +229,7 @@ var CalendarCreate = common.Shortcut{ | 场景 | 最适合 | 原因 | |------|--------|------| -| MCP 后端 + Agent Schema 投影 | **DWS cmdcore** | 内建 Schema 声明、Safety 分级、离线 catalog | +| MCP 后端 + Agent Schema 投影 | **DWS cmdcore** | 内建 Schema 声明、SafetySpec 契约、离线 catalog | | REST API 直连 + OAuth scope 管理 | **lark-cli** | CallAPITyped + scope 预检 + DryRun API 计划 | | API-first 大规模 surface 生成 | **GWS gcloud** | Discovery 驱动,一份 Schema 生成一切 | | 多步编排 (跨服务链式调用) | **lark-cli** / DWS Orchestrate | lark 的 CallAPITyped 链式 + DWS 的 Orchestrate | diff --git a/docs/flag-help-schema-homology.md b/docs/flag-help-schema-homology.md index 30dec826..517855a8 100644 --- a/docs/flag-help-schema-homology.md +++ b/docs/flag-help-schema-homology.md @@ -12,7 +12,7 @@ 1. cobra 实际注册的 flags / required / defaults; 2. `--help` 的 Flags 与「参数约束」段; -3. **嵌入后的** `dws schema` / Catalog(`schema_catalog` / agent metadata 生成物)中的 parameters、关系约束,以及显式 `Risk` 时的 confirmation/effect。 +3. **嵌入后的** `dws schema` / Catalog(`schema_catalog` / agent metadata 生成物)中的 parameters、关系约束和 SafetySpec。 嵌入机制(已落地): @@ -23,12 +23,12 @@ CommandSpec dws.schema.contract=cmdcore dws.schema.property / type / required (per flag) dws.schema.constraints - dws.schema.risk (仅当 Risk 字段非空) - → Schema 组装读取 native_annotation + Contract Risk overlay + → RegisterRuntimeContractFinal(SafetySpec + SchemaDecl) + → Schema 组装透传 Contract Final → go:embed schema_catalog / schema_agent_metadata ``` -空 `Risk` **不**写入 `dws.schema.risk`,避免把默读盖掉今日 write-guard 叶子的 reviewed Safety。 +cmdcore/Leaf 不再写 `dws.schema.risk`;SafetySpec 走类型化 Final 载荷,不使用字符串枚举注解。 ### 1.1 硬规则:声明 = 最终数据源(Schema 透传) @@ -38,7 +38,7 @@ CommandSpec - 在声明体系里再挂「评审字段」并行权威; - 用 hints/registry 盖写已声明字段。 -迁移期未迁完的叶子可暂走旧组装路径;**新声明面不含 review_reason / reviewed 字段**。写命令未设 `Risk`/`Schema.Safety` 时,过渡期仍可用 `runtime_gate` annotate(`HOM-S2`)。 +迁移期未迁完的叶子可暂走旧组装路径;**新声明面不含 review_reason / reviewed 字段**。写命令未设 `Safety` 时,过渡期仍可用 `runtime_gate` annotate(`HOM-S2`)。 **不采用**路径 B(以 `schema_mcp_metadata` 生成全部 CLI flag/help/schema)作为主权威。钉钉 MCP meta 不是飞书 OAPI:粒度与 CLI 特有语义(二选一、OmitEmpty、ConstParams、write guard)无法从裸 meta 推出;强行生成会违反「Schema 描述 CLI,不制造 CLI」。 @@ -68,7 +68,7 @@ CommandSpec |---|---|---|---| | `Flags[]`(`FlagSpec` / `LeafFlag`) | 用户可见参数面:名、类型、默认、必填、usage | 注册 cobra flag;装配 toolArgs | `dws.schema.property` / `type` / `required`;`--help` Flags | | `Constraints[]` | 跨 flag 关系:`at_least_one` / `exactly_one` / `mutually_exclusive` | `ValidateConstraints` | `dws.schema.constraints`;`--help`「参数约束」 | -| `Risk`(**非空**) | 副作用与确认:`write` / `high-risk-write`(或显式 `read`) | `ConfirmRisk`(`--yes` / `--dry-run` 跳过) | `dws.schema.risk` → Safety overlay(`HOM-S1`) | +| `Safety`(`cli.SafetySpec`) | effect/risk/confirmation/idempotency 四个独立事实 | `confirmation=user_required` 时 `ConfirmSafety`;`--yes` / `--dry-run` 跳过 | 同一个 SafetySpec 原样进入 Contract Final(`HOM-S1`) | | `ConstParams` | 固定载荷(不上 flag 表) | 并入 toolArgs;不满足 Required | **不**投影为用户 parameter | | `Use` / `Short` / `Long` / `Example` | 命令身份文案与示例 | cobra 自身 | help;identity 仍以 registry 为准 | @@ -88,30 +88,37 @@ CommandSpec | `Validate` | 条件式/领域校验钩子 | 不得在此 `Flags().String(...)` 注册业务 flag;不得只靠钩子表达「必填/互斥」而不写 `Flags`/`Constraints` | | `Call` / `Invoke` / `Orchestrate` | 执行体 | 不得 `params[k]=…` 装配业务参数(应在 `Flags`/`ConstParams`) | | `PostMount` | 挂载后收尾(annotate、领域工具注入) | 不得注册业务 flag;分页等横切由领域工具注入并可走 annotate | -| `RunE` | 逃生舱(整段手写) | 表面事实仍须 Flags 声明,或对确认语义做 §1.3 标注 | +| `RunE` | 逃生舱(整段手写) | 表面事实仍须 Flags 声明;框架仍按 Safety 执行确认 | | `Server` / `Tool` | MCP 路由 | 不构成 CLI parameter 声明 | #### 1.2.3 最小声明示例 ```go -// 读:无确认语义 → Risk 可空(不写 dws.schema.risk) +// 读:Safety 四字段显式对齐 Schema NewLeafCommand(LeafSpec{ Use: "get", Short: "…", Tool: "…", + Safety: cli.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "…", Bind: "unifiedAppId", Trim: true, Required: true}, }, Call: devAppCall(runner), PostMount: devAppMeta(tool), }) -// 写(升格 Risk = 声明确认):ConfirmRisk + Schema Safety 同源 +// 写:同一个 SafetySpec 同时驱动确认与 Schema NewLeafCommand(LeafSpec{ Use: "publish", Short: "…", Tool: "…", Flags: []LeafFlag{ /* … */ }, - Risk: LeafRiskWrite, // 声明:非空 → dws.schema.risk=write + Safety: cli.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "user_required", Idempotency: "unknown", + }, Call: devAppCall(runner), PostMount: devAppMeta(tool), }) -// 写(今日未升格 Risk):确认走 annotate,见 §1.3 —— 不是「声明了写」 +// 迁移期旧写命令:确认走 annotate,见 §1.3 —— 不是新声明面 NewLeafCommand(LeafSpec{ Use: "create", /* Flags… */, Validate: func(cmd *cobra.Command, _ []string) error { @@ -122,7 +129,7 @@ NewLeafCommand(LeafSpec{ }) ``` -**空 `Risk` 的含义**:运行时等同只读确认(不提示),且 **不**嵌入 `dws.schema.risk`。因此「会改状态却留空 Risk」**不是**合法声明;必须要么写非空 `Risk`,要么按 §1.3 标注 gate。 +**空 `Safety` 的含义**:cmdcore 为兼容旧只读叶保留 `read/low/not_required/idempotent` 默认。因此「会改状态却留空 Safety」**不是**合法声明;新 Leaf 必须写完整 SafetySpec,未迁移旧路径则按 §1.3 标注 gate。 ### 1.3 人工标注(annotate):声明的补充通道 @@ -130,10 +137,10 @@ NewLeafCommand(LeafSpec{ | 标注手段 | 典型值 | 何时用 | |---|---|---| -| `cli.AnnotateRuntimeGate` / `devAppMetaWrite` | `dws.schema.runtime_gate=devAppRequireWriteGuard` | 写命令走 write-guard、尚未 `Risk` 升格(`HOM-S2`) | -| `cli.AnnotateRuntimeRisk` | `dws.schema.risk=…` | 非 Leaf 构建路径需手写嵌入 Risk(Leaf 应优先字段声明) | +| `cli.AnnotateRuntimeGate` / `devAppMetaWrite` | `dws.schema.runtime_gate=devAppRequireWriteGuard` | 尚未迁移到 SafetySpec 的旧写命令(`HOM-S2`) | +| `cli.AnnotateRuntimeRisk` | `dws.schema.risk=…` | Shortcut 暂存的旧兼容路径;cmdcore/Leaf 禁止新增 | | `cli.AnnotateRuntimeFlag` / Constraints | 与 embed 同形 | 手写 cobra 叶补齐表面(长期应迁入 Contract) | -| reviewed `schema_hints/metadata` Safety | effect/risk/confirmation | 迁移期遗留;受管命令收敛后让位于 Risk/gate | +| reviewed `schema_hints/metadata` Safety | effect/risk/confirmation | 迁移期遗留;受管命令收敛后让位于 Safety/gate | 标注与声明冲突时:**Contract 声明胜**(路径 A)。标注不得发明未注册的 CLI flag。 @@ -149,8 +156,7 @@ NewLeafCommand(LeafSpec{ | Parameters.`interface_*` | 评审源 | MCP meta / bindings;**不造 flag** | | Constraints | **声明** | `Constraints` | | Positionals | **声明** 或显式 annotate | 目标 `Args`;禁止推断 | -| Safety.`effect/risk/confirmation` | **声明**非空 `Risk` **或** `runtime_gate` 标注(或迁移期 reviewed Safety) | 空 Risk 不嵌入 | -| Safety.`idempotency` | 评审源 | hints metadata | +| Safety.`effect/risk/confirmation/idempotency` | **声明**完整 `Safety`,或迁移期 `runtime_gate` / reviewed Safety | 四字段独立;不得互相推导 | | DryRun | 评审源 | dry-run capabilities registry | | Interface | 评审源 | MCP + agent metadata | | Selection | 评审源 | `schema_hints/selection` | @@ -169,7 +175,7 @@ NewLeafCommand(LeafSpec{ | use_when / avoid_when / examples / agent_summary 文案 | `schema_hints/selection` | Schema selection | | RPC tool 形状、`interface_ref`、interface 描述 | `schema_mcp_metadata` + `schema_parameter_bindings` | Schema `interface_*` 字段;**不得创建 flag** | | 参数描述 overlay(可选) | `schema_hints/metadata` 仅补充 usage 文案 | 与 Contract/cobra 冲突时 **Contract/cobra 胜** | -| 遗留 Safety 文案(迁移期) | 今日仍可读 `schema_hints/metadata` safety | 受管命令收敛后以 Contract.Risk / runtime_gate 为准(见 §4) | +| 遗留 Safety 文案(迁移期) | 今日仍可读 `schema_hints/metadata` safety | 受管命令收敛后以 Contract.Safety / runtime_gate 为准(见 §4) | | dry-run 正能力 | reviewed dry-run registry | Schema `dry_run` | | positionals | Contract Args / 显式 annotate | Schema `positionals` | | FieldProvenance | 组装派生 | Schema provenance(与值一致) | @@ -180,9 +186,9 @@ Identity 与 selection **刻意不**由 Contract 取代(RFC 决策 8 / schema 已具备: -- Flags / ConstParams / Constraints → 注册、校验、`ConstraintHelp`、运行时 `ConfirmRisk`(cmdcore); +- Flags / ConstParams / Constraints → 注册、校验与 `ConstraintHelp`;SafetySpec → 运行时 `ConfirmSafety`(cmdcore); - Call / Execute 作为执行体;业务参数不得在 Call 内装配(helpers 门禁); -- **Contract → Schema 注解嵌入**(`embedContractIntoSchema`):`dws.schema.contract` / property / type / required / constraints;显式 Risk → `dws.schema.risk` 并在 Schema 组装时 overlay Safety。 +- **Contract → Schema 嵌入**:参数/约束写原生 annotation,SafetySpec 与 SchemaDecl 注册为类型化 Contract Final 并由 Schema 组装透传。 仍缺: @@ -192,8 +198,8 @@ Identity 与 selection **刻意不**由 Contract 取代(RFC 决策 8 / schema 已落地(写命令确认语义,`HOM-S2` 起点): - `AnnotateRuntimeGate` / `dws.schema.runtime_gate`;Leaf `PostMount: devAppMetaWrite`;手写 delete/robot 等同路径显式标注; -- Schema 组装在无 Contract Risk 但有 gate 时 overlay `confirmation=user_required`(`applyContractGateToSafety`); -- AST/mount 测试:Validate 含 `devAppRequireWriteGuard` ⇒ PostMount 必须 `devAppMetaWrite`;挂载叶须 Risk 或 runtime_gate。 +- Schema 组装在无 Contract Safety 但有 gate 时 overlay `confirmation=user_required`(`applyContractGateToSafety`); +- AST/mount 测试:新 Leaf 须声明完整 SafetySpec;尚未迁移的旧路径须有 runtime_gate。 ## 4. Schema 投影与 Safety 门禁规划 diff --git a/docs/rfc-command-framework-convergence.md b/docs/rfc-command-framework-convergence.md index 8d681904..ce0a1697 100644 --- a/docs/rfc-command-framework-convergence.md +++ b/docs/rfc-command-framework-convergence.md @@ -59,7 +59,7 @@ v4 的变更,全部来自第一轮独立评审并经过代码级验证: 13. 每一次受管的后端/输出副作用都必须由调用级许可(permit)把关;不要把裸输入流或裸后端 helper 暴露给受管 Handler。 14. 保持遗留行为 bug-for-bug 兼容,**除非**该行为本身是正确性缺陷。§3.4 的非交互确认要在差分门禁能够要求它之前修好,因为兼容门禁会保护它所度量的一切。 15. 把「没有可用的交互输入」当作一种独立的确认结果,与接受和拒绝都不同。 -16. **flag / help / schema 参数面同源取路径 A,且 Contract 必须嵌入 Schema**:Contract(LeafSpec 门面)经 `cmdcore.NewCommand` 写入 cobra Schema 注解,供 catalog/agent metadata 组装消费;MCP meta 不得创建 CLI flag;1:1 MCP 透传仅作可选子集。**硬规则**:每份 help/Schema 事实须 **声明 OR 人工标注**,禁止纯推断。**声明** = `LeafSpec`/`CommandSpec` 数据字段(`Flags`/`Constraints`/非空 `Risk`/…),钩子不算声明;未设 `Risk` 的写命令须 `runtime_gate` 标注。见 [`flag-help-schema-homology.md`](flag-help-schema-homology.md) §1.1–§1.3。 +16. **flag / help / schema 参数面同源取路径 A,且 Contract 必须嵌入 Schema**:Contract(LeafSpec 门面)经 `cmdcore.NewCommand` 写入 cobra Schema 注解并注册类型化 Final,供 catalog/agent metadata 组装消费;MCP meta 不得创建 CLI flag;1:1 MCP 透传仅作可选子集。**硬规则**:每份 help/Schema 事实须 **声明 OR 人工标注**,禁止纯推断。**声明** = `LeafSpec`/`CommandSpec` 数据字段(`Flags`/`Constraints`/`Safety`/…),钩子不算声明;未设完整 SafetySpec 的旧写命令须 `runtime_gate` 标注。见 [`flag-help-schema-homology.md`](flag-help-schema-homology.md) §1.1–§1.3。 16. 让 dry-run 输出与 `@file`/stdin 输入成为声明式的契约能力,而不是逐 handler 的代码,这样覆盖率就是框架的属性,而不依赖作者自觉。 17. 从 M1 起要求新命令使用新核心,使框架在 Shortcut 分批迁移之前就已改善代码库。 18. 把 LeafSpec 已上线的 `Bind → Call → callMCPTool` 路径视为「声明可以完成执行」的存在性证明:27 个源声明 / 28 条上线命令,生产环境 `RunE` 用量为零。Shortcut 应当获得同样的绑定词汇,而不是把每个 `Execute` 都包成 Handler。 @@ -156,7 +156,7 @@ return answer == "yes" || answer == "y" 影响范围与归类: - 全部 168 个 write/high-risk Shortcut 受影响;read 命令从不提示。 -- `cmdcore.ConfirmRisk` 已经读取 `cmd.InOrStdin()` 而非进程 stdin,因此它是可测试的;但它套用同一条「EOF 等于拒绝」的规则,所以一旦非交互父进程关闭输入,它受影响的程度完全相同。 +- `cmdcore.ConfirmSafety` 读取 `cmd.InOrStdin()` 而非进程 stdin,因此它是可测试的;EOF 会显式返回 `confirmation_required`,不会把未执行误报为成功。 - 这**不是** §5.9 的拒绝策略问题。那个问题是「真实拒绝应当产生哪个退出码」。这里没有任何人拒绝过:拒绝分支在 CLI 最常被使用的那种环境里默认就可达。 - 由于这是用户可见的行为变更,它走 §9 的 approved delta 流程。该流程不依赖 Contract、解析器或 mcpbind,因此这项修复不受新核心的进度约束。 - **因此它作为独立 hotfix(§8 中的 H0)先于本 RFC 的实现序列发布,而不是排在架构工作的第四个 PR。** 这是一个已上线的数据完整性级缺陷:168 条写命令在 agent/CI 环境下静默丢弃写操作并报告成功。把它排在一套架构改造之后,等于让缺陷的存续时间取决于一个与它无关的项目的进度。本 RFC 记录它、给出修复形态与门禁,但**不持有它的排期**;即使本 RFC 被否决或推迟,H0 也应照发。 @@ -304,13 +304,13 @@ Definition(仅声明;不可编译) 1. 业务 flag 只出现在 `Flags`(或领域工具注入 + 同源允许的 annotate),不在 `PostMount`/`Validate` 里 `Flags().String`; 2. 业务参数只由 `Flags`/`ConstParams` 装配,不在 `Call`/`Execute` 里 `params[k]=…`; -3. 写副作用:要么非空 `Risk`(框架 `ConfirmRisk` + Schema overlay),要么显式 `runtime_gate` 标注(今日 write-guard);二者皆无则不合格; +3. 写副作用:新 Leaf 声明完整 `SafetySpec`(框架 `ConfirmSafety` + Schema Final);未迁移旧路径显式标注 `runtime_gate`;二者皆无则不合格; 4. Schema `ToolSpec` 全字段组均落在 §5.0.4 表中某一权威格,禁止无主字段。 #### 5.0.3 与目标 `Contract` 的对应 ```text -今日 LeafSpec / CommandSpec.Flags|Constraints|Risk|… +今日 LeafSpec / CommandSpec.Flags|Constraints|Safety|… ≈ 目标 Definition.Contract 今日 Call / Invoke / Orchestrate / RunE ≈ 目标 mcpbind 派发 或 Handler(形态 1/2/3) @@ -335,7 +335,7 @@ Definition(仅声明;不可编译) | | `interface_description`, `interface_type`, `interface_default` | 评审源(interface) | `schema_mcp_metadata` + bindings | 否;**不得创建 CLI flag**(`HOM-I1`) | | **Constraints** | `require_one_of`, `mutually_exclusive`, `require_together` | **声明** | `Constraints` → `AnnotateConstraints` | **是** | | **Positionals** | 位置参数名/必填/说明 | **声明** 或显式 annotate | 目标 `Args`/`PositionalSpec`;今日少量 cobra Args + 注解 | 受管命令应声明,禁止推断 | -| **Safety** | `effect`, `risk`, `confirmation` | **声明**(非空 `Risk`)**或标注**(`runtime_gate`)或迁移期 reviewed Safety | `Risk` / `AnnotateRuntimeGate` / `schema_hints/metadata` safety | 写路径必须 declare 或 annotate;空 Risk 不嵌入 | +| **Safety** | `effect`, `risk`, `confirmation`, `idempotency` | **声明**(完整 `cli.SafetySpec`)**或标注**(`runtime_gate`)或迁移期 reviewed Safety | `Safety` / `AnnotateRuntimeGate` / `schema_hints/metadata` safety | 四字段独立;confirmation 单独驱动运行时 | | | `idempotency` | 评审源(或未来 Contract) | reviewed metadata | 今日非框架声明;不得推断 | | | `effect_source` / provenance | 组装派生物 | resolver 写入 `FieldProvenance` | 派生,不手写 | | **DryRun** | `preview_kind`, `remote_reads` | 评审源 | `schema_dry_run_capabilities`(正能力声明) | 否;无条目 ≠ 推断「不支持」之外的假能力 | @@ -350,7 +350,7 @@ Definition(仅声明;不可编译) 冲突规则(路径 A): - parameters 的 type / required / default / name 集合:**Contract/cobra 胜** hints 与 MCP; -- Safety 的 confirmation/effect:**`Schema.Safety`(Final 声明)胜**;否则非空 Contract `Risk` 胜;再否则 `runtime_gate` 胜「假装 not_required」;最末迁移期 reviewed Safety。声明 Safety 存在时 gate 标注被忽略(声明 > 标注,§5.10); +- Safety 的 effect/risk/confirmation/idempotency:完整 Contract `Safety`(Final 声明)胜;否则 `runtime_gate` 可把旧路径的 confirmation 升为 user_required;最末迁移期 reviewed Safety。Safety 各字段之间不得互推(声明 > 标注,§5.10); - Identity:**绑定 entry 胜**——Contract 声明必须与之一致,不一致组装报错,不得改写 canonical path;Selection 已声明则声明胜;Interface 不得发明 RPC。 ### 5.1 Definition、Contract 与 Handler @@ -980,7 +980,7 @@ func WithProjector(ResponseProjector) BindOption - 编码通过 `Param[T]` 捕获的类型键经 `cmdcore.Get[T]` 读取;它从不接收原始字符串或公开 `any` 值。 - 包含/省略通过 `IncludeMode` 显式表达,并与编码正交。flag kind 从不静默决定是否发送 `0`、`false` 或空值。Shortcut 模式 `if rt.Changed(name) { params[…] = … }` 映射为 `WhenExplicit` / `WhenSupplied`;它不得继续以手写形式留在形态 1 候选内。 - 仅载荷默认值留在绑定中,且从不满足 CLI required 规则。 -- `Bind` 拒绝 nil/typed-nil 的 Caller 或 ResultSink、未知键、重复后端键、类型不匹配、非法 include/effect 模式、Contract Risk/后端 effect 冲突以及不兼容的默认值。 +- `Bind` 拒绝 nil/typed-nil 的 Caller 或 ResultSink、未知键、重复后端键、类型不匹配、非法 include/effect 模式、Contract Safety.Effect/后端 effect 冲突以及不兼容的默认值。 - 绑定选项有不透明内部,且对内置全函数编码器与类型化载荷默认值仅有封闭、具名的构造函数。没有公开的 `func(string) (any, error)` 转换钩子。可失败的用户输入转换属于 Resolve 或 Validator,因此在 Risk 之前完成。 - 「全函数」指编码器对任意 T 值都不返回用户错误、也不 panic;内置编码器对其输入域有穷尽测试。 - 在成功的 Bind、cmdcore Compile 与 Resolve 之后,在派发器内物化载荷不能产生用户校验错误。它仍可能浮现内部不变量失败、后端失败或输出错误。 @@ -1146,7 +1146,7 @@ cmd.RunE = func(cmd *cobra.Command, args []string) error { | 非 Shortcut 受管定义的 Cobra 命令 Hidden | 可执行 Contract | 挂载的命令可见性与声明匹配 | | Shortcut 列表成员资格与语义 disposition | 经评审的 Shortcut 可见性解析器 | public/all 列表成员资格与经评审决策匹配 | | Runtime Schema / Agent 暴露 | 经评审的 CommandRegistry 加精确排除 | 每个暴露叶子解析到活 Contract;排除显式且不重叠 | -| 运行时 Risk 与确认 | 可执行 Contract(非空 `Risk`)或显式 annotate(如 `runtime_gate`);见 §5.0 | 不得靠推断;空 Risk 不嵌入 schema risk;写命令须 Risk 或 gate;Safety 组装优先级 **`Schema.Safety`(Final 显式字段)> `CommandSpec.Safety` 枚举 tier > `Risk.SafetyDefault()` > `runtime_gate`**,高优先命中即忽略低优先;tier 填充含 idempotency(读 idempotent / 写 unknown),故仅声明 Risk/Safety 枚举即自足。**边界**:tier 恒可推导意味着写命令忘声明 `Risk` 会静默发布 read tier(且失去运行时确认)——写命令必须声明 `Risk`;`ConfirmFirst` 无 `Risk` 属 authoring 错误,构造期 panic | +| Safety 与运行时确认 | 可执行 Contract 的完整 `cli.SafetySpec`,或迁移期显式 annotate(如 `runtime_gate`);见 §5.0 | `confirmation` 单独驱动运行时门,`effect` / `risk` / `idempotency` 原样发布,禁止跨字段机械推导;任一 Safety 字段非空时四字段必须齐全,否则构造期 panic;`ConfirmFirst` 只在 `confirmation=user_required` 时合法 | | 后端 product/tool/载荷绑定 | mcpbind + 后端元数据 | 每个绑定引用真实的 flag/属性 | | Agent 选择文案(`use_when`、`avoid_when`、摘要) | 经评审的 hints/catalog | 身份解析到活契约 | diff --git a/internal/cli/runtime_schema.go b/internal/cli/runtime_schema.go index a2dbfa65..53401881 100644 --- a/internal/cli/runtime_schema.go +++ b/internal/cli/runtime_schema.go @@ -388,8 +388,13 @@ func RuntimeContractGate(cmd *cobra.Command) (string, bool) { } // HasDeclaredOrAnnotatedConfirmation reports whether confirmation semantics are -// covered by Contract Risk or an explicit runtime_gate annotation. +// covered by a typed Contract SafetySpec, the legacy Shortcut Risk bridge, or +// an explicit runtime_gate annotation. func HasDeclaredOrAnnotatedConfirmation(cmd *cobra.Command) bool { + if final, ok := RuntimeContractFinal(cmd); ok && final.Safety != nil && + strings.TrimSpace(final.Safety.Confirmation) != "" { + return true + } if _, ok := RuntimeContractRisk(cmd); ok { return true } diff --git a/internal/cmdcore/cmdcore.go b/internal/cmdcore/cmdcore.go index dbd65b2f..af4ace99 100644 --- a/internal/cmdcore/cmdcore.go +++ b/internal/cmdcore/cmdcore.go @@ -14,12 +14,12 @@ // Package cmdcore is the shared, dispatch-agnostic base for building leaf // commands. It concentrates flag registration, the alias/env/default effective // value fallback chain, required validation, cross-flag constraint declaration -// checks + runtime enforcement, Risk-driven write confirmation, toolArgs +// checks + runtime enforcement, SafetySpec-driven confirmation, toolArgs // assembly, and Agent Runtime Schema projection. // // Declaration vs execution (framework rule): // -// - Declare = CommandSpec data fields (Flags, Constraints, non-empty Risk, +// - Declare = CommandSpec data fields (Flags, Constraints, Safety, // ConstParams, Use/Short/Long/Example). NewCommand registers, validates, // confirms, and embeds those facts into dws.schema.*. // - Execute = Validate / Invoke / Orchestrate / RunE / PostMount. Hooks @@ -36,12 +36,12 @@ // LeafSpec framework (internal/helpers) and, later, the Shortcut framework wrap // these primitives and supply their own dispatch (single-step MCP / multi-step // orchestration / escape hatch). Extracting the primitives here lets both -// frameworks share one flag + constraint + risk + schema base, differing only +// frameworks share one flag + constraint + safety + schema base, differing only // in how they dispatch — the first step toward a single typed command registry. // // Behavioral contract: this package is a pure extraction of the logic that // previously lived in internal/helpers/leaf.go, so flag registration, value -// fallback, required/constraint semantics, risk wording, and schema projection +// fallback, required/constraint semantics, confirmation behavior, and schema projection // stay semantically identical. The evidence is split: check-generated-drift // (catalog unchanged) proves only the build-time projection — identity, flags, // help and annotations — while the runtime pipeline (required validation, @@ -135,65 +135,6 @@ type FlagSpec struct { SchemaDescription string // Schema description; empty uses Usage } -// Risk declares a leaf command's side-effect level, driving pre-dispatch write -// confirmation. Values match the shortcut framework's Risk verbatim. -type Risk string - -const ( - // RiskRead is a read-only operation; never prompts. Empty value == RiskRead. - RiskRead Risk = "read" - // RiskWrite mutates state; prompts unless --yes. - RiskWrite Risk = "write" - // RiskHighWrite is a destructive/irreversible operation; prompts unless --yes. - RiskHighWrite Risk = "high-risk-write" -) - -// Effective returns the effective risk, defaulting an empty value to read-only. -func (r Risk) Effective() Risk { - if r == "" { - return RiskRead - } - return r -} - -// SafetyDefault returns the Safety tier this Risk level implies when no -// explicit Safety is declared. It is the composition seam: Risk drives -// runtime confirmation, Safety drives Schema metadata, and SafetyDefault -// bridges the two when only Risk is declared. -func (r Risk) SafetyDefault() Safety { - switch r.Effective() { - case RiskWrite: - return SafetyWrite - case RiskHighWrite: - return SafetyDestructive - default: - return SafetyRead - } -} - -// Safety declares the Schema safety metadata tier, projected into -// agent-facing Schema independently of the runtime Risk. Empty == SafetyRead. -type Safety string - -const ( - // SafetyRead is a read-only operation: read/low/not_required/idempotent. - SafetyRead Safety = "read" - // SafetyWrite is a reversible mutation: write/medium/user_required/unknown. - SafetyWrite Safety = "write" - // SafetyHighWrite is a hard-to-reverse mutation: write/high/user_required/unknown. - SafetyHighWrite Safety = "high-write" - // SafetyDestructive is a destructive operation: destructive/high/user_required/unknown. - SafetyDestructive Safety = "destructive" -) - -// Effective returns the effective safety tier, defaulting empty to read. -func (s Safety) Effective() Safety { - if s == "" { - return SafetyRead - } - return s -} - // ConstraintKind is the type of a cross-flag relationship constraint. Values // match the shortcut framework's ConstraintKind verbatim. type ConstraintKind string @@ -226,20 +167,20 @@ type Constraint struct { // // Declaration surface is the final Schema data source for managed leaves: // -// Flags (+ parameter Schema fields), Constraints, Risk, ConstParams, +// Flags (+ parameter Schema fields), Constraints, Safety, ConstParams, // Use/Short/Long/Example, Schema (ToolSpec groups) // // Schema assembly pass-throughs embedded dws.schema.* — no reviewed/hints -// parallel authority for declared fields. Empty Risk means runtime read -// confirmation (no prompt) and does NOT embed dws.schema.risk unless -// Schema.Safety supplies risk/confirmation. +// parallel authority for declared fields. Safety uses cli.SafetySpec directly: +// confirmation drives the runtime gate, while effect/risk/idempotency are +// published unchanged. No safety field is inferred from another. // // Execution surface (hooks — not declaration): // // - RunE — full escape hatch: the framework only registers flags/constraints/ // help and hands control over. // - Invoke — single-step: runs after required/constraint/Validate checks, args -// assembly and the Risk write-confirmation, receiving the assembled toolArgs. +// assembly and the Safety confirmation gate, receiving the assembled toolArgs. // - Orchestrate — multi-step: runs after the same checks and confirmation but // receives only the Ctx, so it can chain several backend calls itself. // - Validate / PostMount — orchestration only; must not register business flags @@ -256,12 +197,12 @@ type CommandSpec struct { Flags []FlagSpec Constraints []Constraint - Risk Risk - // Safety declares the Schema safety tier independently of the runtime - // Risk. Empty falls back to Risk.SafetyDefault(); explicit SafetyDecl - // string fields always win over the tier fill. - Safety Safety - // ConfirmFirst runs the Risk write-confirmation before required/constraint/ + // Safety is the command's single safety source. The same cli.SafetySpec is + // used for runtime confirmation and the published Schema. A completely + // empty value keeps the historical read-only default; a non-empty value + // must declare effect/risk/confirmation/idempotency together. + Safety cli.SafetySpec + // ConfirmFirst runs the Safety confirmation before required/constraint/ // Validate checks instead of after them. Use it where the legacy semantics // were guard-first (a write without --yes fails fast with // confirmation_required regardless of parameter completeness). The default @@ -368,8 +309,8 @@ func (c *Ctx) Yes() bool { return BoolFlag(c.cmd, "yes") } // NewCommand builds a cobra command from a CommandSpec. It is the single // orchestration path: dispatch declaration check → flag registration → // constraint declaration checks → Runtime Schema projection → constraint help → -// PostMount → (RunE escape) → generated RunE{ [ConfirmFirst: ConfirmRisk →] -// required → constraints → Validate → BuildArgs → ConfirmRisk → +// PostMount → generated RunE{ [ConfirmFirst: ConfirmSafety →] +// required → constraints → Validate → BuildArgs → ConfirmSafety → // Invoke/Orchestrate }. // // Behavior matches the former helpers.NewLeafCommand, which always dispatched @@ -379,6 +320,7 @@ func (c *Ctx) Yes() bool { return BoolFlag(c.cmd, "yes") } // included — and then silently exit 0 having done nothing. func NewCommand(spec CommandSpec) *cobra.Command { validateDispatchDecl(spec) + validateSafetySpec(spec) validateSchemaDecl(spec) // Help prose inherits the declaration when not authored separately: // Selection.Examples (already contract-validated against the real flags) @@ -404,12 +346,17 @@ func NewCommand(spec CommandSpec) *cobra.Command { spec.PostMount(cmd) } if spec.RunE != nil { - cmd.RunE = spec.RunE + cmd.RunE = func(cmd *cobra.Command, args []string) error { + if err := ConfirmSafety(cmd, spec.Safety); err != nil { + return err + } + return spec.RunE(cmd, args) + } return cmd } cmd.RunE = func(cmd *cobra.Command, args []string) error { if spec.ConfirmFirst { - if err := ConfirmRisk(cmd, spec.Risk); err != nil { + if err := ConfirmSafety(cmd, spec.Safety); err != nil { return err } } @@ -427,7 +374,7 @@ func NewCommand(spec CommandSpec) *cobra.Command { ctx := newCtx(cmd, args, spec.Flags) if spec.Orchestrate != nil { if !spec.ConfirmFirst { - if err := ConfirmRisk(cmd, spec.Risk); err != nil { + if err := ConfirmSafety(cmd, spec.Safety); err != nil { return err } } @@ -441,7 +388,7 @@ func NewCommand(spec CommandSpec) *cobra.Command { toolArgs[key] = value } if !spec.ConfirmFirst { - if err := ConfirmRisk(cmd, spec.Risk); err != nil { + if err := ConfirmSafety(cmd, spec.Safety); err != nil { return err } } @@ -470,17 +417,51 @@ func validateDispatchDecl(spec CommandSpec) { "command %q must declare exactly one of RunE/Invoke/Orchestrate, got %d", spec.Use, declared)) } - // ConfirmFirst only makes sense on a Risk-driven confirmation flow. A - // command with ConfirmFirst but no Risk is always an authoring mistake — - // and for a declared-Schema write it would also silently publish the read - // safety tier (empty Risk infers SafetyRead). - if spec.ConfirmFirst && strings.TrimSpace(string(spec.Risk)) == "" { + // ConfirmFirst only changes the ordering of a declared confirmation gate. + if spec.ConfirmFirst && strings.TrimSpace(spec.Safety.Confirmation) != "user_required" { panic(fmt.Sprintf( - "command %q sets ConfirmFirst but declares no Risk: ConfirmFirst orders the Risk-driven confirmation and is meaningless without one", + "command %q sets ConfirmFirst but Safety.Confirmation is not user_required", spec.Use)) } } +// validateSafetySpec rejects partial safety declarations. A zero value remains +// the historical read-only default, but once any field is authored all four +// independent Schema dimensions must be explicit. +func validateSafetySpec(spec CommandSpec) { + safety := spec.Safety + fields := []struct { + name string + value string + }{ + {"Safety.Effect", safety.Effect}, + {"Safety.Risk", safety.Risk}, + {"Safety.Confirmation", safety.Confirmation}, + {"Safety.Idempotency", safety.Idempotency}, + } + declared := false + for _, field := range fields { + if strings.TrimSpace(field.value) != "" { + declared = true + break + } + } + if !declared { + return + } + missing := make([]string, 0, len(fields)) + for _, field := range fields { + if strings.TrimSpace(field.value) == "" { + missing = append(missing, field.name) + } + } + if len(missing) > 0 { + panic(fmt.Sprintf( + "command %q declares partial SafetySpec; missing %s: safety fields are independent and are never inferred from one another", + spec.Use, strings.Join(missing, ", "))) + } +} + // RegisterFlags registers every flag (plus hidden aliases and MarkFlagRequired) // declared by the spec set onto cmd. func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) { @@ -835,8 +816,9 @@ func ValidateConstraints(cmd *cobra.Command, flags []FlagSpec, constraints []Con return nil } -// ConfirmRisk reproduces the shortcut framework's confirmRisk write-confirmation -// prompt wording (command path substitutes Service+Command). Semantics: +// ConfirmSafety enforces the command's declared confirmation requirement. +// Effect, risk and idempotency are metadata only and never imply confirmation. +// Semantics: // // - read-only, --dry-run, or --yes → nil (proceed); // - interactive yes/y → nil; @@ -844,8 +826,9 @@ func ValidateConstraints(cmd *cobra.Command, flags []FlagSpec, constraints []Con // - no interactive answer (EOF / closed stdin) → confirmation_required. // // EOF must not be treated as decline: that silently drops writes in agent/CI. -func ConfirmRisk(cmd *cobra.Command, risk Risk) error { - if risk.Effective() == RiskRead || BoolFlag(cmd, "dry-run") || BoolFlag(cmd, "yes") { +func ConfirmSafety(cmd *cobra.Command, safety cli.SafetySpec) error { + if strings.TrimSpace(safety.Confirmation) != "user_required" || + BoolFlag(cmd, "dry-run") || BoolFlag(cmd, "yes") { return nil } // Only print the interactive prompt on a real terminal. In non-interactive @@ -855,7 +838,11 @@ func ConfirmRisk(cmd *cobra.Command, risk Risk) error { // answer (printf 'yes\n' | cmd) is still honored: the read happens either // way, only the prompt print is terminal-gated. if stdinIsTerminal(cmd.InOrStdin()) { - fmt.Fprintf(cmd.ErrOrStderr(), "即将执行 %s(%s),确认继续?(yes/no): ", cmd.CommandPath(), risk.Effective()) + fmt.Fprintf( + cmd.ErrOrStderr(), + "即将执行 %s(effect=%s, risk=%s),确认继续?(yes/no): ", + cmd.CommandPath(), strings.TrimSpace(safety.Effect), strings.TrimSpace(safety.Risk), + ) } reader := bufio.NewReader(cmd.InOrStdin()) answer, err := reader.ReadString('\n') @@ -887,7 +874,7 @@ func stdinIsTerminal(in io.Reader) bool { func confirmationRequiredError(operation string) error { return apperrors.NewValidation( - fmt.Sprintf("%s 是写操作,当前环境无法交互确认;加 --dry-run 预览,或确认后加 --yes 执行", operation), + fmt.Sprintf("%s 需要用户确认,当前环境无法交互;加 --dry-run 预览,或确认后加 --yes 执行", operation), apperrors.WithReason("confirmation_required"), apperrors.WithHint("非交互环境(agent/CI)必须显式传入 --yes,不能依赖 stdin 提示"), apperrors.WithActions("确认目标与变更影响", "以相同参数追加 --yes 执行"), @@ -921,9 +908,6 @@ func BoolFlag(cmd *cobra.Command, name string) bool { // annotations; declared fields do not compete with reviewed hints. func embedContractIntoSchema(cmd *cobra.Command, spec CommandSpec) { cli.AnnotateRuntimeContract(cmd) - if strings.TrimSpace(string(spec.Risk)) != "" { - cli.AnnotateRuntimeRisk(cmd, string(spec.Risk.Effective())) - } required := make([]string, 0, len(spec.Flags)) for _, flag := range spec.Flags { name := strings.TrimSpace(flag.Name) @@ -960,8 +944,7 @@ func embedContractIntoSchema(cmd *cobra.Command, spec CommandSpec) { } // embedSchemaDecl does a light runtime write: only when SchemaDecl is authored, -// convert once and RegisterRuntimeContractFinal. Risk-only leaves keep -// dws.schema.risk from embedContractIntoSchema — no Final registration. +// convert once and RegisterRuntimeContractFinal. func embedSchemaDecl(cmd *cobra.Command, spec CommandSpec) { schema := spec.Schema if schema.empty() { @@ -970,7 +953,7 @@ func embedSchemaDecl(cmd *cobra.Command, spec CommandSpec) { payload := cli.ContractFinalPayload{ Title: firstNonEmpty(schema.Title, spec.Short), Description: firstNonEmpty(schema.Description, spec.Long), - Safety: schemaSafetyFromDecl(spec.Risk, spec.Safety, schema.Safety), + Safety: schemaSafetyFromDecl(spec.Safety), } if n := len(schema.Positionals); n > 0 { payload.Positionals = make([]cli.RuntimeSchemaPositional, n) @@ -1031,85 +1014,30 @@ func firstNonEmpty(values ...string) string { return "" } -// schemaSafetyFromDecl composes the Schema safety metadata from three -// sources, in precedence order: explicit SafetyDecl string fields (highest) -// > the declared Safety tier enum > Risk.SafetyDefault() (lowest). The tier -// fill also covers Idempotency (idempotent for reads, unknown for writes), -// so a plain Risk/Safety enum declaration is self-sufficient. -func schemaSafetyFromDecl(risk Risk, safety Safety, decl SafetyDecl) *cli.SafetySpec { - out := cli.SafetySpec{ - Effect: strings.TrimSpace(decl.Effect), - Risk: strings.TrimSpace(decl.Risk), - Confirmation: strings.TrimSpace(decl.Confirmation), - Idempotency: strings.TrimSpace(decl.Idempotency), - } - tier := safety - if tier == "" { - tier = risk.SafetyDefault() - } - switch tier.Effective() { - case SafetyWrite: - if out.Effect == "" { - out.Effect = "write" - } - if out.Risk == "" { - out.Risk = "medium" - } - if out.Confirmation == "" { - out.Confirmation = "user_required" - } - if out.Idempotency == "" { - out.Idempotency = "unknown" - } - case SafetyHighWrite: - if out.Effect == "" { - out.Effect = "write" - } - if out.Risk == "" { - out.Risk = "high" - } - if out.Confirmation == "" { - out.Confirmation = "user_required" - } - if out.Idempotency == "" { - out.Idempotency = "unknown" - } - case SafetyDestructive: - if out.Effect == "" { - out.Effect = "destructive" - } - if out.Risk == "" { - out.Risk = "high" - } - if out.Confirmation == "" { - out.Confirmation = "user_required" - } - if out.Idempotency == "" { - out.Idempotency = "unknown" - } - default: // SafetyRead - if out.Effect == "" { - out.Effect = "read" - } - if out.Risk == "" { - out.Risk = "low" - } - if out.Confirmation == "" { - out.Confirmation = "not_required" - } - if out.Idempotency == "" { - out.Idempotency = "idempotent" - } - } - // effect_source is assembly-derived for every Contract-declared safety, - // whether it came from the Risk shorthand or an explicit SchemaDecl; the - // declaration in code is the source of truth. The tier fill above always - // produces a complete block (read tier by default), so the payload is - // never nil for a declared Schema. +// schemaSafetyFromDecl copies the single command SafetySpec into the final +// Schema payload. The zero value keeps the historical read-only default. +func schemaSafetyFromDecl(safety cli.SafetySpec) *cli.SafetySpec { + out := effectiveSafetySpec(safety) out.EffectSource = "cmdcore.contract" return &out } +func effectiveSafetySpec(safety cli.SafetySpec) cli.SafetySpec { + out := cli.SafetySpec{ + Effect: strings.TrimSpace(safety.Effect), + Risk: strings.TrimSpace(safety.Risk), + Confirmation: strings.TrimSpace(safety.Confirmation), + Idempotency: strings.TrimSpace(safety.Idempotency), + } + if out.Effect == "" && out.Risk == "" && out.Confirmation == "" && out.Idempotency == "" { + out.Effect = "read" + out.Risk = "low" + out.Confirmation = "not_required" + out.Idempotency = "idempotent" + } + return out +} + func flagKindSchemaType(kind FlagKind) string { switch kind { case KindInt: diff --git a/internal/cmdcore/cmdcore_test.go b/internal/cmdcore/cmdcore_test.go index 174413d6..497b8c6d 100644 --- a/internal/cmdcore/cmdcore_test.go +++ b/internal/cmdcore/cmdcore_test.go @@ -19,6 +19,7 @@ import ( "strings" "testing" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/spf13/cobra" ) @@ -34,6 +35,27 @@ func newTestCommand() *cobra.Command { return cmd } +func testReadSafety() cli.SafetySpec { + return cli.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + } +} + +func testWriteSafety() cli.SafetySpec { + return cli.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "user_required", Idempotency: "unknown", + } +} + +func testDestructiveSafety() cli.SafetySpec { + return cli.SafetySpec{ + Effect: "destructive", Risk: "high", + Confirmation: "user_required", Idempotency: "unknown", + } +} + // ── flag registration ────────────────────────────────────────────── func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) { @@ -538,19 +560,10 @@ func TestCrossPlatformCoverageValidateConstraints(t *testing.T) { } } -// ── risk confirmation ────────────────────────────────────────────── +// ── safety confirmation ──────────────────────────────────────────── -func TestCrossPlatformCoverageRiskEffective(t *testing.T) { - if Risk("").Effective() != RiskRead { - t.Fatal("empty risk must default to read") - } - if RiskWrite.Effective() != RiskWrite || RiskHighWrite.Effective() != RiskHighWrite { - t.Fatal("explicit risk must be preserved") - } -} - -func TestCrossPlatformCoverageConfirmRisk(t *testing.T) { - newRiskCmd := func(stdin string) *cobra.Command { +func TestCrossPlatformCoverageConfirmSafety(t *testing.T) { + newSafetyCmd := func(stdin string) *cobra.Command { cmd := newTestCommand() cmd.PersistentFlags().Bool("yes", false, "") cmd.PersistentFlags().Bool("dry-run", false, "") @@ -559,45 +572,45 @@ func TestCrossPlatformCoverageConfirmRisk(t *testing.T) { return cmd } - // read-only never prompts - if err := ConfirmRisk(newRiskCmd(""), RiskRead); err != nil { - t.Fatal("read risk must pass") + // Confirmation is the only field that drives the gate. + if err := ConfirmSafety(newSafetyCmd(""), testReadSafety()); err != nil { + t.Fatal("not_required safety must pass") } - if err := ConfirmRisk(newRiskCmd(""), ""); err != nil { - t.Fatal("empty risk must pass as read") + if err := ConfirmSafety(newSafetyCmd(""), cli.SafetySpec{Effect: "destructive", Risk: "high"}); err != nil { + t.Fatal("effect/risk must not imply confirmation") } // --yes and --dry-run bypass the prompt - yes := newRiskCmd("") + yes := newSafetyCmd("") _ = yes.PersistentFlags().Set("yes", "true") - if err := ConfirmRisk(yes, RiskHighWrite); err != nil { + if err := ConfirmSafety(yes, testDestructiveSafety()); err != nil { t.Fatal("--yes must bypass") } - dry := newRiskCmd("") + dry := newSafetyCmd("") _ = dry.PersistentFlags().Set("dry-run", "true") - if err := ConfirmRisk(dry, RiskWrite); err != nil { + if err := ConfirmSafety(dry, testWriteSafety()); err != nil { t.Fatal("--dry-run must bypass") } // interactive accept / decline for _, answer := range []string{"yes\n", "y\n", "YES\n"} { - if err := ConfirmRisk(newRiskCmd(answer), RiskWrite); err != nil { + if err := ConfirmSafety(newSafetyCmd(answer), testWriteSafety()); err != nil { t.Fatalf("answer %q must confirm, got %v", answer, err) } } for _, answer := range []string{"no\n", "\n", "maybe\n"} { - err := ConfirmRisk(newRiskCmd(answer), RiskHighWrite) + err := ConfirmSafety(newSafetyCmd(answer), testDestructiveSafety()) if err == nil || !strings.Contains(err.Error(), "用户取消了操作") { t.Fatalf("answer %q must decline with cancel, got %v", answer, err) } } // EOF / closed stdin is ConfirmUnavailable, not decline - err := ConfirmRisk(newRiskCmd(""), RiskWrite) + err := ConfirmSafety(newSafetyCmd(""), testWriteSafety()) var appErr *apperrors.Error if !errors.As(err, &appErr) || appErr.Reason != "confirmation_required" { t.Fatalf("EOF must be confirmation_required, got %#v", err) } } -func TestCrossPlatformCoverageConfirmRiskSuppressesPromptOffTerminal(t *testing.T) { +func TestCrossPlatformCoverageConfirmSafetySuppressesPromptOffTerminal(t *testing.T) { // Non-terminal stdin (buffer/pipe/EOF): the interactive prompt line must // not pollute stderr — the structured error carries the semantics. Piped // answers still confirm. @@ -607,7 +620,7 @@ func TestCrossPlatformCoverageConfirmRiskSuppressesPromptOffTerminal(t *testing. cmd.PersistentFlags().Bool("dry-run", false, "") cmd.SetIn(strings.NewReader("")) cmd.SetErr(&stderr) - if err := ConfirmRisk(cmd, RiskWrite); err == nil { + if err := ConfirmSafety(cmd, testWriteSafety()); err == nil { t.Fatal("EOF must fail closed") } if got := stderr.String(); got != "" { @@ -619,7 +632,7 @@ func TestCrossPlatformCoverageConfirmRiskSuppressesPromptOffTerminal(t *testing. piped.PersistentFlags().Bool("yes", false, "") piped.SetIn(strings.NewReader("yes\n")) piped.SetErr(&answered) - if err := ConfirmRisk(piped, RiskWrite); err != nil { + if err := ConfirmSafety(piped, testWriteSafety()); err != nil { t.Fatalf("piped answer must confirm, got %v", err) } if got := answered.String(); got != "" { @@ -872,9 +885,9 @@ func TestCrossPlatformCoverageNewCommandStopsOnFailures(t *testing.T) { func TestCrossPlatformCoverageNewCommandDeclineCancels(t *testing.T) { dispatched := false cmd := NewCommand(CommandSpec{ - Use: "risky", - Risk: RiskHighWrite, - Flags: []FlagSpec{{Name: "x", Usage: "X"}}, + Use: "risky", + Safety: testDestructiveSafety(), + Flags: []FlagSpec{{Name: "x", Usage: "X"}}, Invoke: func(*Ctx, map[string]any) error { dispatched = true return nil @@ -913,7 +926,7 @@ func TestCrossPlatformCoverageNewCommandRequiresExactlyOneDispatcher(t *testing. // Zero dispatchers: a spec with no runnable body must never reach run time, // where it would have prompted for confirmation and then exited 0 doing nothing. - mustPanic("no dispatcher", CommandSpec{Use: "bare", Risk: RiskHighWrite, Flags: flags}, + mustPanic("no dispatcher", CommandSpec{Use: "bare", Safety: testDestructiveSafety(), Flags: flags}, "must declare exactly one of RunE/Invoke/Orchestrate, got 0") // Two competing dispatchers are equally a programming error. @@ -930,14 +943,14 @@ func TestCrossPlatformCoverageNewCommandRequiresExactlyOneDispatcher(t *testing. Invoke: func(*Ctx, map[string]any) error { return nil }, }, "got 2") - // ConfirmFirst without Risk orders a confirmation that does not exist — and - // for declared-Schema writes it would also publish the read safety tier. - mustPanic("confirmFirst without risk", CommandSpec{ + // ConfirmFirst without a user_required confirmation orders a gate that + // does not exist. + mustPanic("confirmFirst without confirmation", CommandSpec{ Use: "guarded", Flags: flags, ConfirmFirst: true, Invoke: func(*Ctx, map[string]any) error { return nil }, - }, "ConfirmFirst but declares no Risk") + }, "Safety.Confirmation is not user_required") } func TestCrossPlatformCoverageNewCommandOrchestrateDispatch(t *testing.T) { @@ -1021,7 +1034,7 @@ func TestCrossPlatformCoverageNewCommandOrchestrateHonorsConfirmation(t *testing build := func() *cobra.Command { cmd := NewCommand(CommandSpec{ Use: "risky-orch", - Risk: RiskHighWrite, + Safety: testDestructiveSafety(), Flags: []FlagSpec{{Name: "x", Usage: "X"}}, Orchestrate: func(*Ctx) error { ran = true; return nil }, }) @@ -1053,18 +1066,18 @@ func TestCrossPlatformCoverageNewCommandOrchestrateHonorsConfirmation(t *testing } } -func TestNewCommandEmbedsContractRiskIntoSchema(t *testing.T) { +func TestNewCommandEmbedsContractFlagsWithoutLegacyRiskAnnotation(t *testing.T) { cmd := NewCommand(CommandSpec{ - Use: "wipe", - Risk: RiskHighWrite, + Use: "wipe", + Safety: testDestructiveSafety(), Flags: []FlagSpec{ {Name: "id", Usage: "ID", Required: true, Bind: "versionId", Kind: KindString}, {Name: "count", Usage: "N", Kind: KindInt, Default: "1"}, }, Invoke: func(*Ctx, map[string]any) error { return nil }, }) - if cmd.Annotations["dws.schema.risk"] != string(RiskHighWrite) { - t.Fatalf("risk annotation = %q", cmd.Annotations["dws.schema.risk"]) + if _, ok := cmd.Annotations["dws.schema.risk"]; ok { + t.Fatal("cmdcore must not emit the legacy dws.schema.risk annotation") } id := cmd.Flags().Lookup("id") if id.Annotations["dws.schema.required"][0] != "true" { @@ -1076,16 +1089,16 @@ func TestNewCommandEmbedsContractRiskIntoSchema(t *testing.T) { if cmd.Flags().Lookup("count").Annotations["dws.schema.type"][0] != "integer" { t.Fatalf("count type = %#v", cmd.Flags().Lookup("count").Annotations["dws.schema.type"]) } - // Empty Risk must not stamp a read default that would clobber write-guard leaves. + // Empty Safety must not stamp the removed Risk annotation. plain := NewCommand(CommandSpec{ Use: "list", Invoke: func(*Ctx, map[string]any) error { return nil }, }) if _, ok := plain.Annotations["dws.schema.risk"]; ok { - t.Fatal("empty Risk must not embed dws.schema.risk") + t.Fatal("empty Safety must not embed dws.schema.risk") } if plain.Annotations["dws.schema.contract"] != "cmdcore" { - t.Fatal("contract marker still required when Risk empty") + t.Fatal("contract marker still required when Safety empty") } } diff --git a/internal/cmdcore/schema_decl.go b/internal/cmdcore/schema_decl.go index fbdd330c..ac53143e 100644 --- a/internal/cmdcore/schema_decl.go +++ b/internal/cmdcore/schema_decl.go @@ -31,7 +31,6 @@ type SchemaDecl struct { Title string Description string Positionals []PositionalDecl - Safety SafetyDecl DryRun *DryRunDecl Interface *InterfaceDecl Selection SelectionDecl @@ -48,16 +47,6 @@ type PositionalDecl struct { Index int } -// SafetyDecl is the final Schema Safety payload. Any field left empty is -// filled from the CommandSpec.Safety tier (or Risk.SafetyDefault() when -// Safety is unset) at embed time, including Idempotency. -type SafetyDecl struct { - Effect string - Risk string - Confirmation string - Idempotency string -} - // DryRunDecl is a positive --dry-run capability declaration for Schema. type DryRunDecl struct { PreviewKind string @@ -128,14 +117,8 @@ func validateSchemaDecl(spec CommandSpec) { missing = append(missing, "Schema.Selection.Examples") } // Interface is an unconditional catalog required key for declared tools - // (no hints fallback). Safety needs no completeness check: the enum tier - // (CommandSpec.Safety, falling back to Risk.SafetyDefault()) fills every - // Safety field including Idempotency, so any declared Schema projects a - // complete safety block by construction. The flip side of always-on - // inference: a WRITE command must declare Risk (which also drives runtime - // confirmation) — an empty Risk publishes the read tier. The detectable - // half of that mistake (ConfirmFirst without Risk) is caught by - // validateDispatchDecl. + // (no hints fallback). CommandSpec.Safety is validated separately and is + // the single source for both runtime confirmation and the Schema block. if iface := spec.Schema.Interface; iface == nil || strings.TrimSpace(iface.Mode) == "" || strings.TrimSpace(iface.Availability) == "" { missing = append(missing, "Schema.Interface (mode/availability)") @@ -158,10 +141,6 @@ func (s SchemaDecl) empty() bool { if len(s.Positionals) > 0 { return false } - if strings.TrimSpace(s.Safety.Effect) != "" || strings.TrimSpace(s.Safety.Risk) != "" || - strings.TrimSpace(s.Safety.Confirmation) != "" || strings.TrimSpace(s.Safety.Idempotency) != "" { - return false - } if s.DryRun != nil && strings.TrimSpace(s.DryRun.PreviewKind) != "" { return false } diff --git a/internal/cmdcore/schema_decl_test.go b/internal/cmdcore/schema_decl_test.go index 8e4a99ff..0f1131b4 100644 --- a/internal/cmdcore/schema_decl_test.go +++ b/internal/cmdcore/schema_decl_test.go @@ -32,12 +32,14 @@ func TestNewCommandEmbedsFullSchemaDeclAsFinalSource(t *testing.T) { RequiredWhen: "when x", SchemaDescription: "schema desc", }, }, - Risk: RiskWrite, + Safety: cli.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "user_required", Idempotency: "retryable", + }, Schema: SchemaDecl{ Title: "Create Title", Description: "Create Desc", Positionals: []PositionalDecl{{Name: "id", Required: true, Index: 0}}, - Safety: SafetyDecl{Idempotency: "retryable"}, DryRun: &DryRunDecl{PreviewKind: "invocation", RemoteReads: true}, Interface: &InterfaceDecl{ Mode: "mcp", Availability: "available", @@ -120,18 +122,15 @@ func TestNewCommandPanicsOnPartialSchemaDecl(t *testing.T) { }, "Schema.Selection.AgentSummary"}, {"missing examples", SchemaDecl{ Description: "d", - Safety: SafetyDecl{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}, Interface: &InterfaceDecl{Mode: "mcp", Availability: "available", ProductID: "dev", RPCName: "get_thing"}, Selection: SelectionDecl{AgentSummary: "s", UseWhen: []string{"u"}, AvoidWhen: []string{"a"}}, }, "Schema.Selection.Examples"}, {"missing interface", SchemaDecl{ Description: "d", - Safety: SafetyDecl{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}, Selection: SelectionDecl{AgentSummary: "s", UseWhen: []string{"u"}, AvoidWhen: []string{"a"}, Examples: []string{"dws x"}}, }, "Schema.Interface"}, {"composite without reason", SchemaDecl{ Description: "d", - Safety: SafetyDecl{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}, Interface: &InterfaceDecl{Mode: "composite", Availability: "available"}, Selection: SelectionDecl{AgentSummary: "s", UseWhen: []string{"u"}, AvoidWhen: []string{"a"}, Examples: []string{"dws x"}}, }, "Schema.Interface.Reason"}, @@ -159,7 +158,6 @@ func TestNewCommandPanicsOnPartialSchemaDecl(t *testing.T) { func TestNewCommandDerivesHelpExampleFromDeclaredSelection(t *testing.T) { schema := SchemaDecl{ Description: "desc", - Safety: SafetyDecl{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"}, Interface: &InterfaceDecl{Mode: "mcp", Availability: "available", ProductID: "dev", RPCName: "create_thing"}, Selection: SelectionDecl{ AgentSummary: "summary", @@ -171,6 +169,7 @@ func TestNewCommandDerivesHelpExampleFromDeclaredSelection(t *testing.T) { cmd := NewCommand(CommandSpec{ Use: "create", Short: "short", + Safety: testWriteSafety(), Schema: schema, Invoke: func(*Ctx, map[string]any) error { return nil }, }) @@ -184,6 +183,7 @@ func TestNewCommandDerivesHelpExampleFromDeclaredSelection(t *testing.T) { Use: "create", Short: "short", Example: " dws create --custom", + Safety: testWriteSafety(), Schema: schema, Invoke: func(*Ctx, map[string]any) error { return nil }, }) @@ -192,7 +192,7 @@ func TestNewCommandDerivesHelpExampleFromDeclaredSelection(t *testing.T) { } } -func TestNewCommandSafetyTierComposition(t *testing.T) { +func TestNewCommandSafetySpecPassThrough(t *testing.T) { schema := func() SchemaDecl { return SchemaDecl{ Description: "desc", @@ -211,29 +211,21 @@ func TestNewCommandSafetyTierComposition(t *testing.T) { return final.Safety } - // Risk-only: tier inferred from Risk.SafetyDefault(), including idempotency. - if got := build(CommandSpec{Use: "w", Short: "w", Risk: RiskWrite, Schema: schema(), - Invoke: func(*Ctx, map[string]any) error { return nil }}); got.Effect != "write" || got.Risk != "medium" || - got.Confirmation != "user_required" || got.Idempotency != "unknown" { - t.Fatalf("RiskWrite default tier = %#v", got) + declared := cli.SafetySpec{ + Effect: "write", Risk: "low", + Confirmation: "not_required", Idempotency: "non_idempotent", } - // Safety enum overrides the Risk default (high-write over destructive). - if got := build(CommandSpec{Use: "w", Short: "w", Risk: RiskHighWrite, Safety: SafetyHighWrite, Schema: schema(), - Invoke: func(*Ctx, map[string]any) error { return nil }}); got.Effect != "write" || got.Risk != "high" { - t.Fatalf("explicit Safety tier must win over Risk default, = %#v", got) + if got := build(CommandSpec{Use: "w", Short: "w", Safety: declared, Schema: schema(), + Invoke: func(*Ctx, map[string]any) error { return nil }}); got.Effect != declared.Effect || + got.Risk != declared.Risk || got.Confirmation != declared.Confirmation || + got.Idempotency != declared.Idempotency { + t.Fatalf("SafetySpec must pass through without cross-field inference: %#v", got) } - // Explicit SafetyDecl string fields win over the tier fill. - s := schema() - s.Safety = SafetyDecl{Effect: "destructive"} - if got := build(CommandSpec{Use: "w", Short: "w", Risk: RiskWrite, Schema: s, - Invoke: func(*Ctx, map[string]any) error { return nil }}); got.Effect != "destructive" || got.Risk != "medium" { - t.Fatalf("SafetyDecl field must win over tier fill, = %#v", got) - } - // Empty Risk + empty Safety infers the read tier. + // A wholly empty declaration preserves the historical read-only default. if got := build(CommandSpec{Use: "r", Short: "r", Schema: schema(), Invoke: func(*Ctx, map[string]any) error { return nil }}); got.Effect != "read" || got.Risk != "low" || got.Confirmation != "not_required" || got.Idempotency != "idempotent" { - t.Fatalf("empty Risk/Safety must infer read tier, = %#v", got) + t.Fatalf("empty Safety must use read default, = %#v", got) } } @@ -242,13 +234,30 @@ func TestSchemaDeclEmptySkipsFinal(t *testing.T) { Use: "get", Short: "g", Flags: []FlagSpec{{Name: "id", Usage: "id"}}, - Risk: RiskWrite, // Risk alone is annotation-only; Final needs Schema + Safety: testWriteSafety(), Invoke: func(*Ctx, map[string]any) error { return nil }, }) if cli.HasRuntimeContractFinal(cmd) { - t.Fatal("Risk without Schema must not register Final (keep runtime write light)") + t.Fatal("Safety without Schema must not register Final (keep runtime write light)") } - if _, ok := cli.RuntimeContractRisk(cmd); !ok { - t.Fatal("Risk must still embed dws.schema.risk") + if _, ok := cmd.Annotations["dws.schema.risk"]; ok { + t.Fatal("Safety must not use the removed dws.schema.risk annotation") } } + +func TestNewCommandRejectsPartialSafetySpec(t *testing.T) { + defer func() { + recovered := recover() + if recovered == nil { + t.Fatal("partial SafetySpec must panic at construction") + } + if msg, _ := recovered.(string); !strings.Contains(msg, "Safety.Confirmation") { + t.Fatalf("panic = %v, want missing Safety.Confirmation", recovered) + } + }() + NewCommand(CommandSpec{ + Use: "partial", + Safety: cli.SafetySpec{Effect: "write", Risk: "medium", Idempotency: "unknown"}, + Invoke: func(*Ctx, map[string]any) error { return nil }, + }) +} diff --git a/internal/helpers/devapp.go b/internal/helpers/devapp.go index 4dc208cd..560b14ad 100644 --- a/internal/helpers/devapp.go +++ b/internal/helpers/devapp.go @@ -18,7 +18,7 @@ import ( "fmt" "strings" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cmdcore" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" @@ -260,6 +260,7 @@ func newDevAppEventListCommand(runner executor.Runner) *cobra.Command { Short: "查询应用已订阅的事件列表", Example: " dws dev app event list --unified-app-id UNIFIED_APP_ID --page-size 20 --format json", Tool: devAppEventListTool, + Safety: devAppSafetyRead(), Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, {Name: "keyword", Usage: "事件搜索关键词,支持按事件码或事件名称模糊匹配", Bind: "keyword", Trim: true, OmitEmpty: true}, @@ -286,8 +287,7 @@ func newDevAppEventSubscribeCommand(runner executor.Runner) *cobra.Command { Short: "订阅应用事件回调", Example: " dws dev app event subscribe --unified-app-id UNIFIED_APP_ID --event-codes bpms_task_change --dry-run --format json", Tool: devAppEventSubscribeTool, - Risk: LeafRiskWrite, - Safety: LeafSafetyWrite, + Safety: devAppSafetyWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ @@ -316,8 +316,7 @@ func newDevAppEventUnsubscribeCommand(runner executor.Runner) *cobra.Command { Short: "取消订阅应用事件", Example: " dws dev app event unsubscribe --unified-app-id UNIFIED_APP_ID --event-codes bpms_task_change --dry-run --format json", Tool: devAppEventUnsubscribeTool, - Risk: LeafRiskWrite, - Safety: LeafSafetyWrite, + Safety: devAppSafetyWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ @@ -346,6 +345,7 @@ func newDevAppListCommand(runner executor.Runner) *cobra.Command { Short: "查询开放平台企业内部应用列表", Example: " dws dev app list --name DemoApp --page-size 20 --format json", Tool: devAppListTool, + Safety: devAppSafetyRead(), Flags: []LeafFlag{ {Name: "name", Usage: "应用名称关键词", Bind: "name", Trim: true, OmitEmpty: true, Aliases: []string{"keyword"}}, {Name: "app-key", Usage: "按 appKey/clientId 过滤", Bind: "appKey", Trim: true, OmitEmpty: true}, @@ -379,6 +379,7 @@ func newDevAppGetCommand(runner executor.Runner) *cobra.Command { Short: "查询开放平台企业内部应用详情", Example: " dws dev app get --unified-app-id UNIFIED_APP_ID --format json\n dws dev app get --app-key APP_KEY --format json", Tool: devAppGetTool, + Safety: devAppSafetyRead(), Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(与 --app-key 二选一)", Bind: "unifiedAppId", Trim: true, OmitEmpty: true}, {Name: "app-key", Usage: "按 appKey/clientId 查询应用详情(与 --unified-app-id 二选一)", Bind: "appKey", Trim: true, OmitEmpty: true}, @@ -411,8 +412,7 @@ func newDevAppCreateCommand(runner executor.Runner) *cobra.Command { Short: "创建开放平台企业内部应用", Example: " dws dev app create --name DemoApp --desc 内部应用 --dry-run --format json", Tool: devAppCreateTool, - Risk: LeafRiskHighWrite, - Safety: LeafSafetyHighWrite, + Safety: devAppSafetyHighWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ @@ -443,8 +443,7 @@ func newDevAppUpdateCommand(runner executor.Runner) *cobra.Command { Long: "修改开放平台企业内部应用基础信息:名称、描述或图标至少提供一项。", Example: " dws dev app update --unified-app-id UNIFIED_APP_ID --name DemoApp2 --dry-run --format json", Tool: devAppUpdateTool, - Risk: LeafRiskWrite, - Safety: LeafSafetyWrite, + Safety: devAppSafetyWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ @@ -478,6 +477,7 @@ func newDevAppCredentialsGetCommand(runner executor.Runner) *cobra.Command { Short: "读取开放平台应用凭证", Example: " dws dev app credentials get --unified-app-id UNIFIED_APP_ID --format json", Tool: devAppCredentialsGetTool, + Safety: devAppSafetyRead(), Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, }, @@ -502,8 +502,7 @@ func newDevAppLifecycleCommand(runner executor.Runner, use, short, tool string, Use: use, Short: short, Tool: tool, - Risk: LeafRiskWrite, - Safety: LeafSafetyWrite, + Safety: devAppSafetyWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ @@ -529,16 +528,14 @@ func newDevAppLifecycleCommand(runner executor.Runner, use, short, tool string, // (gh repo delete, gcloud). // // LeafSpec+RunE:confirm-name 的 get-then-compare 多步二次确认属多步编排, -// 由自定义 RunE 承载;确认门与 Schema 全部声明化(Risk/Schema),RunE 内手动 -// 调 cmdcore.ConfirmRisk(RunE 逃逸舱内框架不自动跑确认)。 +// 由自定义 RunE 承载;框架仍按同一 SafetySpec 执行确认门并发布 Schema。 func newDevAppDeleteCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "delete", Short: "删除开放平台企业内部应用(不可逆,需 --confirm-name 二次确认)", Example: " dws dev app delete --unified-app-id UNIFIED_APP_ID --confirm-name 应用名 --yes --format json", Tool: devAppDeleteTool, - Risk: LeafRiskHighWrite, - Safety: LeafSafetyDestructive, + Safety: devAppSafetyDestructive(), Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, {Name: "confirm-name", Usage: "二次确认:必须与被删应用的名称一致(不可逆操作的防误删)", Bind: "confirmName", Trim: true, OmitEmpty: true}, @@ -555,9 +552,6 @@ func newDevAppDeleteCommand(runner executor.Runner) *cobra.Command { }, }, RunE: func(cmd *cobra.Command, args []string) error { - if err := cmdcore.ConfirmRisk(cmd, cmdcore.RiskHighWrite); err != nil { - return err - } appID, err := requiredDevAppUnifiedID(cmd) if err != nil { return err @@ -636,6 +630,7 @@ func newDevAppWebappGetCommand(runner executor.Runner) *cobra.Command { Short: "查询网页应用配置", Example: " dws dev app webapp get --unified-app-id UNIFIED_APP_ID --format json", Tool: devAppWebappGetTool, + Safety: devAppSafetyRead(), Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, }, @@ -661,8 +656,7 @@ func newDevAppWebappConfigCommand(runner executor.Runner) *cobra.Command { Short: "配置网页应用能力", Example: " dws dev app webapp config --unified-app-id UNIFIED_APP_ID --homepage-url https://example.com --dry-run --format json", Tool: devAppWebappSetTool, - Risk: LeafRiskWrite, - Safety: LeafSafetyWrite, + Safety: devAppSafetyWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ @@ -697,6 +691,7 @@ func newDevAppPermissionListCommand(runner executor.Runner) *cobra.Command { Short: "查询开放平台应用权限列表", Example: " dws dev app permission list --unified-app-id UNIFIED_APP_ID --keyword 通讯录 --page-size 20 --format json", Tool: devAppPermissionListTool, + Safety: devAppSafetyRead(), // 命令级别名 "search" 由 PostMount 设回(LeafSpec 无 Command Aliases 字段)。 Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, @@ -731,8 +726,7 @@ func newDevAppPermissionAddCommand(runner executor.Runner) *cobra.Command { Short: "申请开放平台应用权限点", Example: " dws dev app permission add --unified-app-id UNIFIED_APP_ID --scope-values Contact.User.mobile --dry-run --format json", Tool: devAppPermissionAddTool, - Risk: LeafRiskWrite, - Safety: LeafSafetyWrite, + Safety: devAppSafetyWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ @@ -761,8 +755,7 @@ func newDevAppPermissionRemoveCommand(runner executor.Runner) *cobra.Command { Short: "取消开放平台应用权限点", Example: " dws dev app permission remove --unified-app-id UNIFIED_APP_ID --scope-values Contact.User.mobile,qyapi_robot_sendmsg --dry-run --format json", Tool: devAppPermissionRmTool, - Risk: LeafRiskWrite, - Safety: LeafSafetyWrite, + Safety: devAppSafetyWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ @@ -791,6 +784,7 @@ func newDevAppMemberListCommand(runner executor.Runner) *cobra.Command { Short: "查询开放平台应用成员", Example: " dws dev app member list --unified-app-id ", Tool: devAppMemberListTool, + Safety: devAppSafetyRead(), Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, }, @@ -816,8 +810,7 @@ func newDevAppMemberAddCommand(runner executor.Runner) *cobra.Command { Short: "添加开放平台应用成员", Example: " dws dev app member add --unified-app-id --user-ids userId1,userId2 --member-type DEVELOPER --dry-run", Tool: devAppMemberAddTool, - Risk: LeafRiskWrite, - Safety: LeafSafetyWrite, + Safety: devAppSafetyWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ @@ -855,8 +848,7 @@ func newDevAppMemberRemoveCommand(runner executor.Runner) *cobra.Command { Short: "移除开放平台应用成员", Example: " dws dev app member remove --unified-app-id --user-ids userId1,userId2 --member-type DEVELOPER --dry-run", Tool: devAppMemberRemoveTool, - Risk: LeafRiskWrite, - Safety: LeafSafetyWrite, + Safety: devAppSafetyWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ @@ -895,8 +887,7 @@ func newDevAppSecurityConfigCommand(runner executor.Runner) *cobra.Command { Example: " dws dev app security config --unified-app-id " + "--ip-whitelist 192.0.2.10 --redirect-urls https://callback.example.invalid/callback --sso-urls https://sso.example.invalid/sso --dry-run", Tool: devAppSecurityConfigTool, - Risk: LeafRiskWrite, - Safety: LeafSafetyWrite, + Safety: devAppSafetyWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ @@ -929,17 +920,15 @@ func newDevAppSecurityConfigCommand(runner executor.Runner) *cobra.Command { // --------------------------------------------------------------------------- // LeafSpec+RunE:devAppRobotCreateParams 的 icon/preview 空串占位与失败重试 -// taskId 编排由自定义 RunE 承载;确认门与 Schema 声明化,RunE 内手动 -// ConfirmRisk(RunE 逃逸舱内框架不自动跑确认与必填校验,必填仍由 -// devAppRobotCreateParams 报错,Required 标记仅用于 Schema/help 投影)。 +// taskId 编排由自定义 RunE 承载;框架按 SafetySpec 执行确认门,必填仍由 +// devAppRobotCreateParams 报错,Required 标记仅用于 Schema/help 投影。 func newDevAppRobotSubmitCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "submit", Short: "异步提交钉钉智能体机器人创建任务(支持失败重试)", Example: " dws dev app robot submit --name 我的智能体 --robot-name 小助手 --desc \"处理审批问答\" --dry-run --format json", Tool: devAppRobotSubmitTool, - Risk: LeafRiskHighWrite, - Safety: LeafSafetyHighWrite, + Safety: devAppSafetyHighWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ @@ -962,9 +951,6 @@ func newDevAppRobotSubmitCommand(runner executor.Runner) *cobra.Command { }, }, RunE: func(cmd *cobra.Command, args []string) error { - if err := cmdcore.ConfirmRisk(cmd, cmdcore.RiskWrite); err != nil { - return err - } params, err := devAppRobotCreateParams(cmd) if err != nil { return err @@ -990,6 +976,7 @@ func newDevAppRobotResultCommand(runner executor.Runner) *cobra.Command { Short: "查询机器人异步创建任务结果", Example: " dws dev app robot result --task-id TASK_ID --format json", Tool: devAppRobotResultTool, + Safety: devAppSafetyRead(), Flags: []LeafFlag{ {Name: "task-id", Usage: "提交创建任务时返回的 taskId (必填)", Bind: "taskId", Trim: true, Required: true, RequiredHint: "--task-id 为必填"}, }, @@ -1015,6 +1002,7 @@ func newDevAppRobotConfigGetCommand(runner executor.Runner) *cobra.Command { Short: "查询现有应用的机器人配置", Example: " dws dev app robot get --unified-app-id UNIFIED_APP_ID --format json", Tool: devAppRobotConfigGetTool, + Safety: devAppSafetyRead(), Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, }, @@ -1041,16 +1029,15 @@ func newDevAppRobotConfigGetCommand(runner executor.Runner) *cobra.Command { // `enable` (pure enable, no config fields) is a separate command. // // LeafSpec+RunE:devAppRobotConfigParams 的 mode enum 校验、Bool Changed 语义、 -// skills 列表、i18n JSON 解析与至少一项计数由自定义 RunE 承载;确认门与 -// Schema 声明化,RunE 内手动 ConfirmRisk。 +// skills 列表、i18n JSON 解析与至少一项计数由自定义 RunE 承载;框架按 +// SafetySpec 执行确认门并发布 Schema。 func newDevAppRobotConfigCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "config", Short: "创建或更新现有应用的机器人配置(upsert)", Example: " dws dev app robot config --unified-app-id UNIFIED_APP_ID --name 小助手 --brief 审批助手 --dry-run --format json", Tool: devAppRobotConfigUpsertTool, - Risk: LeafRiskWrite, - Safety: LeafSafetyWrite, + Safety: devAppSafetyWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ @@ -1081,9 +1068,6 @@ func newDevAppRobotConfigCommand(runner executor.Runner) *cobra.Command { }, }, RunE: func(cmd *cobra.Command, args []string) error { - if err := cmdcore.ConfirmRisk(cmd, cmdcore.RiskWrite); err != nil { - return err - } appID, err := requiredDevAppUnifiedID(cmd) if err != nil { return err @@ -1109,8 +1093,7 @@ func newDevAppRobotEnableCommand(runner executor.Runner) *cobra.Command { Short: "启用现有应用机器人能力(纯启用,无需配置字段)", Example: " dws dev app robot enable --unified-app-id UNIFIED_APP_ID --dry-run --format json", Tool: devAppRobotEnableTool, - Risk: LeafRiskWrite, - Safety: LeafSafetyWrite, + Safety: devAppSafetyWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ @@ -1138,8 +1121,7 @@ func newDevAppRobotOfflineCommand(runner executor.Runner) *cobra.Command { Short: "停用现有应用的机器人能力", Example: " dws dev app robot disable --unified-app-id UNIFIED_APP_ID --dry-run --format json", Tool: devAppRobotOfflineTool, - Risk: LeafRiskWrite, - Safety: LeafSafetyWrite, + Safety: devAppSafetyWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ @@ -1252,8 +1234,7 @@ func newDevAppVersionCreateCommand(runner executor.Runner) *cobra.Command { Short: "基于当前配置创建应用新版本", Example: " dws dev app version create --unified-app-id UNIFIED_APP_ID --desc \"新增机器人能力\" --dry-run --format json", Tool: devAppVersionCreateTool, - Risk: LeafRiskHighWrite, - Safety: LeafSafetyHighWrite, + Safety: devAppSafetyHighWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ @@ -1283,6 +1264,7 @@ func newDevAppVersionListCommand(runner executor.Runner) *cobra.Command { Short: "分页查询应用版本列表", Example: " dws dev app version list --unified-app-id UNIFIED_APP_ID --page-size 20 --format json", Tool: devAppVersionListTool, + Safety: devAppSafetyRead(), Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, }, @@ -1308,6 +1290,7 @@ func newDevAppVersionGetCommand(runner executor.Runner) *cobra.Command { Short: "查询指定版本详情", Example: " dws dev app version get --unified-app-id UNIFIED_APP_ID --version-id VERSION_ID --format json", Tool: devAppVersionDetailTool, + Safety: devAppSafetyRead(), Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, {Name: "version-id", Usage: "版本 ID (必填)", Bind: "versionId", Trim: true, Required: true, RequiredHint: "--version-id 为必填"}, @@ -1334,6 +1317,7 @@ func newDevAppVersionCheckApprovalCommand(runner executor.Runner) *cobra.Command Short: "预检版本发布是否需要审批(不实际发布)", Example: " dws dev app version check-approval --unified-app-id UNIFIED_APP_ID --version-id VERSION_ID --format json", Tool: devAppVersionPublishTool, + Safety: devAppSafetyRead(), Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, {Name: "version-id", Usage: "版本 ID (必填)", Bind: "versionId", Trim: true, Required: true, RequiredHint: "--version-id 为必填"}, @@ -1350,8 +1334,7 @@ func newDevAppVersionPublishCommand(runner executor.Runner) *cobra.Command { Short: "发布指定版本(含高敏权限需 --confirmed-sensitive)", Example: " dws dev app version publish --unified-app-id UNIFIED_APP_ID --version-id VERSION_ID --dry-run --format json", Tool: devAppVersionPublishTool, - Risk: LeafRiskHighWrite, - Safety: LeafSafetyDestructive, + Safety: devAppSafetyDestructive(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ @@ -1383,6 +1366,7 @@ func newDevAppVersionStatusCommand(runner executor.Runner) *cobra.Command { Short: "查询版本发布/审批状态", Example: " dws dev app version status --unified-app-id UNIFIED_APP_ID --version-id VERSION_ID --format json", Tool: devAppVersionStatusTool, + Safety: devAppSafetyRead(), Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, {Name: "version-id", Usage: "版本 ID (必填)", Bind: "versionId", Trim: true, Required: true, RequiredHint: "--version-id 为必填"}, @@ -1452,8 +1436,35 @@ func devAppMeta(tool string) func(*cobra.Command) { // InterfaceDecl.Reason 的最终发布值。 const devAppCompositeInterfaceReason = "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command." -// devapp 不写共享 Safety 常量:Safety 元数据由各叶的 LeafSafety 枚举驱动 -// (读叶留空由框架按 LeafRiskRead 推导),字段值全部来自 cmdcore 的 tier 填充。 +// devapp 的 SafetySpec 直接对齐 Agent Runtime Schema。函数每次返回一个值 +// 副本,避免共享可变状态;四个字段彼此独立,不从 effect 或 risk 推导。 +func devAppSafetyRead() cli.SafetySpec { + return cli.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + } +} + +func devAppSafetyWrite() cli.SafetySpec { + return cli.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "user_required", Idempotency: "unknown", + } +} + +func devAppSafetyHighWrite() cli.SafetySpec { + return cli.SafetySpec{ + Effect: "write", Risk: "high", + Confirmation: "user_required", Idempotency: "unknown", + } +} + +func devAppSafetyDestructive() cli.SafetySpec { + return cli.SafetySpec{ + Effect: "destructive", Risk: "high", + Confirmation: "user_required", Idempotency: "unknown", + } +} // devAppDryRun 是 devapp 全树共用的 dry_run 最终声明:本地拼装调用预览 // (invocation preview),dry-run 不发起任何远端读。 @@ -1974,7 +1985,7 @@ func transformDevAppScopeValues(raw string) (any, error) { // 应用定位:写操作统一只用 --unified-app-id;dev app get 额外支持只读 --app-key。 // --name 定位已下线(列表搜索的 --name/--app-key 是过滤参数、不在此列)。 -// 写叶确认门已声明化为 LeafRiskWrite/LeafRiskHighWrite + 框架 ConfirmRisk。 +// 写叶确认门已声明化为 SafetySpec.Confirmation=user_required。 func devAppStringFlag(cmd *cobra.Command, name string) string { value, _ := cmd.Flags().GetString(name) diff --git a/internal/helpers/devapp_leaf_test.go b/internal/helpers/devapp_leaf_test.go index 2c55bd90..7d014d2b 100644 --- a/internal/helpers/devapp_leaf_test.go +++ b/internal/helpers/devapp_leaf_test.go @@ -79,7 +79,7 @@ func TestDevAppLifecycleLeafWriteGuardAndArgs(t *testing.T) { if err := cmd.Flags().Set("unified-app-id", "APP-9"); err != nil { t.Fatal(err) } - // 无 --yes / --dry-run:LeafRiskWrite 声明的 ConfirmRisk 必须拦下。 + // 无 --yes / --dry-run:SafetySpec 的 user_required 确认必须拦下。 if err := cmd.RunE(cmd, nil); err == nil { t.Fatal("RunE() without --yes: want write-guard error, got nil") } diff --git a/internal/helpers/devapp_safety_homology_test.go b/internal/helpers/devapp_safety_homology_test.go index 33d01dd4..604ea741 100644 --- a/internal/helpers/devapp_safety_homology_test.go +++ b/internal/helpers/devapp_safety_homology_test.go @@ -21,8 +21,8 @@ import ( "github.com/spf13/cobra" ) -// Homology §1.1: confirmation facts are Contract-declared (Risk) or explicitly -// annotated (runtime_gate). Write-guard leaves must not rely on inference alone. +// Homology §1.1: confirmation facts are Contract-declared SafetySpec values or +// explicitly annotated runtime gates. Write leaves must not rely on inference. func TestDevAppWriteLeavesDeclareOrAnnotateConfirmation(t *testing.T) { root := newDevAppCommand(&captureRunner{}) want := map[string]struct{}{ @@ -58,16 +58,16 @@ func TestDevAppWriteLeavesDeclareOrAnnotateConfirmation(t *testing.T) { walk(child, path) continue } - // devapp 全树已声明化:确认事实只能来自 Contract Risk, + // devapp 全树已声明化:确认事实只能来自 Contract SafetySpec, // 任何 runtime_gate 标注都是回归(声明 > 标注,旧路径专用)。 if gate, hasGate := cli.RuntimeContractGate(child); hasGate { - t.Errorf("%s: unexpected runtime_gate %q; devapp leaves declare Contract Risk", path, gate) + t.Errorf("%s: unexpected runtime_gate %q; devapp leaves declare Contract SafetySpec", path, gate) } if _, ok := want[path]; !ok { continue } if !cli.HasDeclaredOrAnnotatedConfirmation(child) { - t.Errorf("%s: missing Contract Risk or runtime_gate annotation", path) + t.Errorf("%s: missing Contract SafetySpec or runtime_gate annotation", path) } delete(want, path) } diff --git a/internal/helpers/devapp_test.go b/internal/helpers/devapp_test.go index d4b82f43..fe4a5070 100644 --- a/internal/helpers/devapp_test.go +++ b/internal/helpers/devapp_test.go @@ -343,7 +343,7 @@ func TestDevAppRobotAndVersionWritesRequireGuard(t *testing.T) { if err == nil { t.Fatal("Execute() error = nil, want write guard") } - if !strings.Contains(err.Error(), "写操作") { + if !strings.Contains(err.Error(), "需要用户确认") { t.Fatalf("error = %q, want write guard", err.Error()) } if runner.last.Tool != "" { @@ -1010,7 +1010,7 @@ func TestDevAppMemberAndSecurityRequireWriteGuard(t *testing.T) { if err == nil { t.Fatal("Execute() error = nil, want write guard") } - if !strings.Contains(err.Error(), "写操作") { + if !strings.Contains(err.Error(), "需要用户确认") { t.Fatalf("error = %q, want write guard", err.Error()) } if runner.last.Tool != "" { @@ -1064,7 +1064,7 @@ func TestEveryDevAppWriteCommandRequiresGuard(t *testing.T) { if appErr.Reason != "confirmation_required" { t.Fatalf("error reason = %q, want confirmation_required", appErr.Reason) } - for _, marker := range []string{"写操作", "--dry-run", "--yes"} { + for _, marker := range []string{"需要用户确认", "--dry-run", "--yes"} { if !strings.Contains(err.Error(), marker) { t.Fatalf("error = %q, want %q write-guard marker", err.Error(), marker) } diff --git a/internal/helpers/leaf.go b/internal/helpers/leaf.go index c84c2ac6..e8163e03 100644 --- a/internal/helpers/leaf.go +++ b/internal/helpers/leaf.go @@ -16,6 +16,7 @@ package helpers import ( "github.com/spf13/cobra" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cmdcore" ) @@ -23,7 +24,7 @@ import ( // // 声明 vs 执行(框架规则,详见 RFC §5.0 / 同源文档 §1.2): // -// - 声明 = LeafSpec 数据字段:Flags、Constraints、非空 Risk、ConstParams、 +// - 声明 = LeafSpec 数据字段:Flags、Constraints、Safety、ConstParams、 // Use/Short/Long/Example。经 FromLeafSpec → cmdcore.NewCommand 注册并 // 嵌入 dws.schema.*。 // - 执行 = Validate / Call / RunE / PostMount。钩子消费已装配参数,不得 @@ -34,7 +35,7 @@ import ( // 装配与投影。默认派发走 MCP 直连;非 MCP 命令通过 Call 注入派发器。复杂 // 命令可用 RunE 逃生舱。 // -// 收敛纪律(Phase 2):flag 注册、有效值回退链、required/约束、Risk 写确认、 +// 收敛纪律(Phase 2):flag 注册、有效值回退链、required/约束、Safety 确认、 // toolArgs 装配、Schema 投影与帮助渲染均在 internal/cmdcore;本文件只做 // LeafSpec→CommandSpec 映射与 dispatch 闭包。等价性由 catalog 漂移门禁与 // leaf/risk/约束单测共同兜底。 @@ -62,34 +63,6 @@ const ( // (cmdcore.FlagSpec 的别名,字段含义见 cmdcore 定义)。 type LeafFlag = cmdcore.FlagSpec -// LeafRisk 声明叶子命令的副作用等级(cmdcore.Risk 的别名)。取值与 shortcut -// 框架的 Risk 逐字一致。 -type LeafRisk = cmdcore.Risk - -const ( - // LeafRiskRead 只读操作,从不提示。空值即视为 LeafRiskRead。 - LeafRiskRead = cmdcore.RiskRead - // LeafRiskWrite 变更状态;未加 --yes 时提示确认。 - LeafRiskWrite = cmdcore.RiskWrite - // LeafRiskHighWrite 破坏性/不可逆操作;未加 --yes 时提示确认。 - LeafRiskHighWrite = cmdcore.RiskHighWrite -) - -// LeafSafety 声明 Schema 安全元数据等级(cmdcore.Safety 的别名)。独立于 -// Risk(运行时确认),描述操作对系统状态的影响程度;空值时框架从 Risk 推导。 -type LeafSafety = cmdcore.Safety - -const ( - // LeafSafetyRead 只读操作:read/low/not_required/idempotent。 - LeafSafetyRead = cmdcore.SafetyRead - // LeafSafetyWrite 可逆写操作:write/medium/user_required/unknown。 - LeafSafetyWrite = cmdcore.SafetyWrite - // LeafSafetyHighWrite 难撤回写操作:write/high/user_required/unknown。 - LeafSafetyHighWrite = cmdcore.SafetyHighWrite - // LeafSafetyDestructive 破坏性操作:destructive/high/user_required/unknown。 - LeafSafetyDestructive = cmdcore.SafetyDestructive -) - // LeafConstraintKind 是跨 flag 关系约束的类型(cmdcore.ConstraintKind 的 // 别名)。取值与 shortcut 框架的 ConstraintKind 逐字一致。 type LeafConstraintKind = cmdcore.ConstraintKind @@ -115,7 +88,6 @@ type LeafSchema = cmdcore.SchemaDecl // SchemaDecl 嵌套类型别名(与 LeafSchema 配套使用)。 type ( LeafPositionalDecl = cmdcore.PositionalDecl - LeafSafetyDecl = cmdcore.SafetyDecl LeafDryRunDecl = cmdcore.DryRunDecl LeafInterfaceDecl = cmdcore.InterfaceDecl LeafSelectionDecl = cmdcore.SelectionDecl @@ -125,7 +97,7 @@ type ( // LeafSpec 是命令框架的 Leaf 声明门面(映射为 cmdcore.CommandSpec)。 // // 声明面 = Schema 最终数据源:Flags(含 parameter Schema 字段)、Constraints、 -// Risk、ConstParams、Use/Short/Long/Example、Schema(ToolSpec 各组)。 +// Safety、ConstParams、Use/Short/Long/Example、Schema(ToolSpec 各组)。 // Schema 组装透传嵌入值,声明路径不再引入评审并行字段。 // // 执行面(不算声明):Validate、Call、RunE、PostMount;Server/Tool 仅路由。 @@ -145,14 +117,9 @@ type LeafSpec struct { // 仍放 Validate 钩子(钩子本身不是约束声明)。 Constraints []LeafConstraint - // Risk 声明副作用等级,驱动 ConfirmRisk(对齐 shortcut)。非空时嵌入 - // dws.schema.risk 并合成 Schema.Safety。 - Risk LeafRisk - - // Safety 声明 Schema 安全元数据等级,独立于运行时 Risk。为空时框架从 - // Risk 推导(Risk.SafetyDefault);显式设置时 Safety 驱动 Schema 元数据, - // Risk 仍驱动运行时确认行为。 - Safety LeafSafety + // Safety 直接使用 Agent Runtime Schema 的安全模型。Confirmation 驱动 + // 运行时确认,其余字段原样进入 Schema;字段之间不做机械推导。 + Safety cli.SafetySpec // ConfirmFirst 为 true 时确认门先于 required/约束/Validate 校验执行 //(devapp 旧版写守卫语义:写命令未带 --yes 时快速失败 @@ -185,7 +152,7 @@ type LeafSpec struct { // NewLeafCommand 按 LeafSpec 构建叶子命令:经 FromLeafSpec 归一为 // cmdcore.CommandSpec 后交由统一构建器 cmdcore.NewCommand 编排(flag 注册、 -// 约束声明检查、Schema 投影、帮助渲染、required/约束校验、Risk 写确认、 +// 约束声明检查、Schema 投影、帮助渲染、required/约束校验、Safety 确认、 // toolArgs 装配)。本函数只保留 LeafSpec→CommandSpec 的映射与 MCP dispatch // 闭包(callMCPTool/OnServer/Call)。所有 LeafSpec 命令(含 devapp 全部叶子) // 由此统一流经 cmdcore 单一 spec 路径。 @@ -194,7 +161,7 @@ func NewLeafCommand(spec LeafSpec) *cobra.Command { } // FromLeafSpec 把 LeafSpec 归一为统一的 cmdcore.CommandSpec。契约字段 -// (Flags/Constraints/Risk)与编排钩子(Validate/PostMount/RunE)直接透传; +// (Flags/Constraints/Safety)与编排钩子(Validate/PostMount/RunE)直接透传; // dispatch 收敛为一个闭包:Call 优先,其次显式 Server 路由,最后按 product // 自动路由。RunE 逃生舱存在时不设 Dispatch(与旧行为一致)。 func FromLeafSpec(spec LeafSpec) cmdcore.CommandSpec { @@ -205,7 +172,6 @@ func FromLeafSpec(spec LeafSpec) cmdcore.CommandSpec { Example: spec.Example, Flags: spec.Flags, Constraints: spec.Constraints, - Risk: spec.Risk, Safety: spec.Safety, ConfirmFirst: spec.ConfirmFirst, ConstParams: spec.ConstParams, diff --git a/internal/helpers/leaf_constraints_test.go b/internal/helpers/leaf_constraints_test.go index b3f6edcb..7cc49c20 100644 --- a/internal/helpers/leaf_constraints_test.go +++ b/internal/helpers/leaf_constraints_test.go @@ -23,6 +23,7 @@ import ( "github.com/spf13/cobra" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cmdcore" ) @@ -479,13 +480,34 @@ func TestCrossPlatformCoverageLeafSpecCorePaths(t *testing.T) { var errTransformTest = errors.New("transform failed") -func leafRiskSpec(risk LeafRisk, called *bool) LeafSpec { +func leafTestReadSafety() cli.SafetySpec { + return cli.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + } +} + +func leafTestWriteSafety() cli.SafetySpec { + return cli.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "user_required", Idempotency: "unknown", + } +} + +func leafTestDestructiveSafety() cli.SafetySpec { + return cli.SafetySpec{ + Effect: "destructive", Risk: "high", + Confirmation: "user_required", Idempotency: "unknown", + } +} + +func leafSafetySpec(safety cli.SafetySpec, called *bool) LeafSpec { return LeafSpec{ - Use: "danger", - Short: "危险", - Tool: "danger_thing", - Risk: risk, - Flags: []LeafFlag{{Name: "id", Usage: "ID"}}, + Use: "danger", + Short: "危险", + Tool: "danger_thing", + Safety: safety, + Flags: []LeafFlag{{Name: "id", Usage: "ID"}}, Call: func(*cobra.Command, string, map[string]any) error { *called = true return nil @@ -493,10 +515,10 @@ func leafRiskSpec(risk LeafRisk, called *bool) LeafSpec { } } -func leafRiskRun(t *testing.T, risk LeafRisk, stdin string, args ...string) (bool, error) { +func leafSafetyRun(t *testing.T, safety cli.SafetySpec, stdin string, args ...string) (bool, error) { t.Helper() called := false - cmd := NewLeafCommand(leafRiskSpec(risk, &called)) + cmd := NewLeafCommand(leafSafetySpec(safety, &called)) // 注入根级 --yes/--dry-run 持久 flag,模拟真实根命令。 cmd.PersistentFlags().Bool("yes", false, "") cmd.PersistentFlags().Bool("dry-run", false, "") @@ -509,15 +531,15 @@ func leafRiskRun(t *testing.T, risk LeafRisk, stdin string, args ...string) (boo return called, cmd.Execute() } -// TestCrossPlatformCoverageLeafRiskUnderRootGlobalFlags mirrors the production +// TestCrossPlatformCoverageLeafSafetyUnderRootGlobalFlags mirrors the production // shape: the ROOT command owns the global --yes/--dry-run persistent flags and // the leaf is a child, so the inherited / root-persistent lookup path is what -// resolves them (leafRiskRun registers them on the leaf itself, where +// resolves them (leafSafetyRun registers them on the leaf itself, where // cmd.Root() == cmd). -func TestCrossPlatformCoverageLeafRiskUnderRootGlobalFlags(t *testing.T) { +func TestCrossPlatformCoverageLeafSafetyUnderRootGlobalFlags(t *testing.T) { run := func(globalFlag string, stdin string) (bool, error) { called := false - leaf := NewLeafCommand(leafRiskSpec(LeafRiskHighWrite, &called)) + leaf := NewLeafCommand(leafSafetySpec(leafTestDestructiveSafety(), &called)) root := &cobra.Command{Use: "dws"} root.PersistentFlags().Bool("yes", false, "") root.PersistentFlags().Bool("dry-run", false, "") @@ -553,20 +575,20 @@ func TestCrossPlatformCoverageLeafRiskUnderRootGlobalFlags(t *testing.T) { } } -func TestCrossPlatformCoverageLeafRiskReadNeverPrompts(t *testing.T) { +func TestCrossPlatformCoverageLeafSafetyReadNeverPrompts(t *testing.T) { // 只读:无 stdin 也直接派发。 - if called, err := leafRiskRun(t, LeafRiskRead, "", "--id", "x"); err != nil || !called { - t.Fatalf("read risk err = %v called = %v", err, called) + if called, err := leafSafetyRun(t, leafTestReadSafety(), "", "--id", "x"); err != nil || !called { + t.Fatalf("read safety err = %v called = %v", err, called) } - // 空 Risk 等价只读。 - if called, err := leafRiskRun(t, "", "", "--id", "x"); err != nil || !called { - t.Fatalf("empty risk err = %v called = %v", err, called) + // 空 Safety 保留只读默认。 + if called, err := leafSafetyRun(t, cli.SafetySpec{}, "", "--id", "x"); err != nil || !called { + t.Fatalf("empty safety err = %v called = %v", err, called) } } -func TestCrossPlatformCoverageLeafRiskWriteConfirmation(t *testing.T) { +func TestCrossPlatformCoverageLeafSafetyWriteConfirmation(t *testing.T) { // 拒绝:不派发,返回取消错误。 - called, err := leafRiskRun(t, LeafRiskWrite, "no\n", "--id", "x") + called, err := leafSafetyRun(t, leafTestWriteSafety(), "no\n", "--id", "x") if called { t.Fatal("declined write should not dispatch") } @@ -574,22 +596,22 @@ func TestCrossPlatformCoverageLeafRiskWriteConfirmation(t *testing.T) { t.Fatalf("declined write err = %v", err) } // 同意:派发。 - if called, err := leafRiskRun(t, LeafRiskWrite, "yes\n", "--id", "x"); err != nil || !called { + if called, err := leafSafetyRun(t, leafTestWriteSafety(), "yes\n", "--id", "x"); err != nil || !called { t.Fatalf("confirmed write err = %v called = %v", err, called) } // 高危同样走确认链。 - if called, err := leafRiskRun(t, LeafRiskHighWrite, "y\n", "--id", "x"); err != nil || !called { + if called, err := leafSafetyRun(t, leafTestDestructiveSafety(), "y\n", "--id", "x"); err != nil || !called { t.Fatalf("confirmed high-write err = %v called = %v", err, called) } } -func TestCrossPlatformCoverageLeafRiskYesAndDryRunBypass(t *testing.T) { +func TestCrossPlatformCoverageLeafSafetyYesAndDryRunBypass(t *testing.T) { // --yes 跳过提示直接派发(无 stdin)。 - if called, err := leafRiskRun(t, LeafRiskHighWrite, "", "--id", "x", "--yes"); err != nil || !called { + if called, err := leafSafetyRun(t, leafTestDestructiveSafety(), "", "--id", "x", "--yes"); err != nil || !called { t.Fatalf("--yes bypass err = %v called = %v", err, called) } // --dry-run 跳过提示直接派发(无 stdin)。 - if called, err := leafRiskRun(t, LeafRiskWrite, "", "--id", "x", "--dry-run"); err != nil || !called { + if called, err := leafSafetyRun(t, leafTestWriteSafety(), "", "--id", "x", "--dry-run"); err != nil || !called { t.Fatalf("--dry-run bypass err = %v called = %v", err, called) } } @@ -618,7 +640,7 @@ func TestCrossPlatformCoverageLeafYesFlagAndIntEdges(t *testing.T) { // 写风险但全局无 --yes flag 注册:leafYesFlag 各 getter 均报错走兜底 false, // 于是进入提示;stdin "no" 取消。 called := false - noYes := NewLeafCommand(leafRiskSpec(LeafRiskWrite, &called)) + noYes := NewLeafCommand(leafSafetySpec(leafTestWriteSafety(), &called)) noYes.SilenceErrors = true noYes.SilenceUsage = true noYes.SetIn(strings.NewReader("no\n")) diff --git a/internal/helpers/leaf_policy_test.go b/internal/helpers/leaf_policy_test.go index c4c25d6b..7cb03e21 100644 --- a/internal/helpers/leaf_policy_test.go +++ b/internal/helpers/leaf_policy_test.go @@ -97,13 +97,12 @@ func TestLeafSpecCallDoesNotAssembleBusinessParams(t *testing.T) { } // TestLeafSpecNoLegacyWriteGuard pins the migration end-state: devapp leaves -// declare Contract Risk (ConfirmRisk by the framework, or a manual call inside -// a RunE escape hatch); the legacy devAppRequireWriteGuard helper is gone and -// must not be reintroduced in LeafSpec Validate hooks. +// declare SafetySpec.Confirmation and the framework applies it even around a +// RunE escape hatch; the legacy devAppRequireWriteGuard helper is gone. func TestLeafSpecNoLegacyWriteGuard(t *testing.T) { for _, call := range leafSpecCompositeLits(t) { if validate := fieldExpr(call, "Validate"); validate != nil && astCallsIdent(validate, "devAppRequireWriteGuard") { - t.Errorf("%s: LeafSpec must not call devAppRequireWriteGuard; declare Risk instead", call.pos) + t.Errorf("%s: LeafSpec must not call devAppRequireWriteGuard; declare Safety.Confirmation instead", call.pos) } } } diff --git a/internal/shortcut/adapter.go b/internal/shortcut/adapter.go index 5f3e377d..07293e73 100644 --- a/internal/shortcut/adapter.go +++ b/internal/shortcut/adapter.go @@ -17,15 +17,16 @@ import ( "fmt" "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cmdcore" ) // adapter.go bridges the Shortcut framework toward the unified cmdcore typed -// spec. FromShortcut maps a Shortcut's SHARED BASE (flags, constraints, risk, +// spec. FromShortcut maps a Shortcut's SHARED BASE (flags, constraints, safety, // help identity) into a cmdcore.CommandSpec so both frameworks can eventually -// share one flag + constraint + risk base. +// share one flag + constraint + safety base. // // Scope (Phase 2): this is the typed seam only — it is NOT wired into the live // mount() path. The Shortcut runtime keeps its own mount/RuntimeContext/Execute @@ -67,7 +68,7 @@ func FromShortcut(s Shortcut) cmdcore.CommandSpec { Long: shortcutIntentProse(s), Flags: fromShortcutFlags(s.Flags), Constraints: fromShortcutConstraints(s.Constraints), - Risk: cmdcore.Risk(s.risk()), + Safety: shortcutSafetySpec(s.risk()), // Multi-step body: cmdcore stays backend-agnostic, so the shortcut's own // RuntimeContext — which owns CallMCPData/CallMCPWriteData/Output — is // built here from the Ctx's command. @@ -81,6 +82,29 @@ func FromShortcut(s Shortcut) cmdcore.CommandSpec { } } +// shortcutSafetySpec is the temporary compatibility boundary while the live +// Shortcut framework still owns its legacy Risk enum. cmdcore and Leaf do not +// retain that enum: the adapter expands it once into the existing Schema model. +func shortcutSafetySpec(risk Risk) cli.SafetySpec { + switch risk { + case RiskWrite: + return cli.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "user_required", Idempotency: "unknown", + } + case RiskHighWrite: + return cli.SafetySpec{ + Effect: "destructive", Risk: "high", + Confirmation: "user_required", Idempotency: "unknown", + } + default: + return cli.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + } + } +} + // shortcutIntentProse returns just the intent/description prose that precedes // the 参数约束 section in shortcutLongHelp, so the constraint section is rendered // exactly once (by cmdcore.NewCommand). diff --git a/internal/shortcut/adapter_test.go b/internal/shortcut/adapter_test.go index 31ca0258..7e3749c9 100644 --- a/internal/shortcut/adapter_test.go +++ b/internal/shortcut/adapter_test.go @@ -58,8 +58,9 @@ func TestCrossPlatformCoverageFromShortcutMapsSharedBase(t *testing.T) { if strings.Contains(cs.Long, "参数约束") { t.Fatalf("Long must not pre-render 参数约束: %q", cs.Long) } - if cs.Risk != cmdcore.RiskHighWrite { - t.Fatalf("risk = %q, want high-risk-write", cs.Risk) + if cs.Safety.Effect != "destructive" || cs.Safety.Risk != "high" || + cs.Safety.Confirmation != "user_required" || cs.Safety.Idempotency != "unknown" { + t.Fatalf("adapter safety = %#v, want destructive/high/user_required/unknown", cs.Safety) } if cs.Orchestrate == nil { t.Fatal("multi-step Execute must project into Orchestrate") @@ -165,7 +166,8 @@ func TestCrossPlatformCoverageFromShortcutEmpty(t *testing.T) { if cs.Constraints != nil { t.Fatalf("empty constraints = %#v, want nil", cs.Constraints) } - if cs.Risk != cmdcore.RiskRead { - t.Fatalf("default risk = %q, want read", cs.Risk) + if cs.Safety.Effect != "read" || cs.Safety.Risk != "low" || + cs.Safety.Confirmation != "not_required" || cs.Safety.Idempotency != "idempotent" { + t.Fatalf("default safety = %#v, want read/low/not_required/idempotent", cs.Safety) } } diff --git a/scripts/policy/check-runtime-confirmation-truth.sh b/scripts/policy/check-runtime-confirmation-truth.sh index 753022a5..bcaddb32 100755 --- a/scripts/policy/check-runtime-confirmation-truth.sh +++ b/scripts/policy/check-runtime-confirmation-truth.sh @@ -1,8 +1,9 @@ #!/bin/sh set -eu -# Ensure catalog confirmation=user_required exactly matches metadata -# runtime_gate != none across schema_hints/metadata/*.json. +# Ensure catalog confirmation=user_required exactly matches executable truth: +# typed cmdcore Contract SafetySpec declarations plus migration-only metadata +# runtime_gate != none entries. ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" cd "$ROOT" @@ -27,7 +28,17 @@ jq -r ' | to_entries[] | select((.value.runtime_gate // "none") != "none") | .key -' "$metadata_dir"/*.json | sort -u >"$tmp/truth_gated" +' "$metadata_dir"/*.json >"$tmp/metadata_gated" + +jq -r ' + .tools + | to_entries[] + | select(.value.confirmation == "user_required") + | select(.value.field_provenance.confirmation.source == "cmdcore.contract") + | .key +' "$catalog" >"$tmp/contract_gated" + +cat "$tmp/metadata_gated" "$tmp/contract_gated" | sort -u >"$tmp/truth_gated" jq -r ' .tools @@ -37,8 +48,8 @@ jq -r ' ' "$catalog" | sort >"$tmp/catalog_required" if ! cmp -s "$tmp/truth_gated" "$tmp/catalog_required"; then - printf '%s\n' 'catalog confirmation=user_required differs from schema_hints/metadata runtime_gate!=none set' >&2 - printf '%s\n' 'update internal/cli/schema_hints/metadata/.json runtime_gate/confirmation, then regenerate schema' >&2 + printf '%s\n' 'catalog confirmation=user_required differs from Contract SafetySpec + metadata runtime_gate truth' >&2 + printf '%s\n' 'declare cmdcore Safety.Confirmation or update migration-only metadata runtime_gate, then regenerate schema' >&2 diff -u "$tmp/truth_gated" "$tmp/catalog_required" || true exit 1 fi @@ -63,6 +74,7 @@ jq -r --slurpfile catalog "$catalog" ' | .value as $gate | $tools[$canonical] as $tool | select($tool != null) + | select($tool.field_provenance.confirmation.source != "cmdcore.contract") | select( if $gate == "none" then $tool.confirmation != "not_required" or $tool.risk == "high" or $tool.effect == "destructive" diff --git a/scripts/policy/check-schema-catalog.sh b/scripts/policy/check-schema-catalog.sh index 979d5592..26775b23 100755 --- a/scripts/policy/check-schema-catalog.sh +++ b/scripts/policy/check-schema-catalog.sh @@ -144,7 +144,10 @@ if ! jq -e --arg registry_count "$registry_count" ' elif .interface_mode == "composite" then .interface_ref == null and ((.interface_reason // "") | length) > 0 else false end) and - (((.agent_source_refs // []) | map(test("schema_hints/selection/")) | any)) + ( + ((.agent_source_refs // []) | map(test("schema_hints/selection/")) | any) or + .field_provenance.agent_summary.source == "cmdcore.contract" + ) ) ' "$catalog" >/dev/null; then printf '%s\n' 'schema tools must have complete Agent summary/effect/safety metadata' >&2