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.
This commit is contained in:
修雨
2026-06-13 23:25:04 +08:00
parent c858241b68
commit fb335fb403
2 changed files with 466 additions and 0 deletions
+290
View File
@@ -0,0 +1,290 @@
// 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 "strings"
// CmdClass describes whether a dws command is read-only or mutating. 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).
//
// SAFETY CONTRACT: callers MUST treat CmdClassUnknown conservatively, i.e. as
// if it were CmdClassWrite (require confirmation). The classifier deliberately
// returns Unknown rather than silently coercing it to Write so that the
// confirmation gate keeps full information and can, for example, log/telemeter
// "unclassified" commands separately. Never auto-allow an Unknown command.
type CmdClass int
const (
// CmdClassUnknown means the leaf verb was not recognised by the
// heuristics or any override. Callers must default to requiring
// confirmation (treat as write) for safety.
CmdClassUnknown CmdClass = iota
// CmdClassRead is a read-only / non-mutating command that may be run
// without principal confirmation.
CmdClassRead
// CmdClassWrite is a mutating / state-changing command that must be
// confirmed by the principal before it runs.
CmdClassWrite
)
// String renders the class as a stable lowercase token, handy for logs and
// telemetry.
func (c CmdClass) String() string {
switch c {
case CmdClassRead:
return "read"
case CmdClassWrite:
return "write"
default:
return "unknown"
}
}
// readVerbs and writeVerbs are calibrated against the verbs that real dws
// cobra commands expose (scanned from `Use:` definitions across the repo, e.g.
// todo task create/list/get/update/delete/done, attendance get, approval
// submit, chat send / recall, drive upload / download / mkdir / chmod, doc
// export, contact search, etc). They are NOT invented: every entry below has
// at least one real command using it (or a "<verb>-by-..." / "batch-<verb>"
// compound that normalises to it).
var (
readVerbs = map[string]struct{}{
"list": {},
"get": {},
"search": {},
"read": {},
"view": {},
"query": {},
"show": {},
"detail": {},
"details": {},
"status": {},
"download": {},
"export": {},
"fetch": {},
"info": {},
"find": {},
"stats": {},
"stat": {},
"summary": {},
"check": {},
"inspect": {},
"doctor": {},
"version": {},
"whoami": {},
"ls": {},
"cat": {},
"describe": {},
"diff": {},
"preview": {},
}
writeVerbs = map[string]struct{}{
"create": {},
"update": {},
"delete": {},
"submit": {},
"send": {},
"done": {},
"cancel": {},
"offline": {},
"online": {},
"enable": {},
"disable": {},
"remove": {},
"add": {},
"set": {},
"unset": {},
"approve": {},
"reject": {},
"write": {},
"upload": {},
"move": {},
"mv": {},
"copy": {},
"cp": {},
"rename": {},
"reply": {},
"recall": {},
"publish": {},
"insert": {},
"import": {},
"install": {},
"uninstall": {},
"mkdir": {},
"share": {},
"unshare": {},
"commit": {},
"reset": {},
"stop": {},
"start": {},
"restart": {},
"hide": {},
"unhide": {},
"finalize": {},
"execute": {},
"run": {},
"exec": {},
"chmod": {},
"rm": {},
"clean": {},
"clear": {},
"refresh": {},
"recover": {},
"recovery": {},
"login": {},
"logout": {},
"register": {},
"connect": {},
"disconnect": {},
"upgrade": {},
"setup": {},
"generate": {},
"batch": {},
"apply": {},
"patch": {},
"put": {},
"post": {},
"sync": {},
"link": {},
"unlink": {},
"grant": {},
"revoke": {},
"assign": {},
"close": {},
"open": {},
"archive": {},
"restore": {},
"finish": {},
}
)
// defaultClassOverrides is the package-level override table. It is intentionally
// empty by default: heuristics are expected to cover the common case, and the
// override table exists only to correct individual misclassifications without
// touching the heuristic verb tables. Mutate it via SetCmdClassOverride.
//
// Keys may be either a single verb ("download") or a full space-joined command
// path ("doc export"); both are matched case-insensitively. A full-path key
// wins over a single-verb key when both are present.
var defaultClassOverrides = map[string]CmdClass{}
// SetCmdClassOverride registers a process-wide classification override for the
// given key. The key may be a single verb or a space-joined command path; it is
// normalised to lowercase. Passing CmdClassUnknown removes the override.
func SetCmdClassOverride(key string, class CmdClass) {
key = strings.ToLower(strings.TrimSpace(key))
if key == "" {
return
}
if class == CmdClassUnknown {
delete(defaultClassOverrides, key)
return
}
defaultClassOverrides[key] = class
}
// ClassifyDwsCommand classifies a dws command given its path segments (e.g.
// "todo", "task", "create"). It consults the package-level override table and
// then the read/write verb heuristics, scanning segments right-to-left so the
// leaf action verb dominates a container/noun segment.
//
// Remember the SAFETY CONTRACT on CmdClass: a CmdClassUnknown result MUST be
// treated as write (require confirmation) by the caller.
func ClassifyDwsCommand(parts ...string) CmdClass {
return ClassifyDwsCommandWith(defaultClassOverrides, parts...)
}
// ClassifyDwsCommandWith is like ClassifyDwsCommand but lets the caller supply
// an explicit override table (e.g. a per-tenant or per-request map) instead of
// the package-level one. A nil overrides map is allowed and means "no
// overrides". Override lookups always win over the heuristics.
func ClassifyDwsCommandWith(overrides map[string]CmdClass, parts ...string) CmdClass {
norm := normalizeParts(parts)
if len(norm) == 0 {
return CmdClassUnknown
}
// 1. Full-path override (most specific) wins.
if overrides != nil {
fullKey := strings.Join(norm, " ")
if c, ok := overrides[fullKey]; ok {
return c
}
}
// 2. Walk segments right-to-left: the leaf action verb is the most
// meaningful, but a leaf may be a noun/placeholder (e.g. an id) that
// we do not recognise, so fall back toward the root.
for i := len(norm) - 1; i >= 0; i-- {
seg := norm[i]
if overrides != nil {
if c, ok := overrides[seg]; ok {
return c
}
}
if c, ok := classifyVerb(seg); ok {
return c
}
}
return CmdClassUnknown
}
// classifyVerb classifies a single segment, handling compound verbs such as
// "send-by-bot", "list-forms", "batch-update" or "create_inline" by also
// trying the token before the first separator. Returns (class, true) when the
// segment maps to a known verb.
func classifyVerb(seg string) (CmdClass, bool) {
candidates := verbCandidates(seg)
for _, v := range candidates {
if _, ok := readVerbs[v]; ok {
return CmdClassRead, true
}
if _, ok := writeVerbs[v]; ok {
return CmdClassWrite, true
}
}
return CmdClassUnknown, false
}
// verbCandidates expands a segment into the tokens worth checking against the
// verb tables, in priority order: the whole segment first, then the leading
// token before the first '-' or '_' separator (so "batch-update" tries
// "update"-as-prefix... no: it tries "batch" first which is itself a write
// verb; and "send-by-bot" tries "send"). This handles both "<verb>-<qualifier>"
// and "<verb>_<qualifier>" compounds where the action sits at the front.
func verbCandidates(seg string) []string {
out := []string{seg}
if i := strings.IndexAny(seg, "-_"); i > 0 {
out = append(out, seg[:i])
}
return out
}
// normalizeParts lowercases, trims and drops empty/whitespace segments so that
// callers can pass raw argv-style slices without pre-cleaning them.
func normalizeParts(parts []string) []string {
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.ToLower(strings.TrimSpace(p))
if p == "" {
continue
}
out = append(out, p)
}
return out
}
+176
View File
@@ -0,0 +1,176 @@
// 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)
}
}
}