Files
larksuite-cli/internal/hook/invocation.go
liangshuo-1 461e3c62c9 feat(extension/platform): plugin SDK with policy engine, hooks, and Builder
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
2026-05-16 11:31:27 +08:00

88 lines
2.4 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package hook
import (
"time"
"github.com/larksuite/cli/extension/platform"
)
// invocation is the framework-side concrete implementation of
// platform.Invocation. All setters are unexported so plugin code
// (which only sees the platform.Invocation interface) cannot mutate
// state.
//
// The "denial" / "strict_mode" / "identity" fields are populated by
// the framework's bootstrap pipeline before any hook fires; plugins
// only read them through the interface.
type invocation struct {
cmd platform.CommandView
args []string
started time.Time
err error
denied bool
layer string
source string
strictMode string
strictModeKnown bool
identity string
identityResolved bool
}
// newInvocation copies args so the read-only platform.Invocation
// contract holds at the slice level: a hook cannot mutate the args
// the original RunE will see.
func newInvocation(cmd platform.CommandView, args []string) *invocation {
argsCopy := append([]string(nil), args...)
return &invocation{
cmd: cmd,
args: argsCopy,
started: time.Now(),
}
}
// --- platform.Invocation read interface ---
func (i *invocation) Cmd() platform.CommandView { return i.cmd }
// Args returns a fresh copy every call; see newInvocation.
func (i *invocation) Args() []string {
out := make([]string, len(i.args))
copy(out, i.args)
return out
}
func (i *invocation) Started() time.Time { return i.started }
func (i *invocation) Err() error { return i.err }
func (i *invocation) DeniedByPolicy() bool { return i.denied }
func (i *invocation) DenialLayer() string { return i.layer }
func (i *invocation) DenialPolicySource() string {
return i.source
}
func (i *invocation) StrictMode() (string, bool) { return i.strictMode, i.strictModeKnown }
func (i *invocation) Identity() (string, bool) { return i.identity, i.identityResolved }
// --- framework-internal setters (unexported) ---
func (i *invocation) setDenial(layer, source string) {
i.denied = true
i.layer = layer
i.source = source
}
// StrictMode and Identity setters are intentionally absent in V1: the
// framework does not yet plumb either value to the invocation, and
// platform.Invocation.StrictMode() / Identity() therefore return zero
// values. Add the setters when the bootstrap pipeline starts resolving
// them.
func (i *invocation) setErr(err error) {
i.err = err
}