diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 6a27ee6cf..a519ccc0b 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -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)") } diff --git a/cmd/policy_test.go b/cmd/policy_test.go index 1d492cf45..c09dd0ff0 100644 --- a/cmd/policy_test.go +++ b/cmd/policy_test.go @@ -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" { diff --git a/cmd/prune.go b/cmd/prune.go index b68eafd22..b1b6c5f41 100644 --- a/cmd/prune.go +++ b/cmd/prune.go @@ -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)") }, diff --git a/cmd/root_integration_test.go b/cmd/root_integration_test.go index 416777a44..2f2d4a10e 100644 --- a/cmd/root_integration_test.go +++ b/cmd/root_integration_test.go @@ -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)", }, diff --git a/internal/cmdutil/factory.go b/internal/cmdutil/factory.go index 1ccfee440..5eff1931f 100644 --- a/internal/cmdutil/factory.go +++ b/internal/cmdutil/factory.go @@ -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)") } diff --git a/internal/hook/install.go b/internal/hook/install.go index 3f6fae3cd..f89895333 100644 --- a/internal/hook/install.go +++ b/internal/hook/install.go @@ -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. diff --git a/internal/pruning/aggregation_test.go b/internal/pruning/aggregation_test.go index 54b4640d2..03e6bff5e 100644 --- a/internal/pruning/aggregation_test.go +++ b/internal/pruning/aggregation_test.go @@ -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()) diff --git a/internal/pruning/apply.go b/internal/pruning/apply.go index 0dd7cc6ac..33fbc387a 100644 --- a/internal/pruning/apply.go +++ b/internal/pruning/apply.go @@ -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,