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.
177 lines
6.5 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|