mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
feat(cmdpolicy): enrich denial Reason with attempted value + rule constraint
The envelope reason for command_denied previously told the caller WHAT
axis failed but not the concrete values on each side, so an AI agent
reading the envelope could not tell which command identity / risk /
path was attempted vs. which the rule permits. The natural temptation
was then to recommend modifying the rule -- exactly the wrong nudge,
since policy exists to prevent the agent from rewriting its own limits.
Each Reason now carries both the attempted value and the rule's
constraint:
identity_mismatch:
"command supports identities [user]; rule allows [bot]"
domain_not_allowed:
"command path \"drive/+upload\" not in allow list [docs/** contact/**]"
command_denylisted:
"command path \"docs/+delete-doc\" matched deny pattern \"docs/+delete-*\""
risk_too_high / write_not_allowed:
"command risk \"high-risk-write\" exceeds rule max_risk \"write\""
risk_not_annotated:
"command has no risk_level annotation; rule denies unannotated commands"
(drops the prescriptive "set allow_unannotated=true" hint -- that
belongs in docs, not in the engine's denial path)
Adds firstMatch() helper so command_denylisted can name the specific
glob that fired; matchesAny() now wraps firstMatch.
Regression test pins the substring contract per reason_code so future
"comment cleanup" cannot silently strip the values out again.
Change-Id: I17c7cc9411f58e3e43ade5e1ce875f3b7fe3e5ea
This commit is contained in:
@@ -121,16 +121,16 @@ func (e *Engine) EvaluateOne(cmd *cobra.Command) Decision {
|
||||
return Decision{
|
||||
Allowed: false,
|
||||
ReasonCode: "risk_not_annotated",
|
||||
Reason: "command has no risk_level annotation; required when a Rule is active (set rule.allow_unannotated=true to opt out during gradual adoption)",
|
||||
Reason: "command has no risk_level annotation; rule denies unannotated commands",
|
||||
}
|
||||
}
|
||||
|
||||
// Axis 1: Deny has priority.
|
||||
if matchesAny(r.Deny, path) {
|
||||
if matched, ok := firstMatch(r.Deny, path); ok {
|
||||
return Decision{
|
||||
Allowed: false,
|
||||
ReasonCode: "command_denylisted",
|
||||
Reason: "command denied by rule deny list",
|
||||
Reason: fmt.Sprintf("command path %q matched deny pattern %q", path, matched),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ func (e *Engine) EvaluateOne(cmd *cobra.Command) Decision {
|
||||
return Decision{
|
||||
Allowed: false,
|
||||
ReasonCode: "domain_not_allowed",
|
||||
Reason: "command path not in rule allow list",
|
||||
Reason: fmt.Sprintf("command path %q not in allow list %v", path, r.Allow),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ func (e *Engine) EvaluateOne(cmd *cobra.Command) Decision {
|
||||
return Decision{
|
||||
Allowed: false,
|
||||
ReasonCode: reasonCodeForRisk(cmdRisk),
|
||||
Reason: "command risk exceeds rule max_risk",
|
||||
Reason: fmt.Sprintf("command risk %q exceeds rule max_risk %q", cmdRisk, r.MaxRisk),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,7 +163,7 @@ func (e *Engine) EvaluateOne(cmd *cobra.Command) Decision {
|
||||
return Decision{
|
||||
Allowed: false,
|
||||
ReasonCode: "identity_mismatch",
|
||||
Reason: "command identities do not intersect rule identities",
|
||||
Reason: fmt.Sprintf("command supports identities %v; rule allows %v", cmdIdents, r.Identities),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -348,12 +348,20 @@ func reasonCodeForRisk(risk platform.Risk) string {
|
||||
// Invalid globs are skipped here -- they're rejected upstream by
|
||||
// ValidateRule when the rule first enters the system.
|
||||
func matchesAny(globs []string, path string) bool {
|
||||
_, ok := firstMatch(globs, path)
|
||||
return ok
|
||||
}
|
||||
|
||||
// firstMatch returns the first glob in globs that matches path. Used by
|
||||
// command_denylisted so the envelope can name the specific deny pattern
|
||||
// that fired.
|
||||
func firstMatch(globs []string, path string) (string, bool) {
|
||||
for _, g := range globs {
|
||||
if ok, err := doublestar.Match(g, path); err == nil && ok {
|
||||
return true
|
||||
return g, true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return "", false
|
||||
}
|
||||
|
||||
// hasIdentityIntersection reports whether the rule's typed identities
|
||||
|
||||
@@ -324,6 +324,64 @@ func TestEvaluate_identitiesIntersection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Reason strings must carry both the attempted value and the rule's
|
||||
// constraint so the envelope is self-contained for AI consumers.
|
||||
// Asserting on substrings (not exact match) leaves room for minor wording
|
||||
// tweaks while pinning the value-carrying behaviour.
|
||||
func TestEvaluate_reasonCarriesAttemptAndConstraint(t *testing.T) {
|
||||
root := buildTree()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
rule *platform.Rule
|
||||
path string
|
||||
wantInReason []string
|
||||
}{
|
||||
{
|
||||
name: "identity_mismatch surfaces both identity sets",
|
||||
rule: &platform.Rule{Identities: []platform.Identity{"bot"}},
|
||||
path: "docs/+update", // identities=[user]
|
||||
wantInReason: []string{"[user]", "[bot]"},
|
||||
},
|
||||
{
|
||||
name: "domain_not_allowed surfaces path and allow list",
|
||||
rule: &platform.Rule{Allow: []string{"docs/**"}},
|
||||
path: "im/+send",
|
||||
wantInReason: []string{`"im/+send"`, "docs/**"},
|
||||
},
|
||||
{
|
||||
name: "command_denylisted surfaces matched deny pattern",
|
||||
rule: &platform.Rule{Deny: []string{"docs/+delete-*"}},
|
||||
path: "docs/+delete-doc",
|
||||
wantInReason: []string{`"docs/+delete-doc"`, `"docs/+delete-*"`},
|
||||
},
|
||||
{
|
||||
name: "risk_too_high surfaces cmd risk and max_risk",
|
||||
rule: &platform.Rule{MaxRisk: "write"},
|
||||
path: "docs/+delete-doc", // risk=high-risk-write
|
||||
wantInReason: []string{`"high-risk-write"`, `"write"`},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := cmdpolicy.New(tc.rule).EvaluateAll(root)
|
||||
d, ok := got[tc.path]
|
||||
if !ok {
|
||||
t.Fatalf("no decision for %q", tc.path)
|
||||
}
|
||||
if d.Allowed {
|
||||
t.Fatalf("%q should have been denied", tc.path)
|
||||
}
|
||||
for _, sub := range tc.wantInReason {
|
||||
if !strings.Contains(d.Reason, sub) {
|
||||
t.Errorf("reason %q missing %q", d.Reason, sub)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown identities defaults to ALLOW. A command with risk annotated
|
||||
// but without supportedIdentities passes any identity filter.
|
||||
func TestEvaluate_unknownIdentitiesIsAllow(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user