refactor(envelope): rename error.type pruning/strict_mode to command_denied

The envelope's `type` field was leaking implementation terms ("pruning",
"strict_mode") that describe enforcement mechanism rather than the user-
facing semantic. It also duplicated `detail.layer`, and forced consumers
to branch on two values for the same conceptual error ("a command was
denied by policy").

Collapse both into a single semantic type "command_denied". The
enforcement layer ("pruning" / "strict_mode") is preserved in
`detail.layer` so debugging and per-layer diagnostics still work.
This commit is contained in:
shanglei
2026-05-15 11:44:09 +08:00
parent 9429c525ea
commit adfe1f8ec6
8 changed files with 23 additions and 24 deletions

View File

@@ -50,7 +50,7 @@ For AI agents: this command blocks until the user completes authorization in the
browser. Run it in the background and retrieve the verification URL from its output.`,
RunE: func(cmd *cobra.Command, args []string) error {
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
return output.ErrWithHint(output.ExitValidation, "strict_mode",
return output.ErrWithHint(output.ExitValidation, "command_denied",
fmt.Sprintf("strict mode is %q, user login is disabled in this profile", mode),
"if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
}

View File

@@ -117,8 +117,8 @@ max_risk: write
t.Fatalf("+delete-doc RunE should return an error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "pruning" {
t.Fatalf("expected pruning ExitError, got %T %+v", err, err)
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "command_denied" {
t.Fatalf("expected command_denied ExitError, got %T %+v", err, err)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok || detail["reason_code"] != "command_denylisted" {

View File

@@ -79,7 +79,7 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
return output.ErrWithHint(output.ExitValidation, "strict_mode",
return output.ErrWithHint(output.ExitValidation, "command_denied",
fmt.Sprintf("strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()),
"if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
},

View File

@@ -353,7 +353,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelop
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "strict_mode",
Type: "command_denied",
Message: `strict mode is "bot", only bot-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
@@ -371,7 +371,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnve
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "strict_mode",
Type: "command_denied",
Message: `strict mode is "bot", only bot-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
@@ -409,7 +409,7 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "strict_mode",
Type: "command_denied",
Message: `strict mode is "user", only user-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
@@ -428,7 +428,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
OK: false,
Identity: "user",
Error: &output.ErrDetail{
Type: "strict_mode",
Type: "command_denied",
Message: `strict mode is "bot", only bot-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
@@ -446,7 +446,7 @@ func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsE
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "strict_mode",
Type: "command_denied",
Message: `strict mode is "user", only user-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
@@ -465,7 +465,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop
OK: false,
Identity: "user",
Error: &output.ErrDetail{
Type: "strict_mode",
Type: "command_denied",
Message: `strict mode is "bot", only bot-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},

View File

@@ -161,7 +161,7 @@ func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode {
func (f *Factory) CheckStrictMode(ctx context.Context, as core.Identity) error {
mode := f.ResolveStrictMode(ctx)
if mode.IsActive() && !mode.AllowsIdentity(as) {
return output.ErrWithHint(output.ExitValidation, "strict_mode",
return output.ErrWithHint(output.ExitValidation, "command_denied",
fmt.Sprintf("strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()),
"if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
}

View File

@@ -324,8 +324,9 @@ var stderr = func() interface{ Write(p []byte) (int, error) } {
//
// V1 contract: a denial is signalled by the cobra annotation
// "lark:pruning_denied_layer" being set on the command. The layer
// value is the error.type ("pruning" / "strict_mode"); the source
// follows the annotation "lark:pruning_denied_source".
// value is the enforcement layer ("pruning" / "strict_mode") that
// gets emitted as detail.layer in the envelope; the source follows
// the annotation "lark:pruning_denied_source".
//
// This indirection lets us avoid an import cycle between hook and
// pruning packages.

View File

@@ -180,8 +180,8 @@ func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
if exitErr.Detail == nil {
t.Fatalf("ExitError.Detail required for envelope to render")
}
if exitErr.Detail.Type != "pruning" {
t.Errorf("envelope error.type = %q, want pruning", exitErr.Detail.Type)
if exitErr.Detail.Type != "command_denied" {
t.Errorf("envelope error.type = %q, want command_denied", exitErr.Detail.Type)
}
// JSON envelope shape: detail.reason_code must be present and
// match the closed enum.
@@ -208,8 +208,8 @@ func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
// Envelope round-trip sanity (the actual JSON cmd/root.go would emit).
var buf strings.Builder
output.WriteErrorEnvelope(&buf, exitErr, "user")
if !strings.Contains(buf.String(), `"type": "pruning"`) {
t.Errorf("envelope JSON missing type=pruning, got:\n%s", buf.String())
if !strings.Contains(buf.String(), `"type": "command_denied"`) {
t.Errorf("envelope JSON missing type=command_denied, got:\n%s", buf.String())
}
if !strings.Contains(buf.String(), `"reason_code": "write_not_allowed"`) {
t.Errorf("envelope JSON missing reason_code, got:\n%s", buf.String())

View File

@@ -102,7 +102,7 @@ func installDenyStub(cmd *cobra.Command, path string, d policydecision.Denial) {
// Behaviour without this guard (pre-fix): a user yaml rule matching
// a strict-mode stub's path would replace the RunE with the pruning
// denyStub, hiding the original strict-mode error message AND
// re-labelling error.type from "strict_mode" to "pruning".
// re-labelling detail.layer from "strict_mode" to "pruning".
if cmd.Annotations != nil &&
cmd.Annotations[AnnotationDenialLayer] == policydecision.LayerStrictMode {
return
@@ -154,15 +154,13 @@ func installDenyStub(cmd *cobra.Command, path string, d policydecision.Denial) {
ReasonCode: denial.ReasonCode,
Reason: denial.Reason,
}
// Wrap in *output.ExitError so the cmd/root.go envelope writer
// emits a JSON envelope. error.type uses denial.Layer ("pruning"
// / "strict_mode") to match the design-doc contract. The detail
// map carries the closed-enum reason_code plus the structured
// fields agents read.
// error.type is the user-facing semantic ("a command was denied by
// policy"). detail.layer carries the implementation distinction
// ("pruning" vs "strict_mode") for debugging.
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: denial.Layer,
Type: "command_denied",
Message: cd.Error(),
Detail: map[string]any{
"path": cd.Path,