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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user