Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changes/1096-dangerous-command-confirmation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
category: Changed
---

- **Dangerous command confirmation** (#1096) — `calendar event delete`, `calendar attendee delete`, `calendar room delete`, `chat group members remove`, `minutes replace-text`, and `doc permission update` now require explicit confirmation and publish `high` risk in Schema. Event deletion and minutes text replacement also publish `destructive` effects. Non-interactive scripts must pass `--yes` after obtaining user approval; otherwise these commands return `confirmation_required` without calling the remote service. The mono skill's dangerous-operation table now uses the canonical `calendar attendee delete` path for Schema lookup.
4 changes: 2 additions & 2 deletions internal/app/param_alias_payload_equivalence_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ var paramAliasCompleteCommands = map[string][]string{
"calendar event suggest": {"calendar", "event", "suggest", "--users", "user-1,user-2", "--duration", "30", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--timezone", "Asia/Shanghai"},
"calendar event update": {"calendar", "event", "update", "--id", "event-1", "--timezone", "Asia/Shanghai"},
"calendar room add": {"calendar", "room", "add", "--event", "event-1", "--rooms", "room-1,room-2"},
"calendar room delete": {"calendar", "room", "delete", "--event", "event-1", "--rooms", "room-1,room-2"},
"calendar room delete": {"calendar", "room", "delete", "--event", "event-1", "--rooms", "room-1,room-2", "--yes"},
"calendar room search": {"calendar", "room", "search", "--room-name", "Fixture Room", "--group-id", "group-1", "--start", "2027-03-10T09:00:00+08:00", "--end", "2027-03-10T10:00:00+08:00", "--page", "1", "--limit", "7"},
"chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"},
"chat +chat-add-bot": {"chat", "+chat-add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
Expand Down Expand Up @@ -466,7 +466,7 @@ var paramAliasCandidateCompleteCommands = map[string][]string{
"minutes hot-word add": {"minutes", "hot-word", "add", "--words", "DWS,Minutes"},
"minutes list all": {"minutes", "list", "all", "--end", "2026-03-10T23:59:59+08:00"},
"minutes list mine": {"minutes", "list", "mine", "--start", "2026-03-10T00:00:00+08:00"},
"minutes replace-text": {"minutes", "replace-text", "--id", "u1", "--search", "old", "--replace", "new"},
"minutes replace-text": {"minutes", "replace-text", "--id", "u1", "--search", "old", "--replace", "new", "--yes"},
"minutes tag query": {"minutes", "tag", "query", "--tag-id", "tag-1"},
"minutes update title": {"minutes", "update", "title", "--id", "u1", "--title", "Fixture Minutes", "--yes"},
"minutes upload complete": {"minutes", "upload", "complete", "--session-id", "session-1"},
Expand Down
91 changes: 91 additions & 0 deletions internal/app/schema_confirmation_hardening_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.

package app

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
)

// Exercise the delivered leaf queries, including the compact view consumed by
// Agents, rather than only inspecting the source declarations or a subset fixture.
func TestCrossPlatformCoverageDangerousConfirmationFinalSchema(t *testing.T) {
root := NewRootCommand()
for _, test := range []struct {
path string
effect string
}{
{"calendar event delete", "destructive"},
{"calendar attendee delete", "write"},
{"calendar room delete", "write"},
{"chat group members remove", "write"},
{"minutes replace-text", "destructive"},
{"doc permission update", "write"},
} {
t.Run(test.path, func(t *testing.T) {
cmd := exactCommandForTest(root, test.path)
if cmd == nil || !helpers.HasContractConfirmSafety(cmd) {
t.Fatalf("%s has no declared runtime confirmation gate", test.path)
}
full := executeShortcutSchemaQuery(t, "--cli-path", test.path)
compact := executeShortcutSchemaQuery(t, "--cli-path", test.path, "--compact")
provenance := schemaContractMap(full["field_provenance"])
for field, want := range map[string]string{
"effect": test.effect, "risk": "high", "confirmation": "user_required", "idempotency": "unknown",
} {
if full[field] != want || compact[field] != want {
t.Errorf("%s: full=%#v compact=%#v, want %q", field, full[field], compact[field], want)
}
if p := provenance[field]; p["precedence"] != "contract_final" || p["value"] != want {
t.Errorf("%s winner differs from final delivery: %#v", field, p)
}
}
})
}
// The five destructive controls from #1096 retain their existing contract.
for _, path := range []string{"aitable base delete", "aitable record delete", "doc delete", "doc block delete", "todo task delete"} {
t.Run(path, func(t *testing.T) {
leaf := executeShortcutSchemaQuery(t, "--cli-path", path, "--compact")
if leaf["effect"] != "destructive" || leaf["confirmation"] != "user_required" {
t.Fatalf("control safety changed: effect=%#v confirmation=%#v", leaf["effect"], leaf["confirmation"])
}
})
}
}

func TestCrossPlatformCoverageMonoDangerousCalendarPathsAreCanonical(t *testing.T) {
content, err := os.ReadFile(filepath.Join("..", "..", "skills", "mono", "SKILL.md"))
if err != nil {
t.Fatal(err)
}
_, section, ok := strings.Cut(string(content), "## 危险操作确认\n")
if !ok {
t.Fatal("mono skill has no dangerous-operation table")
}
section, _, _ = strings.Cut(section, "### 确认流程")
root := NewRootCommand()
paths := map[string]bool{}
for _, line := range strings.Split(section, "\n") {
cells := strings.Split(line, "|")
if len(cells) < 4 || strings.TrimSpace(cells[1]) != "`calendar`" {
continue
}
path := "calendar " + strings.Trim(strings.TrimSpace(cells[2]), "`")
cmd := exactCommandForTest(root, path)
if cmd == nil || strings.TrimPrefix(cmd.CommandPath(), root.CommandPath()+" ") != path {
t.Errorf("dangerous-operation table must use a canonical CLI path: %s", path)
}
paths[path] = true
}
for _, path := range []string{"calendar event delete", "calendar attendee delete", "calendar room delete"} {
if !paths[path] {
t.Errorf("dangerous-operation table is missing %s", path)
}
}
}
10 changes: 5 additions & 5 deletions internal/app/schema_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -456,12 +456,12 @@ func TestCalendarAttendeeDeleteSchemaMatchesRuntimeGate(t *testing.T) {
root := NewRootCommand()
snapshot := schemaContractPayloadForBoundCanonicals(t, root, "calendar.remove_calendar_participant")
tool := snapshot.Tools["calendar.remove_calendar_participant"]
// Runtime does not gate this path today; Schema confirmation must follow metadata.runtime_gate.
if got := tool["confirmation"]; got != "not_required" {
t.Fatalf("calendar attendee delete confirmation = %#v, want not_required", got)
// Removing attendees affects others and is irreversible; confirmation must be user_required.
if got := tool["confirmation"]; got != "user_required" {
t.Fatalf("calendar attendee delete confirmation = %#v, want user_required", got)
}
if got := tool["risk"]; got != "medium" {
t.Fatalf("calendar attendee delete risk = %#v, want medium", got)
if got := tool["risk"]; got != "high" {
t.Fatalf("calendar attendee delete risk = %#v, want high", got)
}
}

Expand Down
2 changes: 1 addition & 1 deletion internal/app/schema_safety_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func TestMinutesP0ConfirmationPolicyReachesFinalSchema(t *testing.T) {
{canonical: "minutes.update_minutes_summary", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown"},
{canonical: "minutes.replace_speaker", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown"},
{canonical: "minutes.delete_personal_hotword", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown"},
{canonical: "minutes.replace_minutes_text", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown"},
{canonical: "minutes.replace_minutes_text", effect: "destructive", risk: "high", confirmation: "user_required", idempotency: "unknown"},
{canonical: "minutes.add_member_permission", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown"},
{canonical: "minutes.remove_member_permission", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown"},
{canonical: "minutes.apply_minutes_permission", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown"},
Expand Down
12 changes: 6 additions & 6 deletions internal/helpers/calendar.go
Original file line number Diff line number Diff line change
Expand Up @@ -500,8 +500,8 @@ eventId 可通过 dws calendar event list 查询。
}
DeclareLeafMetadata(eventDeleteCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
Effect: "destructive", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
Expand Down Expand Up @@ -829,8 +829,8 @@ eventId 可通过 dws calendar event list 查询。
}
DeclareLeafMetadata(participantDeleteCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
Expand Down Expand Up @@ -1120,8 +1120,8 @@ eventId 可通过 dws calendar event list 查询。
}
DeclareLeafMetadata(roomDeleteCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
Expand Down
4 changes: 2 additions & 2 deletions internal/helpers/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -3792,8 +3792,8 @@ func newChatCommand() *cobra.Command {
}
DeclareLeafMetadata(chatGroupMemberRemoveCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
Expand Down
9 changes: 9 additions & 0 deletions internal/helpers/chat_coverage_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,15 @@ func TestCrossPlatformCoverageChatCommandFinalBranches(t *testing.T) {
if err := cmd.Flags().Set("users", helperCurrentDOpenID); err != nil {
t.Fatal(err)
}
// 该用例验证的是群主删除保护,而非确认门禁;先显式越过 user_required
// 确认门禁(#1097),才能到达 owner guard 分支。RunE 为直接调用,
// persistent flag 需在 root 上注册并置位(BoolFlag 跨 flagset 取值)。
if root.PersistentFlags().Lookup("yes") == nil {
root.PersistentFlags().Bool("yes", false, "confirm high-risk operation")
}
if err := root.PersistentFlags().Set("yes", "true"); err != nil {
t.Fatal(err)
}
err = cmd.RunE(cmd, nil)
if err == nil || !strings.Contains(err.Error(), "refusing to remove the group owner") {
t.Fatalf("remove owner error = %v", err)
Expand Down
Loading
Loading