Skip to content

feat(extension): Plugin / Hook framework with command pruning#910

Open
sang-neo03 wants to merge 25 commits into
mainfrom
feat/extension-platform
Open

feat(extension): Plugin / Hook framework with command pruning#910
sang-neo03 wants to merge 25 commits into
mainfrom
feat/extension-platform

Conversation

@sang-neo03
Copy link
Copy Markdown
Collaborator

@sang-neo03 sang-neo03 commented May 15, 2026

Summary

Introduce a single public extension contract under extension/platform so integrators can register Observers, Wrappers, Lifecycle handlers, and pruning Rules through one Install call. Adds a fail-closed command-pruning pipeline (plugin Rule + ~/.lark-cli/policy.yml) layered on top of a panic-safe hook framework, with structured JSON envelopes for every denial / install / lifecycle failure so AI agents get a stable protocol signal instead of cobra's silent help fallback.

Changes

extension/platform/ (public contract)

  • Plugin interface + Registrar (Observer / Wrapper / Lifecycle / Restrict)
  • Rule (Allow / Deny / MaxRisk / Identities) with doublestar globs, 4-axis AND evaluation
  • Selectors: ByDomain, ByExactRisk, ByWrite, ByReadOnly; unknown-risk is uniform — read-side selectors never match unannotated commands
  • AbortError + Capabilities{Restricts, FailurePolicy, RequiredCLIVersion}
  • FailurePolicy enum is validated at install time — unrecognised values are rejected with invalid_capability (always-abort) instead of silently fail-open
  • ResetForTesting exported as a plain helper (no //go:build testing) so go test ./... works without an extra build tag

Policy engine (internal/cmdpolicy/)

  • Plugin path is fail-closed (envelope on rule error / multiple Restrict); yaml path is fail-open (warning, CLI continues)
  • When a Rule is active, commands missing a risk annotation or carrying a risk outside the closed taxonomy (read | write | high-risk-write) are denied with reason_code = risk_not_annotated | risk_invalid — closes the gap where an unannotated or typo'd write command could slip past an agent read-only rule
  • New AnnotationPureGroup marker isolates the unknown-subcommand guard from the engine: parent groups that only carry the guard-injected RunE are skipped in EvaluateAll, aggregateParents, and hasRunnableDescendant, so a user policy with max_risk: read no longer denies <group> --help
  • Deny stub and strict-mode stub override Args (→ ArbitraryArgs) and install a no-op leaf PersistentPreRunE, so the wrapped RunE is reached regardless of cobra ValidateArgs / parent-PersistentPreRunE order
  • RedactHomeDir folds both the base config dir (core.GetBaseConfigDir(), which honours LARKSUITE_CLI_CONFIG_DIR) and $HOME so config policy show output and resolver error messages stop leaking absolute filesystem paths to AI agents / CI logs

Hook framework (internal/hook/)

  • Observer (panic-safe, Before/After), Wrapper (middleware, may short-circuit via AbortError), Lifecycle (Startup + Shutdown)
  • recover guards every plugin entry point: Capabilities(), Install(), Wrapper factory composition AND inner Handler, Lifecycle handlers
  • namespacedWrap copies AbortError so a plugin's package-level sentinel is never mutated across concurrent invocations

Bootstrap (cmd/build.go, cmd/platform_bootstrap.go, cmd/root.go)

  • InstallAll uses a staging Registrar + atomic commit
  • FailClosed plugin install / Plugin.Restrict conflict / Startup handler failure each install a structured envelope guard at every dispatch path
  • walkGuard neutralises every cobra bypass: PersistentPreRunE first-wins, ValidateArgs, ParseFlags, legacyArgs, __complete / __completeNoDesc, non-runnable groups, required-arg subcommands
  • installUnknownSubcommandGuard tags every group it attaches RunE to with cmdpolicy.AnnotationPureGroup, preserving the parent-group invariant the policy engine relies on
  • userPolicyPath resolves via core.GetBaseConfigDir() so LARKSUITE_CLI_CONFIG_DIR overrides reach the policy file location consistently
  • Execute emits hook.Emit(Shutdown, runErr) after rootCmd.Execute; completion paths skip Shutdown
  • RegisterShortcutsWithContext tags each service group (im, docs, drive, calendar, …) with cmdmeta.SetDomain, so platform.ByDomain("docs") actually matches via parent-chain inheritance

Envelope contract (internal/output/)

  • error.type closed enum: command_denied / hook / plugin_install / plugin_conflict / plugin_lifecycle — collapses the previous pruning / strict_mode split (enforcement mechanism is preserved in detail.layer)
  • Unknown subcommand now surfaces a structured unknown_subcommand ExitError (exit 2) listing available subcommands instead of silently falling back to help with exit 0

Diagnostic surfaces

  • lark-cli config policy show — JSON view of the active Rule + source
  • lark-cli config plugins show — installed plugins and their contributions
  • Both are read-only and kept behind the policy diagnostic whitelist (always callable so operators can debug a denial). Hidden from --help, but a Long hint on lark-cli config --help advertises their existence.
  • config policy group installs a no-op leaf PersistentPreRunE so its read-only diagnostics work under env-credential deployments (the parent config group's RequireBuiltinCredentialProvider would otherwise reject them with external_provider)

Breaking changes

See CHANGELOG.md [Unreleased] for the full migration notes.

  • error.type rename — strict-mode + policy denials both emit command_denied. detail.layer (policy | strict_mode) preserves which layer rejected the call. Agents matching error.type == "strict_mode" must update.
  • Unknown subcommand returns exit 2 + structured unknown_subcommand envelope instead of exit 0 + help. Scripts relying on the exit-0 fallback must update; lark-cli <group> --help continues to return exit 0 + help.

Test Plan

  • go test ./... — passes from a fresh clone, no build tag needed
  • cmd/plugin_integration_test.go — end-to-end through buildInternal with synthetic and real command trees
  • cmd/install_guard_test.gowalkGuard covers auth / config / __complete / __completeNoDesc / non-runnable parents / required-arg subcommands
  • cmd/unknown_subcommand_test.go — unknown subcommand returns structured envelope (exit 2), no-arg invocation still prints help (exit 0)
  • internal/cmdpolicy/aggregation_test.go — pure-group regression: <group> --help returns exit 0 + help under max_risk: read; aggregate denial still installed when every live child is denied
  • internal/cmdpolicy/path_test.goRedactHomeDir folds LARKSUITE_CLI_CONFIG_DIR override prefix, $HOME prefix, and leaves unrelated paths unchanged
  • Pruning regression tests: deny stub bypasses cobra.NoArgs + parent PersistentPreRunE, observers fire, real envelope is emitted regardless of credential provider
  • Manual: lark-cli drive +bogusunknown_subcommand envelope (exit 2); lark-cli drive --help under max_risk: read → exit 0 + help; config policy show outputs <config>/policy.yml instead of an absolute filesystem path; unannotated write command is denied when a Rule with MaxRisk=read is active

Related Issues

  • None

Summary by CodeRabbit

  • New Features

    • In-process plugin SDK with hook types (observer/wrapper/lifecycle), plugin builder, registration, and runnable examples.
    • User policy support (YAML or plugin rules) that prunes/denies CLI commands, plus diagnostic commands to show/validate policy and plugins.
    • Improved unknown-subcommand handling that reports available shortcuts and emits structured guidance.
    • CLI lifecycle shutdown hooks emitted after command execution.
  • Bug Fixes

    • Strict-mode and pruning denials now surface a consistent "command_denied" structured envelope.

Review Change Stack

sang-neo03 added 13 commits May 13, 2026 11:27
Add a single public extension contract under extension/platform: integrators
implement the Plugin interface and register Observers, Wrappers, Lifecycle
handlers, and pruning Rules through the Registrar in one Install call.

Command pruning:
  - Rule (Allow / Deny / MaxRisk / Identities) with doublestar globs
  - 4-axis AND evaluation, parent-group aggregation, unknown-risk allow
  - Sources: Plugin.Restrict (single-rule) and ~/.lark-cli/policy.yml
  - Plugin path is fail-closed (envelope on rule error / multiple Restrict);
    yaml path is fail-open (warning, CLI continues)
  - strict-mode stubs now also write the denial annotation so the hook
    layer's denial guard physically isolates Wrap chains on them
  - HOME path never leaked through policy_source label

Hook framework:
  - Observer (panic-safe, Before/After), Wrapper (middleware, may short-circuit
    via AbortError), Lifecycle (Startup + Shutdown only)
  - Recover guards every plugin entry point: Capabilities(), Install(),
    Wrapper factory composition AND inner Handler, Lifecycle handlers
  - namespacedWrap copies AbortError so a plugin's package-level sentinel
    is never mutated across concurrent invocations
  - Selector unknown-risk uniform: ByExactRisk / ByWrite / ByReadOnly never
    match unannotated commands; safety-side hooks opt in via
    ByWrite().Or(ByUnknownRisk())

Bootstrap orchestration (cmd/build.go + cmd/policy.go):
  - InstallAll uses a staging Registrar + atomic commit
  - FailClosed plugin install / Plugin.Restrict conflict / Startup handler
    failure each install a structured envelope guard at every dispatch path
  - walkGuard neutralises every cobra bypass we know of (PersistentPreRunE
    first-wins, ValidateArgs, ParseFlags, legacyArgs, __complete /
    __completeNoDesc, non-runnable groups, required-arg subcommands)
  - cmd/root.go::Execute calls hook.Emit(Shutdown, runErr) after
    rootCmd.Execute; isCompletionCommand skips both __complete and
    __completeNoDesc so Tab completion never triggers Shutdown handlers

Capabilities consistency:
  - Restricts=true must declare FailurePolicy=FailClosed
  - RequiredCLIVersion (semver constraint) is validated against build.Version;
    a malformed constraint is treated as untrusted-config and aborts
    unconditionally, regardless of FailurePolicy (DEV builds included)

JSON envelope contract:
  - error.type closed enum: pruning / strict_mode / hook / plugin_install /
    plugin_conflict / plugin_lifecycle
  - reason_code closed enums per type, all referenced by structured tests

Bootstrap surfaces (new user commands):
  - lark-cli config policy show     -- JSON view of the active Rule + source
  - lark-cli config policy validate -- parse + schema + glob check, no apply

Coverage:
  - extension/platform: every public type has a unit test
  - internal/{pruning,hook,platformhost,policydecision,cmdmeta}: full coverage
    of denial guard isolation, AbortError sentinel safety, observer panic
    safety, lifecycle error/panic typing, staging atomic rollback
  - cmd/plugin_integration_test.go: end-to-end through buildInternal with
    synthetic and real command trees
  - cmd/install_guard_test.go: walkGuard covers auth / config / __complete /
    __completeNoDesc / non-runnable parents
The pruning denyStub and the strict-mode stub previously only swapped
RunE plus Hidden + DisableFlagParsing. Cobra's dispatch order means
several pre-RunE gates can fire BEFORE the stub's RunE ever runs:

  1. Args validator: shortcut commands often declare cobra.NoArgs.
     With DisableFlagParsing=true the user's `--doc xxx --mode append`
     looks like positional args, so ValidateArgs surfaces a usage
     error instead of the pruning / strict_mode envelope. Observer
     hooks also miss the dispatch entirely.

  2. Parent PersistentPreRunE: cmd/auth/auth.go declares a
     PersistentPreRunE that returns external_provider when env
     credentials are set. Cobra's "first PersistentPreRunE wins
     walking up from the leaf" then short-circuits with
     external_provider instead of the leaf's denial envelope.

Both stubs now also set:

  - Args               = cobra.ArbitraryArgs   (bypass gate 1)
  - PersistentPreRunE  = no-op leaf hook       (bypass gate 2)
  - PreRunE / PreRun / PersistentPreRun = nil  (defensive)

Effect: dispatch reaches the wrapped RunE, observers fire, the real
pruning / strict_mode envelope is emitted regardless of credential
provider or flag count.

Adds regression tests covering both gates on both stub paths.
cmd/config/config.go::NewCmdConfig declares a PersistentPreRunE that
calls f.RequireBuiltinCredentialProvider; with env credentials set,
it returns external_provider for every config subcommand.

`config policy show` and `config policy validate` are READ-ONLY
diagnostic commands -- they inspect or parse the user-layer rule
without touching credentials. They MUST work regardless of which
credential provider is active, otherwise users on env-credential
deployments cannot debug their policy.

Same shape as the codex C11/C13 fix: install a no-op leaf-level
PersistentPreRunE on the `policy` group so cobra's "first walking up
from leaf" rule picks ours over the config parent's.

Regression caught by divergent e2e (F1-F6 all returned external_provider
before this fix; all pass after). Adds a unit test pinning the
PersistentPreRunE override.
RegisterShortcutsWithContext now calls cmdmeta.SetDomain on each
service-level cobra.Command (im, docs, drive, calendar, ...) so the
business-domain axis is actually populated on every shortcut leaf via
parent-chain inheritance.

Before this change, platform.ByDomain("docs") never matched any
command: the domain annotation was unset across the entire shortcut
tree, so the selector's d != "" guard always failed and risk-style
selectors silently degraded to no-op.

The SetDomain call is placed AFTER the create-or-reuse branch so it
fires whether the service command was freshly created here or had
already been added by cmd/service/service.go's OpenAPI auto-
registration (which runs first and creates im, drive, calendar, etc.).
Without this placement only pure-shortcut services like docs would
have been tagged.

Adds a regression test asserting:
  - service-group cobra.Command carries the cmdmeta.domain annotation
  - leaf shortcuts inherit the domain via parent-chain walk
…lback

When a user passed an unknown subcommand or shortcut (e.g. `lark-cli drive
+bogus`), cobra returned `flag.ErrHelp` for the non-runnable group command,
printed the parent help, and exited 0. AI agents couldn't distinguish a
typo from an intentional help request.

Install a tree-wide guard that attaches a RunE to every group command
without its own Run/RunE. The RunE forwards no-args invocations to help
(preserving prior behavior) and emits a structured unknown_subcommand
ExitError (exit 2) listing available subcommands when args are present.
…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.
…s active

The pruning engine used to treat any command without a risk annotation as
ALLOW even when a Rule with MaxRisk was set, and would silently skip the
MaxRisk comparison whenever the command's risk string was outside the
closed taxonomy. Both gaps let an unannotated or typo'd write command
slip past an "agent read-only" pruning rule.

Engine now denies before any other axis when a Rule is registered:
  - reason_code "risk_not_annotated" for commands with no risk
  - reason_code "risk_invalid"        for commands whose risk is outside
                                      the read | write | high-risk-write
                                      taxonomy (e.g. typo "wrtie")

Main-flow is preserved: a nil Rule still returns Allowed=true
unconditionally, so a CLI with no pruning plugin behaves identically to
before. ByUnknownRisk() is removed from the public surface since the
Unknown state is no longer reachable through risk-based selectors when
any Rule is active; safety-side widening composition is no longer needed.
`config policy show`, `config policy validate`, and `config plugins show`
are local-introspection-only commands kept behind the pruning
diagnostic whitelist so operators can always inspect why a command was
denied. They do not need to surface in `--help` for AI agents and were
contributing to help noise.

Hide the `policy` and `plugins` parent groups and both `show` /
`validate` leaves. Commands remain callable by exact name and continue
to bypass user-layer pruning via diagnosticPaths.
@github-actions github-actions Bot added the size/XL Architecture-level or global-impact change label May 15, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 15, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds an in-process plugin SDK, host installer, hook registry/installer, a policy resolver/engine that prunes commands with structured deny stubs, build-time guards for install/conflicts/lifecycle/unknown-subcommand/strict-mode, diagnostic commands, examples, and comprehensive tests.

Changes

CLI plugin and policy system

Layer / File(s) Summary
Platform public API and builder
extension/platform/*
Adds plugin-facing types and contracts: Plugin/Registrar, Rule/Risk/Identity, Invocation/CommandView, Handler/Observer/Wrapper/Lifecycle, Builder, Register/RegisteredPlugins, and tests/examples.
Plugin host & staging
internal/platform/*
Implements InstallAll host orchestration, stagingRegistrar, plugin install validation (capabilities, RequiredCLIVersion), PluginInstallError, inventory snapshotting, and tests for install semantics and rollbacks.
Hook registry and installer
internal/hook/*
Adds a thread-safe hook Registry, Install() wiring that composes wrappers and observers, Emit lifecycle handling with panic-recovery, stderr testing helpers, and comprehensive unit tests.
Policy resolver, engine, and apply
internal/cmdpolicy/*
Adds YAML parsing, rule validation, resolver precedence (plugin vs YAML), Engine (EvaluateOne/EvaluateAll), BuildDeniedByPath, aggregation, Apply to install deny stubs (command_denied envelopes), active policy/inventory stores, diagnostic allowlist, suggest/validation helpers, and broad unit tests.
CLI wiring, guards, and bootstrap
cmd/*
Extends build.go to run plugin install, apply user policy pruning, wire hooks, and record inventory; adds strict-mode normalization to prune.go; installs fatal/plugin/conflict/lifecycle guards and unknown-subcommand guard; adds config policy and config plugins diagnostic commands and many integration tests.
Shortcuts metadata
shortcuts/register.go, internal/cmdmeta
Tags service shortcut commands with domain metadata for policy/selector use and adds cmdmeta helpers and tests.
Examples, CI, Makefile, deps
extension/platform/examples/*, .github/workflows/ci.yml, Makefile, go.mod
Adds runnable examples, expands CI/test tags to testing, enforces fmt-check and example builds, and adds doublestar/yaml dependency entries.
Small CLI fixes/tests
cmd/root.go, cmd/auth/login.go, internal/cmdutil/factory.go
Execute flow now emits shutdown lifecycle hooks; completion alias __completeNoDesc handled; strict-mode denial key unified to command_denied (was strict_mode); tests adjusted to new buildInternal signature.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested labels

feature

Suggested reviewers

  • liangshuo-1
  • evandance
  • liuxinyanglxy

A rabbit taps keys in the moonlit night,
Wiring hooks and guards so commands behave right;
Policies prune, plugins hum in delight,
Observers whisper, wrappers hold tight.
Build, emit, test — then burrow to bite. 🐇✨

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/extension-platform

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 15, 2026

🚀 PR Preview Install Guide

🧰 CLI update

npm i -g https://pkg.pr.new/larksuite/cli/@larksuite/cli@1f9f75abd545dcef46611e29cecdf09009fcafc8

🧩 Skill update

npx skills add larksuite/cli#feat/extension-platform -y -g

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (4)
cmd/install_guard_test.go (1)

133-183: 💤 Low value

Consider removing unused results array or clarifying test intent.

The results array is populated (line 166) but never inspected (line 182). The test comment at line 128 states "both observed errors must keep their own HookName," but the test only verifies the shared sentinel wasn't mutated (lines 179-180).

If the test's scope is intentionally narrow (verifying sentinel immutability only), consider removing the results array and the loop capture logic (lines 156-168) to eliminate confusion. Otherwise, add assertions to verify each wrapper received a distinct namespaced HookName.

♻️ Option 1: Remove unused results array (if narrow scope is intended)
-	results := make([]string, 2)
 	var wg sync.WaitGroup
 	wg.Add(2)
 	for i, m := range matched {
-		i, m := i, m
+		m := m
 		go func() {
 			defer wg.Done()
-			err := m.Fn(func(context.Context, *platform.Invocation) error { return nil })(
+			_ = m.Fn(func(context.Context, *platform.Invocation) error { return nil })(
 				context.Background(), &platform.Invocation{})
-			if ab, ok := err.(*platform.AbortError); ok {
-				results[i] = ab.HookName
-			}
 		}()
 	}
 	wg.Wait()
 
-	// We are not using namespacedWrap directly here...
+	// The test verifies the sentinel AbortError is not mutated by concurrent wrappers.
 	if shared.HookName != "plugin-shared-name" {
 		t.Errorf("shared sentinel AbortError was mutated: HookName = %q", shared.HookName)
 	}
-	_ = results
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/install_guard_test.go` around lines 133 - 183,
TestNamespacedWrap_doesNotMutateSharedAbortError currently builds and writes
into the results slice from each matched WrapperEntry.Fn but never inspects it,
which is confusing; either remove the results slice and the goroutine
capture/store logic (the results variable and its writes inside the goroutines)
if you only intend to assert the shared AbortError sentinel wasn’t mutated, or
keep results and add assertions after wg.Wait() to verify that each results[i]
contains the expected namespaced HookName(s) (i.e., that matched wrappers
returned distinct HookName values) to match the test comment and intent.
extension/platform/abort_test.go (1)

14-28: 💤 Low value

Consider more explicit assertion for cause appending.

The test on lines 25-26 verifies that the Cause changes the message by checking inequality. While functionally correct, it would be clearer to assert that the message contains the cause's error text explicitly.

📝 Suggested improvement
-	if got := withCause.Error(); got == bare.Error() {
-		t.Errorf("Cause should be appended to message, got %q", got)
+	got := withCause.Error()
+	if !strings.Contains(got, fs.ErrNotExist.Error()) {
+		t.Errorf("Error() should contain cause text, got %q", got)
 	}

You would also need to add "strings" to the imports.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@extension/platform/abort_test.go` around lines 14 - 28, Update
TestAbortError_messageFormats to assert the cause text is explicitly appended
instead of just checking inequality: replace the inequality check on
withCause.Error() with an assertion that the returned string contains
fs.ErrNotExist.Error() (or "file does not exist") and the expected base message
from platform.AbortError for "audit.upload"; reference the test function
TestAbortError_messageFormats and the withCause variable/struct, and add
"strings" to the imports so you can use strings.Contains in the assertion.
internal/platformhost/host.go (1)

59-71: ⚡ Quick win

Resolve plugin names once and reuse throughout install.

Plugin.Name() is invoked in both detectDuplicateNames and the install loop. For stateful/buggy plugins, this can produce inconsistent names or second-call failures after precheck. Cache validated names from the prepass and reuse them for duplicate checks and installOne.

Also applies to: 309-327

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/platformhost/host.go` around lines 59 - 71, The code calls
Plugin.Name() twice (in detectDuplicateNames and again in the install loop via
safeCallName), which can fail or return inconsistent results for stateful
plugins; change the prepass so detectDuplicateNames (or a new prepass function)
validates and returns a cached mapping of plugin -> name (e.g.,
map[*Plugin]string or []string aligned with the plugins slice) and use that
cached name map in the install loop passed into installOne instead of calling
safeCallName again; update detectDuplicateNames/safeCallName to populate and
return the cache and modify the install loop to look up the name from the cache
when calling installOne and any other code paths that previously invoked
Plugin.Name().
cmd/config/policy_test.go (1)

18-25: 💤 Low value

Consider using cmdutil.TestFactory for consistency.

The manual factory construction works but deviates from the test guideline to use cmdutil.TestFactory(t, config) for unit tests. While these lightweight diagnostic tests don't need the full factory setup, using the helper ensures consistency across the codebase.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/config/policy_test.go` around lines 18 - 25, The helper
newPolicyTestFactory manually constructs a cmdutil.Factory and IOStreams;
replace it with the standard test helper by using cmdutil.TestFactory in tests:
update callers to call cmdutil.TestFactory(t, <config>) instead of
newPolicyTestFactory and remove or replace newPolicyTestFactory with a wrapper
that delegates to cmdutil.TestFactory(t, config) so tests use the consistent
TestFactory helper rather than directly instantiating cmdutil.Factory/IOStreams.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cmd/config/policy.go`:
- Around line 121-126: The function runConfigPolicyValidate reads the
user-supplied path directly with os.ReadFile; call validate.SafeInputPath on the
incoming path (handle its error and return an output.Errorf with ExitValidation
like the existing pattern), use the returned safePath when invoking os.ReadFile
instead of the raw path, and ensure any error messages include the original path
context; reference runConfigPolicyValidate, validate.SafeInputPath, and
os.ReadFile when making the change.

In `@extension/platform/register_test.go`:
- Around line 41-50: TestRegister_resetClears mutates package-global state but
doesn't register t.Cleanup, so if the test fails early the global registry
remains dirty; call t.Cleanup(platform.ResetForTesting) at the start of the test
(immediately after platform.ResetForTesting() or before any platform.Register
calls) to ensure platform.ResetForTesting() always runs on test teardown and
reference the existing symbols TestRegister_resetClears,
platform.ResetForTesting, platform.Register, and platform.RegisteredPlugins when
making the change.

In `@extension/platform/selector_test.go`:
- Around line 135-143: The test TestSelector_NilSafeWhenComposed currently never
executes the composed selector and thus doesn't verify behavior; update it to
actually call the composed selector returned by s.And(platform.All()) (and/or
s.Or(...)) and assert the documented nil-as-None semantics — i.e., ensure
composing a zero-value platform.Selector with platform.All() does not panic when
invoked and yields the expected boolean outcome; reference the platform.Selector
type and the And/Or and All methods to locate and update the test so it invokes
the resulting selector and asserts non-panic and correct result.

In `@extension/platform/selector.go`:
- Around line 93-110: The combinators And, Or, and Not call s(cmd) directly and
will panic if a nil Selector is used; update each to treat a nil Selector as
None() (i.e., a selector that always returns false): in And, if s==nil or
other==nil return a selector that always returns false; in Or, if s==nil return
other (which may be nil/None) and if other==nil return s; otherwise return the
composed OR; in Not, if s==nil return a selector that always returns true
(negation of None), else return the negating wrapper. Use the Selector type and
CommandView in the checks/wrappers so the behavior matches the documented
nil-as-None contract.

In `@internal/hook/install.go`:
- Around line 320-333: The comment incorrectly states the function is exported
for tests while the implementation is package-private: rename
populateInvocationDenial to PopulateInvocationDenial if you intend it to be
exported for tests (update all call sites and the package API), otherwise change
the comment to reflect it is internal-only and invoked indirectly (e.g., called
by the wrapper) so tests should use the wrapper; reference the function name
populateInvocationDenial when making the change so you update the correct
symbol.

In `@internal/hook/testing.go`:
- Around line 14-18: SetStderrForTesting currently mutates the package-level
stderr closure with no way to restore it; change its signature to return a
restore function (e.g., func() ) that captures the previous stderr value, set
stderr to wrap the provided io.Writer, and have the returned restore func reset
stderr back to the captured original; reference the SetStderrForTesting function
and the package-level stderr variable so callers/tests can call the returned
restore to avoid leaking global state.

In `@internal/plugininventory/inventory.go`:
- Around line 92-113: SetActive and GetActive currently only do a shallow copy
of Inventory (cp := *inv / *active) which leaves slices and nested structs
(e.g., Inventory.Plugins slice, each Plugin's Hooks slice, Rule internals)
sharing backing memory; update both functions to perform a deep copy: allocate
new slices for Inventory.Plugins and for each Plugin copy the struct value and
allocate/copy its Hooks slice and any pointer/complex fields, and similarly
deep-copy Rule or other nested fields so callers receive completely independent
copies; use the symbols SetActive, GetActive, Inventory, Plugins, Plugin (and
Hook/Rule types) to locate where to implement element-by-element copying and new
allocations.

In `@internal/pruning/yaml/schema.go`:
- Around line 42-48: Parse currently decodes only the first YAML document (in
function Parse, vars s and dec using gopkgyaml.NewDecoder and dec.Decode),
leaving trailing documents ignored; after the initial dec.Decode(&s) succeeds,
attempt to decode a second document into a dummy variable (e.g. var extra
interface{}) and treat any result other than io.EOF as an error—if the second
Decode returns nil (meaning another document exists) or an error other than
io.EOF, return a descriptive error (wrap with fmt.Errorf like the existing error
style) to reject inputs containing multiple YAML documents.

---

Nitpick comments:
In `@cmd/config/policy_test.go`:
- Around line 18-25: The helper newPolicyTestFactory manually constructs a
cmdutil.Factory and IOStreams; replace it with the standard test helper by using
cmdutil.TestFactory in tests: update callers to call cmdutil.TestFactory(t,
<config>) instead of newPolicyTestFactory and remove or replace
newPolicyTestFactory with a wrapper that delegates to cmdutil.TestFactory(t,
config) so tests use the consistent TestFactory helper rather than directly
instantiating cmdutil.Factory/IOStreams.

In `@cmd/install_guard_test.go`:
- Around line 133-183: TestNamespacedWrap_doesNotMutateSharedAbortError
currently builds and writes into the results slice from each matched
WrapperEntry.Fn but never inspects it, which is confusing; either remove the
results slice and the goroutine capture/store logic (the results variable and
its writes inside the goroutines) if you only intend to assert the shared
AbortError sentinel wasn’t mutated, or keep results and add assertions after
wg.Wait() to verify that each results[i] contains the expected namespaced
HookName(s) (i.e., that matched wrappers returned distinct HookName values) to
match the test comment and intent.

In `@extension/platform/abort_test.go`:
- Around line 14-28: Update TestAbortError_messageFormats to assert the cause
text is explicitly appended instead of just checking inequality: replace the
inequality check on withCause.Error() with an assertion that the returned string
contains fs.ErrNotExist.Error() (or "file does not exist") and the expected base
message from platform.AbortError for "audit.upload"; reference the test function
TestAbortError_messageFormats and the withCause variable/struct, and add
"strings" to the imports so you can use strings.Contains in the assertion.

In `@internal/platformhost/host.go`:
- Around line 59-71: The code calls Plugin.Name() twice (in detectDuplicateNames
and again in the install loop via safeCallName), which can fail or return
inconsistent results for stateful plugins; change the prepass so
detectDuplicateNames (or a new prepass function) validates and returns a cached
mapping of plugin -> name (e.g., map[*Plugin]string or []string aligned with the
plugins slice) and use that cached name map in the install loop passed into
installOne instead of calling safeCallName again; update
detectDuplicateNames/safeCallName to populate and return the cache and modify
the install loop to look up the name from the cache when calling installOne and
any other code paths that previously invoked Plugin.Name().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5b28d09f-6813-420c-8dc2-37377303bc1c

📥 Commits

Reviewing files that changed from the base of the PR and between 4a45e00 and 7d28188.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (79)
  • cmd/auth/login.go
  • cmd/build.go
  • cmd/config/config.go
  • cmd/config/plugins.go
  • cmd/config/policy.go
  • cmd/config/policy_test.go
  • cmd/global_flags_test.go
  • cmd/install_guard_test.go
  • cmd/plugin_integration_test.go
  • cmd/policy.go
  • cmd/policy_test.go
  • cmd/prune.go
  • cmd/prune_test.go
  • cmd/root.go
  • cmd/root_integration_test.go
  • cmd/root_test.go
  • cmd/unknown_subcommand_test.go
  • extension/platform/abort.go
  • extension/platform/abort_test.go
  • extension/platform/capabilities.go
  • extension/platform/doc.go
  • extension/platform/errors.go
  • extension/platform/handler.go
  • extension/platform/invocation.go
  • extension/platform/lifecycle.go
  • extension/platform/plugin.go
  • extension/platform/register.go
  • extension/platform/register_test.go
  • extension/platform/registrar.go
  • extension/platform/rule.go
  • extension/platform/selector.go
  • extension/platform/selector_test.go
  • extension/platform/types.go
  • extension/platform/types_test.go
  • extension/platform/view.go
  • go.mod
  • internal/cmdmeta/meta.go
  • internal/cmdmeta/meta_test.go
  • internal/cmdutil/factory.go
  • internal/hook/doc.go
  • internal/hook/emit.go
  • internal/hook/emit_test.go
  • internal/hook/install.go
  • internal/hook/install_default.go
  • internal/hook/install_test.go
  • internal/hook/registry.go
  • internal/hook/testing.go
  • internal/hook/walk.go
  • internal/platformhost/doc.go
  • internal/platformhost/error.go
  • internal/platformhost/host.go
  • internal/platformhost/host_test.go
  • internal/platformhost/staging.go
  • internal/platformhost/version.go
  • internal/platformhost/version_test.go
  • internal/plugininventory/build.go
  • internal/plugininventory/build_test.go
  • internal/plugininventory/inventory.go
  • internal/policydecision/denial.go
  • internal/policydecision/denial_test.go
  • internal/pruning/active.go
  • internal/pruning/aggregation_test.go
  • internal/pruning/apply.go
  • internal/pruning/diagnostic.go
  • internal/pruning/diagnostic_test.go
  • internal/pruning/engine.go
  • internal/pruning/engine_test.go
  • internal/pruning/path.go
  • internal/pruning/resolver.go
  • internal/pruning/resolver_test.go
  • internal/pruning/source_label_test.go
  • internal/pruning/strict_mode_skip_test.go
  • internal/pruning/validate.go
  • internal/pruning/validate_test.go
  • internal/pruning/yaml/reader.go
  • internal/pruning/yaml/schema.go
  • internal/pruning/yaml/schema_test.go
  • shortcuts/register.go
  • shortcuts/register_test.go

Comment thread cmd/config/policy.go Outdated
Comment thread extension/platform/register_test.go
Comment thread extension/platform/selector_test.go
Comment thread extension/platform/selector.go
Comment thread internal/hook/install.go Outdated
Comment thread internal/hook/testing.go Outdated
Comment thread internal/plugininventory/inventory.go Outdated
Comment thread internal/cmdpolicy/yaml/schema.go
…olicy yaml

- selector.go: And/Or/Not now treat nil Selector as None() per godoc,
  preventing runtime panic when composed selectors are invoked.
- schema.go: Parse rejects multi-document YAML input so a stray '---'
  separator can't silently drop trailing policy constraints.
@sang-neo03
Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 16, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 16, 2026

Codecov Report

❌ Patch coverage is 78.87399% with 394 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.38%. Comparing base (898e0ee) to head (1f9f75a).

Files with missing lines Patch % Lines
cmd/platform_bootstrap.go 57.52% 42 Missing and 6 partials ⚠️
internal/platform/staging.go 61.68% 27 Missing and 14 partials ⚠️
internal/platform/inventory.go 63.46% 38 Missing ⚠️
cmd/config/plugins.go 33.96% 33 Missing and 2 partials ⚠️
internal/cmdpolicy/active.go 0.00% 28 Missing ⚠️
internal/platform/host.go 83.55% 18 Missing and 7 partials ⚠️
internal/hook/install.go 79.48% 20 Missing and 4 partials ⚠️
internal/hook/registry.go 66.19% 21 Missing and 3 partials ⚠️
cmd/platform_guards.go 78.21% 17 Missing and 5 partials ⚠️
internal/cmdpolicy/engine.go 91.39% 8 Missing and 5 partials ⚠️
... and 18 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #910      +/-   ##
==========================================
+ Coverage   65.92%   66.38%   +0.46%     
==========================================
  Files         523      560      +37     
  Lines       49692    51543    +1851     
==========================================
+ Hits        32758    34219    +1461     
- Misses      14134    14457     +323     
- Partials     2800     2867      +67     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

🧹 Nitpick comments (2)
cmd/config/plugins.go (1)

63-67: ⚡ Quick win

Keep the JSON shape consistent when inventory is absent.

The success branch always emits plugins and total, but this branch drops total. Returning "total": 0 here keeps stdout machine-friendly without forcing callers to special-case the bootstrap-not-finished path.

♻️ Minimal change
 	if inv == nil {
 		output.PrintJson(f.IOStreams.Out, map[string]any{
 			"plugins": []any{},
+			"total":   0,
 			"note":    "no inventory recorded; bootstrap did not finish",
 		})
 		return nil
 	}

As per coding guidelines, stdout is data (JSON envelopes), so the machine-readable payload should stay stable across branches.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/config/plugins.go` around lines 63 - 67, The nil-inventory branch in the
function emitting plugin JSON uses output.PrintJson with "plugins" and "note"
but omits "total", breaking the JSON shape; update that call (where inv == nil)
to include "total": 0 so the output matches the success branch schema and keeps
stdout machine-friendly (modify the map passed to output.PrintJson in
cmd/config/plugins.go).
internal/cmdpolicy/diagnostic_test.go (1)

17-31: ⚡ Quick win

Add config/plugins/show to the EvaluateAll diagnostic-bypass test path.

TestIsDiagnosticPath covers this path statically, but there’s no runtime EvaluateAll assertion ensuring it still bypasses rule pruning like the other diagnostic commands.

Suggested test addition
 func configPolicyTree() *cobra.Command {
 	root := &cobra.Command{Use: "lark-cli"}

 	config := &cobra.Command{Use: "config"}
 	root.AddCommand(config)
 	policy := &cobra.Command{Use: "policy"}
 	config.AddCommand(policy)
 	policy.AddCommand(&cobra.Command{Use: "show", RunE: noop})
 	policy.AddCommand(&cobra.Command{Use: "validate", RunE: noop})
+	plugins := &cobra.Command{Use: "plugins"}
+	config.AddCommand(plugins)
+	plugins.AddCommand(&cobra.Command{Use: "show", RunE: noop})
 	if !got["config/policy/validate"].Allowed {
 		t.Errorf("config/policy/validate must be unconditionally allowed; got Allowed=false reason=%q",
 			got["config/policy/validate"].ReasonCode)
 	}
+	if !got["config/plugins/show"].Allowed {
+		t.Errorf("config/plugins/show must be unconditionally allowed; got Allowed=false reason=%q",
+			got["config/plugins/show"].ReasonCode)
+	}

Also applies to: 56-72

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cmdpolicy/diagnostic_test.go` around lines 17 - 31, The EvaluateAll
diagnostic-bypass test is missing the runtime path for "config/plugins/show" so
it isn't asserted to bypass rule pruning; update the test that exercises
EvaluateAll (and the configPolicyTree used by tests) to include a command node
for "config plugins show" (mirror the static TestIsDiagnosticPath expectation)
and add an EvaluateAll assertion that the path "config/plugins/show" is treated
as a diagnostic path and therefore is not pruned by rules; locate
configPolicyTree, TestIsDiagnosticPath and the EvaluateAll test helpers to add
the new command and the corresponding runtime assertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cmd/build.go`:
- Around line 175-180: When applyUserPolicyPruning returns an error and we
choose to warn-and-continue (the path that calls warnPolicyError), clear the
globally cached active policy so a previous build's policy isn't reported as
active; update the error branch in cmd/build.go to call cmdpolicy.SetActive(nil)
(or the appropriate zero value via cmdpolicy.SetActive(...)) before calling
warnPolicyError. Ensure this change is applied in the same conditional that
handles the non-plugin (warn-and-continue) case so installation/return behavior
(installPluginConflictGuard + return) is unchanged.

In `@cmd/platform_bootstrap_test.go`:
- Around line 23-32: In the tmpHome helper, set the LARKSUITE_CLI_CONFIG_DIR
environment variable so tests don't pick up runner configs: inside the tmpHome(t
*testing.T) function (after creating dir or cfgDir) call
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", <temp-dir>) — e.g. use the same temp dir or
a fresh t.TempDir() — so the config root is isolated for tests that build the
real command tree.

In `@cmd/platform_guards_test.go`:
- Around line 41-43: The tests call an undefined helper
platform.ResetForTesting(), so add an exported ResetForTesting function to the
platform package that clears/resets any global test state (e.g., plugin
registry) so subsequent platform.Register(failClosedAbortingPlugin{}) behaves
predictably; keep its signature as func ResetForTesting() so it can be called
directly and passed to t.Cleanup(platform.ResetForTesting) from the test. Ensure
ResetForTesting resets whatever platform.Register and the package-wide globals
rely on.
- Around line 79-90: The test selects a runnable leaf with walk/auth but always
calls leaf.RunE(...), which will panic if the command only defines Run (not
RunE); update the test to detect which handler exists for the selected command:
if leaf.RunE != nil call checkGuardError(t, leaf.RunE(leaf, nil)), otherwise if
leaf.Run != nil call checkGuardError(t, func(...) error { leaf.Run(leaf, nil);
return nil }(/* adapt call signature */)) — i.e. invoke Run when RunE is nil (or
wrap Run into an error-returning invocation) so the test never dereferences a
nil RunE. Ensure you reference the leaf variable and the Run/RunE methods when
making the change.

In `@cmd/plugin_integration_test.go`:
- Around line 104-105: Replace the undefined symbol platform.ResetForTesting
with the actual reset helper that exists in your test helpers: either import the
package that defines ResetForTesting and alias it to platform, or change the
call sites to the correct package name (for example testhelpers.ResetForTesting
or platformtest.ResetForTesting) so the symbol resolves; update all occurrences
(the direct calls and the t.Cleanup(...) registrations) consistently across the
file.

In `@internal/cmdpolicy/active.go`:
- Around line 40-53: The ActivePolicy copies currently only perform a shallow
copy so the embedded Rule remains aliased; update both places that do cp := *p
(the setter that assigns activePolicy) and cp := *activePolicy (GetActive) to
deep-copy the Rule field before assigning/returning. Concretely: after making
the shallow copy of ActivePolicy, create a new Rule value (or clone the Rule
slice/map if Rule is a collection, or instantiate a new Rule struct if Rule is a
pointer) and copy its contents into cp.Rule so cp.Rule is an independent copy;
do this in the setter (where activePolicy = &cp) and in GetActive() before
returning the cp pointer to prevent callers from mutating shared Rule state.
Ensure the deep-copy logic uses the actual Rule type semantics (allocate new
struct/slice/map and copy elements) so no references to the original Rule
remain.

In `@internal/cmdpolicy/apply.go`:
- Around line 63-65: The Apply function is over-counting because it
unconditionally increments count after calling installDenyStub even when that
helper returns early for strict-mode stubs; change the contract so
installDenyStub returns a boolean (or error) indicating whether a stub was
actually installed and only increment count when that return value is true
(update the two call sites in Apply that currently call installDenyStub and
increment count—also the equivalent loop around lines 135-148—to check the
returned flag before incrementing).

In `@internal/cmdpolicy/engine.go`:
- Around line 246-256: The early-return on no live children incorrectly ignores
a runnable node's own denied state; modify the block that currently checks
!liveChildSeen so that if cmdRunnable is true you consult denied[cmdPath] and
return that value (true if the command itself is denied) otherwise return false;
update the logic around the liveChildSeen check in the function (referencing
liveChildSeen, cmdRunnable, denied and cmdPath) so runnable nodes with only
placeholder branches correctly propagate their own denied status to parents.

In `@internal/cmdpolicy/resolver_test.go`:
- Around line 65-67: Replace the hardcoded absolute missing path in
TestResolve_missingYamlIsNoRule with a path guaranteed to not exist in the
test's temp directory: call t.TempDir(), then join a non-existent filename
(e.g., filepath.Join(t.TempDir(), "policy.yml")) and pass that to
cmdpolicy.Resolve; update references in the test function
TestResolve_missingYamlIsNoRule to use filepath.Join and ensure import of
"path/filepath" if missing.

In `@internal/hook/install_test.go`:
- Around line 197-199: The test currently calls
hook.SetStderrForTesting(&bytes.Buffer{}) and mutates global stderr without
restoring it; fix this by saving the previous stderr sink returned by
hook.SetStderrForTesting (e.g., prev := hook.SetStderrForTesting(...)) and
register a t.Cleanup(func() { hook.SetStderrForTesting(prev) }) so the original
sink is restored after the test, keeping the override scoped to the test.

In `@internal/hook/install.go`:
- Around line 45-58: Install currently guards root and reg but not snapshot;
because wrapRunE dereferences snapshot (CommandViewSource) you must check
snapshot for nil before wrapping commands — either return early from Install if
snapshot is nil or skip calling wrapRunE for each command when snapshot == nil;
update Install (which calls walkTree and wrapRunE) to perform this nil check and
avoid invoking wrapRunE with a nil snapshot.
- Around line 132-134: The wrapper-propagated context is being ignored when
calling the original handlers: update invokeOriginal (invoked from
finalHandler/composed) to set the command's context to the wrapper context
before calling originalRunE or originalRun (e.g., use c.SetContext(ctx) or
c.Context = ctx), then invoke originalRunE(c, args) / originalRun(c, args);
optionally restore the command's previous context after the call to avoid
side-effects.

In `@internal/platform/inventory.go`:
- Around line 212-225: SetActiveInventory and GetActiveInventory currently
perform a shallow copy of Inventory (cp := *inv / cp := *activeInventory),
leaving fields like Plugins and their nested slices/maps sharing backing memory;
change both to produce a deep copy of the Inventory struct by allocating a new
Inventory value, copying scalar fields, and for each slice/map field (notably
Inventory.Plugins and any nested slices/maps inside Plugin) allocate new
slices/maps and copy/clone each element/value so callers cannot mutate global
state; update uses of activeInventory/inventoryMu accordingly to set/get the
fully deep-copied value.

In `@internal/platform/staging.go`:
- Around line 128-139: The Restrict method on stagingRegistrar currently stores
the caller's *platform.Rule pointer directly (r.rule = rule), allowing external
mutation; instead, create a deep copy of the incoming rule (copy all scalar
fields and allocate+copy each slice/array field and any nested structs) and
assign that copy to r.rule; locate the stagingRegistrar.Restrict function and
replace the direct assignment with code that clones the Rule (including slice
fields like any []string, []RuleCondition, etc.) before storing it so the
registrar holds an independent snapshot.

In `@internal/platform/version.go`:
- Around line 124-127: The code currently truncates versions with more than
three numeric components (fields := strings.Split(s, ".") ... if len(fields) > 3
{ fields = fields[:3] }), which lets malformed RequiredCLIVersion values like
"1.2.3.4" pass; instead, detect len(fields) > 3 and return an error (or mark the
version invalid) indicating a malformed version string including the original s,
so parsing/validation fails closed; update the parsing function that uses
s/fields (the version parse/validate function handling RequiredCLIVersion) to
return that error and ensure callers propagate or handle it.

---

Nitpick comments:
In `@cmd/config/plugins.go`:
- Around line 63-67: The nil-inventory branch in the function emitting plugin
JSON uses output.PrintJson with "plugins" and "note" but omits "total", breaking
the JSON shape; update that call (where inv == nil) to include "total": 0 so the
output matches the success branch schema and keeps stdout machine-friendly
(modify the map passed to output.PrintJson in cmd/config/plugins.go).

In `@internal/cmdpolicy/diagnostic_test.go`:
- Around line 17-31: The EvaluateAll diagnostic-bypass test is missing the
runtime path for "config/plugins/show" so it isn't asserted to bypass rule
pruning; update the test that exercises EvaluateAll (and the configPolicyTree
used by tests) to include a command node for "config plugins show" (mirror the
static TestIsDiagnosticPath expectation) and add an EvaluateAll assertion that
the path "config/plugins/show" is treated as a diagnostic path and therefore is
not pruned by rules; locate configPolicyTree, TestIsDiagnosticPath and the
EvaluateAll test helpers to add the new command and the corresponding runtime
assertion.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d9983c38-0376-4d6c-8c3e-79f30bd08b04

📥 Commits

Reviewing files that changed from the base of the PR and between 2a6f0f4 and e72dcdd.

📒 Files selected for processing (76)
  • .github/workflows/ci.yml
  • Makefile
  • cmd/build.go
  • cmd/config/plugins.go
  • cmd/config/policy.go
  • cmd/config/policy_test.go
  • cmd/platform_bootstrap.go
  • cmd/platform_bootstrap_test.go
  • cmd/platform_guards.go
  • cmd/platform_guards_test.go
  • cmd/plugin_integration_test.go
  • cmd/prune.go
  • cmd/prune_test.go
  • cmd/root_integration_test.go
  • extension/platform/README.md
  • extension/platform/builder.go
  • extension/platform/builder_test.go
  • extension/platform/doc.go
  • extension/platform/errors.go
  • extension/platform/errors_test.go
  • extension/platform/example_test.go
  • extension/platform/examples/.gitignore
  • extension/platform/examples/README.md
  • extension/platform/examples/audit-observer/README.md
  • extension/platform/examples/audit-observer/main.go
  • extension/platform/examples/readonly-policy/README.md
  • extension/platform/examples/readonly-policy/main.go
  • extension/platform/handler.go
  • extension/platform/identity.go
  • extension/platform/invocation.go
  • extension/platform/register.go
  • extension/platform/register_testing.go
  • extension/platform/risk.go
  • extension/platform/risk_test.go
  • extension/platform/rule.go
  • extension/platform/selector.go
  • extension/platform/selector_test.go
  • extension/platform/view.go
  • go.mod
  • internal/cmdmeta/meta.go
  • internal/cmdmeta/meta_test.go
  • internal/cmdpolicy/active.go
  • internal/cmdpolicy/aggregation_test.go
  • internal/cmdpolicy/apply.go
  • internal/cmdpolicy/denial.go
  • internal/cmdpolicy/denial_test.go
  • internal/cmdpolicy/diagnostic.go
  • internal/cmdpolicy/diagnostic_test.go
  • internal/cmdpolicy/engine.go
  • internal/cmdpolicy/engine_test.go
  • internal/cmdpolicy/path.go
  • internal/cmdpolicy/resolver.go
  • internal/cmdpolicy/resolver_test.go
  • internal/cmdpolicy/source_label_test.go
  • internal/cmdpolicy/strict_mode_skip_test.go
  • internal/cmdpolicy/suggest.go
  • internal/cmdpolicy/suggest_test.go
  • internal/cmdpolicy/validate.go
  • internal/cmdpolicy/validate_test.go
  • internal/cmdpolicy/yaml/reader.go
  • internal/cmdpolicy/yaml/schema.go
  • internal/cmdpolicy/yaml/schema_test.go
  • internal/hook/doc.go
  • internal/hook/install.go
  • internal/hook/install_test.go
  • internal/hook/invocation.go
  • internal/hook/registry.go
  • internal/platform/doc.go
  • internal/platform/error.go
  • internal/platform/host.go
  • internal/platform/host_test.go
  • internal/platform/inventory.go
  • internal/platform/inventory_test.go
  • internal/platform/staging.go
  • internal/platform/version.go
  • internal/platform/version_test.go
✅ Files skipped from review due to trivial changes (8)
  • extension/platform/examples/audit-observer/README.md
  • internal/cmdpolicy/yaml/reader.go
  • extension/platform/examples/README.md
  • extension/platform/README.md
  • extension/platform/examples/readonly-policy/README.md
  • internal/platform/doc.go
  • extension/platform/doc.go
  • go.mod
🚧 Files skipped from review as they are similar to previous changes (8)
  • extension/platform/errors.go
  • extension/platform/view.go
  • extension/platform/handler.go
  • extension/platform/selector_test.go
  • cmd/config/policy_test.go
  • extension/platform/selector.go
  • internal/cmdmeta/meta_test.go
  • internal/cmdmeta/meta.go

Comment thread cmd/build.go
Comment thread cmd/platform_bootstrap_test.go
Comment thread cmd/platform_guards_test.go
Comment thread cmd/platform_guards_test.go
Comment thread cmd/plugin_integration_test.go
Comment thread internal/hook/install.go
Comment thread internal/hook/install.go
Comment thread internal/platform/inventory.go Outdated
Comment thread internal/platform/staging.go
Comment thread internal/platform/version.go
…uilder

Introduces extension/platform — the in-process plugin SDK external
Go forks of lark-cli use to extend or restrict the command surface.
Plugins compile in via blank import; there is no dynamic loading
and no RPC isolation.

Public SDK (extension/platform):

  - Plugin interface (Name / Version / Capabilities / Install).
  - Registrar verbs: Observe, Wrap, On, Restrict.
  - Hook types: Observer (side-effect, panic-safe, fires Before/After
    RunE), Wrapper (middleware, may short-circuit via AbortError),
    LifecycleHandler (Startup / Shutdown), Selector with nil-safe
    And/Or/Not composition.
  - Risk / Identity are defined string types with closed taxonomies;
    ParseRisk / ParseIdentity convert raw strings with the
    absent-vs-invalid distinction the engine relies on.
  - Builder ergonomic constructor (NewPlugin().Observer().Wrap()
    ...MustBuild()) that enforces name/hookName grammar, hookName
    uniqueness, and the Restrict ↔ FailClosed pairing regardless of
    call order.
  - Invocation is a read-only interface; the framework's concrete
    invocation type lives in internal/hook so plugins cannot
    fabricate denial / strict-mode / identity state. Args() returns
    a defensive copy on every call so hook mutation cannot leak
    into the original RunE.
  - CommandDeniedError + AbortError carry structured fields for the
    closed `command_denied` / `hook` envelope contract.
  - ResetForTesting gated behind //go:build testing.
  - README + godoc examples (Observer / Wrapper / Restrict) + two
    runnable example forks (audit-observer, readonly-policy).

Host (internal/platform, internal/hook, internal/cmdpolicy):

  - InstallAll: staged plugin registration with atomic commit, panic
    isolation, FailOpen / FailClosed semantics, RequiredCLIVersion
    semver check, single-Restrict invariant, duplicate-plugin-name
    detection.
  - hook.Install wraps every runnable cmd.RunE with:
    Before observers (panic-safe) → denial guard → composed Wrap
    chain → original RunE → After observers (always fire, even on
    err). Denied commands physically bypass the Wrap chain so a
    plugin Wrapper cannot suppress or rewrite a denial; observers
    still see the attempt for audit.
  - Recover shim around plugin Wrappers converts panics (including
    the factory call) into a structured `hook` envelope with
    reason_code=panic; namespacing shim attributes AbortError to
    the namespaced hook name.
  - cmdpolicy (renamed from internal/pruning) is the user-layer
    command policy engine: walks the cobra tree, evaluates each
    runnable command against a Rule's four-axis filter (Allow /
    Deny / MaxRisk / Identities), produces parent-group aggregate
    denials, and installs denyStubs. Rule.AllowUnannotated opts out
    of the unannotated-deny gate for gradual adoption; risk_invalid
    typos always deny with an edit-distance "did you mean"
    suggestion.
  - Strict-mode stub in cmd/prune.go composes the shared
    detail.* / wrapped CommandDeniedError shape via cmdpolicy
    helpers (BuildDenialError / CommandDeniedFromDenial /
    DenialDetailMap), so command_denied envelopes from strict-mode
    and user-layer policy carry the same closed-enum fields
    (detail.layer / reason_code / policy_source). The historical
    short Message + independent Hint are preserved unchanged.
  - cmdpolicy/yaml: structural parsing of ~/.lark-cli/policy.yml
    with KnownFields strict mode, including allow_unannotated.
  - `config policy show` / `config policy validate` and the plugin
    inventory diagnostic surface the resolved Rule (allow,
    deny, max_risk, identities, allow_unannotated) and the hook
    contributions per plugin.

Envelope contract (docs/extension/reason-codes.md):

  - error.type is a closed set: command_denied, hook, plugin_install,
    plugin_conflict, plugin_lifecycle.
  - reason_code is a closed enum per error.type, dispatched on by
    external agents and CI integrations.
  - detail.layer = "policy" | "strict_mode" attributes the rejection.

Build / CI:

  - Makefile unit-test / vet / coverage and ci.yml fast-gate +
    unit-test + coverage now pass -tags testing so register_testing.go
    is visible; ./extension/... is in the package list so the SDK's
    own tests actually run.
  - fmt-check and examples-build Makefile targets.
  - bmatcuk/doublestar/v4 added as a direct dependency for `**` glob
    matching in Rule.Allow / Rule.Deny.

Author-facing material:

  - docs/extension/ (quickstart, plugin-author-guide, reason-codes)
    is provided in the working tree but kept out of git tracking
    per repo convention (.gitignore covers docs/).

Change-Id: I3b8ecc2923bd54c2dff19e5dce8a0855a6f9e703
@liangshuo-1 liangshuo-1 force-pushed the feat/extension-platform branch from e72dcdd to 461e3c6 Compare May 16, 2026 03:31
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (3)
cmd/config/policy.go (1)

100-100: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate untrusted policy paths and switch filesystem calls to vfs

Line 123 reads a CLI-supplied path directly, and Line 100/Line 123 use os.* APIs. This bypasses required path validation and testable filesystem abstraction.

Suggested fix
 import (
 	"fmt"
-	"os"
 
 	"github.com/spf13/cobra"
 
 	"github.com/larksuite/cli/internal/cmdpolicy"
 	pyaml "github.com/larksuite/cli/internal/cmdpolicy/yaml"
 	"github.com/larksuite/cli/internal/cmdutil"
 	"github.com/larksuite/cli/internal/output"
+	"github.com/larksuite/cli/internal/validate"
+	"github.com/larksuite/cli/internal/vfs"
 )
@@
 	if active.Source.Kind == cmdpolicy.SourcePlugin && active.YAMLPath != "" {
-		if _, err := os.Stat(active.YAMLPath); err == nil {
+		if _, err := vfs.Stat(active.YAMLPath); err == nil {
 			out["yaml_shadowed"] = true
 			fmt.Fprintln(f.IOStreams.ErrOut,
 				"note: a plugin contributed Restrict(); yaml IGNORED")
 		}
 	}
@@
 func runConfigPolicyValidate(f *cmdutil.Factory, path string) error {
-	data, err := os.ReadFile(path)
+	safePath, err := validate.SafeInputPath(path)
+	if err != nil {
+		return output.Errorf(output.ExitValidation, "validation",
+			"invalid path %q: %v", path, err)
+	}
+	data, err := vfs.ReadFile(safePath)
 	if err != nil {
 		return output.Errorf(output.ExitValidation, "validation",
 			"read policy yaml %q: %v", path, err)
 	}

As per coding guidelines: “Use vfs.* instead of os.* for all filesystem access” and “Validate paths before reading with validate.SafeInputPath because CLI arguments are untrusted.”

Also applies to: 123-127

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/config/policy.go` at line 100, The code currently calls os.Stat and reads
a CLI-supplied path via active.YAMLPath without validation; replace those os.*
calls with the vfs equivalents (e.g., vfs.Stat, vfs.ReadFile) and validate the
CLI path first using validate.SafeInputPath(active.YAMLPath) before any
filesystem operation; update the conditional that checks the file existence (the
os.Stat call) and the subsequent file read logic to use vfs and ensure you
handle and return validation or vfs errors appropriately so the path is not used
untrusted.
internal/cmdpolicy/resolver_test.go (1)

65-67: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use a tempdir-based missing path for deterministic test behavior

Line 66 should avoid a hardcoded absolute path and use a guaranteed-missing file under t.TempDir().

Suggested fix
 func TestResolve_missingYamlIsNoRule(t *testing.T) {
-	got, src, err := cmdpolicy.Resolve(nil, "/nonexistent/policy.yml")
+	yamlPath := filepath.Join(t.TempDir(), "missing-policy.yml")
+	got, src, err := cmdpolicy.Resolve(nil, yamlPath)
 	if err != nil {
 		t.Fatalf("missing yaml should not error, got %v", err)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cmdpolicy/resolver_test.go` around lines 65 - 67,
TestResolve_missingYamlIsNoRule uses a hardcoded absolute path; change the test
to build a guaranteed-missing path under t.TempDir() and pass that to
cmdpolicy.Resolve. In the TestResolve_missingYamlIsNoRule function, call
t.TempDir(), join a non-existing filename (e.g. filepath.Join(t.TempDir(),
"policy.yml")) and use that path instead of "/nonexistent/policy.yml"; ensure
you import "path/filepath" if needed.
internal/platform/inventory.go (1)

205-225: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Deep-copy active inventory snapshots to prevent shared mutable state

Line 212 and Line 224 only clone the top-level struct. Nested slices/pointers remain shared, so callers can mutate global inventory outside lock protection.

Suggested fix
+func cloneInventory(in *Inventory) *Inventory {
+	if in == nil {
+		return nil
+	}
+	out := &Inventory{Plugins: make([]PluginEntry, len(in.Plugins))}
+	for i := range in.Plugins {
+		p := in.Plugins[i]
+		p.Observers = append([]HookEntry(nil), p.Observers...)
+		p.Wrappers = append([]HookEntry(nil), p.Wrappers...)
+		p.Lifecycles = append([]HookEntry(nil), p.Lifecycles...)
+		if p.Rule != nil {
+			r := *p.Rule
+			r.Allow = append([]string(nil), r.Allow...)
+			r.Deny = append([]string(nil), r.Deny...)
+			r.Identities = append([]string(nil), r.Identities...)
+			p.Rule = &r
+		}
+		out.Plugins[i] = p
+	}
+	return out
+}
+
 func SetActiveInventory(inv *Inventory) {
 	inventoryMu.Lock()
 	defer inventoryMu.Unlock()
-	if inv == nil {
-		activeInventory = nil
-		return
-	}
-	cp := *inv
-	activeInventory = &cp
+	activeInventory = cloneInventory(inv)
 }
@@
 func GetActiveInventory() *Inventory {
 	inventoryMu.RLock()
 	defer inventoryMu.RUnlock()
-	if activeInventory == nil {
-		return nil
-	}
-	cp := *activeInventory
-	return &cp
+	return cloneInventory(activeInventory)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/platform/inventory.go` around lines 205 - 225, SetActiveInventory
and GetActiveInventory only make a shallow copy of Inventory, leaving nested
slices/maps/pointers shared and mutable; update both functions
(SetActiveInventory and GetActiveInventory) to perform a full deep copy of the
Inventory contents (copy all nested slices, maps, and any pointer fields into
new allocations) before assigning to activeInventory or returning it so callers
never share internal mutable state with the global snapshot; ensure the
deep-copy logic handles nils and preserves lengths/capacities and reuse or add a
helper like deepCopyInventory(inv *Inventory) *Inventory to centralize the copy
logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@cmd/config/policy.go`:
- Line 100: The code currently calls os.Stat and reads a CLI-supplied path via
active.YAMLPath without validation; replace those os.* calls with the vfs
equivalents (e.g., vfs.Stat, vfs.ReadFile) and validate the CLI path first using
validate.SafeInputPath(active.YAMLPath) before any filesystem operation; update
the conditional that checks the file existence (the os.Stat call) and the
subsequent file read logic to use vfs and ensure you handle and return
validation or vfs errors appropriately so the path is not used untrusted.

In `@internal/cmdpolicy/resolver_test.go`:
- Around line 65-67: TestResolve_missingYamlIsNoRule uses a hardcoded absolute
path; change the test to build a guaranteed-missing path under t.TempDir() and
pass that to cmdpolicy.Resolve. In the TestResolve_missingYamlIsNoRule function,
call t.TempDir(), join a non-existing filename (e.g. filepath.Join(t.TempDir(),
"policy.yml")) and use that path instead of "/nonexistent/policy.yml"; ensure
you import "path/filepath" if needed.

In `@internal/platform/inventory.go`:
- Around line 205-225: SetActiveInventory and GetActiveInventory only make a
shallow copy of Inventory, leaving nested slices/maps/pointers shared and
mutable; update both functions (SetActiveInventory and GetActiveInventory) to
perform a full deep copy of the Inventory contents (copy all nested slices,
maps, and any pointer fields into new allocations) before assigning to
activeInventory or returning it so callers never share internal mutable state
with the global snapshot; ensure the deep-copy logic handles nils and preserves
lengths/capacities and reuse or add a helper like deepCopyInventory(inv
*Inventory) *Inventory to centralize the copy logic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6fd45d8d-5cef-4924-86a0-c64cf77b801d

📥 Commits

Reviewing files that changed from the base of the PR and between e72dcdd and 461e3c6.

📒 Files selected for processing (75)
  • .github/workflows/ci.yml
  • Makefile
  • cmd/build.go
  • cmd/config/plugins.go
  • cmd/config/policy.go
  • cmd/config/policy_test.go
  • cmd/platform_bootstrap.go
  • cmd/platform_bootstrap_test.go
  • cmd/platform_guards.go
  • cmd/platform_guards_test.go
  • cmd/plugin_integration_test.go
  • cmd/prune.go
  • cmd/prune_test.go
  • cmd/root_integration_test.go
  • extension/platform/README.md
  • extension/platform/builder.go
  • extension/platform/builder_test.go
  • extension/platform/doc.go
  • extension/platform/errors.go
  • extension/platform/errors_test.go
  • extension/platform/example_test.go
  • extension/platform/examples/.gitignore
  • extension/platform/examples/README.md
  • extension/platform/examples/audit-observer/README.md
  • extension/platform/examples/audit-observer/main.go
  • extension/platform/examples/readonly-policy/README.md
  • extension/platform/examples/readonly-policy/main.go
  • extension/platform/handler.go
  • extension/platform/identity.go
  • extension/platform/invocation.go
  • extension/platform/register.go
  • extension/platform/register_testing.go
  • extension/platform/risk.go
  • extension/platform/risk_test.go
  • extension/platform/rule.go
  • extension/platform/selector.go
  • extension/platform/selector_test.go
  • extension/platform/view.go
  • internal/cmdmeta/meta.go
  • internal/cmdmeta/meta_test.go
  • internal/cmdpolicy/active.go
  • internal/cmdpolicy/aggregation_test.go
  • internal/cmdpolicy/apply.go
  • internal/cmdpolicy/denial.go
  • internal/cmdpolicy/denial_test.go
  • internal/cmdpolicy/diagnostic.go
  • internal/cmdpolicy/diagnostic_test.go
  • internal/cmdpolicy/engine.go
  • internal/cmdpolicy/engine_test.go
  • internal/cmdpolicy/path.go
  • internal/cmdpolicy/resolver.go
  • internal/cmdpolicy/resolver_test.go
  • internal/cmdpolicy/source_label_test.go
  • internal/cmdpolicy/strict_mode_skip_test.go
  • internal/cmdpolicy/suggest.go
  • internal/cmdpolicy/suggest_test.go
  • internal/cmdpolicy/validate.go
  • internal/cmdpolicy/validate_test.go
  • internal/cmdpolicy/yaml/reader.go
  • internal/cmdpolicy/yaml/schema.go
  • internal/cmdpolicy/yaml/schema_test.go
  • internal/hook/doc.go
  • internal/hook/install.go
  • internal/hook/install_test.go
  • internal/hook/invocation.go
  • internal/hook/registry.go
  • internal/platform/doc.go
  • internal/platform/error.go
  • internal/platform/host.go
  • internal/platform/host_test.go
  • internal/platform/inventory.go
  • internal/platform/inventory_test.go
  • internal/platform/staging.go
  • internal/platform/version.go
  • internal/platform/version_test.go
✅ Files skipped from review due to trivial changes (7)
  • extension/platform/examples/.gitignore
  • extension/platform/README.md
  • extension/platform/examples/README.md
  • extension/platform/examples/audit-observer/README.md
  • extension/platform/examples/readonly-policy/README.md
  • extension/platform/doc.go
  • internal/hook/doc.go
🚧 Files skipped from review as they are similar to previous changes (60)
  • internal/platform/doc.go
  • internal/cmdpolicy/yaml/reader.go
  • extension/platform/errors_test.go
  • internal/platform/error.go
  • extension/platform/invocation.go
  • extension/platform/view.go
  • internal/cmdpolicy/diagnostic_test.go
  • extension/platform/examples/audit-observer/main.go
  • internal/cmdpolicy/suggest.go
  • extension/platform/errors.go
  • internal/cmdpolicy/path.go
  • extension/platform/examples/readonly-policy/main.go
  • internal/cmdmeta/meta.go
  • internal/cmdpolicy/strict_mode_skip_test.go
  • extension/platform/risk.go
  • internal/platform/inventory_test.go
  • internal/cmdpolicy/diagnostic.go
  • extension/platform/example_test.go
  • internal/hook/registry.go
  • extension/platform/rule.go
  • extension/platform/identity.go
  • cmd/config/policy_test.go
  • internal/cmdpolicy/validate.go
  • extension/platform/register_testing.go
  • cmd/prune_test.go
  • internal/cmdpolicy/yaml/schema.go
  • internal/cmdpolicy/aggregation_test.go
  • internal/platform/staging.go
  • extension/platform/builder_test.go
  • cmd/root_integration_test.go
  • internal/cmdpolicy/active.go
  • cmd/config/plugins.go
  • internal/cmdpolicy/suggest_test.go
  • internal/cmdpolicy/source_label_test.go
  • extension/platform/selector.go
  • internal/cmdpolicy/validate_test.go
  • extension/platform/selector_test.go
  • cmd/plugin_integration_test.go
  • internal/cmdpolicy/engine.go
  • cmd/platform_guards_test.go
  • internal/cmdpolicy/yaml/schema_test.go
  • internal/cmdmeta/meta_test.go
  • cmd/platform_bootstrap_test.go
  • internal/platform/version_test.go
  • extension/platform/handler.go
  • internal/platform/version.go
  • extension/platform/builder.go
  • cmd/build.go
  • internal/cmdpolicy/denial_test.go
  • extension/platform/risk_test.go
  • internal/cmdpolicy/apply.go
  • internal/cmdpolicy/denial.go
  • cmd/prune.go
  • internal/platform/host.go
  • extension/platform/register.go
  • internal/hook/install.go
  • internal/hook/install_test.go
  • internal/cmdpolicy/engine_test.go
  • internal/cmdpolicy/resolver.go
  • internal/platform/host_test.go

…uilder

Introduces extension/platform — the in-process plugin SDK external
Go forks of lark-cli use to extend or restrict the command surface.
Plugins compile in via blank import; there is no dynamic loading
and no RPC isolation.

Public SDK (extension/platform):

  - Plugin interface (Name / Version / Capabilities / Install).
  - Registrar verbs: Observe, Wrap, On, Restrict.
  - Hook types: Observer (side-effect, panic-safe, fires Before/After
    RunE), Wrapper (middleware, may short-circuit via AbortError),
    LifecycleHandler (Startup / Shutdown), Selector with nil-safe
    And/Or/Not composition.
  - Risk / Identity are defined string types with closed taxonomies;
    ParseRisk / ParseIdentity convert raw strings with the
    absent-vs-invalid distinction the engine relies on.
  - Builder ergonomic constructor (NewPlugin().Observer().Wrap()
    ...MustBuild()) that enforces name/hookName grammar, hookName
    uniqueness, and the Restrict ↔ FailClosed pairing regardless of
    call order.
  - Invocation is a read-only interface; the framework's concrete
    invocation type lives in internal/hook so plugins cannot
    fabricate denial / strict-mode / identity state. Args() returns
    a defensive copy on every call so hook mutation cannot leak
    into the original RunE.
  - CommandDeniedError + AbortError carry structured fields for the
    closed `command_denied` / `hook` envelope contract.
  - ResetForTesting gated behind //go:build testing.
  - README + godoc examples (Observer / Wrapper / Restrict) + two
    runnable example forks (audit-observer, readonly-policy).

Host (internal/platform, internal/hook, internal/cmdpolicy):

  - InstallAll: staged plugin registration with atomic commit, panic
    isolation, FailOpen / FailClosed semantics, RequiredCLIVersion
    semver check, single-Restrict invariant, duplicate-plugin-name
    detection.
  - hook.Install wraps every runnable cmd.RunE with:
    Before observers (panic-safe) → denial guard → composed Wrap
    chain → original RunE → After observers (always fire, even on
    err). Denied commands physically bypass the Wrap chain so a
    plugin Wrapper cannot suppress or rewrite a denial; observers
    still see the attempt for audit.
  - Recover shim around plugin Wrappers converts panics (including
    the factory call) into a structured `hook` envelope with
    reason_code=panic; namespacing shim attributes AbortError to
    the namespaced hook name.
  - cmdpolicy (renamed from internal/pruning) is the user-layer
    command policy engine: walks the cobra tree, evaluates each
    runnable command against a Rule's four-axis filter (Allow /
    Deny / MaxRisk / Identities), produces parent-group aggregate
    denials, and installs denyStubs. Rule.AllowUnannotated opts out
    of the unannotated-deny gate for gradual adoption; risk_invalid
    typos always deny with an edit-distance "did you mean"
    suggestion.
  - Strict-mode stub in cmd/prune.go composes the shared
    detail.* / wrapped CommandDeniedError shape via cmdpolicy
    helpers (BuildDenialError / CommandDeniedFromDenial /
    DenialDetailMap), so command_denied envelopes from strict-mode
    and user-layer policy carry the same closed-enum fields
    (detail.layer / reason_code / policy_source). The historical
    short Message + independent Hint are preserved unchanged.
  - cmdpolicy/yaml: structural parsing of ~/.lark-cli/policy.yml
    with KnownFields strict mode, including allow_unannotated.
  - `config policy show` / `config policy validate` and the plugin
    inventory diagnostic surface the resolved Rule (allow,
    deny, max_risk, identities, allow_unannotated) and the hook
    contributions per plugin.

Envelope contract (docs/extension/reason-codes.md):

  - error.type is a closed set: command_denied, hook, plugin_install,
    plugin_conflict, plugin_lifecycle.
  - reason_code is a closed enum per error.type, dispatched on by
    external agents and CI integrations.
  - detail.layer = "policy" | "strict_mode" attributes the rejection.

Build / CI:

  - Makefile unit-test / vet / coverage and ci.yml fast-gate +
    unit-test + coverage now pass -tags testing so register_testing.go
    is visible; ./extension/... is in the package list so the SDK's
    own tests actually run.
  - fmt-check and examples-build Makefile targets.
  - bmatcuk/doublestar/v4 added as a direct dependency for `**` glob
    matching in Rule.Allow / Rule.Deny.

Author-facing material:

  - docs/extension/ (quickstart, plugin-author-guide, reason-codes)
    is provided in the working tree but kept out of git tracking
    per repo convention (.gitignore covers docs/).

Change-Id: I3b8ecc2923bd54c2dff19e5dce8a0855a6f9e703
@liangshuo-1 liangshuo-1 force-pushed the feat/extension-platform branch from 461e3c6 to 4ff5ad2 Compare May 16, 2026 03:41
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
cmd/root_integration_test.go (1)

436-439: ⚡ Quick win

Use the shared strict-mode constants in the remaining envelope assertions.

Line 437/438, Line 456/457, and Line 501/502 still hardcode strings even though canonical constants were introduced at Line 33-Line 35. Reusing them avoids fixture drift.

Proposed cleanup
-			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)",
+			Message: strictModeUserMessage,
+			Hint:    strictModeHint,
-			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)",
+			Message: strictModeBotMessage,
+			Hint:    strictModeHint,
-			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)",
+			Message: strictModeBotMessage,
+			Hint:    strictModeHint,

Also applies to: 455-458, 500-503

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/root_integration_test.go` around lines 436 - 439, Replace the hardcoded
strict-mode strings in the envelope assertions (the Type/Message/Hint fields
that currently use "command_denied" and the message `strict mode is "user", only
user-identity commands are available` and its hint) with the canonical
strict-mode constants introduced earlier in the file near the top (the shared
strict-mode constants added around lines 33–35); update all three assertion
blocks (the ones around the shown diff plus the blocks at the other two
locations) to reference those package-level constants instead of literal strings
so the fixtures stay in sync.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cmd/config/plugins.go`:
- Around line 63-67: When inventory is nil, the JSON response omits the "total"
field causing clients to branch; update the nil branch that calls
output.PrintJson(f.IOStreams.Out, ...) so the map includes "total": 0 (matching
the normal branch schema) alongside "plugins" and "note" — locate the
conditional handling around inv == nil in plugins.go (the block that calls
output.PrintJson) and add the "total" key to the returned map.

---

Nitpick comments:
In `@cmd/root_integration_test.go`:
- Around line 436-439: Replace the hardcoded strict-mode strings in the envelope
assertions (the Type/Message/Hint fields that currently use "command_denied" and
the message `strict mode is "user", only user-identity commands are available`
and its hint) with the canonical strict-mode constants introduced earlier in the
file near the top (the shared strict-mode constants added around lines 33–35);
update all three assertion blocks (the ones around the shown diff plus the
blocks at the other two locations) to reference those package-level constants
instead of literal strings so the fixtures stay in sync.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 72a35f0a-4393-4ed5-8898-fc95adfdfe46

📥 Commits

Reviewing files that changed from the base of the PR and between 461e3c6 and 4ff5ad2.

📒 Files selected for processing (76)
  • .github/workflows/ci.yml
  • Makefile
  • cmd/build.go
  • cmd/config/plugins.go
  • cmd/config/policy.go
  • cmd/config/policy_test.go
  • cmd/platform_bootstrap.go
  • cmd/platform_bootstrap_test.go
  • cmd/platform_guards.go
  • cmd/platform_guards_test.go
  • cmd/plugin_integration_test.go
  • cmd/prune.go
  • cmd/prune_test.go
  • cmd/root_integration_test.go
  • extension/platform/README.md
  • extension/platform/builder.go
  • extension/platform/builder_test.go
  • extension/platform/doc.go
  • extension/platform/errors.go
  • extension/platform/errors_test.go
  • extension/platform/example_test.go
  • extension/platform/examples/.gitignore
  • extension/platform/examples/README.md
  • extension/platform/examples/audit-observer/README.md
  • extension/platform/examples/audit-observer/main.go
  • extension/platform/examples/readonly-policy/README.md
  • extension/platform/examples/readonly-policy/main.go
  • extension/platform/handler.go
  • extension/platform/identity.go
  • extension/platform/invocation.go
  • extension/platform/register.go
  • extension/platform/register_testing.go
  • extension/platform/risk.go
  • extension/platform/risk_test.go
  • extension/platform/rule.go
  • extension/platform/selector.go
  • extension/platform/selector_test.go
  • extension/platform/view.go
  • internal/cmdmeta/meta.go
  • internal/cmdmeta/meta_test.go
  • internal/cmdpolicy/active.go
  • internal/cmdpolicy/aggregation_test.go
  • internal/cmdpolicy/apply.go
  • internal/cmdpolicy/denial.go
  • internal/cmdpolicy/denial_test.go
  • internal/cmdpolicy/diagnostic.go
  • internal/cmdpolicy/diagnostic_test.go
  • internal/cmdpolicy/engine.go
  • internal/cmdpolicy/engine_test.go
  • internal/cmdpolicy/path.go
  • internal/cmdpolicy/resolver.go
  • internal/cmdpolicy/resolver_test.go
  • internal/cmdpolicy/source_label_test.go
  • internal/cmdpolicy/strict_mode_skip_test.go
  • internal/cmdpolicy/suggest.go
  • internal/cmdpolicy/suggest_test.go
  • internal/cmdpolicy/validate.go
  • internal/cmdpolicy/validate_test.go
  • internal/cmdpolicy/yaml/reader.go
  • internal/cmdpolicy/yaml/schema.go
  • internal/cmdpolicy/yaml/schema_test.go
  • internal/hook/doc.go
  • internal/hook/install.go
  • internal/hook/install_default.go
  • internal/hook/install_test.go
  • internal/hook/invocation.go
  • internal/hook/registry.go
  • internal/platform/doc.go
  • internal/platform/error.go
  • internal/platform/host.go
  • internal/platform/host_test.go
  • internal/platform/inventory.go
  • internal/platform/inventory_test.go
  • internal/platform/staging.go
  • internal/platform/version.go
  • internal/platform/version_test.go
✅ Files skipped from review due to trivial changes (10)
  • extension/platform/examples/readonly-policy/main.go
  • extension/platform/examples/.gitignore
  • internal/hook/install_default.go
  • extension/platform/examples/README.md
  • extension/platform/examples/readonly-policy/README.md
  • extension/platform/examples/audit-observer/README.md
  • internal/hook/doc.go
  • extension/platform/README.md
  • extension/platform/doc.go
  • internal/platform/doc.go
🚧 Files skipped from review as they are similar to previous changes (63)
  • extension/platform/invocation.go
  • extension/platform/view.go
  • extension/platform/register_testing.go
  • extension/platform/errors.go
  • extension/platform/handler.go
  • extension/platform/identity.go
  • internal/cmdpolicy/diagnostic_test.go
  • internal/platform/inventory_test.go
  • internal/cmdpolicy/suggest.go
  • internal/cmdpolicy/suggest_test.go
  • internal/cmdpolicy/validate.go
  • cmd/prune.go
  • internal/cmdpolicy/strict_mode_skip_test.go
  • internal/cmdmeta/meta_test.go
  • extension/platform/examples/audit-observer/main.go
  • internal/cmdpolicy/validate_test.go
  • .github/workflows/ci.yml
  • internal/hook/registry.go
  • cmd/prune_test.go
  • extension/platform/errors_test.go
  • internal/platform/error.go
  • internal/cmdpolicy/active.go
  • internal/cmdpolicy/aggregation_test.go
  • cmd/config/policy.go
  • internal/cmdpolicy/yaml/reader.go
  • extension/platform/risk_test.go
  • internal/platform/version_test.go
  • internal/cmdpolicy/denial_test.go
  • internal/cmdpolicy/denial.go
  • internal/cmdpolicy/diagnostic.go
  • extension/platform/builder_test.go
  • internal/cmdpolicy/resolver_test.go
  • extension/platform/rule.go
  • extension/platform/register.go
  • internal/platform/version.go
  • cmd/config/policy_test.go
  • extension/platform/selector_test.go
  • internal/cmdpolicy/resolver.go
  • internal/cmdpolicy/yaml/schema.go
  • internal/hook/invocation.go
  • extension/platform/example_test.go
  • internal/platform/inventory.go
  • extension/platform/selector.go
  • internal/cmdpolicy/source_label_test.go
  • extension/platform/risk.go
  • cmd/platform_bootstrap.go
  • internal/cmdpolicy/path.go
  • internal/cmdpolicy/yaml/schema_test.go
  • cmd/build.go
  • internal/cmdpolicy/engine.go
  • internal/platform/host.go
  • cmd/platform_guards_test.go
  • cmd/platform_guards.go
  • internal/cmdpolicy/apply.go
  • internal/cmdmeta/meta.go
  • internal/platform/staging.go
  • cmd/platform_bootstrap_test.go
  • internal/hook/install_test.go
  • internal/cmdpolicy/engine_test.go
  • internal/hook/install.go
  • cmd/plugin_integration_test.go
  • internal/platform/host_test.go
  • extension/platform/builder.go

Comment thread cmd/config/plugins.go
…at/extension-platform

# Conflicts:
#	cmd/config/plugins.go
#	cmd/platform_guards_test.go
#	cmd/plugin_integration_test.go
#	internal/platform/host_test.go
- cmdpolicy: skip AnnotationPureGroup commands in EvaluateAll,
  aggregateParents, and hasRunnableDescendant so user-layer policy
  no longer blocks `<group> --help` after the unknown-subcommand
  guard attaches RunE to every parent
- cmd/root: tag guarded parent groups with AnnotationPureGroup
- extension/platform: drop `//go:build testing` from register_testing.go
  so `go test ./...` works without an extra build tag
- extension/platform/README: inline reason_code reference, fix plugin
  lifecycle diagram order (init/Register precede RegisteredPlugins)
- cmd/platform_bootstrap: route userPolicyPath through
  core.GetBaseConfigDir so LARKSUITE_CLI_CONFIG_DIR is honoured
- cmdpolicy: add RedactHomeDir helper, fold base config dir and
  $HOME prefixes for config policy show + resolver errors
- internal/platform: reject unrecognised FailurePolicy values with
  invalid_capability instead of silently fail-open
- cmd/config: surface diagnostic policy/plugins commands in
  `config --help` Long text
- CHANGELOG: document command_denied error.type rename and
  unknown_subcommand exit-2 behavior change
- hook/install: propagate wrapper-injected ctx to invokeOriginal so
  RunE/Run see context values added by upstream Wrappers
- hook/testing: SetStderrForTesting returns a restore func; tests now
  defer it via t.Cleanup to avoid cross-test sink leakage
- cmdpolicy/active: deep-copy ActivePolicy.Rule on SetActive/GetActive
  so callers can't mutate the stored global through shared slices
- platform/inventory: deep-copy Inventory + nested Plugins / HookEntry
  / RuleView slices on SetActiveInventory / GetActiveInventory
- platform/staging: Restrict clones the plugin-supplied Rule before
  retaining it so the plugin can't mutate it after Install returns
- platform/version: reject RequiredCLIVersion with more than three
  numeric components instead of silently truncating 1.2.3.4 to 1.2.3
- cmd/platform_bootstrap: clear cmdpolicy.SetActive on yaml resolver
  error so config policy show doesn't surface a stale rule
- cmd/platform_bootstrap_test: tmpHome pins LARKSUITE_CLI_CONFIG_DIR
  so host env can't bleed into the policy test fixtures
- cmdpolicy/apply: installDenyStub returns bool; Apply count no longer
  over-reports when strict-mode short-circuits the install
- cmdpolicy/engine: aggregateParents now returns the runnable hybrid's
  own denial status when all children are placeholder branches
- cmdpolicy/resolver_test: use t.TempDir()-rooted missing path instead
  of hardcoded /nonexistent for hermetic missing-file assertion
- cmd/config/plugins: empty-inventory branch emits total: 0 so the
  JSON schema stays stable across populated/empty cases
- cmd/platform_guards_test: select leaf by RunE != nil (not Runnable)
  so the test doesn't nil-deref on Run-only commands
- gofmt run on previously committed cmdpolicy/path*.go (CI fast-gate)
The depguard / forbidigo rule blocks filepath.Abs in internal/ on the
grounds that it accesses the filesystem (Getwd) directly. Switch
RedactHomeDir + foldPrefix to operate on filepath.Clean strings; real
callers pass already-absolute paths (resolver builds yamlPath via
filepath.Join on the absolute config root), so the redaction outcome
is unchanged for production inputs. Relative inputs fall through to
the unchanged branch — filepath.Rel rejects the mixed-absoluteness
case with an error, which the foldPrefix helper already treats as
"not a hit".
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Architecture-level or global-impact change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants