Files
dingtalk-workspace-cli/internal/helpers/connect_cmd_class_test.go
修雨 fb335fb403 feat(connect): add dws command read/write classifier
Add ClassifyDwsCommand, a standalone heuristic classifier that labels a
dws command (by path segments) as read / write / unknown. It is the
signal a connector confirmation gate consumes to decide whether a robot
may run a command directly (read-only) or must first ask the principal
to approve it (write / mutating).

- Read/write verb tables calibrated against real dws cobra Use: verbs
  scanned from the repo (list/get/search/status/export/download vs
  create/update/delete/send/done/upload/recall/mkdir/chmod ...).
- Segments scanned right-to-left so the leaf action verb dominates a
  container/placeholder segment; falls back toward the root when the
  leaf is unrecognised (e.g. a trailing id).
- Compound leaf verbs normalised by their leading token before the
  first '-'/'_' (send-by-bot, list-forms, batch-update, add-bot ...).
- Override mechanism: per-call map (ClassifyDwsCommandWith) and a
  process-wide table (SetCmdClassOverride), keyed by single verb or
  full space-joined path, full-path winning. Overrides beat heuristics.
- Unknown is returned losslessly; the CmdClass doc comment documents the
  safety contract that callers must treat Unknown conservatively as
  write (require confirmation), never auto-allow.

This is the classifier component only; wiring it into the confirmation
gate is a separate change.
2026-06-13 23:25:04 +08:00

177 lines
6.5 KiB
Go

// 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.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import "testing"
func TestClassifyDwsCommandReadVerbs(t *testing.T) {
readLeaves := []string{
"list", "get", "search", "read", "view", "query", "show",
"detail", "status", "download", "export", "fetch", "info",
"find", "summary",
}
for _, v := range readLeaves {
if got := ClassifyDwsCommand("todo", "task", v); got != CmdClassRead {
t.Errorf("verb %q: got %v, want CmdClassRead", v, got)
}
}
}
func TestClassifyDwsCommandWriteVerbs(t *testing.T) {
writeLeaves := []string{
"create", "update", "delete", "submit", "send", "done",
"cancel", "offline", "enable", "disable", "remove", "add",
"set", "approve", "reject", "write", "upload", "move", "copy",
}
for _, v := range writeLeaves {
if got := ClassifyDwsCommand("approval", "instance", v); got != CmdClassWrite {
t.Errorf("verb %q: got %v, want CmdClassWrite", v, got)
}
}
}
func TestClassifyDwsCommandUnknown(t *testing.T) {
cases := [][]string{
{},
{"todo"}, // container only, no action verb
{"todo", "task"}, // still only containers
{"frobnicate"}, // nonsense verb
{"todo", "task", "wibble"}, // unknown leaf, no known ancestor
{" ", ""}, // all empty after trim
}
for _, c := range cases {
if got := ClassifyDwsCommand(c...); got != CmdClassUnknown {
t.Errorf("parts %v: got %v, want CmdClassUnknown", c, got)
}
}
}
func TestClassifyDwsCommandRealSamples(t *testing.T) {
cases := []struct {
parts []string
want CmdClass
}{
// Real dws command paths sampled from the repo's cobra Use: defs.
{[]string{"todo", "task", "create"}, CmdClassWrite},
{[]string{"todo", "task", "list"}, CmdClassRead},
{[]string{"todo", "task", "get"}, CmdClassRead},
{[]string{"todo", "task", "update"}, CmdClassWrite},
{[]string{"todo", "task", "delete"}, CmdClassWrite},
{[]string{"todo", "task", "done"}, CmdClassWrite},
{[]string{"approval", "instance", "submit"}, CmdClassWrite},
{[]string{"attendance", "get"}, CmdClassRead},
{[]string{"chat", "message", "send"}, CmdClassWrite},
{[]string{"chat", "message", "list"}, CmdClassRead},
{[]string{"drive", "file", "download"}, CmdClassRead},
{[]string{"drive", "file", "upload"}, CmdClassWrite},
{[]string{"drive", "dir", "mkdir"}, CmdClassWrite},
{[]string{"doc", "export"}, CmdClassRead},
{[]string{"contact", "user", "search"}, CmdClassRead},
{[]string{"report", "status"}, CmdClassRead},
}
for _, c := range cases {
if got := ClassifyDwsCommand(c.parts...); got != c.want {
t.Errorf("cmd %v: got %v, want %v", c.parts, got, c.want)
}
}
}
func TestClassifyDwsCommandCompoundVerbs(t *testing.T) {
cases := []struct {
parts []string
want CmdClass
}{
// Compound leaf verbs that exist in the repo.
{[]string{"chat", "send-by-bot"}, CmdClassWrite},
{[]string{"chat", "send-by-webhook"}, CmdClassWrite},
{[]string{"chat", "recall-by-bot"}, CmdClassWrite},
{[]string{"aitable", "form", "list-forms"}, CmdClassRead},
{[]string{"wiki", "space", "list-spaces"}, CmdClassRead},
{[]string{"record", "batch-update"}, CmdClassWrite},
{[]string{"group", "add-bot"}, CmdClassWrite},
{[]string{"group", "remove-bot"}, CmdClassWrite},
}
for _, c := range cases {
if got := ClassifyDwsCommand(c.parts...); got != c.want {
t.Errorf("compound %v: got %v, want %v", c.parts, got, c.want)
}
}
}
func TestClassifyDwsCommandLeafFallbackToAncestor(t *testing.T) {
// When the true leaf is a placeholder/id segment we don't recognise,
// classification falls back to the nearest known ancestor verb.
if got := ClassifyDwsCommand("todo", "task", "get", "abc123"); got != CmdClassRead {
t.Errorf("trailing id segment: got %v, want CmdClassRead", got)
}
if got := ClassifyDwsCommand("todo", "task", "create", "xyz"); got != CmdClassWrite {
t.Errorf("trailing id segment: got %v, want CmdClassWrite", got)
}
}
func TestCmdClassOverrideTable(t *testing.T) {
// Suppose "download" should require confirmation for some tenant: a
// full-path override flips a normally-read command to write without
// touching the heuristic tables.
overrides := map[string]CmdClass{
"drive file download": CmdClassWrite, // full-path override
"export": CmdClassWrite, // single-verb override
}
if got := ClassifyDwsCommandWith(overrides, "drive", "file", "download"); got != CmdClassWrite {
t.Errorf("full-path override: got %v, want CmdClassWrite", got)
}
// A different path whose leaf is "download" is NOT affected by the
// full-path override and stays read.
if got := ClassifyDwsCommandWith(overrides, "media", "download"); got != CmdClassRead {
t.Errorf("unrelated download path: got %v, want CmdClassRead", got)
}
// Single-verb override flips every "export" leaf.
if got := ClassifyDwsCommandWith(overrides, "doc", "export"); got != CmdClassWrite {
t.Errorf("single-verb override: got %v, want CmdClassWrite", got)
}
}
func TestSetCmdClassOverrideProcessWide(t *testing.T) {
defer SetCmdClassOverride("status", CmdClassUnknown) // cleanup
// Baseline: status is read.
if got := ClassifyDwsCommand("report", "status"); got != CmdClassRead {
t.Fatalf("baseline status: got %v, want CmdClassRead", got)
}
// Register a process-wide override making status write.
SetCmdClassOverride("STATUS", CmdClassWrite) // case-insensitive key
if got := ClassifyDwsCommand("report", "status"); got != CmdClassWrite {
t.Errorf("after override: got %v, want CmdClassWrite", got)
}
// Removing the override restores the heuristic result.
SetCmdClassOverride("status", CmdClassUnknown)
if got := ClassifyDwsCommand("report", "status"); got != CmdClassRead {
t.Errorf("after override removal: got %v, want CmdClassRead", got)
}
}
func TestCmdClassString(t *testing.T) {
cases := map[CmdClass]string{
CmdClassRead: "read",
CmdClassWrite: "write",
CmdClassUnknown: "unknown",
}
for c, want := range cases {
if got := c.String(); got != want {
t.Errorf("String(%d): got %q, want %q", c, got, want)
}
}
}