From fb335fb403e136e1863c4d329742abb28bd417fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Sat, 13 Jun 2026 23:25:04 +0800 Subject: [PATCH] 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. --- internal/helpers/connect_cmd_class.go | 290 +++++++++++++++++++++ internal/helpers/connect_cmd_class_test.go | 176 +++++++++++++ 2 files changed, 466 insertions(+) create mode 100644 internal/helpers/connect_cmd_class.go create mode 100644 internal/helpers/connect_cmd_class_test.go diff --git a/internal/helpers/connect_cmd_class.go b/internal/helpers/connect_cmd_class.go new file mode 100644 index 00000000..da702b61 --- /dev/null +++ b/internal/helpers/connect_cmd_class.go @@ -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 "-by-..." / "batch-" +// 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 "-" +// and "_" 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 +} diff --git a/internal/helpers/connect_cmd_class_test.go b/internal/helpers/connect_cmd_class_test.go new file mode 100644 index 00000000..c481fcbe --- /dev/null +++ b/internal/helpers/connect_cmd_class_test.go @@ -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) + } + } +}