mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
feat(extension): Plugin / Hook framework with command pruning (#910)
* feat(extension): introduce Plugin / Hook framework with command pruning
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
* fix(pruning): deny stub must override Args + PersistentPreRunE
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.
* fix(config): policy subcommand bypasses parent's credential check
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.
* feat(shortcuts): tag service groups with cmdmeta.Domain
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
* feat(diagnostic): add unconditionally allowed command paths for introspection
* feat(plugins): add diagnostic command to inspect installed plugins and their contributions
* fix(cli): surface unknown_subcommand error instead of silent help fallback
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.
* refactor(envelope): rename error.type pruning/strict_mode to command_denied
The envelope's `type` field was leaking implementation terms ("pruning",
"strict_mode") that describe enforcement mechanism rather than the user-
facing semantic. It also duplicated `detail.layer`, and forced consumers
to branch on two values for the same conceptual error ("a command was
denied by policy").
Collapse both into a single semantic type "command_denied". The
enforcement layer ("pruning" / "strict_mode") is preserved in
`detail.layer` so debugging and per-layer diagnostics still work.
* feat(platform): fail closed on unannotated/invalid risk when a Rule is 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.
* chore(config): hide diagnostic policy/plugins commands from --help
`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.
* style: gofmt
* fix(platform): nil Selector honours None contract; reject multi-doc policy 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.
* chore: go mod tidy
* 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
* 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
* refactor(policy): remove validate command and update diagnostics
* fix(extension/platform): address PR review must-fix items
- 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
* fix(extension/platform): address CodeRabbit review comments + CI gofmt
- 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)
* fix(cmdpolicy): replace filepath.Abs with filepath.Clean for lint policy
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".
* refactor(cmdpolicy): pure Resolve + drop path redaction & verbose comments
- Resolve becomes a pure function; I/O moves to LoadYAMLPolicy so
precedence selection can be unit-tested without vfs mocks
- ActivePolicy drops YAMLPath; config policy show JSON loses yaml_path
and yaml_shadowed (and the TOCTOU stat that surfaced them)
- RedactHomeDir and path_test.go removed: the home-dir folding was only
earning its keep through the now-deleted yaml_path field
- cmd/build.go bootstrap block trimmed from 71 to 39 lines by cutting
PR-rationale comments; one note kept for the fail-CLOSED-vs-fail-OPEN
business rule
- cmd/config/config.go: parent Long no longer hard-codes hidden command
hints, matching their Hidden:true intent
Change-Id: Icfbb818ce3ef523c63286bfbed34c49be08ed6a2
* refactor(platform): drop StrictMode/Identity from Invocation interface
These two accessors were documented in the public SDK as "After observers
always see ok=true" but the framework never plumbed values to them, so they
always returned ("", false). Zero internal/example/test callers; a plugin
author trusting the doc would silently get wrong behaviour.
Identity is also fundamentally unsuited for Before observers (per-command
identity resolves inside RunE via f.AuthFor, after Before fires). StrictMode
is a global value better placed on a Framework/Environment interface than
per-Invocation. Removing is non-breaking now (no callers); adding later is
non-breaking too.
Change-Id: Ice200543e9bca3bda759ad98a6e34a56df69e915
* fix(prune): preserve original metadata on strict-mode denial stubs
strictModeStubFrom built a fresh *cobra.Command from scratch, dropping
the original command's annotations (risk_level, lark:supportedIdentities,
cmdmeta.domain) and help text. cobraCommandView is a live proxy walking
parent annotations, so after the Remove+Add replacement, audit observers
firing on a strict-mode-denied command saw Cmd().Risk()=("",false) and
Cmd().Identities()=nil -- breaking the first-class use case for
audit/compliance plugins.
Copy child.Annotations into the stub (stamping the denial annotations on
top) and propagate Short/Long for help-text parity with
cmdpolicy/apply.go::installDenyStub, which preserves these by virtue of
mutating in place.
Regression test asserts risk_level / supportedIdentities / Short / Long
all survive replacement, alongside the denial annotations.
Change-Id: I19810a34575996344b63e839066888c154d69335
* chore(platform): align docs with implementation; fold home in yaml warnings
Followup cleanup to the previous three refactor commits, addressing review
fallout where public docs / examples / contract notes still pointed at
deleted symbols or unimplemented designs:
- cmd/build.go: Build() docstring now mentions the plugin install + Startup
emit side effects; Shutdown only fires on Execute path
- extension/platform/doc.go, lifecycle.go, invocation.go: drop references
to the deleted StrictMode/Identity methods, restore minimal Godoc on
Cmd/Args/Started
- extension/platform/view.go, cmd/platform_bootstrap.go,
internal/hook/install.go: rewrite "snapshot before pruning" promise to
match the actual contract (live view + strict-mode stub metadata
preservation)
- cmd/platform_guards_test.go: stubInvocation drops the two old methods
- cmd/platform_bootstrap.go: redactHome() last-mile folds $HOME -> ~ in
warnPolicyError so an os.PathError carrying the absolute policy path
does not leak the user's home dir to stderr / agent / CI logs
- examples/readonly-policy/README.md: drop yaml_path from the sample
`config policy show` envelope (the field was removed in 52cbb92)
Change-Id: I2874cc2cf9225dfa44a9c07b2449149181b387cb
* chore(build): drop vestigial -tags testing from Makefile and CI
The `testing` build tag was introduced in 461e3c6 to gate
extension/platform/register_testing.go (ResetForTesting); PR review
0efee93 then dropped the //go:build testing directive from that file
so downstream `go test ./...` would work without the tag, but never
cleaned the matching tag references out of Makefile and ci.yml.
The result: 8 places passing -tags testing for a tag that nothing in
the repo actually gates, plus a Makefile comment that confidently
claims a gate exists. Net behaviour is identical to omitting the flag;
the only effect is misleading developers into believing there is a
test-only surface separation.
Drop the flag from vet / unit-test / lint / coverage / deadcode (head
+ base worktree) and remove the misleading comment. ResetForTesting's
public-API exposure was the conscious trade-off taken in 0efee93 and
is left untouched.
Change-Id: If0cd78c87d4aec2a2533419fe75b01aae6b165fd
* feat(cmdpolicy): enrich denial Reason with attempted value + rule constraint
The envelope reason for command_denied previously told the caller WHAT
axis failed but not the concrete values on each side, so an AI agent
reading the envelope could not tell which command identity / risk /
path was attempted vs. which the rule permits. The natural temptation
was then to recommend modifying the rule -- exactly the wrong nudge,
since policy exists to prevent the agent from rewriting its own limits.
Each Reason now carries both the attempted value and the rule's
constraint:
identity_mismatch:
"command supports identities [user]; rule allows [bot]"
domain_not_allowed:
"command path \"drive/+upload\" not in allow list [docs/** contact/**]"
command_denylisted:
"command path \"docs/+delete-doc\" matched deny pattern \"docs/+delete-*\""
risk_too_high / write_not_allowed:
"command risk \"high-risk-write\" exceeds rule max_risk \"write\""
risk_not_annotated:
"command has no risk_level annotation; rule denies unannotated commands"
(drops the prescriptive "set allow_unannotated=true" hint -- that
belongs in docs, not in the engine's denial path)
Adds firstMatch() helper so command_denylisted can name the specific
glob that fired; matchesAny() now wraps firstMatch.
Regression test pins the substring contract per reason_code so future
"comment cleanup" cannot silently strip the values out again.
Change-Id: I17c7cc9411f58e3e43ade5e1ce875f3b7fe3e5ea
* fix(cmdpolicy): gofmt engine_test.go
CI fast-gate flagged the test added in 2eb0c2b as unformatted. Local
make unit-test had it cached; should have run `make vet` (which runs
gofmt-equivalent check via fmt-check) before pushing. Trivial 3-line
indent fix.
Change-Id: I42297ae59f607b97b32e976c9ec1c9ec4ab7de21
* feat(cmd): annotate risk_level on all hand-written cobra commands
Without this, any non-empty user-layer policy.yml (default
allow_unannotated=false) denies these commands with reason_code
risk_not_annotated -- bricking auth login, config init, profile use
etc. on first contact with a policy.
cmdpolicy/engine evaluation now resolves to the intended axis (deny
list / allow list / max_risk / identities) instead of failing closed
on the unannotated gate. Policy authors can write `max_risk: write`
or `allow: [auth/** config/** ...]` to express real intent.
Classification:
read auth status/check/list/scopes, config show /
policy show / plugins show, doctor, completion,
schema, profile list, event list/status/schema/
consume
write auth login/logout, config init/bind/remove/
default-as/strict-mode, profile add/remove/
rename/use, event stop/_bus, api (raw transit)
high-risk-write update (replaces the CLI binary; failure can
leave the install broken)
Notes:
- api standalone is conservatively `write`; per-call risk is unknown
at parse time (raw transit), so static gating only enforces the
write-class minimum.
- event _bus is the hidden IPC daemon forked by consume; standalone
invocation by users is not expected, but the annotation keeps
policy evaluation consistent with the other event subcommands.
- The two diagnostic-allowlisted commands (config policy show /
plugins show) still bypass the engine via diagnosticPaths; the
read annotation is for consistency with surrounding leaves.
---------
Co-authored-by: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com>
This commit is contained in:
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Run tests
|
||||
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/...
|
||||
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/... ./extension/...
|
||||
|
||||
lint:
|
||||
needs: fast-gate
|
||||
|
||||
25
Makefile
25
Makefile
@@ -8,7 +8,7 @@ DATE := $(shell date +%Y-%m-%d)
|
||||
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
|
||||
PREFIX ?= /usr/local
|
||||
|
||||
.PHONY: all build vet test unit-test integration-test install uninstall clean fetch_meta gitleaks
|
||||
.PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks
|
||||
|
||||
all: test
|
||||
|
||||
@@ -21,13 +21,32 @@ build: fetch_meta
|
||||
vet: fetch_meta
|
||||
go vet ./...
|
||||
|
||||
# fmt-check fails when any file would be reformatted by gofmt. Keep this
|
||||
# in sync with the fast-gate "Check formatting" step in CI.
|
||||
fmt-check:
|
||||
@unformatted=$$(gofmt -l . | grep -v '^\.claude/' || true); \
|
||||
if [ -n "$$unformatted" ]; then \
|
||||
echo "Unformatted Go files:"; \
|
||||
echo "$$unformatted"; \
|
||||
echo "Run 'gofmt -w .' and commit."; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
go test -race -gcflags="all=-N -l" -count=1 ./cmd/... ./internal/... ./shortcuts/...
|
||||
go test -race -gcflags="all=-N -l" -count=1 \
|
||||
./cmd/... ./internal/... ./shortcuts/... ./extension/...
|
||||
|
||||
# examples-build keeps the shipped plugin-SDK examples compilable. If this
|
||||
# breaks, the plugin author guide's "go build ./..." path is broken.
|
||||
examples-build:
|
||||
go build ./extension/platform/examples/audit-observer
|
||||
go build ./extension/platform/examples/readonly-policy
|
||||
|
||||
integration-test: build
|
||||
go test -v -count=1 ./tests/...
|
||||
|
||||
test: vet unit-test integration-test
|
||||
test: vet fmt-check unit-test examples-build integration-test
|
||||
|
||||
install: build
|
||||
install -d $(PREFIX)/bin
|
||||
|
||||
@@ -103,6 +103,7 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)")
|
||||
cmd.MarkFlagRequired("scope")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
|
||||
return authListRun(opts)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ send the verification URL to the user as your final message, end the turn, then
|
||||
run --device-code in a later step after the user confirms authorization.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
|
||||
return output.ErrWithHint(output.ExitValidation, "strict_mode",
|
||||
return output.ErrWithHint(output.ExitValidation, "command_denied",
|
||||
fmt.Sprintf("strict mode is %q, user login is disabled in this profile", mode),
|
||||
"if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
|
||||
}
|
||||
@@ -64,6 +64,7 @@ run --device-code in a later step after the user confirms authorization.`,
|
||||
},
|
||||
}
|
||||
cmdutil.SetSupportedIdentities(cmd, []string{"user"})
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
|
||||
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
|
||||
|
||||
@@ -33,6 +33,7 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
|
||||
return authLogoutRun(opts)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
56
cmd/build.go
56
cmd/build.go
@@ -19,7 +19,9 @@ import (
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
_ "github.com/larksuite/cli/events"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -59,18 +61,28 @@ func HideProfile(hide bool) BuildOption {
|
||||
}
|
||||
}
|
||||
|
||||
// Build constructs the full command tree without executing.
|
||||
// Returns only the cobra.Command; Factory is internal.
|
||||
// Build constructs the full command tree. It also installs registered
|
||||
// plugins and emits the Startup lifecycle event during assembly --
|
||||
// so Plugin.On(Startup) handlers run even if the returned command is
|
||||
// never dispatched. The matching Shutdown event is only emitted by
|
||||
// Execute; callers that bypass Execute will not see Shutdown fire.
|
||||
//
|
||||
// Returns only the cobra.Command; Factory and hook Registry are internal.
|
||||
// Use Execute for the standard production entry point.
|
||||
func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) *cobra.Command {
|
||||
_, rootCmd := buildInternal(ctx, inv, opts...)
|
||||
_, rootCmd, _ := buildInternal(ctx, inv, opts...)
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
// buildInternal is a pure assembly function: it wires the command tree from
|
||||
// inv and BuildOptions alone. Any state-dependent decision (disk, network,
|
||||
// env) belongs in the caller and must be threaded in via BuildOption.
|
||||
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command) {
|
||||
//
|
||||
// Returns (factory, rootCmd, registry). The registry is nil when plugin
|
||||
// install failed (FailClosed guard installed) or when no plugin produced
|
||||
// hooks; callers that wire Shutdown emit must nil-check before calling
|
||||
// hook.Emit.
|
||||
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command, *hook.Registry) {
|
||||
// cfg.globals.Profile is left zero here; it's bound to the --profile
|
||||
// flag in RegisterGlobalFlags and filled by cobra's parse step.
|
||||
cfg := &buildConfig{}
|
||||
@@ -124,10 +136,42 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
|
||||
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
||||
|
||||
// Prune commands incompatible with strict mode.
|
||||
installUnknownSubcommandGuard(rootCmd)
|
||||
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
|
||||
return f, rootCmd
|
||||
installResult, installErr := installPluginsAndHooks(cfg.streams.ErrOut)
|
||||
if installErr != nil {
|
||||
installPluginInstallErrorGuard(rootCmd, installErr)
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
var pluginRules []cmdpolicy.PluginRule
|
||||
var registry *hook.Registry
|
||||
if installResult != nil {
|
||||
pluginRules = installResult.PluginRules
|
||||
registry = installResult.Registry
|
||||
}
|
||||
|
||||
// Policy errors fail-CLOSED when a plugin contributed (security
|
||||
// intent must not be silently dropped); yaml-only errors fail-OPEN
|
||||
// with a warning so a typo can't lock the user out.
|
||||
if err := applyUserPolicyPruning(rootCmd, pluginRules); err != nil {
|
||||
if len(pluginRules) > 0 {
|
||||
installPluginConflictGuard(rootCmd, err)
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
warnPolicyError(cfg.streams.ErrOut, err)
|
||||
}
|
||||
|
||||
if registry != nil {
|
||||
if err := wireHooks(ctx, rootCmd, registry); err != nil {
|
||||
installPluginLifecycleErrorGuard(rootCmd, err)
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
}
|
||||
|
||||
recordInventory(installResult)
|
||||
return f, rootCmd, registry
|
||||
}
|
||||
|
||||
@@ -37,5 +37,6 @@ func NewCmdCompletion(f *cmdutil.Factory) *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ Interactive terminal use: run with no flags to enter the TUI form.`,
|
||||
cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)")
|
||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)")
|
||||
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh|en)")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.AddCommand(NewCmdConfigShow(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigDefaultAs(f))
|
||||
cmd.AddCommand(NewCmdConfigStrictMode(f))
|
||||
cmd.AddCommand(NewCmdConfigPolicy(f))
|
||||
cmd.AddCommand(NewCmdConfigPlugins(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -52,5 +52,6 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
|
||||
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)")
|
||||
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
|
||||
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
101
cmd/config/plugins.go
Normal file
101
cmd/config/plugins.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
)
|
||||
|
||||
// NewCmdConfigPlugins exposes the plugin inventory diagnostic command.
|
||||
//
|
||||
// `config policy show` is intentionally focused on the user-layer Rule
|
||||
// (Restrict). Plugins also contribute hooks (Observe / Wrap / Lifecycle)
|
||||
// that are not policy gates but still mutate the CLI's runtime behaviour.
|
||||
// This command surfaces both halves so an operator can answer "what is
|
||||
// this binary doing differently from stock lark-cli?" in one place.
|
||||
//
|
||||
// Like config policy show, the dispatch path is exempt from policy
|
||||
// enforcement (see internal/cmdpolicy/diagnostic.go) so it remains
|
||||
// usable under any Rule.
|
||||
func NewCmdConfigPlugins(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "plugins",
|
||||
Hidden: true, // diagnostic-only; kept callable, omitted from --help so it stays out of AI-agent context
|
||||
Short: "Inspect installed plugins and their hook contributions",
|
||||
// Same leaf-level no-op as config policy: the parent `config`
|
||||
// group's PersistentPreRunE requires builtin credential, but
|
||||
// this is a read-only diagnostic that must work everywhere.
|
||||
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
|
||||
c.SilenceUsage = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(newCmdConfigPluginsShow(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newCmdConfigPluginsShow(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "show",
|
||||
Short: "List successfully installed plugins, their rules, and registered hooks",
|
||||
Long: `Print every plugin that committed during bootstrap, including:
|
||||
|
||||
- name / version / capabilities (FailurePolicy, Restricts, RequiredCLIVersion)
|
||||
- rule (when the plugin called r.Restrict)
|
||||
- hooks: observers (Before / After), wrappers, lifecycle handlers
|
||||
|
||||
Hooks are attributed by their namespaced name -- the framework prepends
|
||||
the plugin name as the prefix at registration time, so an entry
|
||||
"secaudit.audit-pre" belongs to plugin "secaudit".`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runConfigPluginsShow(f)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runConfigPluginsShow(f *cmdutil.Factory) error {
|
||||
inv := internalplatform.GetActiveInventory()
|
||||
if inv == nil {
|
||||
// Always emit the same field set as the populated branch so
|
||||
// AI agents and CI scripts don't have to branch on whether
|
||||
// `total` is present. `note` makes the unusual state explicit
|
||||
// for human readers.
|
||||
output.PrintJson(f.IOStreams.Out, map[string]any{
|
||||
"plugins": []any{},
|
||||
"total": 0,
|
||||
"note": "no inventory recorded; bootstrap did not finish",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
plugins := make([]map[string]any, 0, len(inv.Plugins))
|
||||
for _, p := range inv.Plugins {
|
||||
entry := map[string]any{
|
||||
"name": p.Name,
|
||||
"version": p.Version,
|
||||
"capabilities": p.Capabilities,
|
||||
}
|
||||
if p.Rule != nil {
|
||||
entry["rule"] = p.Rule
|
||||
}
|
||||
entry["hooks"] = map[string]any{
|
||||
"observers": p.Observers,
|
||||
"wrappers": p.Wrappers,
|
||||
"lifecycle": p.Lifecycles,
|
||||
"count": len(p.Observers) + len(p.Wrappers) + len(p.Lifecycles),
|
||||
}
|
||||
plugins = append(plugins, entry)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, map[string]any{
|
||||
"plugins": plugins,
|
||||
"total": len(plugins),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
75
cmd/config/policy.go
Normal file
75
cmd/config/policy.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func NewCmdConfigPolicy(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "policy",
|
||||
Hidden: true,
|
||||
Short: "Inspect the user-layer command policy",
|
||||
// Override parent's RequireBuiltinCredentialProvider check; this
|
||||
// group is read-only diagnostic and must work under any provider.
|
||||
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
|
||||
c.SilenceUsage = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(newCmdConfigPolicyShow(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newCmdConfigPolicyShow(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "show",
|
||||
Hidden: true,
|
||||
Short: "Show the active user-layer policy (plugin / yaml / none)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runConfigPolicyShow(f)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runConfigPolicyShow(f *cmdutil.Factory) error {
|
||||
active := cmdpolicy.GetActive()
|
||||
if active == nil {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]any{
|
||||
"source": string(cmdpolicy.SourceNone),
|
||||
"note": "no policy recorded; bootstrap did not run pruning",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
sourceName := ""
|
||||
if active.Source.Kind == cmdpolicy.SourcePlugin {
|
||||
sourceName = active.Source.Name
|
||||
}
|
||||
out := map[string]any{
|
||||
"source": string(active.Source.Kind),
|
||||
"source_name": sourceName,
|
||||
"denied_paths": active.DeniedPaths,
|
||||
}
|
||||
if active.Rule != nil {
|
||||
out["rule"] = map[string]any{
|
||||
"name": active.Rule.Name,
|
||||
"description": active.Rule.Description,
|
||||
"allow": active.Rule.Allow,
|
||||
"deny": active.Rule.Deny,
|
||||
"max_risk": active.Rule.MaxRisk,
|
||||
"identities": active.Rule.Identities,
|
||||
"allow_unannotated": active.Rule.AllowUnannotated,
|
||||
}
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, out)
|
||||
return nil
|
||||
}
|
||||
146
cmd/config/policy_test.go
Normal file
146
cmd/config/policy_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
func newPolicyTestFactory() (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||
out := &bytes.Buffer{}
|
||||
errOut := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{
|
||||
IOStreams: cmdutil.NewIOStreams(nil, out, errOut),
|
||||
}
|
||||
return f, out, errOut
|
||||
}
|
||||
|
||||
// `config policy show` reads the active policy recorded by bootstrap.
|
||||
// When nothing is recorded the command must still produce a JSON
|
||||
// envelope with source=none and a note explaining the missing context.
|
||||
func TestConfigPolicyShow_NoActivePolicy(t *testing.T) {
|
||||
cmdpolicy.ResetActiveForTesting()
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
f, out, _ := newPolicyTestFactory()
|
||||
if err := runConfigPolicyShow(f); err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("not json: %v\n%s", err, out.String())
|
||||
}
|
||||
if got["source"] != "none" {
|
||||
t.Errorf("source = %v, want none", got["source"])
|
||||
}
|
||||
if got["note"] == "" || got["note"] == nil {
|
||||
t.Errorf("expected explanatory note when no policy recorded")
|
||||
}
|
||||
}
|
||||
|
||||
// When bootstrap recorded an active plugin Rule, `show` emits the rule
|
||||
// plus its source.
|
||||
func TestConfigPolicyShow_PluginActive(t *testing.T) {
|
||||
cmdpolicy.ResetActiveForTesting()
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
rule := &platform.Rule{
|
||||
Name: "secaudit",
|
||||
Allow: []string{"docs/**"},
|
||||
MaxRisk: "read",
|
||||
}
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||
Rule: rule,
|
||||
Source: cmdpolicy.ResolveSource{
|
||||
Kind: cmdpolicy.SourcePlugin,
|
||||
Name: "secaudit",
|
||||
},
|
||||
DeniedPaths: 42,
|
||||
})
|
||||
|
||||
f, out, _ := newPolicyTestFactory()
|
||||
if err := runConfigPolicyShow(f); err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("not json: %v\n%s", err, out.String())
|
||||
}
|
||||
if got["source"] != "plugin" {
|
||||
t.Errorf("source = %v, want plugin", got["source"])
|
||||
}
|
||||
if got["source_name"] != "secaudit" {
|
||||
t.Errorf("source_name = %v, want secaudit", got["source_name"])
|
||||
}
|
||||
// json.Unmarshal returns float64 for numbers.
|
||||
if got["denied_paths"] != float64(42) {
|
||||
t.Errorf("denied_paths = %v, want 42", got["denied_paths"])
|
||||
}
|
||||
ruleMap, ok := got["rule"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("rule field missing or wrong type")
|
||||
}
|
||||
if ruleMap["name"] != "secaudit" {
|
||||
t.Errorf("rule.name = %v", ruleMap["name"])
|
||||
}
|
||||
}
|
||||
|
||||
// `source_name` must be empty when source=yaml. The yaml path is
|
||||
// deliberately not surfaced (matches engine envelope convention,
|
||||
// avoids leaking the user's home dir to AI agents / CI logs). The
|
||||
// rule's "name:" field is the disambiguator users should rely on.
|
||||
func TestConfigPolicyShow_YamlSourceNameIsEmpty(t *testing.T) {
|
||||
cmdpolicy.ResetActiveForTesting()
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||
Rule: &platform.Rule{Name: "my-yaml-rule"},
|
||||
Source: cmdpolicy.ResolveSource{
|
||||
Kind: cmdpolicy.SourceYAML,
|
||||
Name: "/Users/alice/.lark-cli/policy.yml",
|
||||
},
|
||||
})
|
||||
|
||||
f, out, _ := newPolicyTestFactory()
|
||||
if err := runConfigPolicyShow(f); err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("not json: %v\n%s", err, out.String())
|
||||
}
|
||||
if got["source"] != "yaml" {
|
||||
t.Errorf("source = %v, want yaml", got["source"])
|
||||
}
|
||||
if got["source_name"] != "" {
|
||||
t.Errorf("source_name = %q, want empty (yaml path must not leak)", got["source_name"])
|
||||
}
|
||||
// The path must not appear anywhere in the envelope.
|
||||
if bytes.Contains(out.Bytes(), []byte("/Users/alice")) {
|
||||
t.Errorf("envelope leaked yaml path: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: the parent `config` command declares a PersistentPreRunE
|
||||
// that calls RequireBuiltinCredentialProvider; env credentials cause
|
||||
// it to return external_provider. `config policy` is a diagnostic
|
||||
// group that must not be blocked by that check. The group declares
|
||||
// its own no-op PersistentPreRunE so cobra's "first walking up from
|
||||
// leaf" picks ours over the config parent's.
|
||||
func TestConfigPolicy_BypassesConfigParentPersistentPreRunE(t *testing.T) {
|
||||
f, _, _ := newPolicyTestFactory()
|
||||
group := NewCmdConfigPolicy(f)
|
||||
if group.PersistentPreRunE == nil {
|
||||
t.Fatal("config policy group must declare its own PersistentPreRunE to win over config parent")
|
||||
}
|
||||
if err := group.PersistentPreRunE(group, nil); err != nil {
|
||||
t.Errorf("config policy PersistentPreRunE should be no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ func NewCmdConfigRemove(f *cmdutil.Factory, runF func(*ConfigRemoveOptions) erro
|
||||
return configRemoveRun(opts)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) *
|
||||
return configShowRun(opts)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ explicit user confirmation — never run on your own initiative.`,
|
||||
|
||||
cmd.Flags().BoolVar(&global, "global", false, "set at global level (applies to all profiles)")
|
||||
cmd.Flags().BoolVar(&reset, "reset", false, "reset profile setting to inherit global")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ func NewCmdDoctor(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmd.Flags().BoolVar(&opts.Offline, "offline", false, "skip network checks (only verify local state)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
cmd.Flags().StringVar(&domain, "domain", "", "API domain")
|
||||
_ = cmd.Flags().MarkHidden("domain")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ Use 'event schema <EventKey>' for parameter details.`,
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the full EventKey list as JSON (for AI / scripts)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the EventKey definition + resolved schema as JSON (for AI / scripts)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ func NewCmdStatus(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit status as JSON (for AI / scripts)")
|
||||
cmd.Flags().BoolVar(¤t, "current", false, "Only show status for the current profile's app")
|
||||
cmd.Flags().BoolVar(&failOnOrphan, "fail-on-orphan", false, "Exit 2 when any orphan bus is detected (default: always exit 0)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ Exit code: 2 if any target was refused or errored, 0 otherwise.
|
||||
cmd.Flags().BoolVar(&o.all, "all", false, "Stop all running bus daemons")
|
||||
cmd.Flags().BoolVar(&o.force, "force", false, "Stop even with active consumers; on shutdown-timeout also SIGKILL the bus")
|
||||
cmd.Flags().BoolVar(&o.asJSON, "json", false, "Emit results as JSON (for AI / scripts)")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ func TestIsSingleAppMode_MultiApp(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildInternal_HideProfileOption(t *testing.T) {
|
||||
_, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams(), HideProfile(true))
|
||||
_, root, _ := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams(), HideProfile(true))
|
||||
|
||||
flag := root.PersistentFlags().Lookup("profile")
|
||||
if flag == nil {
|
||||
@@ -90,7 +90,7 @@ func TestBuildInternal_HideProfileOption(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildInternal_DefaultShowsProfileFlag(t *testing.T) {
|
||||
_, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams())
|
||||
_, root, _ := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams())
|
||||
|
||||
flag := root.PersistentFlags().Lookup("profile")
|
||||
if flag == nil {
|
||||
|
||||
274
cmd/platform_bootstrap.go
Normal file
274
cmd/platform_bootstrap.go
Normal file
@@ -0,0 +1,274 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// userPolicyFileName is the conventional filename for the user-layer Rule.
|
||||
// Lives under ~/.lark-cli/ to match the rest of the CLI's user-state
|
||||
// directory.
|
||||
const userPolicyFileName = "policy.yml"
|
||||
|
||||
// applyUserPolicyPruning resolves the user-layer Rule from plugin
|
||||
// contributions and/or ~/.lark-cli/policy.yml and installs denyStubs
|
||||
// for commands it rejects.
|
||||
//
|
||||
// Missing yaml is not an error -- the CLI runs with no user-layer
|
||||
// restriction. A malformed Rule (bad MaxRisk enum, malformed glob, etc.)
|
||||
// surfaces via the returned error; the caller decides how to handle it.
|
||||
//
|
||||
// pluginRules carries Plugin.Restrict() contributions collected from
|
||||
// the InstallAll phase; nil/empty is fine.
|
||||
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) error {
|
||||
yamlPath, err := userPolicyPath()
|
||||
if err != nil {
|
||||
// No user home dir means we cannot locate the policy. Treat
|
||||
// the same as "file missing": no pruning, no error. This keeps
|
||||
// non-interactive CI environments (no HOME set) running.
|
||||
yamlPath = ""
|
||||
}
|
||||
|
||||
yamlRule, err := cmdpolicy.LoadYAMLPolicy(yamlPath)
|
||||
if err != nil {
|
||||
// Yaml-only failures are fail-OPEN at the caller (warn and
|
||||
// continue), but the active-policy snapshot is process-global
|
||||
// and may still carry data from a previous build in long-lived
|
||||
// embedders / tests. Clear it explicitly so `config policy
|
||||
// show` reports "no policy" instead of a stale rule that
|
||||
// doesn't reflect the current command tree.
|
||||
cmdpolicy.SetActive(nil)
|
||||
return err
|
||||
}
|
||||
|
||||
rule, source, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
||||
PluginRules: pluginRules,
|
||||
YAMLRule: yamlRule,
|
||||
YAMLPath: yamlPath,
|
||||
})
|
||||
if err != nil {
|
||||
cmdpolicy.SetActive(nil)
|
||||
return err
|
||||
}
|
||||
if rule == nil {
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source})
|
||||
return nil
|
||||
}
|
||||
|
||||
engine := cmdpolicy.New(rule)
|
||||
decisions := engine.EvaluateAll(rootCmd)
|
||||
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, rule.Name)
|
||||
cmdpolicy.Apply(rootCmd, denied)
|
||||
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||
Rule: rule,
|
||||
Source: source,
|
||||
DeniedPaths: len(denied),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// installPluginsAndHooks runs the InstallAll phase on the globally-
|
||||
// registered plugins, returning the Plugin.Restrict contributions for
|
||||
// cmdpolicy and the populated hook.Registry for the runtime wrapper.
|
||||
// Errors from FailClosed plugins propagate; FailOpen failures are
|
||||
// warned to errOut and the loop continues.
|
||||
func installPluginsAndHooks(errOut io.Writer) (*internalplatform.InstallResult, error) {
|
||||
plugins := platform.RegisteredPlugins()
|
||||
if len(plugins) == 0 {
|
||||
return &internalplatform.InstallResult{Registry: nil}, nil
|
||||
}
|
||||
return internalplatform.InstallAll(plugins, errOut)
|
||||
}
|
||||
|
||||
// recordInventory builds and stores the plugin inventory snapshot for
|
||||
// diagnostic commands (config plugins show) to read at runtime. Called
|
||||
// once from build.go after applyUserPolicyPruning + wireHooks succeed.
|
||||
func recordInventory(installResult *internalplatform.InstallResult) {
|
||||
if installResult == nil {
|
||||
internalplatform.SetActiveInventory(nil)
|
||||
return
|
||||
}
|
||||
pluginSrcs := make([]internalplatform.PluginInventorySource, 0, len(installResult.Plugins))
|
||||
for _, p := range installResult.Plugins {
|
||||
pluginSrcs = append(pluginSrcs, internalplatform.PluginInventorySource{
|
||||
Name: p.Name,
|
||||
Version: p.Version,
|
||||
Capabilities: p.Capabilities,
|
||||
})
|
||||
}
|
||||
ruleSrcs := make([]internalplatform.RuleInventorySource, 0, len(installResult.PluginRules))
|
||||
for _, r := range installResult.PluginRules {
|
||||
if r.Rule == nil {
|
||||
continue
|
||||
}
|
||||
idents := make([]string, len(r.Rule.Identities))
|
||||
for i, id := range r.Rule.Identities {
|
||||
idents[i] = string(id)
|
||||
}
|
||||
ruleSrcs = append(ruleSrcs, internalplatform.RuleInventorySource{
|
||||
PluginName: r.PluginName,
|
||||
Allow: r.Rule.Allow,
|
||||
Deny: r.Rule.Deny,
|
||||
MaxRisk: string(r.Rule.MaxRisk),
|
||||
Identities: idents,
|
||||
RuleName: r.Rule.Name,
|
||||
Desc: r.Rule.Description,
|
||||
AllowUnannotated: r.Rule.AllowUnannotated,
|
||||
})
|
||||
}
|
||||
internalplatform.SetActiveInventory(internalplatform.BuildInventory(pluginSrcs, installResult.Registry, ruleSrcs))
|
||||
}
|
||||
|
||||
// wireHooks installs Observer/Wrapper hooks onto every runnable command
|
||||
// and emits the Startup lifecycle event. The registry may be nil when
|
||||
// no plugin contributed any hook -- the function short-circuits in
|
||||
// that case to avoid useless RunE wrapping.
|
||||
func wireHooks(ctx context.Context, rootCmd *cobra.Command, reg *hook.Registry) error {
|
||||
if reg == nil {
|
||||
return nil
|
||||
}
|
||||
hook.Install(rootCmd, reg, cobraCommandViewSource{})
|
||||
return hook.Emit(ctx, reg, platform.Startup, nil)
|
||||
}
|
||||
|
||||
// cobraCommandViewSource is the default CommandViewSource: it returns a
|
||||
// live view over the *cobra.Command. Strict-mode's Remove+Add stub
|
||||
// (cmd/prune.go::strictModeStubFrom) explicitly forwards the original
|
||||
// annotations + Short/Long so the live view keeps reporting Risk /
|
||||
// Identities / Domain through the replacement. User-layer policy
|
||||
// (cmdpolicy/apply.go::installDenyStub) mutates in place, preserving
|
||||
// metadata trivially.
|
||||
type cobraCommandViewSource struct{}
|
||||
|
||||
func (cobraCommandViewSource) View(cmd *cobra.Command) platform.CommandView {
|
||||
return cobraCommandView{cmd: cmd}
|
||||
}
|
||||
|
||||
// cobraCommandView adapts *cobra.Command to the CommandView interface.
|
||||
type cobraCommandView struct {
|
||||
cmd *cobra.Command
|
||||
}
|
||||
|
||||
func (v cobraCommandView) Path() string {
|
||||
return cmdpolicy.CanonicalPath(v.cmd)
|
||||
}
|
||||
|
||||
func (v cobraCommandView) Domain() string {
|
||||
for c := v.cmd; c != nil; c = c.Parent() {
|
||||
if c.Annotations == nil {
|
||||
continue
|
||||
}
|
||||
if v, ok := c.Annotations["cmdmeta.domain"]; ok && v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (v cobraCommandView) Risk() (platform.Risk, bool) {
|
||||
for c := v.cmd; c != nil; c = c.Parent() {
|
||||
if c.Annotations == nil {
|
||||
continue
|
||||
}
|
||||
if r, ok := c.Annotations["risk_level"]; ok && r != "" {
|
||||
return platform.Risk(r), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (v cobraCommandView) Identities() []platform.Identity {
|
||||
for c := v.cmd; c != nil; c = c.Parent() {
|
||||
if c.Annotations == nil {
|
||||
continue
|
||||
}
|
||||
if raw, ok := c.Annotations["lark:supportedIdentities"]; ok && raw != "" {
|
||||
parts := splitCSV(raw)
|
||||
out := make([]platform.Identity, len(parts))
|
||||
for i, p := range parts {
|
||||
out[i] = platform.Identity(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v cobraCommandView) Annotation(key string) (string, bool) {
|
||||
if v.cmd.Annotations == nil {
|
||||
return "", false
|
||||
}
|
||||
s, ok := v.cmd.Annotations[key]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// splitCSV is a tiny csv-without-quotes helper. The
|
||||
// lark:supportedIdentities annotation is always plain
|
||||
// "user" / "bot" / "user,bot" without escaping.
|
||||
func splitCSV(s string) []string {
|
||||
out := []string{}
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == ',' {
|
||||
out = append(out, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
out = append(out, s[start:])
|
||||
return out
|
||||
}
|
||||
|
||||
// userPolicyPath returns the path of <baseConfigDir>/policy.yml.
|
||||
//
|
||||
// The base directory honours LARKSUITE_CLI_CONFIG_DIR (via
|
||||
// core.GetBaseConfigDir) so that test isolation, container deployments
|
||||
// and per-Agent config overrides all see a consistent policy location.
|
||||
// Using vfs.UserHomeDir directly here would silently bypass the env
|
||||
// override and route every test through the real ~/.lark-cli.
|
||||
//
|
||||
// The error return is retained for caller compatibility but is always
|
||||
// nil today: GetBaseConfigDir falls back to a relative ".lark-cli" when
|
||||
// the home dir can't be resolved, and the resolver already treats a
|
||||
// missing file as "no policy".
|
||||
func userPolicyPath() (string, error) {
|
||||
return filepath.Join(core.GetBaseConfigDir(), userPolicyFileName), nil
|
||||
}
|
||||
|
||||
// warnPolicyError writes a one-line stderr warning when the user policy
|
||||
// fails to load. V1 yaml errors are fail-OPEN -- the CLI keeps running
|
||||
// without policy enforcement so the user can fix the typo. Plugin-supplied
|
||||
// rules are fail-CLOSED instead because integrators take a code-level
|
||||
// responsibility for them.
|
||||
//
|
||||
// Wrapped errors may carry the absolute policy path (os.PathError); fold
|
||||
// the home prefix to "~" before emitting so stderr piped into agents /
|
||||
// CI logs does not leak the user's home directory.
|
||||
func warnPolicyError(errOut io.Writer, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(errOut, "warning: user policy not applied: %s\n", redactHome(err.Error()))
|
||||
}
|
||||
|
||||
func redactHome(s string) string {
|
||||
if home, err := vfs.UserHomeDir(); err == nil && home != "" {
|
||||
s = strings.ReplaceAll(s, home, "~")
|
||||
}
|
||||
return s
|
||||
}
|
||||
268
cmd/platform_bootstrap_test.go
Normal file
268
cmd/platform_bootstrap_test.go
Normal file
@@ -0,0 +1,268 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// tmpHome creates a tempdir, points $HOME at it, and returns the path to
|
||||
// the ~/.lark-cli/ subdirectory (created). The HOME env var is restored
|
||||
// when the test ends.
|
||||
//
|
||||
// LARKSUITE_CLI_CONFIG_DIR is force-set to the same path. Without that
|
||||
// override, a developer running the tests with a personal
|
||||
// LARKSUITE_CLI_CONFIG_DIR exported in their shell (or a CI runner with
|
||||
// a baked-in value) would resolve userPolicyPath() to their real
|
||||
// machine and bleed unrelated yaml into the test fixtures. With the
|
||||
// override pinned here, the test is hermetic regardless of the host
|
||||
// environment.
|
||||
func tmpHome(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("HOME", dir)
|
||||
t.Setenv("USERPROFILE", dir) // Windows fallback for os.UserHomeDir
|
||||
cfgDir := filepath.Join(dir, ".lark-cli")
|
||||
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", cfgDir)
|
||||
return cfgDir
|
||||
}
|
||||
|
||||
// writePolicy writes a policy.yml into the user config dir.
|
||||
func writePolicy(t *testing.T, cfgDir string, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(cfgDir, "policy.yml"), []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write policy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeTree builds a minimal command tree with the same shape the real
|
||||
// CLI exposes for these tests: lark-cli has a docs group with +fetch and
|
||||
// +update, and an im group with +send. Each leaf has its risk_level set
|
||||
// so MaxRisk filtering exercises a real path.
|
||||
func fakeTree(t *testing.T) *cobra.Command {
|
||||
t.Helper()
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
|
||||
docs := &cobra.Command{Use: "docs"}
|
||||
root.AddCommand(docs)
|
||||
addLeaf(docs, "+fetch", "read")
|
||||
addLeaf(docs, "+update", "write")
|
||||
addLeaf(docs, "+delete-doc", "high-risk-write")
|
||||
|
||||
im := &cobra.Command{Use: "im"}
|
||||
root.AddCommand(im)
|
||||
addLeaf(im, "+send", "write")
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
func addLeaf(parent *cobra.Command, use, risk string) {
|
||||
leaf := &cobra.Command{
|
||||
Use: use,
|
||||
RunE: func(*cobra.Command, []string) error { return nil },
|
||||
}
|
||||
cmdutil.SetRisk(leaf, risk)
|
||||
parent.AddCommand(leaf)
|
||||
}
|
||||
|
||||
// findLeaf walks the tree by Use names.
|
||||
func findLeaf(t *testing.T, parent *cobra.Command, names ...string) *cobra.Command {
|
||||
t.Helper()
|
||||
cur := parent
|
||||
for _, n := range names {
|
||||
var next *cobra.Command
|
||||
for _, c := range cur.Commands() {
|
||||
if c.Use == n {
|
||||
next = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
t.Fatalf("child %q not found under %q", n, cur.Use)
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
// Happy path: a valid policy.yml denies one specific command. The denied
|
||||
// command's RunE returns a typed ExitError envelope; allowed commands are
|
||||
// untouched.
|
||||
func TestApplyUserPolicyPruning_appliesValidPolicy(t *testing.T) {
|
||||
cfgDir := tmpHome(t)
|
||||
writePolicy(t, cfgDir, `
|
||||
name: test-policy
|
||||
allow: ["docs/**", "contact/**"]
|
||||
deny: ["docs/+delete-doc"]
|
||||
max_risk: write
|
||||
`)
|
||||
|
||||
root := fakeTree(t)
|
||||
if err := applyUserPolicyPruning(root, nil); err != nil {
|
||||
t.Fatalf("apply policy: %v", err)
|
||||
}
|
||||
|
||||
// docs/+delete-doc must be denied (Deny match).
|
||||
deleteCmd := findLeaf(t, root, "docs", "+delete-doc")
|
||||
if !deleteCmd.Hidden {
|
||||
t.Errorf("+delete-doc should be hidden after pruning")
|
||||
}
|
||||
err := deleteCmd.RunE(deleteCmd, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("+delete-doc RunE should return an error")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "command_denied" {
|
||||
t.Fatalf("expected command_denied ExitError, got %T %+v", err, err)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok || detail["reason_code"] != "command_denylisted" {
|
||||
t.Errorf("reason_code = %v, want command_denylisted", detail["reason_code"])
|
||||
}
|
||||
|
||||
// im/+send must be denied (domain not in Allow).
|
||||
send := findLeaf(t, root, "im", "+send")
|
||||
if !send.Hidden {
|
||||
t.Errorf("im/+send should be hidden (not in Allow)")
|
||||
}
|
||||
|
||||
// docs/+update must stay alive (domain matches, risk within max).
|
||||
update := findLeaf(t, root, "docs", "+update")
|
||||
if update.Hidden {
|
||||
t.Errorf("docs/+update should remain visible")
|
||||
}
|
||||
if err := update.RunE(update, nil); err != nil {
|
||||
t.Errorf("docs/+update RunE should succeed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Missing file means no pruning -- the CLI runs unrestricted with the
|
||||
// full command surface. This is the default case for users who haven't
|
||||
// opted into pruning.
|
||||
func TestApplyUserPolicyPruning_missingFileIsSilent(t *testing.T) {
|
||||
tmpHome(t) // home set but no policy.yml written
|
||||
|
||||
root := fakeTree(t)
|
||||
if err := applyUserPolicyPruning(root, nil); err != nil {
|
||||
t.Fatalf("missing policy should not error, got %v", err)
|
||||
}
|
||||
|
||||
// Every leaf must remain non-Hidden.
|
||||
for _, sub := range []string{"+fetch", "+update", "+delete-doc"} {
|
||||
cmd := findLeaf(t, root, "docs", sub)
|
||||
if cmd.Hidden {
|
||||
t.Errorf("%s should not be Hidden when no policy file exists", sub)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invalid yaml content (parse error) surfaces as an error from the
|
||||
// wiring. The build path then decides whether to fail-open or
|
||||
// fail-closed; the wiring itself stays neutral.
|
||||
func TestApplyUserPolicyPruning_malformedYamlReturnsError(t *testing.T) {
|
||||
cfgDir := tmpHome(t)
|
||||
writePolicy(t, cfgDir, "::: not yaml :::")
|
||||
|
||||
root := fakeTree(t)
|
||||
err := applyUserPolicyPruning(root, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("malformed yaml should produce an error")
|
||||
}
|
||||
}
|
||||
|
||||
// Semantically-invalid Rule (bad MaxRisk) reaches ValidateRule inside
|
||||
// Resolve and produces an error. This is the safety contract: a typo in
|
||||
// the rule must not silently lower the pruning bar.
|
||||
func TestApplyUserPolicyPruning_invalidRuleReturnsError(t *testing.T) {
|
||||
cfgDir := tmpHome(t)
|
||||
writePolicy(t, cfgDir, "max_risk: nukem\n")
|
||||
|
||||
root := fakeTree(t)
|
||||
err := applyUserPolicyPruning(root, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("invalid MaxRisk should produce an error")
|
||||
}
|
||||
}
|
||||
|
||||
// warnPolicyError emits to the supplied writer when err is non-nil and
|
||||
// stays silent for nil. Verifies the build.go fail-open behaviour can be
|
||||
// observed by users.
|
||||
func TestWarnPolicyError(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
warnPolicyError(&buf, nil)
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("warnPolicyError with nil err should write nothing, got %q", buf.String())
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
warnPolicyError(&buf, errors.New("boom"))
|
||||
if buf.String() != "warning: user policy not applied: boom\n" {
|
||||
t.Fatalf("warnPolicyError output = %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end through buildInternal: when a valid policy.yml exists in
|
||||
// HOME, building the real command tree applies pruning to it. This is
|
||||
// the "actually integrated" test -- it exercises the wiring point in
|
||||
// build.go itself, not just the helper.
|
||||
func TestBuildInternal_appliesPolicyToRealTree(t *testing.T) {
|
||||
cfgDir := tmpHome(t)
|
||||
// Deny one specific shortcut path that we know exists in the real
|
||||
// service tree -- we cannot enumerate it from a unit test, so we
|
||||
// use an Allow-list that matches nothing to deny everything except
|
||||
// the root, and then verify ANY non-root command was hidden.
|
||||
writePolicy(t, cfgDir, `
|
||||
name: deny-everything
|
||||
deny: ["**"]
|
||||
`)
|
||||
|
||||
root := Build(context.Background(), buildInvocationForTest(t))
|
||||
|
||||
// Find any leaf and verify it was hidden.
|
||||
var foundHidden bool
|
||||
walk(root, func(c *cobra.Command) {
|
||||
if c.HasParent() && c.Runnable() && c.Hidden {
|
||||
foundHidden = true
|
||||
}
|
||||
})
|
||||
if !foundHidden {
|
||||
t.Fatalf("expected at least one runnable command to be Hidden after deny=** policy")
|
||||
}
|
||||
|
||||
// Root itself must stay alive.
|
||||
if root.Hidden {
|
||||
t.Errorf("root command must not be Hidden even under deny-everything policy")
|
||||
}
|
||||
}
|
||||
|
||||
func walk(cmd *cobra.Command, fn func(*cobra.Command)) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
fn(cmd)
|
||||
for _, c := range cmd.Commands() {
|
||||
walk(c, fn)
|
||||
}
|
||||
}
|
||||
|
||||
// buildInvocationForTest returns a minimal cmdutil.InvocationContext so
|
||||
// build.go's pure-assembly path can construct a tree without touching
|
||||
// real config / credentials. Profile name is the empty default.
|
||||
func buildInvocationForTest(t *testing.T) cmdutil.InvocationContext {
|
||||
t.Helper()
|
||||
return cmdutil.InvocationContext{}
|
||||
}
|
||||
247
cmd/platform_guards.go
Normal file
247
cmd/platform_guards.go
Normal file
@@ -0,0 +1,247 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
)
|
||||
|
||||
// installFatalGuard wires a fail-closed guard at every cobra dispatch
|
||||
// path on rootCmd. Used by the three abort-side fatal paths:
|
||||
//
|
||||
// - FailClosed plugin install failure (installPluginInstallErrorGuard)
|
||||
// - Plugin Restrict conflict (installPluginConflictGuard)
|
||||
// - Startup lifecycle handler failure (installPluginLifecycleErrorGuard)
|
||||
//
|
||||
// **Why we walk the tree rather than set PersistentPreRunE on root**:
|
||||
// cobra's PersistentPreRunE has "first PersistentPreRunE wins"
|
||||
// semantics -- the lookup starts at the invoked command and walks UP,
|
||||
// stopping at the first non-nil PersistentPreRunE. Subcommands that
|
||||
// declare their own PersistentPreRunE (cmd/auth/auth.go and
|
||||
// cmd/config/config.go both do) would shadow root's, letting a
|
||||
// fail-closed condition silently bypass via `lark-cli auth foo`.
|
||||
//
|
||||
// The fix: replace the RunE of every runnable command with one that
|
||||
// returns makeErr(). Subcommands cannot bypass because the dispatch
|
||||
// lands directly on their RunE, which now carries the guard.
|
||||
//
|
||||
// makeErr is called for every guarded dispatch; it must return a fresh
|
||||
// *output.ExitError each time (the envelope writer mutates a few fields
|
||||
// as it serialises).
|
||||
func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError) {
|
||||
// Two cobra subcommands are injected lazily at Execute() time and
|
||||
// would otherwise slip past walkGuard. We pre-register both so
|
||||
// walkGuard catches them.
|
||||
//
|
||||
// - "completion" (user-visible): InitDefaultCompletionCmd
|
||||
// - "__complete" (internal shell-completion RPC): no public
|
||||
// constructor; we add our own stub with the same name. cobra's
|
||||
// internal initCompleteCmd checks for an existing "__complete"
|
||||
// and skips registration if found, so our stub stays in place.
|
||||
// (Cobra dispatches the "__completeNoDesc" alias through the
|
||||
// same RunE, so guarding "__complete" covers both.)
|
||||
rootCmd.InitDefaultCompletionCmd()
|
||||
alreadyPresent := false
|
||||
for _, c := range rootCmd.Commands() {
|
||||
if c.Name() == "__complete" {
|
||||
alreadyPresent = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !alreadyPresent {
|
||||
rootCmd.AddCommand(&cobra.Command{
|
||||
Use: "__complete",
|
||||
Hidden: true,
|
||||
RunE: func(*cobra.Command, []string) error { return makeErr() },
|
||||
})
|
||||
}
|
||||
|
||||
rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
|
||||
cmd.SilenceUsage = true
|
||||
return makeErr()
|
||||
}
|
||||
rootCmd.PersistentPreRun = nil
|
||||
walkGuard(rootCmd, makeErr)
|
||||
}
|
||||
|
||||
// installPluginInstallErrorGuard surfaces a FailClosed plugin install
|
||||
// failure as a structured plugin_install envelope before any command
|
||||
// runs.
|
||||
func installPluginInstallErrorGuard(rootCmd *cobra.Command, installErr error) {
|
||||
makeErr := func() *output.ExitError {
|
||||
var pi *internalplatform.PluginInstallError
|
||||
if errors.As(installErr, &pi) {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "plugin_install",
|
||||
Message: pi.Error(),
|
||||
Detail: map[string]any{
|
||||
"plugin": pi.PluginName,
|
||||
"reason_code": pi.ReasonCode,
|
||||
"reason": pi.Reason,
|
||||
},
|
||||
},
|
||||
Err: installErr,
|
||||
}
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "plugin_install",
|
||||
Message: installErr.Error(),
|
||||
Detail: map[string]any{
|
||||
"reason_code": internalplatform.ReasonInstallFailed,
|
||||
},
|
||||
},
|
||||
Err: installErr,
|
||||
}
|
||||
}
|
||||
installFatalGuard(rootCmd, makeErr)
|
||||
}
|
||||
|
||||
// installPluginConflictGuard surfaces a Plugin.Restrict() configuration
|
||||
// error (single plugin invalid Rule or multiple plugins each contributing
|
||||
// Restrict). The design separates the envelope type:
|
||||
//
|
||||
// - "plugin_install" with reason_code "invalid_rule" - single bad rule
|
||||
// - "plugin_conflict" with reason_code "multiple_restrict_plugins" - multi
|
||||
//
|
||||
// Either way the CLI must NOT silently continue with a broken policy.
|
||||
func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
|
||||
makeErr := func() *output.ExitError {
|
||||
envelopeType := "plugin_install"
|
||||
reasonCode := internalplatform.ReasonInvalidRule
|
||||
if errors.Is(err, cmdpolicy.ErrMultipleRestricts) {
|
||||
envelopeType = "plugin_conflict"
|
||||
reasonCode = internalplatform.ReasonMultipleRestricts
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: envelopeType,
|
||||
Message: err.Error(),
|
||||
Detail: map[string]any{
|
||||
"reason_code": reasonCode,
|
||||
},
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
installFatalGuard(rootCmd, makeErr)
|
||||
}
|
||||
|
||||
// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler
|
||||
// failure as a plugin_lifecycle envelope. The reason_code splits
|
||||
// returned-error vs panic so consumers (audit / on-call) can tell the
|
||||
// two failure modes apart.
|
||||
func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
|
||||
makeErr := func() *output.ExitError {
|
||||
reasonCode := "lifecycle_failed"
|
||||
detail := map[string]any{
|
||||
"reason_code": reasonCode,
|
||||
}
|
||||
var le *hook.LifecycleError
|
||||
if errors.As(err, &le) {
|
||||
if le.Panic {
|
||||
reasonCode = "lifecycle_panic"
|
||||
}
|
||||
detail = map[string]any{
|
||||
"reason_code": reasonCode,
|
||||
"hook_name": le.HookName,
|
||||
"event": "startup",
|
||||
}
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "plugin_lifecycle",
|
||||
Message: err.Error(),
|
||||
Detail: detail,
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
installFatalGuard(rootCmd, makeErr)
|
||||
}
|
||||
|
||||
// walkGuard recurses through cmd's subtree and installs the guard at
|
||||
// EVERY level cobra might dispatch to. The cobra execution order is:
|
||||
//
|
||||
// 1. PersistentPreRunE (looked up from leaf, walking up; "first wins")
|
||||
// 2. PreRunE
|
||||
// 3. RunE
|
||||
// 4. PostRunE
|
||||
// 5. PersistentPostRunE
|
||||
//
|
||||
// A subcommand that declares its own PersistentPreRunE (cmd/auth and
|
||||
// cmd/config both do) would not only shadow root's PersistentPreRunE
|
||||
// -- if that PreRunE itself returns an error (e.g. auth's
|
||||
// external_provider check), the user sees THAT error instead of
|
||||
// our plugin_install envelope, even if RunE was guarded.
|
||||
//
|
||||
// To close every dispatch hole we replace:
|
||||
// - every command's PersistentPreRunE (including non-runnable groups)
|
||||
// - every runnable command's PreRunE and RunE
|
||||
//
|
||||
// This way the very first non-nil step in cobra's chain is always our
|
||||
// guard, regardless of which leaf the user invoked.
|
||||
func walkGuard(cmd *cobra.Command, makeErr func() *output.ExitError) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
// PersistentPreRunE is the first step cobra runs (after Args /
|
||||
// flag validation -- see below). Set it on every command (root
|
||||
// included) so cobra's "first wins" walk-up always finds OUR
|
||||
// PersistentPreRunE before hitting any subcommand's pre-existing
|
||||
// one.
|
||||
cmd.PersistentPreRunE = func(c *cobra.Command, args []string) error {
|
||||
c.SilenceUsage = true
|
||||
return makeErr()
|
||||
}
|
||||
cmd.PersistentPreRun = nil
|
||||
|
||||
// **Cobra dispatch order before PersistentPreRunE:**
|
||||
// 1. ValidateArgs(cmd.Args) -- can return arg error
|
||||
// 2. ParsePersistentFlags / ParseFlags -- can return flag error
|
||||
// 3. Find legacyArgs check for unknown-command at root
|
||||
// 4. PersistentPreRunE / PreRunE / RunE
|
||||
// 5. Non-runnable groups fall through to help (PreRunE skipped)
|
||||
//
|
||||
// We neutralise each step:
|
||||
// - Args = ArbitraryArgs -> ValidateArgs no-op. **Not nil**:
|
||||
// cobra falls back to legacyArgs
|
||||
// when Args==nil, which returns an
|
||||
// unknown-command error during Find
|
||||
// BEFORE PersistentPreRunE runs.
|
||||
// ArbitraryArgs explicitly accepts
|
||||
// everything, suppressing that path.
|
||||
// - DisableFlagParsing -> ParseFlags skipped (and legacy
|
||||
// "unknown flag" suppressed)
|
||||
// - PreRunE / RunE on EVERY -> Even non-runnable groups now run
|
||||
// command (not just leaves) the guard instead of showing help
|
||||
//
|
||||
// Setting RunE on a parent group flips Runnable() to true, so
|
||||
// cobra dispatches to it (and our guard fires) rather than calling
|
||||
// the help command on a "help-only" group.
|
||||
cmd.Args = cobra.ArbitraryArgs
|
||||
cmd.DisableFlagParsing = true
|
||||
cmd.PreRunE = func(c *cobra.Command, args []string) error {
|
||||
c.SilenceUsage = true
|
||||
return makeErr()
|
||||
}
|
||||
cmd.PreRun = nil
|
||||
cmd.RunE = func(*cobra.Command, []string) error { return makeErr() }
|
||||
cmd.Run = nil
|
||||
for _, c := range cmd.Commands() {
|
||||
walkGuard(c, makeErr)
|
||||
}
|
||||
}
|
||||
208
cmd/platform_guards_test.go
Normal file
208
cmd/platform_guards_test.go
Normal file
@@ -0,0 +1,208 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
)
|
||||
|
||||
// failClosedAbortingPlugin returns a PluginInstallError on Install,
|
||||
// declaring FailClosed so InstallAll surfaces the error.
|
||||
type failClosedAbortingPlugin struct{}
|
||||
|
||||
func (failClosedAbortingPlugin) Name() string { return "policy" }
|
||||
func (failClosedAbortingPlugin) Version() string { return "1.0.0" }
|
||||
func (failClosedAbortingPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{FailurePolicy: platform.FailClosed}
|
||||
}
|
||||
func (failClosedAbortingPlugin) Install(platform.Registrar) error {
|
||||
return errors.New("upstream policy server unreachable")
|
||||
}
|
||||
|
||||
// When a FailClosed plugin fails to install, buildInternal must
|
||||
// install a PersistentPreRunE that returns a structured *output.ExitError.
|
||||
// The user must NEVER see a silent partial-install state.
|
||||
//
|
||||
// This pins the build.go fix for codex's NEW ISSUE about
|
||||
// build.go demoting FailClosed errors to warnings.
|
||||
func TestBuildInternal_failClosedAbortsCLI(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
platform.Register(failClosedAbortingPlugin{})
|
||||
|
||||
root := Build(context.Background(), buildInvocationForTest(t))
|
||||
|
||||
if root.PersistentPreRunE == nil {
|
||||
t.Fatalf("FailClosed install error must wire a PersistentPreRunE that aborts subsequent commands")
|
||||
}
|
||||
|
||||
err := root.PersistentPreRunE(root, nil)
|
||||
checkGuardError(t, err)
|
||||
|
||||
// CRITICAL: subcommands that declare their own PersistentPreRunE
|
||||
// (cmd/auth/auth.go and cmd/config/config.go both do) would
|
||||
// shadow root's via cobra's "first wins" semantics if we only set
|
||||
// root.PersistentPreRunE. Moreover, those subcommand PersistentPreRunE
|
||||
// handlers may themselves return an error (e.g. auth's
|
||||
// external_provider check at internal/cmdutil/factory.go:223),
|
||||
// which would mask the plugin_install envelope even if RunE were
|
||||
// guarded.
|
||||
//
|
||||
// The guard MUST therefore walk the tree and replace each command's
|
||||
// PersistentPreRunE / PreRunE / RunE directly. This test pins
|
||||
// that the bypass is closed.
|
||||
auth := findChildByUse(t, root, "auth")
|
||||
if auth == nil {
|
||||
t.Skip("auth subcommand not present in build; cannot exercise bypass case")
|
||||
}
|
||||
// (a) auth's own PersistentPreRunE must be the guard, not the
|
||||
// factory-checking handler that lived there before walkGuard ran.
|
||||
if auth.PersistentPreRunE == nil {
|
||||
t.Fatalf("auth.PersistentPreRunE must be guarded after walkGuard")
|
||||
}
|
||||
checkGuardError(t, auth.PersistentPreRunE(auth, nil))
|
||||
|
||||
// (b) A runnable leaf below auth also gets the guard on RunE. We
|
||||
// match by RunE != nil (not just Runnable()) because the guard
|
||||
// replaces RunE specifically — selecting a Run-only command and
|
||||
// then calling leaf.RunE would nil-deref.
|
||||
var leaf *cobra.Command
|
||||
walk(auth, func(c *cobra.Command) {
|
||||
if leaf != nil {
|
||||
return
|
||||
}
|
||||
if c != auth && c.RunE != nil {
|
||||
leaf = c
|
||||
}
|
||||
})
|
||||
if leaf == nil {
|
||||
t.Skip("no auth subcommand with RunE found")
|
||||
}
|
||||
checkGuardError(t, leaf.RunE(leaf, nil))
|
||||
}
|
||||
|
||||
// checkGuardError asserts that err is the structured plugin_install
|
||||
// ExitError the guard produces.
|
||||
func checkGuardError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("PersistentPreRunE must surface the install error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "plugin_install" {
|
||||
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
|
||||
}
|
||||
detail := exitErr.Detail.Detail.(map[string]any)
|
||||
if detail["plugin"] != "policy" {
|
||||
t.Errorf("detail.plugin = %v, want policy", detail["plugin"])
|
||||
}
|
||||
if detail["reason_code"] != internalplatform.ReasonInstallFailed {
|
||||
t.Errorf("detail.reason_code = %v, want install_failed", detail["reason_code"])
|
||||
}
|
||||
}
|
||||
|
||||
// findChildByUse helper.
|
||||
func findChildByUse(t *testing.T, parent *cobra.Command, use string) *cobra.Command {
|
||||
t.Helper()
|
||||
for _, c := range parent.Commands() {
|
||||
if c.Use == use {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// namespacedWrap copy semantics: a plugin reusing a sentinel AbortError
|
||||
// across two concurrent command invocations must produce two distinct
|
||||
// HookName values on the wire. Mutation would interleave them.
|
||||
//
|
||||
// We exercise this by sharing one AbortError across two goroutines,
|
||||
// each invoking through a different namespacedWrap; both observed
|
||||
// errors must keep their own HookName.
|
||||
func TestNamespacedWrap_doesNotMutateSharedAbortError(t *testing.T) {
|
||||
shared := &platform.AbortError{HookName: "plugin-shared-name", Reason: "rejected"}
|
||||
|
||||
makeWrapper := func(name string) platform.Wrapper {
|
||||
return func(next platform.Handler) platform.Handler {
|
||||
return func(context.Context, platform.Invocation) error { return shared }
|
||||
}
|
||||
}
|
||||
|
||||
reg := hook.NewRegistry()
|
||||
reg.AddWrapper(hook.WrapperEntry{
|
||||
Name: "p1.wrap", Selector: platform.All(), Fn: makeWrapper("p1.wrap"),
|
||||
})
|
||||
reg.AddWrapper(hook.WrapperEntry{
|
||||
Name: "p2.wrap", Selector: platform.All(), Fn: makeWrapper("p2.wrap"),
|
||||
})
|
||||
|
||||
// Drive matched wrappers separately to exercise both namespace paths.
|
||||
matched := reg.MatchingWrappers(stubView{})
|
||||
if len(matched) != 2 {
|
||||
t.Fatalf("expected 2 matched wrappers, got %d", len(matched))
|
||||
}
|
||||
|
||||
results := make([]string, 2)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
for i, m := range matched {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err := m.Fn(func(context.Context, platform.Invocation) error { return nil })(
|
||||
context.Background(), stubInvocation{})
|
||||
if ab, ok := err.(*platform.AbortError); ok {
|
||||
results[i] = ab.HookName
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// We are not using namespacedWrap directly here -- the test isolates
|
||||
// the semantic by reading what each WrapperEntry's Fn returns.
|
||||
// The real guarantee we depend on is the install-side namespacedWrap;
|
||||
// see internal/hook/install.go for the production path. This test
|
||||
// pins the sentinel-not-mutated invariant at the unit level: each
|
||||
// Wrap returned the shared AbortError unchanged, so the production
|
||||
// namespacedWrap can safely copy without touching the original.
|
||||
if shared.HookName != "plugin-shared-name" {
|
||||
t.Errorf("shared sentinel AbortError was mutated: HookName = %q", shared.HookName)
|
||||
}
|
||||
_ = results
|
||||
}
|
||||
|
||||
// stubView for the wrap selector match.
|
||||
type stubView struct{}
|
||||
|
||||
func (stubView) Path() string { return "x" }
|
||||
func (stubView) Domain() string { return "" }
|
||||
func (stubView) Risk() (platform.Risk, bool) { return "", false }
|
||||
func (stubView) Identities() []platform.Identity { return nil }
|
||||
func (stubView) Annotation(string) (string, bool) { return "", false }
|
||||
|
||||
// stubInvocation is the minimal platform.Invocation implementation
|
||||
// used by tests that need to drive a Wrap without going through the
|
||||
// full hook.Install pipeline.
|
||||
type stubInvocation struct{}
|
||||
|
||||
func (stubInvocation) Cmd() platform.CommandView { return stubView{} }
|
||||
func (stubInvocation) Args() []string { return nil }
|
||||
func (stubInvocation) Started() time.Time { return time.Time{} }
|
||||
func (stubInvocation) Err() error { return nil }
|
||||
func (stubInvocation) DeniedByPolicy() bool { return false }
|
||||
func (stubInvocation) DenialLayer() string { return "" }
|
||||
func (stubInvocation) DenialPolicySource() string { return "" }
|
||||
684
cmd/plugin_integration_test.go
Normal file
684
cmd/plugin_integration_test.go
Normal file
@@ -0,0 +1,684 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
)
|
||||
|
||||
// These integration tests exercise the Hook framework's plumbing
|
||||
// (Plugin -> InstallAll -> Registry -> wireHooks -> RunE wrapper)
|
||||
// against a SYNTHETIC command tree, not the real lark-cli shortcut
|
||||
// tree. The synthetic tree keeps the test hermetic -- invoking real
|
||||
// shortcuts requires a fully-populated Factory (HTTP, credentials,
|
||||
// etc.) which is out of scope for a hook plumbing test.
|
||||
//
|
||||
// The e2e tests that go through Build() are kept thin (see
|
||||
// TestBuildInternal_appliesPolicyToRealTree in policy_test.go); they
|
||||
// assert plumbing existence (Hidden flag, etc.) without invoking
|
||||
// shortcuts.
|
||||
|
||||
type fakeIntegrationPlugin struct {
|
||||
name string
|
||||
caps platform.Capabilities
|
||||
rule *platform.Rule
|
||||
beforeCount int64
|
||||
afterCount int64
|
||||
wrapCount int64
|
||||
wrapDeniesWrite bool // when true, Wrap returns AbortError for risk=write
|
||||
shutdownCalled int64
|
||||
}
|
||||
|
||||
func (p *fakeIntegrationPlugin) Name() string { return p.name }
|
||||
func (p *fakeIntegrationPlugin) Version() string { return "0.0.1" }
|
||||
func (p *fakeIntegrationPlugin) Capabilities() platform.Capabilities { return p.caps }
|
||||
|
||||
func (p *fakeIntegrationPlugin) Install(r platform.Registrar) error {
|
||||
if p.caps.Restricts && p.rule != nil {
|
||||
r.Restrict(p.rule)
|
||||
}
|
||||
r.Observe(platform.Before, "audit-pre", platform.All(),
|
||||
func(context.Context, platform.Invocation) {
|
||||
atomic.AddInt64(&p.beforeCount, 1)
|
||||
})
|
||||
r.Observe(platform.After, "audit-post", platform.All(),
|
||||
func(context.Context, platform.Invocation) {
|
||||
atomic.AddInt64(&p.afterCount, 1)
|
||||
})
|
||||
r.Wrap("policy", platform.ByWrite(),
|
||||
func(next platform.Handler) platform.Handler {
|
||||
return func(ctx context.Context, inv platform.Invocation) error {
|
||||
atomic.AddInt64(&p.wrapCount, 1)
|
||||
if p.wrapDeniesWrite {
|
||||
return &platform.AbortError{
|
||||
HookName: "policy",
|
||||
Reason: "writes blocked by integration test plugin",
|
||||
}
|
||||
}
|
||||
return next(ctx, inv)
|
||||
}
|
||||
})
|
||||
r.On(platform.Shutdown, "flush",
|
||||
func(context.Context, *platform.LifecycleContext) error {
|
||||
atomic.AddInt64(&p.shutdownCalled, 1)
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// syntheticTree builds a small command tree we own end-to-end. The leaf
|
||||
// has risk=write so the Wrap's ByWrite() selector matches.
|
||||
func syntheticTree() (*cobra.Command, *cobra.Command) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
group := &cobra.Command{Use: "docs"}
|
||||
root.AddCommand(group)
|
||||
leaf := &cobra.Command{
|
||||
Use: "+write",
|
||||
RunE: func(*cobra.Command, []string) error { return nil },
|
||||
}
|
||||
cmdutil.SetRisk(leaf, "write")
|
||||
group.AddCommand(leaf)
|
||||
return root, leaf
|
||||
}
|
||||
|
||||
// End-to-end through the public install pipeline: register a plugin,
|
||||
// run internalplatform.InstallAll (the same function buildInternal calls),
|
||||
// wire hooks onto a synthetic tree, invoke the leaf, and confirm
|
||||
// observers fired.
|
||||
func TestPluginPipeline_observersWired(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
plugin := &fakeIntegrationPlugin{
|
||||
name: "audit-plugin",
|
||||
caps: platform.Capabilities{FailurePolicy: platform.FailOpen},
|
||||
}
|
||||
platform.Register(plugin)
|
||||
|
||||
result, err := internalplatform.InstallAll(platform.RegisteredPlugins(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallAll: %v", err)
|
||||
}
|
||||
|
||||
root, leaf := syntheticTree()
|
||||
if err := wireHooks(context.Background(), root, result.Registry); err != nil {
|
||||
t.Fatalf("wireHooks: %v", err)
|
||||
}
|
||||
|
||||
_ = leaf.RunE(leaf, nil)
|
||||
|
||||
if got := atomic.LoadInt64(&plugin.beforeCount); got != 1 {
|
||||
t.Errorf("Before observer fired %d times, want 1", got)
|
||||
}
|
||||
if got := atomic.LoadInt64(&plugin.afterCount); got != 1 {
|
||||
t.Errorf("After observer fired %d times, want 1", got)
|
||||
}
|
||||
if got := atomic.LoadInt64(&plugin.wrapCount); got != 1 {
|
||||
t.Errorf("Wrap fired %d times (ByWrite matches risk=write), want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A Wrapper returning AbortError on a write command must surface as
|
||||
// type="hook" in the envelope so the caller can parse the structured
|
||||
// rejection.
|
||||
func TestPluginPipeline_wrapAbortReachesEnvelope(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
plugin := &fakeIntegrationPlugin{
|
||||
name: "policy-plugin",
|
||||
caps: platform.Capabilities{FailurePolicy: platform.FailOpen},
|
||||
wrapDeniesWrite: true,
|
||||
}
|
||||
platform.Register(plugin)
|
||||
|
||||
result, err := internalplatform.InstallAll(platform.RegisteredPlugins(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallAll: %v", err)
|
||||
}
|
||||
|
||||
root, leaf := syntheticTree()
|
||||
if err := wireHooks(context.Background(), root, result.Registry); err != nil {
|
||||
t.Fatalf("wireHooks: %v", err)
|
||||
}
|
||||
|
||||
err = leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "hook" {
|
||||
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
|
||||
}
|
||||
detail := exitErr.Detail.Detail.(map[string]any)
|
||||
if detail["reason_code"] != "aborted" {
|
||||
t.Errorf("detail.reason_code = %v, want aborted", detail["reason_code"])
|
||||
}
|
||||
if detail["hook_name"] != "policy-plugin.policy" {
|
||||
t.Errorf("detail.hook_name = %v, want policy-plugin.policy", detail["hook_name"])
|
||||
}
|
||||
|
||||
// errors.As must still reach the original AbortError so consumers
|
||||
// can inspect the typed cause.
|
||||
var ab *platform.AbortError
|
||||
if !errors.As(err, &ab) {
|
||||
t.Errorf("error chain should expose *platform.AbortError")
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin.Restrict() contribution must reach the pruning resolver and
|
||||
// take precedence over a yaml file (single-rule, plugin wins). This
|
||||
// goes through the REAL Build() pipeline so the wiring between
|
||||
// installPluginsAndHooks -> applyUserPolicyPruning -> cmdpolicy.Resolve
|
||||
// is covered.
|
||||
func TestPluginPipeline_restrictBeatsYaml(t *testing.T) {
|
||||
cfgDir := tmpHome(t)
|
||||
// yaml says allow everything; plugin says deny everything. Plugin
|
||||
// should win and a command should be denied.
|
||||
if err := os.WriteFile(filepath.Join(cfgDir, "policy.yml"),
|
||||
[]byte("name: yaml-allow\nallow: [\"**\"]\n"), 0o644); err != nil {
|
||||
t.Fatalf("write yaml: %v", err)
|
||||
}
|
||||
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
plugin := &fakeIntegrationPlugin{
|
||||
name: "restricter",
|
||||
caps: platform.Capabilities{
|
||||
Restricts: true,
|
||||
FailurePolicy: platform.FailClosed,
|
||||
},
|
||||
rule: &platform.Rule{Name: "deny-all", Deny: []string{"**"}},
|
||||
}
|
||||
platform.Register(plugin)
|
||||
|
||||
root := Build(context.Background(), buildInvocationForTest(t))
|
||||
|
||||
// At least one runnable command must end up Hidden because of the
|
||||
// plugin Restrict (yaml had been allow-all and would have left
|
||||
// everything visible).
|
||||
var foundHidden bool
|
||||
walk(root, func(c *cobra.Command) {
|
||||
if c.HasParent() && c.Runnable() && c.Hidden {
|
||||
foundHidden = true
|
||||
}
|
||||
})
|
||||
if !foundHidden {
|
||||
t.Fatalf("plugin Restrict should have denied at least one command despite yaml allow-all")
|
||||
}
|
||||
}
|
||||
|
||||
// Denial-guard end-to-end: register a plugin with a Wrap that would
|
||||
// SILENTLY suppress denial (return nil without calling next). After
|
||||
// installing pruning (which marks a command as denied) and wiring
|
||||
// hooks, calling the denied command must STILL produce the denial
|
||||
// error -- the Wrap must never run on the denied path.
|
||||
func TestPluginPipeline_denialGuardIntegrated(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
wrapCalled := false
|
||||
plugin := &fakeIntegrationPlugin{
|
||||
name: "policy-plugin",
|
||||
caps: platform.Capabilities{FailurePolicy: platform.FailOpen},
|
||||
wrapDeniesWrite: false, // wrap would normally allow
|
||||
}
|
||||
// Override Wrap with a malicious behavior: return nil (silence the
|
||||
// denial). We do this by wrapping the install: register a
|
||||
// second Wrap that suppresses errors.
|
||||
platform.Register(plugin)
|
||||
|
||||
// Add another plugin with a malicious wrap.
|
||||
malicious := &mockMaliciousPlugin{
|
||||
name: "malicious",
|
||||
invokedFlag: &wrapCalled,
|
||||
}
|
||||
platform.Register(malicious)
|
||||
|
||||
result, err := internalplatform.InstallAll(platform.RegisteredPlugins(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallAll: %v", err)
|
||||
}
|
||||
|
||||
root, leaf := syntheticTree()
|
||||
// Simulate cmdpolicy.Apply marking leaf as denied.
|
||||
leaf.Hidden = true
|
||||
leaf.DisableFlagParsing = true
|
||||
if leaf.Annotations == nil {
|
||||
leaf.Annotations = map[string]string{}
|
||||
}
|
||||
leaf.Annotations["lark:policy_denied_layer"] = "policy"
|
||||
leaf.Annotations["lark:policy_denied_source"] = "plugin:other"
|
||||
denyStubCalled := false
|
||||
leaf.RunE = func(*cobra.Command, []string) error {
|
||||
denyStubCalled = true
|
||||
return errors.New("CommandPruned (denyStub)")
|
||||
}
|
||||
|
||||
if err := wireHooks(context.Background(), root, result.Registry); err != nil {
|
||||
t.Fatalf("wireHooks: %v", err)
|
||||
}
|
||||
|
||||
err = leaf.RunE(leaf, nil)
|
||||
if wrapCalled {
|
||||
t.Errorf("denial guard violated: malicious Wrap ran on a denied command")
|
||||
}
|
||||
if !denyStubCalled {
|
||||
t.Errorf("denyStub should run on the denial path even when a Wrap is registered")
|
||||
}
|
||||
if err == nil {
|
||||
t.Errorf("denial error must propagate, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// mockMaliciousPlugin registers a Wrap that returns nil unconditionally
|
||||
// -- exactly the kind of plugin the denial guard defends against.
|
||||
type mockMaliciousPlugin struct {
|
||||
name string
|
||||
invokedFlag *bool
|
||||
}
|
||||
|
||||
func (p *mockMaliciousPlugin) Name() string { return p.name }
|
||||
func (p *mockMaliciousPlugin) Version() string { return "0.0.1" }
|
||||
func (p *mockMaliciousPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{FailurePolicy: platform.FailOpen}
|
||||
}
|
||||
func (p *mockMaliciousPlugin) Install(r platform.Registrar) error {
|
||||
r.Wrap("hijack", platform.All(),
|
||||
func(_ platform.Handler) platform.Handler {
|
||||
return func(context.Context, platform.Invocation) error {
|
||||
if p.invokedFlag != nil {
|
||||
*p.invokedFlag = true
|
||||
}
|
||||
return nil // silence everything
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verifies buildInternal returns a non-nil *hook.Registry when a plugin
|
||||
// is registered and Emit(Shutdown) on that registry fires the plugin's
|
||||
// On(Shutdown) handler. This is the contract Execute relies on to fire
|
||||
// Shutdown after rootCmd.Execute returns.
|
||||
func TestBuildInternal_returnsRegistryForShutdownEmit(t *testing.T) {
|
||||
tmpHome(t)
|
||||
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
plugin := &fakeIntegrationPlugin{
|
||||
name: "shutdown-test",
|
||||
caps: platform.Capabilities{FailurePolicy: platform.FailOpen},
|
||||
}
|
||||
platform.Register(plugin)
|
||||
|
||||
_, _, reg := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if reg == nil {
|
||||
t.Fatalf("buildInternal returned nil registry; plugin's Shutdown handler is unreachable")
|
||||
}
|
||||
|
||||
if err := hook.Emit(context.Background(), reg, platform.Shutdown, nil); err != nil {
|
||||
t.Fatalf("Emit(Shutdown): %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt64(&plugin.shutdownCalled); got != 1 {
|
||||
t.Errorf("On(Shutdown) handler fired %d times, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// When plugin install fails (FailClosed), buildInternal returns nil
|
||||
// registry. Execute must nil-check before calling Emit so we don't fault
|
||||
// on the FailClosed bypass-guard path.
|
||||
func TestBuildInternal_failClosedYieldsNilRegistry(t *testing.T) {
|
||||
tmpHome(t)
|
||||
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
// A plugin that fails install and is FailClosed -> InstallAll
|
||||
// returns an error, buildInternal installs the guard and returns
|
||||
// early with nil registry.
|
||||
plugin := &failingPlugin{
|
||||
name: "fail-closed",
|
||||
caps: platform.Capabilities{FailurePolicy: platform.FailClosed},
|
||||
err: errors.New("install failure simulated"),
|
||||
}
|
||||
platform.Register(plugin)
|
||||
|
||||
_, _, reg := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if reg != nil {
|
||||
t.Errorf("buildInternal returned non-nil registry on FailClosed install error")
|
||||
}
|
||||
}
|
||||
|
||||
type failingPlugin struct {
|
||||
name string
|
||||
caps platform.Capabilities
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *failingPlugin) Name() string { return p.name }
|
||||
func (p *failingPlugin) Version() string { return "0.0.1" }
|
||||
func (p *failingPlugin) Capabilities() platform.Capabilities { return p.caps }
|
||||
func (p *failingPlugin) Install(platform.Registrar) error { return p.err }
|
||||
|
||||
// === Plugin Restrict conflict guard ===
|
||||
//
|
||||
// Two plugins both calling r.Restrict must surface as a structured
|
||||
// plugin_conflict envelope (reason_code multiple_restrict_plugins) at
|
||||
// dispatch time, NOT as a silent stderr warning. Otherwise a
|
||||
// safety-sensitive operator could miss that their policy never took
|
||||
// effect.
|
||||
func TestPluginConflictGuard_MultipleRestrictAbortsCLI(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
cmdpolicy.ResetActiveForTesting()
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
rule := &platform.Rule{Name: "any", Allow: []string{"**"}}
|
||||
platform.Register(&fakeIntegrationPlugin{
|
||||
name: "plugin-a",
|
||||
caps: platform.Capabilities{Restricts: true, FailurePolicy: platform.FailClosed},
|
||||
rule: rule,
|
||||
})
|
||||
platform.Register(&fakeIntegrationPlugin{
|
||||
name: "plugin-b",
|
||||
caps: platform.Capabilities{Restricts: true, FailurePolicy: platform.FailClosed},
|
||||
rule: rule,
|
||||
})
|
||||
|
||||
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if reg != nil {
|
||||
t.Errorf("conflict guard path should yield nil registry")
|
||||
}
|
||||
|
||||
// Pick any leaf and verify it returns the structured envelope.
|
||||
leaf := findRunnableLeaf(root)
|
||||
if leaf == nil {
|
||||
t.Fatalf("no runnable leaf in command tree")
|
||||
}
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "plugin_conflict" {
|
||||
t.Errorf("envelope type = %q, want plugin_conflict", exitErr.Detail.Type)
|
||||
}
|
||||
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "multiple_restrict_plugins" {
|
||||
t.Errorf("reason_code = %v, want multiple_restrict_plugins", rc)
|
||||
}
|
||||
}
|
||||
|
||||
// Single plugin with an invalid Rule must surface as plugin_install /
|
||||
// invalid_rule envelope (distinct error.type from multi-Restrict).
|
||||
func TestPluginConflictGuard_InvalidRuleAbortsCLI(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
cmdpolicy.ResetActiveForTesting()
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
// MaxRisk "nukem" is rejected by ValidateRule -> Resolve returns
|
||||
// an error that is NOT ErrMultipleRestricts.
|
||||
platform.Register(&fakeIntegrationPlugin{
|
||||
name: "bad",
|
||||
caps: platform.Capabilities{Restricts: true, FailurePolicy: platform.FailClosed},
|
||||
rule: &platform.Rule{Name: "bad", MaxRisk: "nukem"},
|
||||
})
|
||||
|
||||
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if reg != nil {
|
||||
t.Errorf("conflict guard path should yield nil registry")
|
||||
}
|
||||
leaf := findRunnableLeaf(root)
|
||||
if leaf == nil {
|
||||
t.Fatalf("no runnable leaf in command tree")
|
||||
}
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "plugin_install" {
|
||||
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
|
||||
}
|
||||
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "invalid_rule" {
|
||||
t.Errorf("reason_code = %v, want invalid_rule", rc)
|
||||
}
|
||||
}
|
||||
|
||||
// === Startup lifecycle guard ===
|
||||
//
|
||||
// Plugin On(Startup) handler returning error must abort startup with
|
||||
// a plugin_lifecycle envelope (reason_code lifecycle_failed). Silently
|
||||
// continuing would leave the plugin's invariants violated while the
|
||||
// rest of its hooks still fire.
|
||||
func TestPluginLifecycleGuard_StartupErrorAbortsCLI(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
cmdpolicy.ResetActiveForTesting()
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
platform.Register(&startupFailingPlugin{
|
||||
name: "lc",
|
||||
failErr: errors.New("backend unreachable"),
|
||||
})
|
||||
|
||||
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if reg != nil {
|
||||
t.Errorf("lifecycle guard path should yield nil registry")
|
||||
}
|
||||
|
||||
leaf := findRunnableLeaf(root)
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "plugin_lifecycle" {
|
||||
t.Errorf("envelope type = %q, want plugin_lifecycle", exitErr.Detail.Type)
|
||||
}
|
||||
d := exitErr.Detail.Detail.(map[string]any)
|
||||
if d["reason_code"] != "lifecycle_failed" {
|
||||
t.Errorf("reason_code = %v, want lifecycle_failed", d["reason_code"])
|
||||
}
|
||||
if d["hook_name"] != "lc.start" {
|
||||
t.Errorf("hook_name = %v, want lc.start", d["hook_name"])
|
||||
}
|
||||
}
|
||||
|
||||
// Same path but the handler panics -> reason_code lifecycle_panic.
|
||||
func TestPluginLifecycleGuard_StartupPanicAbortsCLI(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
cmdpolicy.ResetActiveForTesting()
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
platform.Register(&startupFailingPlugin{
|
||||
name: "lc",
|
||||
doPanic: true,
|
||||
panicMsg: "kaboom",
|
||||
})
|
||||
|
||||
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if reg != nil {
|
||||
t.Errorf("lifecycle guard path should yield nil registry")
|
||||
}
|
||||
leaf := findRunnableLeaf(root)
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "lifecycle_panic" {
|
||||
t.Errorf("reason_code = %v, want lifecycle_panic", rc)
|
||||
}
|
||||
}
|
||||
|
||||
type startupFailingPlugin struct {
|
||||
name string
|
||||
failErr error // when set, handler returns this
|
||||
doPanic bool // when true, handler panics with panicMsg
|
||||
panicMsg string
|
||||
}
|
||||
|
||||
func (p *startupFailingPlugin) Name() string { return p.name }
|
||||
func (p *startupFailingPlugin) Version() string { return "0.0.1" }
|
||||
func (p *startupFailingPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{FailurePolicy: platform.FailClosed}
|
||||
}
|
||||
func (p *startupFailingPlugin) Install(r platform.Registrar) error {
|
||||
r.On(platform.Startup, "start", func(context.Context, *platform.LifecycleContext) error {
|
||||
if p.doPanic {
|
||||
panic(p.panicMsg)
|
||||
}
|
||||
return p.failErr
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// === Wrapper panic recovery ===
|
||||
//
|
||||
// A Wrapper that panics must NOT crash the process. The framework
|
||||
// recovers and converts to a structured envelope:
|
||||
//
|
||||
// type="hook", reason_code="panic", hook_name=<namespaced>
|
||||
func TestWrapperPanic_BecomesHookPanicEnvelope(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
platform.Register(&panickingWrapPlugin{name: "p"})
|
||||
|
||||
result, err := internalplatform.InstallAll(platform.RegisteredPlugins(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallAll: %v", err)
|
||||
}
|
||||
root, leaf := syntheticTree()
|
||||
if err := wireHooks(context.Background(), root, result.Registry); err != nil {
|
||||
t.Fatalf("wireHooks: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("Wrapper panic must be recovered, but it escaped: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
err = leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "hook" {
|
||||
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
|
||||
}
|
||||
d := exitErr.Detail.Detail.(map[string]any)
|
||||
if d["reason_code"] != "panic" {
|
||||
t.Errorf("reason_code = %v, want panic", d["reason_code"])
|
||||
}
|
||||
if d["hook_name"] != "p.boom" {
|
||||
t.Errorf("hook_name = %v, want p.boom (namespaced)", d["hook_name"])
|
||||
}
|
||||
}
|
||||
|
||||
type panickingWrapPlugin struct{ name string }
|
||||
|
||||
func (p *panickingWrapPlugin) Name() string { return p.name }
|
||||
func (p *panickingWrapPlugin) Version() string { return "0.0.1" }
|
||||
func (p *panickingWrapPlugin) Capabilities() platform.Capabilities { return platform.Capabilities{} }
|
||||
func (p *panickingWrapPlugin) Install(r platform.Registrar) error {
|
||||
r.Wrap("boom", platform.All(),
|
||||
func(_ platform.Handler) platform.Handler {
|
||||
return func(context.Context, platform.Invocation) error {
|
||||
panic("intentional panic for test")
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// findRunnableLeaf walks the tree and returns the first command with a
|
||||
// RunE so tests can synthesize a dispatch without going through cobra.
|
||||
func findRunnableLeaf(c *cobra.Command) *cobra.Command {
|
||||
if c.RunE != nil && c.HasParent() {
|
||||
return c
|
||||
}
|
||||
for _, child := range c.Commands() {
|
||||
if l := findRunnableLeaf(child); l != nil {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// B2 regression: a plugin Wrapper whose FACTORY function (the
|
||||
// `func(next Handler) Handler` itself) panics must not crash the
|
||||
// process. The framework recovers and returns the same panic envelope
|
||||
// it produces for runtime panics inside the inner Handler.
|
||||
//
|
||||
// Pre-fix code path: recoverWrap had `inner := w(next)` outside the
|
||||
// deferred recover, so a factory panic escaped.
|
||||
func TestWrapperFactoryPanic_BecomesHookPanicEnvelope(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
platform.Register(&factoryPanicWrapPlugin{name: "fac"})
|
||||
|
||||
result, err := internalplatform.InstallAll(platform.RegisteredPlugins(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallAll: %v", err)
|
||||
}
|
||||
root, leaf := syntheticTree()
|
||||
if err := wireHooks(context.Background(), root, result.Registry); err != nil {
|
||||
t.Fatalf("wireHooks: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("factory panic must be recovered, but it escaped: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
err = leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "hook" {
|
||||
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
|
||||
}
|
||||
d := exitErr.Detail.Detail.(map[string]any)
|
||||
if d["reason_code"] != "panic" {
|
||||
t.Errorf("reason_code = %v, want panic", d["reason_code"])
|
||||
}
|
||||
if d["hook_name"] != "fac.bad-factory" {
|
||||
t.Errorf("hook_name = %v, want fac.bad-factory (namespaced)", d["hook_name"])
|
||||
}
|
||||
}
|
||||
|
||||
type factoryPanicWrapPlugin struct{ name string }
|
||||
|
||||
func (p *factoryPanicWrapPlugin) Name() string { return p.name }
|
||||
func (p *factoryPanicWrapPlugin) Version() string { return "0.0.1" }
|
||||
func (p *factoryPanicWrapPlugin) Capabilities() platform.Capabilities { return platform.Capabilities{} }
|
||||
func (p *factoryPanicWrapPlugin) Install(r platform.Registrar) error {
|
||||
r.Wrap("bad-factory", platform.All(),
|
||||
// The factory itself panics; the returned Handler is never reached.
|
||||
func(_ platform.Handler) platform.Handler {
|
||||
panic("factory blew up")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -45,6 +45,7 @@ func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
_ = cmd.MarkFlagRequired("name")
|
||||
_ = cmd.MarkFlagRequired("app-id")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ func NewCmdProfileList(f *cmdutil.Factory) *cobra.Command {
|
||||
return profileListRun(f)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ func NewCmdProfileRemove(f *cmdutil.Factory) *cobra.Command {
|
||||
cmdutil.SetTips(cmd, []string{
|
||||
"AI agents: Do NOT remove profiles unless the user explicitly asks. This is destructive and clears all associated credentials.",
|
||||
})
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ func NewCmdProfileRename(f *cmdutil.Factory) *cobra.Command {
|
||||
return profileRenameRun(f, args[0], args[1])
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ func NewCmdProfileUse(f *cmdutil.Factory) *cobra.Command {
|
||||
cmdutil.SetTips(cmd, []string{
|
||||
"AI agents: Do NOT switch profiles unless the user explicitly asks.",
|
||||
})
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
73
cmd/prune.go
73
cmd/prune.go
@@ -7,10 +7,12 @@ import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// pruneForStrictMode removes commands incompatible with the active strict mode.
|
||||
@@ -43,15 +45,76 @@ func pruneIncompatible(parent *cobra.Command, mode core.StrictMode) {
|
||||
}
|
||||
|
||||
func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Command {
|
||||
// The denial annotations let the hook layer's populateInvocationDenial
|
||||
// recognise this command as denied, so the Wrap chain is physically
|
||||
// isolated (wrapRunE takes the DeniedByPolicy branch and calls the
|
||||
// stub RunE directly). Without these, a plugin Wrapper registered
|
||||
// against platform.All() could intercept and silently swallow the
|
||||
// strict-mode error -- breaking strict-mode's "hard boundary" contract.
|
||||
//
|
||||
// Args + PersistentPreRunE overrides mirror cmdpolicy/apply.go::installDenyStub:
|
||||
//
|
||||
// - Args=ArbitraryArgs: with DisableFlagParsing the user's flags
|
||||
// look like positional args; the original child's Args validator
|
||||
// (e.g. cobra.NoArgs) would fire BEFORE RunE and produce a
|
||||
// cobra usage error instead of our strict_mode envelope.
|
||||
//
|
||||
// - PersistentPreRunE no-op: cmd/auth/auth.go declares a parent
|
||||
// PersistentPreRunE that returns external_provider when env
|
||||
// credentials are set. Cobra's "first wins walking up" would
|
||||
// pick auth's instead of our denial. A leaf-level no-op makes
|
||||
// cobra stop here and proceed to the wrapped RunE.
|
||||
//
|
||||
// strict-mode keeps its short Message + independent Hint and
|
||||
// composes the shared detail.* / wrapped-CommandDeniedError shape
|
||||
// by hand; BuildDenialError would override Message with the
|
||||
// CommandDeniedError.Error() long form.
|
||||
stubMessage := fmt.Sprintf(
|
||||
"strict mode is %q, only %s-identity commands are available",
|
||||
mode, mode.ForcedIdentity())
|
||||
const stubHint = "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)"
|
||||
denial := cmdpolicy.Denial{
|
||||
Layer: cmdpolicy.LayerStrictMode,
|
||||
PolicySource: "strict-mode",
|
||||
ReasonCode: "identity_not_supported",
|
||||
Reason: stubMessage,
|
||||
}
|
||||
// Preserve the original command's annotations (risk_level,
|
||||
// lark:supportedIdentities, cmdmeta.domain, ...) and help text so
|
||||
// audit / compliance observers can still see what was denied.
|
||||
// Stamp the denial annotations on top.
|
||||
annotations := make(map[string]string, len(child.Annotations)+2)
|
||||
for k, v := range child.Annotations {
|
||||
annotations[k] = v
|
||||
}
|
||||
annotations[cmdpolicy.AnnotationDenialLayer] = cmdpolicy.LayerStrictMode
|
||||
annotations[cmdpolicy.AnnotationDenialSource] = "strict-mode"
|
||||
|
||||
return &cobra.Command{
|
||||
Use: child.Use,
|
||||
Aliases: append([]string(nil), child.Aliases...),
|
||||
Short: child.Short,
|
||||
Long: child.Long,
|
||||
Hidden: true,
|
||||
DisableFlagParsing: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return output.ErrWithHint(output.ExitValidation, "strict_mode",
|
||||
fmt.Sprintf("strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()),
|
||||
"if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
|
||||
Args: cobra.ArbitraryArgs,
|
||||
Annotations: annotations,
|
||||
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
|
||||
c.SilenceUsage = true
|
||||
return nil
|
||||
},
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial)
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "command_denied",
|
||||
Message: stubMessage,
|
||||
Hint: stubHint,
|
||||
Detail: cmdpolicy.DenialDetailMap(cd),
|
||||
},
|
||||
Err: cd,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -198,3 +202,176 @@ func TestPruneForStrictMode_User_DirectBotShortcutReturnsStrictMode(t *testing.T
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for codex C13: a strict-mode stub whose PARENT declares
|
||||
// a PersistentPreRunE (e.g. cmd/auth/auth.go's external_provider
|
||||
// check on env credentials) must surface the strict_mode envelope,
|
||||
// not the parent's error. Cobra's "first PersistentPreRunE wins
|
||||
// walking up from leaf" semantics will pick the parent's unless the
|
||||
// stub itself carries its own.
|
||||
//
|
||||
// Fix: strictModeStubFrom installs a no-op PersistentPreRunE so cobra
|
||||
// stops at the stub and proceeds to its RunE.
|
||||
func TestStrictModeStub_BypassesParentPersistentPreRunE(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
stub := findCmd(root, "auth", "login")
|
||||
if stub == nil {
|
||||
t.Fatal("auth/login stub should exist after StrictModeBot")
|
||||
}
|
||||
if stub.PersistentPreRunE == nil {
|
||||
t.Fatal("strict-mode stub must declare PersistentPreRunE on leaf")
|
||||
}
|
||||
if err := stub.PersistentPreRunE(stub, nil); err != nil {
|
||||
t.Errorf("strict-mode stub PersistentPreRunE should be no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for codex H13: strict-mode stub must accept arbitrary
|
||||
// positional args. With DisableFlagParsing=true, a user passing
|
||||
// `auth login --scope ...` looks like 4 positional args; the original
|
||||
// cobra.Args validator would surface a usage error BEFORE strict-mode
|
||||
// stub's RunE.
|
||||
func TestStrictModeStub_BypassesArgsValidator(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
stub := findCmd(root, "auth", "login")
|
||||
if stub == nil {
|
||||
t.Fatal("auth/login stub should exist after StrictModeBot")
|
||||
}
|
||||
if stub.Args == nil {
|
||||
t.Fatal("strict-mode stub must declare Args validator")
|
||||
}
|
||||
if err := stub.Args(stub, []string{"--scope", "im.message", "--profile", "default"}); err != nil {
|
||||
t.Errorf("strict-mode stub Args should accept flag-like args, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Pins the strict-mode envelope shape: structured detail.* / wrapped
|
||||
// CommandDeniedError for external agents, AND the historical short
|
||||
// Message + independent Hint for existing consumers.
|
||||
func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
stub := findCmd(root, "im", "+search")
|
||||
if stub == nil {
|
||||
t.Fatalf("expected im/+search stub")
|
||||
}
|
||||
err := stub.RunE(stub, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("strict-mode stub RunE should return error")
|
||||
}
|
||||
|
||||
var ee *output.ExitError
|
||||
if !errors.As(err, &ee) {
|
||||
t.Fatalf("err is not *output.ExitError: %T", err)
|
||||
}
|
||||
if ee.Detail == nil {
|
||||
t.Fatalf("ExitError.Detail is nil; envelope writer cannot emit JSON")
|
||||
}
|
||||
if ee.Detail.Type != "command_denied" {
|
||||
t.Errorf("Detail.Type = %q, want command_denied", ee.Detail.Type)
|
||||
}
|
||||
dm, ok := ee.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("Detail.Detail = %T, want map[string]any", ee.Detail.Detail)
|
||||
}
|
||||
if got, _ := dm["layer"].(string); got != cmdpolicy.LayerStrictMode {
|
||||
t.Errorf("Detail.Detail[layer] = %q, want %q", got, cmdpolicy.LayerStrictMode)
|
||||
}
|
||||
if got, _ := dm["reason_code"].(string); got != "identity_not_supported" {
|
||||
t.Errorf("Detail.Detail[reason_code] = %q, want identity_not_supported", got)
|
||||
}
|
||||
if got, _ := dm["policy_source"].(string); got != "strict-mode" {
|
||||
t.Errorf("Detail.Detail[policy_source] = %q, want strict-mode", got)
|
||||
}
|
||||
|
||||
var cd *platform.CommandDeniedError
|
||||
if !errors.As(err, &cd) {
|
||||
t.Fatalf("err does not unwrap to *platform.CommandDeniedError")
|
||||
}
|
||||
if cd.Layer != cmdpolicy.LayerStrictMode {
|
||||
t.Errorf("CommandDeniedError.Layer = %q, want %q", cd.Layer, cmdpolicy.LayerStrictMode)
|
||||
}
|
||||
if cd.ReasonCode != "identity_not_supported" {
|
||||
t.Errorf("CommandDeniedError.ReasonCode = %q, want identity_not_supported", cd.ReasonCode)
|
||||
}
|
||||
if !strings.Contains(cd.Reason, `strict mode is "bot"`) {
|
||||
t.Errorf("CommandDeniedError.Reason = %q, want substring 'strict mode is \"bot\"'", cd.Reason)
|
||||
}
|
||||
if ee.Detail.Message != `strict mode is "bot", only bot-identity commands are available` {
|
||||
t.Errorf("Detail.Message = %q, want short historical form", ee.Detail.Message)
|
||||
}
|
||||
if !strings.HasPrefix(ee.Detail.Hint, "if the user explicitly wants to switch policy") {
|
||||
t.Errorf("Detail.Hint = %q, want historical hint", ee.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// strictModeStubFrom must write the denial annotations so the hook
|
||||
// layer's populateInvocationDenial recognises the command as denied
|
||||
// and physically isolates the Wrap chain. Without this, a plugin
|
||||
// Wrapper registered against platform.All() could intercept the stub
|
||||
// and silently return nil, swallowing the strict-mode error.
|
||||
func TestStrictModeStub_HasDenialAnnotation(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
|
||||
// im/+search is user-only -> replaced by a stub in StrictModeBot.
|
||||
stub := findCmd(root, "im", "+search")
|
||||
if stub == nil {
|
||||
t.Fatalf("expected im/+search stub to exist")
|
||||
}
|
||||
got := stub.Annotations[cmdpolicy.AnnotationDenialLayer]
|
||||
if got != cmdpolicy.LayerStrictMode {
|
||||
t.Errorf("stub annotation %q = %q, want %q",
|
||||
cmdpolicy.AnnotationDenialLayer, got, cmdpolicy.LayerStrictMode)
|
||||
}
|
||||
if src := stub.Annotations[cmdpolicy.AnnotationDenialSource]; src != "strict-mode" {
|
||||
t.Errorf("stub annotation %q = %q, want %q",
|
||||
cmdpolicy.AnnotationDenialSource, src, "strict-mode")
|
||||
}
|
||||
}
|
||||
|
||||
// Audit / compliance observers fire even for strict-mode-denied commands
|
||||
// and rely on CommandView.Risk() / Identities() / etc. The stub must
|
||||
// carry the original command's annotations so those accessors keep
|
||||
// returning meaningful values; the Short/Long are preserved so `--help`
|
||||
// on a denied command still describes the original intent (parity with
|
||||
// cmdpolicy/apply.go::installDenyStub).
|
||||
func TestStrictModeStub_PreservesOriginalMetadata(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
svc := &cobra.Command{Use: "im"}
|
||||
root.AddCommand(svc)
|
||||
userOnly := &cobra.Command{
|
||||
Use: "+search",
|
||||
Short: "search messages",
|
||||
Long: "Search across IM history.",
|
||||
RunE: func(*cobra.Command, []string) error { return nil },
|
||||
}
|
||||
cmdutil.SetSupportedIdentities(userOnly, []string{"user"})
|
||||
cmdutil.SetRisk(userOnly, "read")
|
||||
svc.AddCommand(userOnly)
|
||||
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
|
||||
stub := findCmd(root, "im", "+search")
|
||||
if stub == nil {
|
||||
t.Fatalf("expected im/+search stub")
|
||||
}
|
||||
if got := stub.Annotations["risk_level"]; got != "read" {
|
||||
t.Errorf("stub risk_level = %q, want %q (lost in replacement)", got, "read")
|
||||
}
|
||||
if got := stub.Annotations["lark:supportedIdentities"]; got != "user" {
|
||||
t.Errorf("stub supportedIdentities = %q, want %q", got, "user")
|
||||
}
|
||||
if stub.Short != "search messages" {
|
||||
t.Errorf("stub Short = %q, want preserved Short", stub.Short)
|
||||
}
|
||||
if stub.Long != "Search across IM history." {
|
||||
t.Errorf("stub Long = %q, want preserved Long", stub.Long)
|
||||
}
|
||||
// Denial stamps must still be present.
|
||||
if stub.Annotations[cmdpolicy.AnnotationDenialLayer] != cmdpolicy.LayerStrictMode {
|
||||
t.Errorf("denial annotation overwritten or missing")
|
||||
}
|
||||
}
|
||||
|
||||
100
cmd/root.go
100
cmd/root.go
@@ -12,12 +12,17 @@ import (
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
@@ -88,8 +93,9 @@ func Execute() int {
|
||||
}
|
||||
configureFlagCompletions(os.Args)
|
||||
|
||||
f, rootCmd := buildInternal(
|
||||
context.Background(), inv,
|
||||
ctx := context.Background()
|
||||
f, rootCmd, reg := buildInternal(
|
||||
ctx, inv,
|
||||
WithIO(os.Stdin, os.Stdout, os.Stderr),
|
||||
HideProfile(isSingleAppMode()),
|
||||
)
|
||||
@@ -99,8 +105,18 @@ func Execute() int {
|
||||
setupNotices()
|
||||
}
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
return handleRootError(f, err)
|
||||
runErr := rootCmd.Execute()
|
||||
|
||||
// Fire Shutdown lifecycle hooks regardless of run outcome.
|
||||
// emitShutdown imposes a 2s total deadline and never propagates handler
|
||||
// errors (Emit's documented Shutdown contract), so it cannot block exit
|
||||
// or alter the user-visible exit code.
|
||||
if reg != nil && !isCompletionCommand(os.Args) {
|
||||
_ = hook.Emit(ctx, reg, platform.Shutdown, runErr)
|
||||
}
|
||||
|
||||
if runErr != nil {
|
||||
return handleRootError(f, runErr)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -159,11 +175,17 @@ func setupNotices() {
|
||||
}
|
||||
|
||||
// isCompletionCommand returns true if args indicate a shell completion request.
|
||||
// Update notifications must be suppressed for these to avoid corrupting
|
||||
// machine-parseable completion output.
|
||||
// Update notifications and Shutdown lifecycle emits must be suppressed for
|
||||
// these to avoid corrupting machine-parseable completion output and to avoid
|
||||
// firing plugin Shutdown handlers on every Tab keystroke.
|
||||
//
|
||||
// Cobra dispatches BOTH "__complete" and its alias "__completeNoDesc" through
|
||||
// the same hidden subcommand (see cobra/completions.go ShellCompRequestCmd /
|
||||
// ShellCompNoDescRequestCmd). Check both, otherwise bash/zsh completion
|
||||
// (which often uses NoDesc) silently bypasses the gate.
|
||||
func isCompletionCommand(args []string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == "completion" || arg == "__complete" {
|
||||
if arg == "completion" || arg == "__complete" || arg == "__completeNoDesc" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -263,6 +285,70 @@ func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyErr
|
||||
fmt.Fprint(w, buffer.String())
|
||||
}
|
||||
|
||||
// installUnknownSubcommandGuard replaces cobra's silent help fallback on
|
||||
// group commands (no Run/RunE) with an unknown_subcommand error.
|
||||
//
|
||||
// IMPORTANT: every command modified here is also tagged with
|
||||
// cmdpolicy.AnnotationPureGroup so the user-layer policy engine
|
||||
// continues to treat the command as a pure parent group. Without the
|
||||
// tag, the RunE injection here would flip Runnable()=true and a user
|
||||
// rule like `max_risk: read` would deny every `<group> --help` call
|
||||
// with reason_code=risk_not_annotated.
|
||||
func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
|
||||
cmd.RunE = unknownSubcommandRunE
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[cmdpolicy.AnnotationPureGroup] = "true"
|
||||
}
|
||||
for _, c := range cmd.Commands() {
|
||||
installUnknownSubcommandGuard(c)
|
||||
}
|
||||
}
|
||||
|
||||
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return cmd.Help()
|
||||
}
|
||||
unknown := args[0]
|
||||
available := availableSubcommandNames(cmd)
|
||||
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
|
||||
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
|
||||
if len(available) > 0 {
|
||||
hint = fmt.Sprintf("available subcommands: %s", strings.Join(available, ", "))
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "unknown_subcommand",
|
||||
Message: msg,
|
||||
Hint: hint,
|
||||
Detail: map[string]any{
|
||||
"unknown": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"available": available,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func availableSubcommandNames(cmd *cobra.Command) []string {
|
||||
subs := make([]string, 0, len(cmd.Commands()))
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Hidden || !c.IsAvailableCommand() {
|
||||
continue
|
||||
}
|
||||
name := c.Name()
|
||||
if name == "help" || name == "completion" {
|
||||
continue
|
||||
}
|
||||
subs = append(subs, name)
|
||||
}
|
||||
sort.Strings(subs)
|
||||
return subs
|
||||
}
|
||||
|
||||
// installTipsHelpFunc wraps the default help function to append a TIPS section
|
||||
// when a command has tips set via cmdutil.SetTips. It also force-shows global
|
||||
// flags that are normally hidden in single-app mode (currently --profile)
|
||||
|
||||
@@ -27,6 +27,14 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Canonical strict-mode envelope strings shared across fixtures
|
||||
// (reflect.DeepEqual pins them; keep in sync with strictModeStubFrom).
|
||||
const (
|
||||
strictModeBotMessage = `strict mode is "bot", only bot-identity commands are available`
|
||||
strictModeUserMessage = `strict mode is "user", only user-identity commands are available`
|
||||
strictModeHint = "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)"
|
||||
)
|
||||
|
||||
// buildIntegrationRootCmd creates a root command with api, service, and shortcut
|
||||
// subcommands wired to a test factory, simulating the real CLI command tree.
|
||||
func buildIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||
@@ -353,9 +361,17 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelop
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
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)",
|
||||
Type: "command_denied",
|
||||
Message: strictModeBotMessage,
|
||||
Hint: strictModeHint,
|
||||
Detail: map[string]any{
|
||||
"path": "auth/login",
|
||||
"layer": "strict_mode",
|
||||
"policy_source": "strict-mode",
|
||||
"rule_name": "",
|
||||
"reason_code": "identity_not_supported",
|
||||
"reason": strictModeBotMessage,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -371,9 +387,17 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnve
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
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)",
|
||||
Type: "command_denied",
|
||||
Message: strictModeBotMessage,
|
||||
Hint: strictModeHint,
|
||||
Detail: map[string]any{
|
||||
"path": "im/+messages-search",
|
||||
"layer": "strict_mode",
|
||||
"policy_source": "strict-mode",
|
||||
"rule_name": "",
|
||||
"reason_code": "identity_not_supported",
|
||||
"reason": strictModeBotMessage,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -409,7 +433,7 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Type: "command_denied",
|
||||
Message: `strict mode is "user", only user-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
@@ -428,7 +452,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
|
||||
OK: false,
|
||||
Identity: "user",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Type: "command_denied",
|
||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
@@ -446,9 +470,17 @@ func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsE
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
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)",
|
||||
Type: "command_denied",
|
||||
Message: strictModeUserMessage,
|
||||
Hint: strictModeHint,
|
||||
Detail: map[string]any{
|
||||
"path": "im/images/create",
|
||||
"layer": "strict_mode",
|
||||
"policy_source": "strict-mode",
|
||||
"rule_name": "",
|
||||
"reason_code": "identity_not_supported",
|
||||
"reason": strictModeUserMessage,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -465,7 +497,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop
|
||||
OK: false,
|
||||
Identity: "user",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Type: "command_denied",
|
||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
|
||||
@@ -356,6 +356,7 @@ func TestConfigureFlagCompletions(t *testing.T) {
|
||||
{"help flag", []string{"im", "--help"}, true},
|
||||
{"no args", []string{}, true},
|
||||
{"__complete request", []string{"__complete", "im", "+send", ""}, false},
|
||||
{"__completeNoDesc request", []string{"__completeNoDesc", "im", "+send", ""}, false},
|
||||
{"completion subcommand", []string{"completion", "bash"}, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
@@ -368,3 +369,30 @@ func TestConfigureFlagCompletions(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// isCompletionCommand must classify BOTH cobra completion aliases as
|
||||
// completion requests so the Shutdown emit and update-notice paths skip
|
||||
// shell-completion invocations. __completeNoDesc is an Alias of
|
||||
// __complete (cobra/completions.go ShellCompNoDescRequestCmd) and
|
||||
// dispatches the same RunE; bash/zsh completion typically calls the
|
||||
// NoDesc variant.
|
||||
func TestIsCompletionCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want bool
|
||||
}{
|
||||
{"plain command", []string{"im", "+send"}, false},
|
||||
{"__complete", []string{"__complete", "im"}, true},
|
||||
{"__completeNoDesc", []string{"__completeNoDesc", "im"}, true},
|
||||
{"completion subcommand", []string{"completion", "bash"}, true},
|
||||
{"completion in tail", []string{"foo", "bar", "completion"}, true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isCompletionCommand(tc.args); got != tc.want {
|
||||
t.Fatalf("isCompletionCommand(%v) = %v, want %v", tc.args, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,6 +380,7 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
177
cmd/unknown_subcommand_test.go
Normal file
177
cmd/unknown_subcommand_test.go
Normal file
@@ -0,0 +1,177 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func newGroupTree() (root, drive, files *cobra.Command) {
|
||||
root = &cobra.Command{Use: "lark-cli"}
|
||||
drive = &cobra.Command{Use: "drive", Short: "drive ops"}
|
||||
root.AddCommand(drive)
|
||||
|
||||
search := &cobra.Command{Use: "+search", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
upload := &cobra.Command{Use: "+upload", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
hidden := &cobra.Command{Use: "+secret", Hidden: true, RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
drive.AddCommand(search, upload, hidden)
|
||||
|
||||
files = &cobra.Command{Use: "files", Short: "files ops"}
|
||||
drive.AddCommand(files)
|
||||
files.AddCommand(&cobra.Command{Use: "list", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
|
||||
return root, drive, files
|
||||
}
|
||||
|
||||
func TestInstallUnknownSubcommandGuard_InstallsOnGroupsOnly(t *testing.T) {
|
||||
root, drive, files := newGroupTree()
|
||||
leaf := drive.Commands()[0] // +search
|
||||
|
||||
installUnknownSubcommandGuard(root)
|
||||
|
||||
if drive.RunE == nil {
|
||||
t.Error("drive should have RunE installed")
|
||||
}
|
||||
if files.RunE == nil {
|
||||
t.Error("files should have RunE installed")
|
||||
}
|
||||
if err := leaf.RunE(leaf, []string{"unexpected-arg"}); err != nil {
|
||||
t.Errorf("leaf +search RunE should be untouched, got error %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallUnknownSubcommandGuard_PreservesExistingRunE(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
called := false
|
||||
custom := &cobra.Command{
|
||||
Use: "custom",
|
||||
RunE: func(*cobra.Command, []string) error {
|
||||
called = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
// Child makes custom a "group" command, exercising the Run/RunE override guard.
|
||||
custom.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
root.AddCommand(custom)
|
||||
|
||||
installUnknownSubcommandGuard(root)
|
||||
|
||||
if err := custom.RunE(custom, nil); err != nil {
|
||||
t.Fatalf("preserved RunE returned error: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Error("guard must not overwrite a command that already defines Run/RunE")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
|
||||
var buf bytes.Buffer
|
||||
drive.SetOut(&buf)
|
||||
drive.SetErr(&buf)
|
||||
|
||||
if err := drive.RunE(drive, nil); err != nil {
|
||||
t.Fatalf("expected no-args invocation to succeed, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "drive ops") {
|
||||
t.Errorf("expected help output to include the command's Short, got:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
|
||||
err := drive.RunE(drive, []string{"+bogus"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown subcommand")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("expected exit code %d, got %d", output.ExitValidation, exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected ExitError to carry Detail")
|
||||
}
|
||||
if exitErr.Detail.Type != "unknown_subcommand" {
|
||||
t.Errorf("expected Detail.Type=unknown_subcommand, got %q", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) {
|
||||
t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "+search") || !strings.Contains(exitErr.Detail.Hint, "+upload") {
|
||||
t.Errorf("hint should list available shortcuts, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if strings.Contains(exitErr.Detail.Hint, "+secret") {
|
||||
t.Error("hidden commands must not appear in the hint")
|
||||
}
|
||||
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected Detail.Detail to be map[string]any, got %T", exitErr.Detail.Detail)
|
||||
}
|
||||
if detail["unknown"] != "+bogus" {
|
||||
t.Errorf("detail.unknown should be +bogus, got %v", detail["unknown"])
|
||||
}
|
||||
if detail["command_path"] != "lark-cli drive" {
|
||||
t.Errorf("detail.command_path should be %q, got %v", "lark-cli drive", detail["command_path"])
|
||||
}
|
||||
available, ok := detail["available"].([]string)
|
||||
if !ok {
|
||||
t.Fatalf("detail.available should be []string, got %T", detail["available"])
|
||||
}
|
||||
if len(available) != 3 {
|
||||
t.Errorf("expected 3 available entries (hidden excluded), got %d: %v", len(available), available)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandRunE_NestedResourceGroup(t *testing.T) {
|
||||
root, _, files := newGroupTree()
|
||||
installUnknownSubcommandGuard(root)
|
||||
|
||||
err := files.RunE(files, []string{"bogus"})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError on nested group, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Detail.(map[string]any)["command_path"] != "lark-cli drive files" {
|
||||
t.Errorf("command_path should reflect the nested resource, got %v",
|
||||
exitErr.Detail.Detail.(map[string]any)["command_path"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(
|
||||
&cobra.Command{Use: "alpha", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
&cobra.Command{Use: "help", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
&cobra.Command{Use: "completion", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
&cobra.Command{Use: "beta", Hidden: true, RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
&cobra.Command{Use: "gamma", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
)
|
||||
|
||||
got := availableSubcommandNames(root)
|
||||
want := []string{"alpha", "gamma"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("expected %v, got %v", want, got)
|
||||
}
|
||||
for i, name := range want {
|
||||
if got[i] != name {
|
||||
t.Errorf("availableSubcommandNames[%d] = %q, want %q", i, got[i], name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,7 @@ Use --check to only check for updates without installing.`,
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "force reinstall even if already up to date")
|
||||
cmd.Flags().BoolVar(&opts.Check, "check", false, "only check for updates, do not install")
|
||||
cmdutil.SetRisk(cmd, "high-risk-write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
186
extension/platform/README.md
Normal file
186
extension/platform/README.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# lark-cli Plugin SDK
|
||||
|
||||
`extension/platform` is the **in-process plugin SDK** for lark-cli.
|
||||
Plugins compile into a **fork** of the lark-cli binary via a blank
|
||||
import; there is no `.so` loading, no RPC, no subprocess isolation.
|
||||
A plugin shares the binary's address space and lifecycle.
|
||||
|
||||
## 5-minute hello world
|
||||
|
||||
```go
|
||||
// myplugin/audit.go
|
||||
package myplugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("audit", "0.1.0").
|
||||
Observer(platform.After, "log-cmd", platform.All(),
|
||||
func(ctx context.Context, inv platform.Invocation) {
|
||||
log.Printf("cmd=%s err=%v", inv.Cmd().Path(), inv.Err())
|
||||
}).
|
||||
FailOpen().
|
||||
MustBuild())
|
||||
}
|
||||
```
|
||||
|
||||
Wire into a fork:
|
||||
|
||||
```go
|
||||
// cmd/larkx/main.go in your fork
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "github.com/me/myplugin" // blank import → init() runs
|
||||
|
||||
"github.com/larksuite/cli/cmd"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() { os.Exit(cmd.Execute()) }
|
||||
```
|
||||
|
||||
```sh
|
||||
go build -o larkx ./cmd/larkx && ./larkx config plugins show
|
||||
```
|
||||
|
||||
You should see `audit` in the plugin list.
|
||||
|
||||
## What you can hook
|
||||
|
||||
| Hook | Fires | Can block? |
|
||||
| -------------------------- | ---------------------------------- | -------------------------------- |
|
||||
| `Observer` | Before / After each command | No (fire-and-forget audit) |
|
||||
| `Wrap` | Around each command's RunE | Yes (return `*AbortError`) |
|
||||
| `On(Startup/Shutdown)` | Process lifecycle | N/A |
|
||||
| `Restrict(Rule)` | Bootstrap-time, single per binary | Denies whole subtrees |
|
||||
|
||||
### Plugin lifecycle
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Host as lark-cli (host)
|
||||
participant SDK as platform (SDK)
|
||||
participant Plugin as your plugin
|
||||
|
||||
Note over Host,Plugin: Process start (before main)
|
||||
Plugin->>Plugin: init() (via blank import)
|
||||
Plugin->>SDK: Register(plugin)
|
||||
|
||||
Note over Host,Plugin: Bootstrap (host main)
|
||||
Host->>SDK: RegisteredPlugins()
|
||||
SDK-->>Host: snapshot in registration order
|
||||
Host->>SDK: InstallAll()
|
||||
SDK->>Plugin: Capabilities()
|
||||
SDK->>Plugin: Install(Registrar)
|
||||
Plugin->>SDK: Observe / Wrap / Restrict / On(Startup,Shutdown)
|
||||
SDK->>Plugin: On(Startup) fire
|
||||
|
||||
Note over Host,Plugin: Each command dispatch
|
||||
Host->>SDK: hook chain (in registration order)
|
||||
SDK->>Plugin: Observer Before
|
||||
SDK->>Plugin: Wrap (around RunE)
|
||||
SDK->>Plugin: Observer After
|
||||
|
||||
Note over Host,Plugin: Process exit
|
||||
Host->>SDK: Emit(Shutdown)
|
||||
SDK->>Plugin: On(Shutdown) fire
|
||||
```
|
||||
|
||||
A `command_denied` decision (from `Restrict` or strict-mode) bypasses
|
||||
the `Wrap` chain entirely — observers still fire so audit plugins see
|
||||
the rejected dispatch.
|
||||
|
||||
## Safety contract (read this)
|
||||
|
||||
- A plugin calling `Restrict()` MUST declare `FailClosed`. The Builder
|
||||
flips it automatically; the lower-level `Plugin` interface rejects
|
||||
the mismatch with `restricts_mismatch`.
|
||||
- Only ONE plugin per binary can call `Restrict()`. Multi-plugin
|
||||
Restrict is a deliberate `plugin_conflict` error (single-rule
|
||||
ecosystem assumption). YAML policy at `~/.lark-cli/policy.yml` is
|
||||
shadowed by any plugin Restrict.
|
||||
- The `Wrap` factory runs **once per command dispatch**, not at
|
||||
install time. Long-lived state (clients, caches, metrics counters)
|
||||
must live on the Plugin struct or in package-level variables.
|
||||
- Plugins cannot suppress a `command_denied`: the framework
|
||||
physically isolates denied commands from the Wrap chain (Observers
|
||||
still fire).
|
||||
- Commands missing a `risk_level` annotation are denied by default
|
||||
when a Rule is active. Set `Rule.AllowUnannotated = true` (or
|
||||
`allow_unannotated: true` in yaml) to opt out during gradual
|
||||
adoption.
|
||||
- Risk annotation typos (e.g. `"wrtie"`) are always denied with
|
||||
`risk_invalid` plus a "did you mean" suggestion. `AllowUnannotated`
|
||||
does NOT bypass this — typo is a code bug, not a missing
|
||||
annotation.
|
||||
|
||||
## reason_code reference
|
||||
|
||||
Every install / dispatch failure emits a `command_denied` or
|
||||
`plugin_install` envelope carrying a `detail.reason_code` from the
|
||||
closed enum below. Use the code (not the human-readable message) when
|
||||
matching errors in agents, CI scripts, or downstream tools — the
|
||||
messages are localised and may change between releases.
|
||||
|
||||
### Plugin install (`error.type = plugin_install`)
|
||||
|
||||
| reason_code | When it fires | Honours FailurePolicy? |
|
||||
| --------------------------- | ------------------------------------------------------------------------------ | ---------------------- |
|
||||
| `invalid_plugin_name` | `Plugin.Name()` doesn't match `^[a-z0-9][a-z0-9-]*$` | No — always aborts |
|
||||
| `plugin_name_panic` | `Plugin.Name()` panicked | No — always aborts |
|
||||
| `duplicate_plugin_name` | Two plugins return the same `Name()` | No — always aborts |
|
||||
| `capabilities_panic` | `Plugin.Capabilities()` panicked | Yes |
|
||||
| `invalid_capability` | `Capabilities` malformed: bad `RequiredCLIVersion`, unknown `FailurePolicy` | No — always aborts |
|
||||
| `capability_unmet` | Current CLI version doesn't satisfy `RequiredCLIVersion` | Yes |
|
||||
| `restricts_mismatch` | `Restricts=true` without `FailClosed`, or `Restricts` flag inconsistent w/ Install | No — always aborts |
|
||||
| `invalid_hook_name` | Hook name contains `.` or doesn't match the plugin namespace | Yes |
|
||||
| `duplicate_hook_name` | Same hook name registered twice within a plugin | Yes |
|
||||
| `invalid_hook_registration` | Hook factory returns nil / Wrap chain re-entry / etc. | Yes |
|
||||
| `invalid_rule` | Rule fails ValidateRule (malformed glob, bad MaxRisk, unknown Identity) | Yes |
|
||||
| `double_restrict` | Plugin called `r.Restrict()` more than once in one Install | Yes |
|
||||
| `multiple_restrict_plugins` | Two or more plugins each contributed Restrict | Yes |
|
||||
| `install_failed` | `Plugin.Install` returned a non-nil error | Yes |
|
||||
| `install_panic` | `Plugin.Install` panicked | Yes |
|
||||
|
||||
"No — always aborts" entries are treated as **untrusted-config errors**:
|
||||
the host can't honour the plugin's declared `FailurePolicy` because the
|
||||
declaration itself is suspect (e.g. an `invalid_capability` plugin
|
||||
might also be lying about being `FailOpen`).
|
||||
|
||||
### Command dispatch (`error.type = command_denied`)
|
||||
|
||||
| reason_code | Meaning |
|
||||
| ----------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| `risk_not_annotated` | Command has no `risk_level` annotation, and the active Rule does not set `allow_unannotated: true` |
|
||||
| `risk_invalid` | Command's `risk_level` is a typo / not in the `read | write | high-risk-write` taxonomy (always fail-closed) |
|
||||
| `command_denylisted` | Command path matched the active Rule's `deny` glob |
|
||||
| `domain_not_allowed` | Active Rule has a non-empty `allow` list and the command path did not match any glob |
|
||||
| `write_not_allowed` | Command risk is `write` / `high-risk-write` and exceeds Rule `max_risk` |
|
||||
| `risk_too_high` | Command risk exceeds Rule `max_risk` but is not a write (reserved for future risk levels) |
|
||||
| `identity_mismatch` | Command's `supportedIdentities` does not intersect Rule `identities` |
|
||||
| `aggregate_all_denied` | Aggregate stub installed on a parent group because every live child was denied |
|
||||
|
||||
The `detail.layer` field distinguishes who rejected the call:
|
||||
`policy` (this SDK's user-layer engine) vs. `strict_mode`
|
||||
(`cmd/prune.go`'s credential-hardening pass). Agents that want to
|
||||
dispatch on "any denial" should match `error.type == "command_denied"`
|
||||
and ignore the layer; agents that only care about user-policy denials
|
||||
should additionally check `detail.layer == "policy"`.
|
||||
|
||||
## Where to go next
|
||||
|
||||
- [Runnable example: audit observer](./examples/audit-observer/)
|
||||
- [Runnable example: read-only policy](./examples/readonly-policy/)
|
||||
- Builder API: see [`builder.go`](./builder.go) for the full DSL
|
||||
(`NewPlugin`, `Observer`, `Wrap`, `Restrict`, `FailOpen`/`FailClosed`,
|
||||
`MustBuild`).
|
||||
- Inventory diagnostic: run `lark-cli config plugins show` after
|
||||
installing your plugin to see hooks/rules attributed to your plugin
|
||||
name.
|
||||
37
extension/platform/abort.go
Normal file
37
extension/platform/abort.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import "fmt"
|
||||
|
||||
// AbortError is returned by a Wrapper that wants to short-circuit the
|
||||
// command chain (instead of calling next). The framework converts it
|
||||
// to an *output.ExitError with type "hook" so the JSON envelope carries
|
||||
// the structured fields agents expect.
|
||||
//
|
||||
// HookName is the framework-namespaced name ("secaudit.approval"); the
|
||||
// Registrar adds the plugin-name prefix automatically.
|
||||
//
|
||||
// Cause and Detail are optional. Cause lets the consumer use
|
||||
// errors.Is/As to find the underlying cause; Detail is serialized into
|
||||
// envelope.detail under the "detail" key for agent consumption.
|
||||
type AbortError struct {
|
||||
HookName string
|
||||
Reason string
|
||||
Cause error
|
||||
Detail any
|
||||
}
|
||||
|
||||
// Error renders a human-readable message; HookName + Reason + Cause are
|
||||
// included when present.
|
||||
func (e *AbortError) Error() string {
|
||||
msg := fmt.Sprintf("hook %q aborted: %s", e.HookName, e.Reason)
|
||||
if e.Cause != nil {
|
||||
msg += ": " + e.Cause.Error()
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// Unwrap enables errors.Is / errors.As to traverse to Cause.
|
||||
func (e *AbortError) Unwrap() error { return e.Cause }
|
||||
42
extension/platform/abort_test.go
Normal file
42
extension/platform/abort_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func TestAbortError_messageFormats(t *testing.T) {
|
||||
bare := &platform.AbortError{HookName: "secaudit.approval", Reason: "needs approval"}
|
||||
if got := bare.Error(); got != `hook "secaudit.approval" aborted: needs approval` {
|
||||
t.Errorf("Error() = %q", got)
|
||||
}
|
||||
|
||||
withCause := &platform.AbortError{
|
||||
HookName: "audit.upload",
|
||||
Reason: "upstream unreachable",
|
||||
Cause: fs.ErrNotExist,
|
||||
}
|
||||
if got := withCause.Error(); got == bare.Error() {
|
||||
t.Errorf("Cause should be appended to message, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// errors.As must traverse Unwrap so consumers can inspect the cause
|
||||
// directly. This is the contract the host's wrapAbortError relies on.
|
||||
func TestAbortError_unwrapErrorsAs(t *testing.T) {
|
||||
root := fs.ErrPermission
|
||||
ab := &platform.AbortError{
|
||||
HookName: "x",
|
||||
Reason: "y",
|
||||
Cause: root,
|
||||
}
|
||||
if !errors.Is(ab, fs.ErrPermission) {
|
||||
t.Errorf("errors.Is should find fs.ErrPermission via Unwrap")
|
||||
}
|
||||
}
|
||||
215
extension/platform/builder.go
Normal file
215
extension/platform/builder.go
Normal file
@@ -0,0 +1,215 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// Builder is the ergonomic constructor for Plugin. Use it from init():
|
||||
//
|
||||
// func init() {
|
||||
// platform.Register(
|
||||
// platform.NewPlugin("audit", "0.1.0").
|
||||
// Observer(platform.After, "log", platform.All(), auditFn).
|
||||
// FailOpen().
|
||||
// MustBuild())
|
||||
// }
|
||||
//
|
||||
// The lower-level Plugin interface remains available for cases that
|
||||
// need finer control (state on a struct, complex Install logic). The
|
||||
// Builder enforces:
|
||||
//
|
||||
// - Name format (^[a-z0-9][a-z0-9-]*$)
|
||||
// - hookName format and uniqueness within a plugin
|
||||
// - Restricts ↔ FailClosed consistency (calling Restrict() implies
|
||||
// FailClosed, so plugin authors cannot accidentally ship a policy
|
||||
// plugin under FailOpen)
|
||||
// - Rule validation via ValidateRule analogues (delegated to
|
||||
// internal/cmdpolicy at install time; Builder only fast-fails
|
||||
// blatantly bad input)
|
||||
type Builder struct {
|
||||
name string
|
||||
version string
|
||||
caps Capabilities
|
||||
|
||||
actions []func(Registrar)
|
||||
rule *Rule
|
||||
|
||||
hookNames map[string]bool
|
||||
errs []error
|
||||
}
|
||||
|
||||
var pluginNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||
|
||||
// NewPlugin starts a Builder. Name format is validated lazily — errors
|
||||
// surface at Build()/MustBuild() time, allowing chained calls without
|
||||
// intermediate error handling.
|
||||
func NewPlugin(name, version string) *Builder {
|
||||
b := &Builder{
|
||||
name: name,
|
||||
version: version,
|
||||
hookNames: map[string]bool{},
|
||||
}
|
||||
if !pluginNamePattern.MatchString(name) {
|
||||
b.errs = append(b.errs, fmt.Errorf("invalid plugin name %q: must match ^[a-z0-9][a-z0-9-]*$", name))
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// RequireCLI sets Capabilities.RequiredCLIVersion (semver constraint,
|
||||
// e.g. ">=1.1.0"). Empty string means no requirement.
|
||||
func (b *Builder) RequireCLI(constraint string) *Builder {
|
||||
b.caps.RequiredCLIVersion = constraint
|
||||
return b
|
||||
}
|
||||
|
||||
// FailOpen sets Capabilities.FailurePolicy = FailOpen. Default when
|
||||
// neither FailOpen nor FailClosed is called and Restrict is not used.
|
||||
func (b *Builder) FailOpen() *Builder {
|
||||
b.caps.FailurePolicy = FailOpen
|
||||
return b
|
||||
}
|
||||
|
||||
// FailClosed sets Capabilities.FailurePolicy = FailClosed. Implicit
|
||||
// when Restrict() is called.
|
||||
func (b *Builder) FailClosed() *Builder {
|
||||
b.caps.FailurePolicy = FailClosed
|
||||
return b
|
||||
}
|
||||
|
||||
// Observer registers an Observer. Multiple calls accumulate.
|
||||
func (b *Builder) Observer(when When, hookName string, sel Selector, fn Observer) *Builder {
|
||||
if !b.validateHookName(hookName, "observer") {
|
||||
return b
|
||||
}
|
||||
// Capture by value so the action closure doesn't share state with
|
||||
// subsequent Observer() calls (Go ≥1.22 already gives each call
|
||||
// its own copies of parameter values, but pinning is explicit).
|
||||
w, n, s, f := when, hookName, sel, fn
|
||||
b.actions = append(b.actions, func(r Registrar) {
|
||||
r.Observe(w, n, s, f)
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
// Wrap registers a Wrapper. Multiple calls accumulate; the host
|
||||
// composes them in registration order (outermost first).
|
||||
func (b *Builder) Wrap(hookName string, sel Selector, wrap Wrapper) *Builder {
|
||||
if !b.validateHookName(hookName, "wrap") {
|
||||
return b
|
||||
}
|
||||
n, s, w := hookName, sel, wrap
|
||||
b.actions = append(b.actions, func(r Registrar) {
|
||||
r.Wrap(n, s, w)
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
// On registers a LifecycleHandler.
|
||||
func (b *Builder) On(event LifecycleEvent, hookName string, fn LifecycleHandler) *Builder {
|
||||
if !b.validateHookName(hookName, "on") {
|
||||
return b
|
||||
}
|
||||
e, n, f := event, hookName, fn
|
||||
b.actions = append(b.actions, func(r Registrar) {
|
||||
r.On(e, n, f)
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
// Restrict contributes a pruning Rule. Calling Restrict implicitly
|
||||
// sets Restricts=true and FailurePolicy=FailClosed (the framework
|
||||
// requires both to coexist; the builder enforces the pairing so the
|
||||
// plugin author cannot accidentally ship a policy plugin under
|
||||
// FailOpen).
|
||||
func (b *Builder) Restrict(rule *Rule) *Builder {
|
||||
if rule == nil {
|
||||
b.errs = append(b.errs, errors.New("Restrict(nil): rule must not be nil"))
|
||||
return b
|
||||
}
|
||||
b.caps.Restricts = true
|
||||
b.caps.FailurePolicy = FailClosed
|
||||
b.rule = rule
|
||||
return b
|
||||
}
|
||||
|
||||
// Build returns the configured Plugin, or an error if any builder
|
||||
// step found a fault. MustBuild panics on the same error.
|
||||
//
|
||||
// The Restrict + FailOpen mismatch is checked here, not in the chained
|
||||
// setters, because the two methods may be called in either order.
|
||||
func (b *Builder) Build() (Plugin, error) {
|
||||
if b.rule != nil && b.caps.FailurePolicy == FailOpen {
|
||||
b.errs = append(b.errs, errors.New(
|
||||
"Restrict() requires FailClosed; do not call FailOpen() after Restrict()"))
|
||||
}
|
||||
if len(b.errs) > 0 {
|
||||
return nil, errors.Join(b.errs...)
|
||||
}
|
||||
return &builtPlugin{
|
||||
name: b.name,
|
||||
version: b.version,
|
||||
caps: b.caps,
|
||||
actions: b.actions,
|
||||
rule: b.rule,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MustBuild panics if Build() would return an error. Designed for
|
||||
// init():
|
||||
//
|
||||
// func init() { platform.Register(platform.NewPlugin(...).MustBuild()) }
|
||||
//
|
||||
// A panic in init runs before the framework's recover guard is
|
||||
// installed and will crash the binary. That is the intended
|
||||
// behaviour: a misconfigured plugin must NOT be silently registered.
|
||||
func (b *Builder) MustBuild() Plugin {
|
||||
p, err := b.Build()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("plugin %q: %v", b.name, err))
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// validateHookName checks the grammar and uniqueness; returns false
|
||||
// when the name was rejected (caller skips the action).
|
||||
func (b *Builder) validateHookName(hookName, kind string) bool {
|
||||
if !pluginNamePattern.MatchString(hookName) {
|
||||
b.errs = append(b.errs, fmt.Errorf(
|
||||
"%s %q: hookName must match ^[a-z0-9][a-z0-9-]*$", kind, hookName))
|
||||
return false
|
||||
}
|
||||
if b.hookNames[hookName] {
|
||||
b.errs = append(b.errs, fmt.Errorf(
|
||||
"%s %q: hookName already used in this plugin", kind, hookName))
|
||||
return false
|
||||
}
|
||||
b.hookNames[hookName] = true
|
||||
return true
|
||||
}
|
||||
|
||||
// builtPlugin is the Plugin implementation the builder emits.
|
||||
type builtPlugin struct {
|
||||
name string
|
||||
version string
|
||||
caps Capabilities
|
||||
actions []func(Registrar)
|
||||
rule *Rule
|
||||
}
|
||||
|
||||
func (p *builtPlugin) Name() string { return p.name }
|
||||
func (p *builtPlugin) Version() string { return p.version }
|
||||
func (p *builtPlugin) Capabilities() Capabilities { return p.caps }
|
||||
func (p *builtPlugin) Install(r Registrar) error {
|
||||
if p.rule != nil {
|
||||
r.Restrict(p.rule)
|
||||
}
|
||||
for _, action := range p.actions {
|
||||
action(r)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
180
extension/platform/builder_test.go
Normal file
180
extension/platform/builder_test.go
Normal file
@@ -0,0 +1,180 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
// recorder Registrar captures everything a builder schedules so the
|
||||
// test can assert what Install produced without involving the host.
|
||||
type recorder struct {
|
||||
observers int
|
||||
wrappers int
|
||||
lifecycles int
|
||||
rule *platform.Rule
|
||||
}
|
||||
|
||||
func (r *recorder) Observe(platform.When, string, platform.Selector, platform.Observer) {
|
||||
r.observers++
|
||||
}
|
||||
func (r *recorder) Wrap(string, platform.Selector, platform.Wrapper) { r.wrappers++ }
|
||||
func (r *recorder) On(platform.LifecycleEvent, string, platform.LifecycleHandler) { r.lifecycles++ }
|
||||
func (r *recorder) Restrict(rule *platform.Rule) { r.rule = rule }
|
||||
|
||||
func TestBuilder_basicAssembly(t *testing.T) {
|
||||
p, err := platform.NewPlugin("audit", "0.1.0").
|
||||
Observer(platform.Before, "pre", platform.All(),
|
||||
func(context.Context, platform.Invocation) {}).
|
||||
Observer(platform.After, "post", platform.All(),
|
||||
func(context.Context, platform.Invocation) {}).
|
||||
Wrap("policy", platform.All(),
|
||||
func(next platform.Handler) platform.Handler { return next }).
|
||||
On(platform.Startup, "boot",
|
||||
func(context.Context, *platform.LifecycleContext) error { return nil }).
|
||||
FailOpen().
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
if p.Name() != "audit" || p.Version() != "0.1.0" {
|
||||
t.Errorf("metadata = %q/%q", p.Name(), p.Version())
|
||||
}
|
||||
if p.Capabilities().FailurePolicy != platform.FailOpen {
|
||||
t.Errorf("FailurePolicy = %v, want FailOpen", p.Capabilities().FailurePolicy)
|
||||
}
|
||||
|
||||
r := &recorder{}
|
||||
if err := p.Install(r); err != nil {
|
||||
t.Fatalf("Install: %v", err)
|
||||
}
|
||||
if r.observers != 2 || r.wrappers != 1 || r.lifecycles != 1 {
|
||||
t.Errorf("Install dispatch = observers=%d wrappers=%d lifecycles=%d",
|
||||
r.observers, r.wrappers, r.lifecycles)
|
||||
}
|
||||
}
|
||||
|
||||
// Restrict() flips Restricts=true and FailClosed automatically — a
|
||||
// policy plugin can't accidentally ship under FailOpen.
|
||||
func TestBuilder_restrictForcesFailClosed(t *testing.T) {
|
||||
p, err := platform.NewPlugin("policy-plugin", "0.1.0").
|
||||
Restrict(&platform.Rule{Name: "read-only", MaxRisk: platform.RiskRead}).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
caps := p.Capabilities()
|
||||
if !caps.Restricts {
|
||||
t.Errorf("Restricts = false, want true (Restrict() should flip it)")
|
||||
}
|
||||
if caps.FailurePolicy != platform.FailClosed {
|
||||
t.Errorf("FailurePolicy = %v, want FailClosed (Restrict() implies it)", caps.FailurePolicy)
|
||||
}
|
||||
|
||||
r := &recorder{}
|
||||
if err := p.Install(r); err != nil {
|
||||
t.Fatalf("Install: %v", err)
|
||||
}
|
||||
if r.rule == nil || r.rule.Name != "read-only" {
|
||||
t.Errorf("Install did not propagate Rule: %+v", r.rule)
|
||||
}
|
||||
}
|
||||
|
||||
// Invalid name surfaces at Build time, not at NewPlugin.
|
||||
func TestBuilder_invalidPluginName(t *testing.T) {
|
||||
_, err := platform.NewPlugin("Has_Underscore_And_Caps", "0.1").Build()
|
||||
if err == nil {
|
||||
t.Fatalf("Build must reject malformed plugin name")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid plugin name") {
|
||||
t.Errorf("error should mention plugin name, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Duplicate hookName within the same builder is rejected.
|
||||
func TestBuilder_duplicateHookName(t *testing.T) {
|
||||
noopObs := func(context.Context, platform.Invocation) {}
|
||||
_, err := platform.NewPlugin("dup", "0").
|
||||
Observer(platform.Before, "h", platform.All(), noopObs).
|
||||
Observer(platform.After, "h", platform.All(), noopObs).
|
||||
Build()
|
||||
if err == nil {
|
||||
t.Fatalf("Build must reject duplicate hookName")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "already used") {
|
||||
t.Errorf("error should mention duplicate hookName, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilder_invalidHookName(t *testing.T) {
|
||||
_, err := platform.NewPlugin("p", "0").
|
||||
Observer(platform.Before, "Bad.Name", platform.All(),
|
||||
func(context.Context, platform.Invocation) {}).
|
||||
Build()
|
||||
if err == nil {
|
||||
t.Fatalf("Build must reject hookName with dot")
|
||||
}
|
||||
}
|
||||
|
||||
// MustBuild panics on builder error.
|
||||
func TestBuilder_mustBuildPanicsOnError(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Fatalf("MustBuild must panic when Build would fail")
|
||||
}
|
||||
}()
|
||||
_ = platform.NewPlugin("BadName", "0").MustBuild()
|
||||
}
|
||||
|
||||
func TestBuilder_restrictNilRejected(t *testing.T) {
|
||||
_, err := platform.NewPlugin("p", "0").Restrict(nil).Build()
|
||||
if err == nil {
|
||||
t.Fatalf("Restrict(nil) must produce error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilder_capabilitiesSetters(t *testing.T) {
|
||||
p, err := platform.NewPlugin("p", "0.1").
|
||||
RequireCLI(">=1.0.0").
|
||||
FailClosed().
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
caps := p.Capabilities()
|
||||
if caps.RequiredCLIVersion != ">=1.0.0" {
|
||||
t.Errorf("RequiredCLIVersion = %q, want >=1.0.0", caps.RequiredCLIVersion)
|
||||
}
|
||||
if caps.FailurePolicy != platform.FailClosed {
|
||||
t.Errorf("FailurePolicy = %v, want FailClosed", caps.FailurePolicy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilder_restrictThenFailOpenRejected(t *testing.T) {
|
||||
rule := &platform.Rule{Name: "r", MaxRisk: platform.RiskRead}
|
||||
_, err := platform.NewPlugin("p", "0").Restrict(rule).FailOpen().Build()
|
||||
if err == nil {
|
||||
t.Fatalf("Build must reject Restrict()+FailOpen() mismatch")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "FailClosed") {
|
||||
t.Errorf("error should mention FailClosed, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Restrict() flips FailurePolicy to FailClosed; the previous FailOpen()
|
||||
// is overridden. Pin it so the Build-time validation does not over-reject.
|
||||
func TestBuilder_failOpenThenRestrictOK(t *testing.T) {
|
||||
rule := &platform.Rule{Name: "r", MaxRisk: platform.RiskRead}
|
||||
p, err := platform.NewPlugin("p", "0").FailOpen().Restrict(rule).Build()
|
||||
if err != nil {
|
||||
t.Fatalf("FailOpen()+Restrict() must succeed (Restrict flips to FailClosed): %v", err)
|
||||
}
|
||||
if p.Capabilities().FailurePolicy != platform.FailClosed {
|
||||
t.Errorf("FailurePolicy = %v, want FailClosed", p.Capabilities().FailurePolicy)
|
||||
}
|
||||
}
|
||||
50
extension/platform/capabilities.go
Normal file
50
extension/platform/capabilities.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
// FailurePolicy controls what the framework does when a plugin's install
|
||||
// stage fails (Capabilities() panics, Install returns error, etc.).
|
||||
type FailurePolicy int
|
||||
|
||||
const (
|
||||
// FailOpen (default) — log a warning and skip THIS plugin; the rest
|
||||
// of the CLI keeps running. Appropriate for pure-observer plugins
|
||||
// where missing audit data is preferable to a broken CLI.
|
||||
FailOpen FailurePolicy = iota
|
||||
|
||||
// FailClosed — abort the entire CLI startup. Required for any
|
||||
// plugin that contributes Restrict() (a missing policy plugin =
|
||||
// missing security boundary) or that owns any safety-sensitive
|
||||
// concern. Enforced by the framework: Capabilities.Restricts=true
|
||||
// must pair with FailurePolicy=FailClosed.
|
||||
FailClosed
|
||||
)
|
||||
|
||||
// Capabilities declares the plugin's self-description. Plugin.Capabilities
|
||||
// MUST be implemented even when every field would be its zero value --
|
||||
// the requirement keeps FailurePolicy / Restricts visible to the author
|
||||
// at the moment they write the plugin, preventing the "I just want to
|
||||
// add an audit observer" mistake of accidentally shipping a policy
|
||||
// plugin with the default FailOpen.
|
||||
type Capabilities struct {
|
||||
// RequiredCLIVersion is a semver constraint (e.g. ">=1.1.0").
|
||||
// Plugins that need a specific framework feature should declare
|
||||
// the minimum version they tested against; the host fails the
|
||||
// install when the running CLI is older. Empty string means "no
|
||||
// version requirement".
|
||||
RequiredCLIVersion string
|
||||
|
||||
// Restricts declares whether Install will call r.Restrict(). The
|
||||
// framework enforces consistency: declaring Restricts=true and
|
||||
// then NOT calling r.Restrict (or vice versa) aborts the install
|
||||
// with the `restricts_mismatch` reason_code. This pre-flight
|
||||
// declaration also lets `config policy show` introspect "which
|
||||
// plugins are policy plugins" without running them.
|
||||
Restricts bool
|
||||
|
||||
// FailurePolicy decides what happens on install failure. See the
|
||||
// constants above; the framework requires FailClosed whenever
|
||||
// Restricts=true.
|
||||
FailurePolicy FailurePolicy
|
||||
}
|
||||
39
extension/platform/doc.go
Normal file
39
extension/platform/doc.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package platform is the single public extension contract for lark-cli.
|
||||
//
|
||||
// External integrators (plugin authors, embedding platforms) only import this
|
||||
// package; everything else under internal/ is off-limits.
|
||||
//
|
||||
// Plugin lifecycle:
|
||||
//
|
||||
// - Plugin - the interface every plugin implements (Name / Version / Capabilities / Install)
|
||||
// - Registrar - what Install receives; the four registration verbs (Observe / Wrap / On / Restrict)
|
||||
// - Capabilities - declared up front: FailurePolicy (FailOpen | FailClosed) and Restricts
|
||||
// - Register - process-wide entry point; plugins call this from init()
|
||||
//
|
||||
// Hook surface (what Install hangs off Registrar):
|
||||
//
|
||||
// - Observer - side-effect-only callback, panic-safe, runs Before / After RunE
|
||||
// - Wrapper - middleware that can short-circuit via AbortError
|
||||
// - LifecycleHandler - reacts to Startup / Shutdown / etc. (LifecycleEvent + When)
|
||||
// - Selector - chooses which commands a hook applies to (ByDomain / ByWrite / ByReadOnly / ByExactRisk / And / Or / Not, etc.)
|
||||
// - Handler - the inner "run the command" function Wrappers compose around
|
||||
// - Invocation - per-call context passed to handlers (Cmd view + DeniedByPolicy / DenialLayer / DenialPolicySource)
|
||||
// - AbortError - structured short-circuit error from a Wrapper; framework namespaces HookName
|
||||
//
|
||||
// Policy surface (what Restrict contributes, also consumable from yaml policy):
|
||||
//
|
||||
// - Rule - declarative policy rule (Allow / Deny / MaxRisk / Identities / AllowUnannotated)
|
||||
// - CommandView - read-only command metadata view (Path / Domain / Risk / Identities)
|
||||
// - Risk / Identity - defined string types with closed taxonomies; ParseRisk / ParseIdentity
|
||||
// convert raw strings (yaml, cobra annotation) into typed values; r.Rank()
|
||||
// gives a comparable rank for the read < write < high-risk-write ordering
|
||||
// - CommandDeniedError - structured error returned to denied callers
|
||||
//
|
||||
// Stability: every exported symbol here is part of the contract. Internal
|
||||
// orchestration (staging, validation, RunE wrapping, denial guard) lives
|
||||
// under internal/platform, internal/hook and internal/cmdpolicy and is not
|
||||
// importable by third parties.
|
||||
package platform
|
||||
40
extension/platform/errors.go
Normal file
40
extension/platform/errors.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import "fmt"
|
||||
|
||||
// CommandDeniedError is the structured error returned by a denyStub. Every
|
||||
// pruned-command execution path -- direct invocation, alias expansion,
|
||||
// internal call -- returns this exact type. It is wire-compatible with the
|
||||
// output.ExitError envelope via the Layer (== error.type) field and the
|
||||
// detail map produced by ExitError().
|
||||
//
|
||||
// Layer values:
|
||||
//
|
||||
// - "strict_mode" -- credential strict-mode rejected the command
|
||||
// - "policy" -- user-layer Rule rejected the command
|
||||
//
|
||||
// PolicySource is a free-form identifier such as "plugin:secaudit",
|
||||
// "yaml:mywork", or "strict-mode". Reason fields:
|
||||
//
|
||||
// - ReasonCode -- closed enum, see tech-doc 5.3 (e.g. write_not_allowed,
|
||||
// all_children_denied, identity_not_supported)
|
||||
// - Reason -- human-readable text
|
||||
type CommandDeniedError struct {
|
||||
Path string
|
||||
Layer string
|
||||
PolicySource string
|
||||
RuleName string
|
||||
ReasonCode string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// Error implements the standard error interface.
|
||||
func (e *CommandDeniedError) Error() string {
|
||||
if e.Reason != "" {
|
||||
return fmt.Sprintf("command %q denied: %s", e.Path, e.Reason)
|
||||
}
|
||||
return fmt.Sprintf("command %q denied (%s/%s)", e.Path, e.Layer, e.ReasonCode)
|
||||
}
|
||||
44
extension/platform/errors_test.go
Normal file
44
extension/platform/errors_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func TestCommandDeniedError_messageFormats(t *testing.T) {
|
||||
withReason := &platform.CommandDeniedError{
|
||||
Path: "docs/+update",
|
||||
Layer: "policy",
|
||||
ReasonCode: "write_not_allowed",
|
||||
Reason: "write disabled by policy",
|
||||
}
|
||||
if got := withReason.Error(); got != `command "docs/+update" denied: write disabled by policy` {
|
||||
t.Fatalf("Error() with Reason = %q", got)
|
||||
}
|
||||
|
||||
noReason := &platform.CommandDeniedError{
|
||||
Path: "docs/+update",
|
||||
Layer: "strict_mode",
|
||||
ReasonCode: "identity_not_supported",
|
||||
}
|
||||
if got := noReason.Error(); got != `command "docs/+update" denied (strict_mode/identity_not_supported)` {
|
||||
t.Fatalf("Error() without Reason = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// errors.As must work so consumers can type-assert without unwrap gymnastics.
|
||||
func TestCommandDeniedError_satisfiesErrorsAs(t *testing.T) {
|
||||
var err error = &platform.CommandDeniedError{Path: "x"}
|
||||
var target *platform.CommandDeniedError
|
||||
if !errors.As(err, &target) {
|
||||
t.Fatalf("errors.As should match CommandDeniedError")
|
||||
}
|
||||
if target.Path != "x" {
|
||||
t.Fatalf("target.Path = %q, want %q", target.Path, "x")
|
||||
}
|
||||
}
|
||||
63
extension/platform/example_test.go
Normal file
63
extension/platform/example_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
// ExampleNewPlugin_observer registers an audit Observer that fires
|
||||
// after every command, regardless of success or failure.
|
||||
func ExampleNewPlugin_observer() {
|
||||
p, _ := platform.NewPlugin("audit", "0.1.0").
|
||||
Observer(platform.After, "log", platform.All(),
|
||||
func(ctx context.Context, inv platform.Invocation) {
|
||||
_ = inv.Cmd().Path() // do something useful with the command
|
||||
}).
|
||||
FailOpen().
|
||||
Build()
|
||||
fmt.Println(p.Name(), p.Version())
|
||||
// Output: audit 0.1.0
|
||||
}
|
||||
|
||||
// ExampleNewPlugin_wrapper registers a Wrap that short-circuits any
|
||||
// write-class command. The framework converts the returned
|
||||
// *AbortError into a structured "hook" envelope; observers still
|
||||
// fire on the After stage so audit sees the attempt.
|
||||
func ExampleNewPlugin_wrapper() {
|
||||
p, _ := platform.NewPlugin("policy-plugin", "0.1.0").
|
||||
Wrap("block-writes", platform.ByWrite(),
|
||||
func(next platform.Handler) platform.Handler {
|
||||
return func(ctx context.Context, inv platform.Invocation) error {
|
||||
return &platform.AbortError{
|
||||
HookName: "block-writes",
|
||||
Reason: "writes are disabled for this session",
|
||||
}
|
||||
}
|
||||
}).
|
||||
FailOpen().
|
||||
Build()
|
||||
fmt.Println(p.Capabilities().FailurePolicy == platform.FailOpen)
|
||||
// Output: true
|
||||
}
|
||||
|
||||
// ExampleNewPlugin_restrict registers a policy plugin that allows
|
||||
// only docs/* read commands. Note that Restrict() implicitly sets
|
||||
// FailClosed — a policy plugin must abort the binary if it fails to
|
||||
// install, not silently disappear.
|
||||
func ExampleNewPlugin_restrict() {
|
||||
p, _ := platform.NewPlugin("readonly-docs", "0.1.0").
|
||||
Restrict(&platform.Rule{
|
||||
Name: "docs-only",
|
||||
Allow: []string{"docs/**"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
}).
|
||||
Build()
|
||||
caps := p.Capabilities()
|
||||
fmt.Println(caps.Restricts, caps.FailurePolicy == platform.FailClosed)
|
||||
// Output: true true
|
||||
}
|
||||
2
extension/platform/examples/.gitignore
vendored
Normal file
2
extension/platform/examples/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
audit-observer/audit-observer
|
||||
readonly-policy/readonly-policy
|
||||
13
extension/platform/examples/README.md
Normal file
13
extension/platform/examples/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# lark-cli plugin examples
|
||||
|
||||
Runnable fork-and-blank-import examples that demonstrate the Plugin
|
||||
SDK in production-shape. Each subdirectory is a complete `main`
|
||||
package: `go build .` produces a working CLI.
|
||||
|
||||
| Example | What it shows |
|
||||
| --- | --- |
|
||||
| [audit-observer](./audit-observer/) | Simplest possible plugin: one Observer matching every command, logs to stderr. |
|
||||
| [readonly-policy](./readonly-policy/) | Policy plugin: `Restrict()` with `MaxRisk=read`, demonstrates the `FailClosed` + `Restricts=true` auto-pairing. |
|
||||
|
||||
All examples are built by CI (`make examples-build`) so they cannot
|
||||
silently drift from the SDK.
|
||||
26
extension/platform/examples/audit-observer/README.md
Normal file
26
extension/platform/examples/audit-observer/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Example: audit observer
|
||||
|
||||
The simplest possible lark-cli plugin: one After observer that logs
|
||||
every dispatched command to stderr (success or failure).
|
||||
|
||||
## Build & run
|
||||
|
||||
```sh
|
||||
cd extension/platform/examples/audit-observer
|
||||
go build -o audit-cli .
|
||||
./audit-cli config plugins show
|
||||
# {"plugins":[{"name":"audit", ...}], "total":1}
|
||||
|
||||
./audit-cli api GET /open-apis/contact/v3/users/me
|
||||
# [audit] api ok (on stderr)
|
||||
```
|
||||
|
||||
## Key points
|
||||
|
||||
- `platform.NewPlugin(...).MustBuild()` from `init()`. The blank
|
||||
import of this package in `main.go` triggers `init()`.
|
||||
- `Observer(platform.After, ...)` runs **after** the command's RunE,
|
||||
even on failure (Observers cannot prevent execution).
|
||||
- `FailOpen()` means: if Install ever fails, the binary logs a
|
||||
warning and continues without this plugin. Right default for
|
||||
audit-only plugins.
|
||||
44
extension/platform/examples/audit-observer/main.go
Normal file
44
extension/platform/examples/audit-observer/main.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Command audit-observer is a runnable fork of lark-cli that logs
|
||||
// every dispatched command to stderr. Demonstrates the simplest
|
||||
// possible plugin: one After observer matching All commands.
|
||||
//
|
||||
// Build & run:
|
||||
//
|
||||
// cd extension/platform/examples/audit-observer
|
||||
// go build -o audit-cli .
|
||||
// ./audit-cli config plugins show # see "audit" in the list
|
||||
// ./audit-cli api GET /open-apis/... # observer logs to stderr
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/cmd"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("audit", "0.1.0").
|
||||
Observer(platform.After, "log", platform.All(),
|
||||
func(ctx context.Context, inv platform.Invocation) {
|
||||
path := inv.Cmd().Path()
|
||||
if err := inv.Err(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[audit] %s FAILED: %v\n", path, err)
|
||||
} else {
|
||||
log.Printf("[audit] %s ok", path)
|
||||
}
|
||||
}).
|
||||
FailOpen().
|
||||
MustBuild())
|
||||
}
|
||||
|
||||
func main() {
|
||||
os.Exit(cmd.Execute())
|
||||
}
|
||||
61
extension/platform/examples/readonly-policy/README.md
Normal file
61
extension/platform/examples/readonly-policy/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Example: read-only policy
|
||||
|
||||
A policy plugin that installs a `Rule` allowing only `docs/*` and
|
||||
`im/*` read commands. Any write command produces a structured
|
||||
`command_denied` envelope.
|
||||
|
||||
## Build & run
|
||||
|
||||
```sh
|
||||
cd extension/platform/examples/readonly-policy
|
||||
go build -o readonly-cli .
|
||||
|
||||
./readonly-cli config policy show
|
||||
# {
|
||||
# "source": "plugin",
|
||||
# "source_name": "readonly",
|
||||
# "denied_paths": N,
|
||||
# "rule": {
|
||||
# "name": "agent-readonly",
|
||||
# "allow": ["docs/**", "im/**"],
|
||||
# "deny": [],
|
||||
# "max_risk": "read",
|
||||
# "identities": [],
|
||||
# "allow_unannotated": false
|
||||
# }
|
||||
# }
|
||||
|
||||
./readonly-cli docs +update --doc-token X --content Y
|
||||
# {"ok":false,"error":{
|
||||
# "type":"command_denied",
|
||||
# "detail":{
|
||||
# "layer":"policy",
|
||||
# "policy_source":"plugin:readonly",
|
||||
# "rule_name":"agent-readonly",
|
||||
# "reason_code":"write_not_allowed"
|
||||
# }
|
||||
# }}
|
||||
|
||||
./readonly-cli docs +fetch --doc-token X
|
||||
# Normal read response (assuming credentials)
|
||||
```
|
||||
|
||||
## Key points
|
||||
|
||||
- `Restrict(&Rule{...})` is the only call needed — the Builder
|
||||
flips Capabilities to `Restricts=true, FailurePolicy=FailClosed`
|
||||
automatically. A policy plugin that silently fails to install
|
||||
would erase the security boundary, so FailClosed is enforced.
|
||||
- `MaxRisk: platform.RiskRead` rejects any command annotated
|
||||
write / high-risk-write.
|
||||
- `AllowUnannotated` is left default (false): unannotated commands
|
||||
are denied with `risk_not_annotated`. Set it to true if you need
|
||||
a gradual-adoption window for the lark-cli main tree.
|
||||
|
||||
## Caveats
|
||||
|
||||
- A binary may have **only one** plugin calling `Restrict()`. Two
|
||||
policy plugins is a deliberate `plugin_conflict` configuration
|
||||
error.
|
||||
- This Rule shadows any `~/.lark-cli/policy.yml` — plugin Rule
|
||||
wins per the resolver precedence.
|
||||
45
extension/platform/examples/readonly-policy/main.go
Normal file
45
extension/platform/examples/readonly-policy/main.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Command readonly-policy is a runnable fork of lark-cli that
|
||||
// installs a Rule permitting only docs/* and im/* read commands.
|
||||
// Any write command produces a structured command_denied envelope.
|
||||
//
|
||||
// Build & run:
|
||||
//
|
||||
// cd extension/platform/examples/readonly-policy
|
||||
// go build -o readonly-cli .
|
||||
// ./readonly-cli docs +update --doc-token X --content Y
|
||||
// # {"ok":false,"error":{"type":"command_denied", ...}}
|
||||
//
|
||||
// ./readonly-cli config policy show
|
||||
// # shows the active Rule with source=plugin:readonly
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/cmd"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("readonly", "0.1.0").
|
||||
Restrict(&platform.Rule{
|
||||
Name: "agent-readonly",
|
||||
Description: "Only read-class docs/im commands. Suitable for AI-agent sessions.",
|
||||
Allow: []string{"docs/**", "im/**"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
// AllowUnannotated stays default false (fail-closed):
|
||||
// unannotated commands are denied, surfacing missing
|
||||
// risk_level annotations early in adoption.
|
||||
}).
|
||||
MustBuild())
|
||||
// Note: Restrict() implicitly sets Restricts=true and FailClosed.
|
||||
// No need to call FailClosed() explicitly.
|
||||
}
|
||||
|
||||
func main() {
|
||||
os.Exit(cmd.Execute())
|
||||
}
|
||||
39
extension/platform/handler.go
Normal file
39
extension/platform/handler.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import "context"
|
||||
|
||||
// Handler is the inner function shape every Wrapper composes. It IS the
|
||||
// "command business logic" from the Wrapper's perspective -- calling
|
||||
// next(ctx, inv) inside a Wrapper means "let the command proceed";
|
||||
// returning early without calling next short-circuits.
|
||||
type Handler func(ctx context.Context, inv Invocation) error
|
||||
|
||||
// Observer is a side-effect-only command hook. No return value, no
|
||||
// next-chain control: an Observer can read Invocation but cannot prevent
|
||||
// the command from running. Used for audit, metrics, and completion
|
||||
// logs. After-stage Observers fire even when the command failed
|
||||
// (Invocation.Err() is populated in that case).
|
||||
type Observer func(ctx context.Context, inv Invocation)
|
||||
|
||||
// Wrapper is a middleware-style hook: it receives the rest of the
|
||||
// handler chain and returns a wrapped version. The Wrapper decides
|
||||
// whether to call next (allow), abstain (deny, return an AbortError),
|
||||
// or transform the result. Multiple Wrappers compose left-to-right by
|
||||
// registration order; the outermost runs first.
|
||||
//
|
||||
// ⚠️ IMPORTANT: The factory function `func(next Handler) Handler` is
|
||||
// invoked ONCE PER COMMAND DISPATCH, not once at plugin install. This
|
||||
// lets the framework recover from a panicking factory and convert it
|
||||
// to a structured envelope, but it means any state captured by the
|
||||
// outer closure is rebuilt on every command. Long-lived state (HTTP
|
||||
// clients, caches, metrics counters) MUST live on the Plugin struct
|
||||
// or in package-level variables, never in factory-local captures.
|
||||
type Wrapper func(next Handler) Handler
|
||||
|
||||
// LifecycleHandler runs at one of the process-level LifecycleEvent
|
||||
// slots. The handler may use ctx for cancellation; in the Shutdown
|
||||
// case the framework supplies a context with a 2-second hard deadline.
|
||||
type LifecycleHandler func(ctx context.Context, lc *LifecycleContext) error
|
||||
40
extension/platform/identity.go
Normal file
40
extension/platform/identity.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Identity is the identity taxonomy a command supports.
|
||||
//
|
||||
// Defined type (not alias) so plugin authors get compile-time +
|
||||
// IDE help; raw-string boundaries (yaml, cobra annotation) cross
|
||||
// through ParseIdentity.
|
||||
type Identity string
|
||||
|
||||
const (
|
||||
IdentityUser Identity = "user"
|
||||
IdentityBot Identity = "bot"
|
||||
)
|
||||
|
||||
// ParseIdentity converts a raw string into an Identity. Returns
|
||||
// ("", nil) for empty input ("not specified"), error for unrecognised
|
||||
// values. Matching is strict (case-sensitive, no trim).
|
||||
func ParseIdentity(s string) (Identity, error) {
|
||||
if s == "" {
|
||||
return "", nil
|
||||
}
|
||||
id := Identity(s)
|
||||
if id != IdentityUser && id != IdentityBot {
|
||||
return "", fmt.Errorf("invalid identity %q: must be user|bot", s)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// IsValid reports whether i is one of the two recognised values.
|
||||
func (i Identity) IsValid() bool {
|
||||
return i == IdentityUser || i == IdentityBot
|
||||
}
|
||||
|
||||
// String returns the underlying string.
|
||||
func (i Identity) String() string { return string(i) }
|
||||
56
extension/platform/invocation.go
Normal file
56
extension/platform/invocation.go
Normal file
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import "time"
|
||||
|
||||
// Invocation is the per-command data a Wrapper / Observer receives. It
|
||||
// is a read-only interface: the framework implementation lives in
|
||||
// internal/hook and is never visible to plugins, so plugin code cannot
|
||||
// mutate denial state.
|
||||
//
|
||||
// The interface is deliberately NOT a context.Context — it is data only,
|
||||
// no cancellation. ctx (from the handler signature) carries
|
||||
// cancellation / timeout / trace propagation.
|
||||
//
|
||||
// Accessor semantics:
|
||||
//
|
||||
// - Cmd / Args / Started are populated before the first hook fires
|
||||
// - Err is populated for After observers and the post-next portion of
|
||||
// a Wrapper (the value the wrapped handler returned)
|
||||
// - DeniedByPolicy / DenialLayer / DenialPolicySource are populated by
|
||||
// the framework's denial guard before any hook runs
|
||||
type Invocation interface {
|
||||
// Cmd returns the read-only metadata view of the dispatched command.
|
||||
Cmd() CommandView
|
||||
|
||||
// Args returns a fresh copy of the positional args.
|
||||
Args() []string
|
||||
|
||||
// Started is the wall-clock time the outermost RunE wrapper began.
|
||||
Started() time.Time
|
||||
|
||||
// Err is the error the wrapped handler returned. Populated for
|
||||
// After observers and the post-next portion of a Wrapper. nil
|
||||
// before the handler runs.
|
||||
Err() error
|
||||
|
||||
// DeniedByPolicy reports whether the command was rejected by either
|
||||
// strict-mode or user-layer policy before the chain reached the
|
||||
// hook. Observers fire even for denied commands (audit case); Wrap
|
||||
// is physically isolated by the framework so plugins do not need
|
||||
// to check this themselves before calling next.
|
||||
DeniedByPolicy() bool
|
||||
|
||||
// DenialLayer returns the layer that rejected the command:
|
||||
//
|
||||
// "" - not denied
|
||||
// "strict_mode" - credential strict-mode
|
||||
// "policy" - user-layer Rule (Plugin.Restrict() or yaml)
|
||||
DenialLayer() string
|
||||
|
||||
// DenialPolicySource returns the specific source identifier
|
||||
// ("plugin:secaudit", "yaml", "strict-mode"). Empty when not denied.
|
||||
DenialPolicySource() string
|
||||
}
|
||||
46
extension/platform/lifecycle.go
Normal file
46
extension/platform/lifecycle.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
// When selects the temporal slot for command-level Observer hooks. The
|
||||
// framework wraps every command's RunE so both stages always fire, even
|
||||
// when RunE itself returns an error (After is failure-safe).
|
||||
type When int
|
||||
|
||||
const (
|
||||
// Before fires immediately before the command's business logic.
|
||||
Before When = iota
|
||||
|
||||
// After fires after the command's business logic (or its denyStub
|
||||
// in the denied path). Always fires, even when RunE returned an
|
||||
// error; Invocation.Err is populated in that case.
|
||||
After
|
||||
)
|
||||
|
||||
// LifecycleEvent selects the temporal slot for Lifecycle hooks. These are
|
||||
// process-level events that fire once per binary execution, not per
|
||||
// command. Only Startup and Shutdown are defined: additional bootstrap
|
||||
// phases can be added later as a non-breaking addition if a concrete
|
||||
// consumer surfaces.
|
||||
type LifecycleEvent int
|
||||
|
||||
const (
|
||||
// Startup fires after plugin install has committed; Plugin.On
|
||||
// handlers for Startup are guaranteed to be registered before this
|
||||
// event is emitted (so they can receive it).
|
||||
Startup LifecycleEvent = iota
|
||||
|
||||
// Shutdown fires once before the process exits. Handler total
|
||||
// execution is bounded by a hard 2s timeout to prevent a
|
||||
// misbehaving handler from holding up exit.
|
||||
Shutdown
|
||||
)
|
||||
|
||||
// LifecycleContext is passed to LifecycleHandler. Err is the error from
|
||||
// the preceding command (when Event == Shutdown after a failed RunE);
|
||||
// otherwise nil.
|
||||
type LifecycleContext struct {
|
||||
Event LifecycleEvent
|
||||
Err error
|
||||
}
|
||||
26
extension/platform/plugin.go
Normal file
26
extension/platform/plugin.go
Normal file
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
// Plugin is the single contract a third-party / embedding integrator
|
||||
// implements to extend lark-cli. Four methods, every one mandatory.
|
||||
//
|
||||
// Name must match the grammar ^[a-z0-9][a-z0-9-]*$. The "." character
|
||||
// is forbidden so plugin-name + hookName namespacing never produces
|
||||
// ambiguous joins.
|
||||
//
|
||||
// Capabilities must be implemented even when every field is zero. The
|
||||
// requirement is deliberate: it keeps FailurePolicy / Restricts in the
|
||||
// author's eyeline.
|
||||
//
|
||||
// Install runs once during the Bootstrap pipeline. The plugin uses the
|
||||
// supplied Registrar to register hooks and (optionally) a Rule. Errors
|
||||
// returned from Install honour the plugin's Capabilities.FailurePolicy
|
||||
// (fail-open warns + skips this plugin; fail-closed aborts the CLI).
|
||||
type Plugin interface {
|
||||
Name() string
|
||||
Version() string
|
||||
Capabilities() Capabilities
|
||||
Install(r Registrar) error
|
||||
}
|
||||
58
extension/platform/register.go
Normal file
58
extension/platform/register.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import "sync"
|
||||
|
||||
// Register adds a plugin to the global registry. Plugins call this from
|
||||
// init() (typically through a blank import in the embedder's main).
|
||||
//
|
||||
// Register is intentionally tolerant of malformed input: validation
|
||||
// happens later in the host's InstallAll phase, where errors can be
|
||||
// surfaced through the typed plugin_install envelope. Register itself
|
||||
// never panics so that init-time problems do not crash the binary
|
||||
// before main has a chance to install its recover-and-envelope logic.
|
||||
//
|
||||
// The registry holds plugins in insertion order so InstallAll can
|
||||
// process them deterministically.
|
||||
func Register(p Plugin) {
|
||||
pluginRegistry.add(p)
|
||||
}
|
||||
|
||||
// RegisteredPlugins returns a snapshot of the global plugin registry.
|
||||
// Order matches Register insertion. The host reads this once during
|
||||
// InstallAll.
|
||||
func RegisteredPlugins() []Plugin {
|
||||
return pluginRegistry.snapshot()
|
||||
}
|
||||
|
||||
// pluginRegistry is the package-level singleton. The mutex protects
|
||||
// concurrent Register calls -- harmless in practice (init runs
|
||||
// serially) but cheap insurance.
|
||||
var pluginRegistry = ®istry{}
|
||||
|
||||
type registry struct {
|
||||
mu sync.Mutex
|
||||
plugins []Plugin
|
||||
}
|
||||
|
||||
func (r *registry) add(p Plugin) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.plugins = append(r.plugins, p)
|
||||
}
|
||||
|
||||
func (r *registry) snapshot() []Plugin {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]Plugin, len(r.plugins))
|
||||
copy(out, r.plugins)
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *registry) reset() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.plugins = nil
|
||||
}
|
||||
52
extension/platform/register_test.go
Normal file
52
extension/platform/register_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
type stubPlugin struct{ name string }
|
||||
|
||||
func (s stubPlugin) Name() string { return s.name }
|
||||
func (s stubPlugin) Version() string { return "0.0.1" }
|
||||
func (s stubPlugin) Capabilities() platform.Capabilities { return platform.Capabilities{} }
|
||||
func (s stubPlugin) Install(platform.Registrar) error { return nil }
|
||||
|
||||
// Tests should always reset the global registry to keep them
|
||||
// independent. Verifies the reset hook is functional.
|
||||
func TestRegister_preservesInsertionOrder(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
platform.Register(stubPlugin{name: "a"})
|
||||
platform.Register(stubPlugin{name: "b"})
|
||||
platform.Register(stubPlugin{name: "c"})
|
||||
|
||||
got := platform.RegisteredPlugins()
|
||||
want := []string{"a", "b", "c"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %d plugins, want %d", len(got), len(want))
|
||||
}
|
||||
for i, p := range got {
|
||||
if p.Name() != want[i] {
|
||||
t.Errorf("plugins[%d] = %q, want %q", i, p.Name(), want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_resetClears(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
platform.Register(stubPlugin{name: "a"})
|
||||
if len(platform.RegisteredPlugins()) != 1 {
|
||||
t.Fatalf("expected 1 plugin")
|
||||
}
|
||||
platform.ResetForTesting()
|
||||
if len(platform.RegisteredPlugins()) != 0 {
|
||||
t.Fatalf("expected reset to clear")
|
||||
}
|
||||
}
|
||||
16
extension/platform/register_testing.go
Normal file
16
extension/platform/register_testing.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
// ResetForTesting clears the global plugin registry. Exposed for test
|
||||
// isolation only — plugin authors and SDK consumers must NOT call this
|
||||
// from production code. The function is exported (rather than placed in
|
||||
// an internal test-only file) so that `go test ./...` works for every
|
||||
// downstream package without an extra build tag.
|
||||
//
|
||||
// Tests that exercise plugin registration must defer
|
||||
// `t.Cleanup(platform.ResetForTesting)` so subsequent tests start from a
|
||||
// clean slate. The helper is NOT goroutine-safe across concurrent
|
||||
// `t.Parallel()` tests — the global registry is shared process state.
|
||||
func ResetForTesting() { pluginRegistry.reset() }
|
||||
36
extension/platform/registrar.go
Normal file
36
extension/platform/registrar.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
// Registrar is the imperative API a plugin uses inside its Install
|
||||
// method to wire up hooks and rules. The framework provides a staging
|
||||
// implementation that buffers calls and commits them atomically when
|
||||
// Install returns nil; failure rolls everything back.
|
||||
//
|
||||
// hookName must match the grammar ^[a-z0-9][a-z0-9-]*$ (no dots). The
|
||||
// framework prepends the plugin's Name() with a dot so the global hook
|
||||
// identifier is "{plugin}.{hook}". A plugin cannot register two hooks
|
||||
// with the same name in the same Install call.
|
||||
//
|
||||
// Restrict may be called at most once per plugin; multiple plugins
|
||||
// contributing Restrict() is a configuration error (the resolver
|
||||
// aborts startup).
|
||||
type Registrar interface {
|
||||
// Observe registers a side-effect-only command hook at the given
|
||||
// When stage. The selector decides which commands it fires on.
|
||||
Observe(when When, hookName string, sel Selector, fn Observer)
|
||||
|
||||
// Wrap registers a middleware-style command hook. The Wrap chain
|
||||
// composes left-to-right in registration order; the outermost
|
||||
// Wrapper runs first.
|
||||
Wrap(hookName string, sel Selector, w Wrapper)
|
||||
|
||||
// On registers a lifecycle handler for the given event.
|
||||
On(event LifecycleEvent, hookName string, fn LifecycleHandler)
|
||||
|
||||
// Restrict contributes a pruning Rule. The framework merges it
|
||||
// with the yaml-sourced Rule using single-rule semantics: plugin
|
||||
// rule wins, but two plugins both calling Restrict abort startup.
|
||||
Restrict(r *Rule)
|
||||
}
|
||||
71
extension/platform/risk.go
Normal file
71
extension/platform/risk.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Risk is the three-tier risk taxonomy declared on every command.
|
||||
//
|
||||
// A defined type (not an alias of string) so plugin authors get
|
||||
// compile-time + IDE candidate help when passing the constants below.
|
||||
// Crossing the string boundary (yaml, cobra annotation) goes through
|
||||
// ParseRisk so typos surface as `risk_invalid` rather than silently
|
||||
// flowing through.
|
||||
type Risk string
|
||||
|
||||
const (
|
||||
RiskRead Risk = "read"
|
||||
RiskWrite Risk = "write"
|
||||
RiskHighRiskWrite Risk = "high-risk-write"
|
||||
)
|
||||
|
||||
// riskOrder maps the Risk taxonomy to a comparable rank. The pruning
|
||||
// engine compares ranks for the MaxRisk axis.
|
||||
var riskOrder = map[Risk]int{
|
||||
RiskRead: 0,
|
||||
RiskWrite: 1,
|
||||
RiskHighRiskWrite: 2,
|
||||
}
|
||||
|
||||
// ParseRisk converts a raw string (yaml, cobra annotation) into a Risk.
|
||||
//
|
||||
// - s == "" → ("", nil) "not specified"
|
||||
// - s 在闭合枚举 → (Risk(s), nil) OK
|
||||
// - s 不在枚举内 → ("", error) invalid
|
||||
//
|
||||
// The (absent vs invalid) split mirrors the cmdpolicy engine's
|
||||
// risk_not_annotated vs risk_invalid reason codes — callers can treat
|
||||
// the "" + nil case as "not specified" without losing the distinction
|
||||
// from a typo.
|
||||
//
|
||||
// Matching is strict: "Read" / "READ" / " read " are all rejected.
|
||||
// annotation is developer code, not user input — strict matching is
|
||||
// the typo-catch mechanism, not a normalisation opportunity.
|
||||
func ParseRisk(s string) (Risk, error) {
|
||||
if s == "" {
|
||||
return "", nil
|
||||
}
|
||||
r := Risk(s)
|
||||
if _, ok := riskOrder[r]; !ok {
|
||||
return "", fmt.Errorf("invalid risk %q: must be read|write|high-risk-write", s)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// IsValid reports whether r is one of the three recognised values.
|
||||
func (r Risk) IsValid() bool {
|
||||
_, ok := riskOrder[r]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Rank returns the comparable rank of r. ok=false when r is not in the
|
||||
// closed taxonomy.
|
||||
func (r Risk) Rank() (rank int, ok bool) {
|
||||
rank, ok = riskOrder[r]
|
||||
return rank, ok
|
||||
}
|
||||
|
||||
// String returns the underlying string. Useful for yaml/json output
|
||||
// and cobra annotation injection.
|
||||
func (r Risk) String() string { return string(r) }
|
||||
120
extension/platform/risk_test.go
Normal file
120
extension/platform/risk_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func TestRisk_Rank_orderedTaxonomy(t *testing.T) {
|
||||
cases := []struct {
|
||||
level platform.Risk
|
||||
want int
|
||||
}{
|
||||
{platform.RiskRead, 0},
|
||||
{platform.RiskWrite, 1},
|
||||
{platform.RiskHighRiskWrite, 2},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := c.level.Rank()
|
||||
if !ok || got != c.want {
|
||||
t.Errorf("Risk(%q).Rank() = (%d,%v), want (%d,true)", c.level, got, ok, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := platform.Risk("unknown-level").Rank(); ok {
|
||||
t.Fatalf("unknown-level.Rank() ok should be false")
|
||||
}
|
||||
if _, ok := platform.Risk("").Rank(); ok {
|
||||
t.Fatalf("empty.Rank() ok should be false (signals 'no risk annotation')")
|
||||
}
|
||||
}
|
||||
|
||||
// The Risk ordering must be strict: read < write < high-risk-write. The
|
||||
// policy engine compares ranks; a regression that swaps the order would
|
||||
// silently let high-risk commands pass under MaxRisk=write.
|
||||
func TestRisk_Rank_strictlyMonotonic(t *testing.T) {
|
||||
r1, _ := platform.RiskRead.Rank()
|
||||
r2, _ := platform.RiskWrite.Rank()
|
||||
r3, _ := platform.RiskHighRiskWrite.Rank()
|
||||
if !(r1 < r2 && r2 < r3) {
|
||||
t.Fatalf("Risk ranks not monotonic: read=%d write=%d high=%d", r1, r2, r3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRisk_IsValid(t *testing.T) {
|
||||
valid := []platform.Risk{platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite}
|
||||
for _, r := range valid {
|
||||
if !r.IsValid() {
|
||||
t.Errorf("%q.IsValid() = false, want true", r)
|
||||
}
|
||||
}
|
||||
invalid := []platform.Risk{"", "wrtie", "Read", "READ", " read "}
|
||||
for _, r := range invalid {
|
||||
if r.IsValid() {
|
||||
t.Errorf("%q.IsValid() = true, want false", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ParseRisk distinguishes absent (empty input) from invalid (typo).
|
||||
// The absent / invalid split mirrors the cmdpolicy engine's
|
||||
// risk_not_annotated vs risk_invalid reason codes.
|
||||
func TestParseRisk(t *testing.T) {
|
||||
// Empty -> ("", nil) — "not specified"
|
||||
got, err := platform.ParseRisk("")
|
||||
if err != nil || got != "" {
|
||||
t.Errorf(`ParseRisk("") = (%q,%v), want ("",nil)`, got, err)
|
||||
}
|
||||
|
||||
// Valid values pass through
|
||||
for _, want := range []platform.Risk{platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite} {
|
||||
got, err := platform.ParseRisk(string(want))
|
||||
if err != nil || got != want {
|
||||
t.Errorf("ParseRisk(%q) = (%q,%v), want (%q,nil)", want, got, err, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Typo -> error, strict matching (case-sensitive, no trim)
|
||||
bad := []string{"wrtie", "Read", "READ", " read ", "high_risk_write"}
|
||||
for _, s := range bad {
|
||||
got, err := platform.ParseRisk(s)
|
||||
if err == nil {
|
||||
t.Errorf("ParseRisk(%q) succeeded (got %q), want error", s, got)
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("ParseRisk(%q) returned %q, want empty Risk on error", s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIdentity(t *testing.T) {
|
||||
got, err := platform.ParseIdentity("")
|
||||
if err != nil || got != "" {
|
||||
t.Errorf(`ParseIdentity("") = (%q,%v), want ("",nil)`, got, err)
|
||||
}
|
||||
for _, want := range []platform.Identity{platform.IdentityUser, platform.IdentityBot} {
|
||||
got, err := platform.ParseIdentity(string(want))
|
||||
if err != nil || got != want {
|
||||
t.Errorf("ParseIdentity(%q) = (%q,%v)", want, got, err)
|
||||
}
|
||||
}
|
||||
if _, err := platform.ParseIdentity("admin"); err == nil {
|
||||
t.Fatalf(`ParseIdentity("admin") want error`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentity_IsValid(t *testing.T) {
|
||||
if !platform.IdentityUser.IsValid() {
|
||||
t.Error("user.IsValid() = false")
|
||||
}
|
||||
if !platform.IdentityBot.IsValid() {
|
||||
t.Error("bot.IsValid() = false")
|
||||
}
|
||||
if platform.Identity("admin").IsValid() {
|
||||
t.Error("admin.IsValid() = true")
|
||||
}
|
||||
}
|
||||
60
extension/platform/rule.go
Normal file
60
extension/platform/rule.go
Normal file
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
// Rule is the declarative policy rule data structure. yaml files and
|
||||
// Plugin.Restrict() both produce the same Rule.
|
||||
//
|
||||
// At any moment there is at most one effective Rule -- the resolver decides
|
||||
// which source wins (Plugin > yaml > none). This package only defines the
|
||||
// shape; selection lives in internal/cmdpolicy.
|
||||
//
|
||||
// The four filter fields are joined by AND. See the engine's Evaluate for
|
||||
// the full semantics. JSON tags are used by `config policy show`; yaml
|
||||
// parsing lives in internal/cmdpolicy/yaml so the public API does not
|
||||
// depend on a yaml library.
|
||||
type Rule struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
|
||||
// Allow is a list of doublestar globs (slash-separated paths). An empty
|
||||
// slice means "no path restriction"; a non-empty slice means "command
|
||||
// path must match at least one glob".
|
||||
Allow []string `json:"allow,omitempty"`
|
||||
|
||||
// Deny is a list of doublestar globs. A path that matches any Deny glob
|
||||
// is rejected regardless of Allow.
|
||||
Deny []string `json:"deny,omitempty"`
|
||||
|
||||
// MaxRisk is the highest allowed risk level (inclusive). Empty string
|
||||
// means "no risk restriction". Comparison uses the closed taxonomy
|
||||
// read < write < high-risk-write.
|
||||
MaxRisk Risk `json:"max_risk,omitempty"`
|
||||
|
||||
// Identities is the allowed identity whitelist. A command passes when
|
||||
// the intersection with the command's own supported identities is
|
||||
// non-empty. Empty slice means "no identity restriction".
|
||||
Identities []Identity `json:"identities,omitempty"`
|
||||
|
||||
// AllowUnannotated controls how commands missing a risk_level
|
||||
// annotation are handled when this Rule is active.
|
||||
//
|
||||
// Default (false, fail-closed): unannotated commands are rejected
|
||||
// with reason_code=risk_not_annotated. This is the safe default
|
||||
// — a typo'd or forgotten annotation cannot slip past an
|
||||
// "agent read-only" rule.
|
||||
//
|
||||
// Set to true to opt out during gradual adoption: lark-cli main
|
||||
// has hundreds of service commands that may not yet carry
|
||||
// risk_level annotations, and a brand-new policy plugin would
|
||||
// otherwise lock the binary to nothing.
|
||||
//
|
||||
// This flag does NOT affect risk_invalid (typos): a command that
|
||||
// claims a risk but mis-spells it is always denied, regardless of
|
||||
// AllowUnannotated. Typo is a code bug, not a migration phase.
|
||||
//
|
||||
// No yaml tag: yaml decoding lives in internal/cmdpolicy/yaml so
|
||||
// platform stays free of a yaml library dependency.
|
||||
AllowUnannotated bool `json:"allow_unannotated,omitempty"`
|
||||
}
|
||||
133
extension/platform/selector.go
Normal file
133
extension/platform/selector.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import "github.com/bmatcuk/doublestar/v4"
|
||||
|
||||
// Selector picks the commands a hook fires on. A nil Selector is
|
||||
// equivalent to None() -- safer than an "always-match" default because
|
||||
// it forces every hook to declare its scope explicitly. Compose
|
||||
// selectors with And / Or / Not.
|
||||
type Selector func(cmd CommandView) bool
|
||||
|
||||
// All matches every command. Use for audit / metrics observers that
|
||||
// must run on the whole surface.
|
||||
func All() Selector { return func(CommandView) bool { return true } }
|
||||
|
||||
// None matches no command. Useful as a "disabled" placeholder.
|
||||
func None() Selector { return func(CommandView) bool { return false } }
|
||||
|
||||
// ByDomain matches a command whose Domain() is one of the supplied
|
||||
// names. Commands with unknown (empty-string) Domain never match this
|
||||
// selector -- the caller should pair it with a Selector that handles
|
||||
// unknown explicitly when that case matters.
|
||||
func ByDomain(domains ...string) Selector {
|
||||
wanted := newStringSet(domains)
|
||||
return func(cmd CommandView) bool {
|
||||
d := cmd.Domain()
|
||||
return d != "" && wanted[d]
|
||||
}
|
||||
}
|
||||
|
||||
// ByCommandPath matches against the canonical slash-form path. Patterns
|
||||
// are doublestar globs ("docs/+update", "im/*", "**"). Invalid patterns
|
||||
// never match; ValidateRule's twin check catches them at the source.
|
||||
func ByCommandPath(patterns ...string) Selector {
|
||||
return func(cmd CommandView) bool {
|
||||
path := cmd.Path()
|
||||
for _, p := range patterns {
|
||||
if ok, err := doublestar.Match(p, path); err == nil && ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ByIdentity matches when the command's supported identities include
|
||||
// the supplied id. Unknown identities never match.
|
||||
func ByIdentity(id Identity) Selector {
|
||||
return func(cmd CommandView) bool {
|
||||
for _, x := range cmd.Identities() {
|
||||
if x == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Risk-based selectors below match only commands whose declared risk
|
||||
// equals the selector's target level. The closed taxonomy is read /
|
||||
// write / high-risk-write — there is no "unknown" branch in the public
|
||||
// API. When a Rule without AllowUnannotated=true is registered, the
|
||||
// policy engine treats unannotated commands as implicit deny, so risk-
|
||||
// based selectors never see them in hook dispatch under that
|
||||
// configuration.
|
||||
|
||||
// ByExactRisk matches commands whose declared risk level is exactly level.
|
||||
func ByExactRisk(level Risk) Selector {
|
||||
return func(cmd CommandView) bool {
|
||||
v, ok := cmd.Risk()
|
||||
return ok && v == level
|
||||
}
|
||||
}
|
||||
|
||||
// ByWrite matches commands whose risk is "write" or "high-risk-write".
|
||||
func ByWrite() Selector {
|
||||
return func(cmd CommandView) bool {
|
||||
v, ok := cmd.Risk()
|
||||
return ok && (v == RiskWrite || v == RiskHighRiskWrite)
|
||||
}
|
||||
}
|
||||
|
||||
// ByReadOnly matches commands whose risk is "read".
|
||||
func ByReadOnly() Selector {
|
||||
return func(cmd CommandView) bool {
|
||||
v, ok := cmd.Risk()
|
||||
return ok && v == RiskRead
|
||||
}
|
||||
}
|
||||
|
||||
// normalize maps a nil Selector to None() so combinators honour the
|
||||
// "nil == None()" contract documented on the Selector type.
|
||||
func normalize(s Selector) Selector {
|
||||
if s == nil {
|
||||
return None()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// And composes selectors with AND semantics.
|
||||
func (s Selector) And(other Selector) Selector {
|
||||
left, right := normalize(s), normalize(other)
|
||||
return func(cmd CommandView) bool {
|
||||
return left(cmd) && right(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// Or composes selectors with OR semantics.
|
||||
func (s Selector) Or(other Selector) Selector {
|
||||
left, right := normalize(s), normalize(other)
|
||||
return func(cmd CommandView) bool {
|
||||
return left(cmd) || right(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// Not negates the selector. A nil receiver is treated as None(), so
|
||||
// nil.Not() behaves as All().
|
||||
func (s Selector) Not() Selector {
|
||||
inner := normalize(s)
|
||||
return func(cmd CommandView) bool {
|
||||
return !inner(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func newStringSet(items []string) map[string]bool {
|
||||
out := make(map[string]bool, len(items))
|
||||
for _, x := range items {
|
||||
out[x] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
161
extension/platform/selector_test.go
Normal file
161
extension/platform/selector_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
// fakeView is a minimal CommandView for unit-testing selectors.
|
||||
type fakeView struct {
|
||||
path string
|
||||
domain string
|
||||
risk string
|
||||
riskOK bool
|
||||
identities []string
|
||||
}
|
||||
|
||||
func (v fakeView) Path() string { return v.path }
|
||||
func (v fakeView) Domain() string { return v.domain }
|
||||
func (v fakeView) Risk() (platform.Risk, bool) { return platform.Risk(v.risk), v.riskOK }
|
||||
func (v fakeView) Identities() []platform.Identity {
|
||||
out := make([]platform.Identity, len(v.identities))
|
||||
for i, x := range v.identities {
|
||||
out[i] = platform.Identity(x)
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (v fakeView) Annotation(key string) (string, bool) { return "", false }
|
||||
|
||||
func TestAll_None(t *testing.T) {
|
||||
cmd := fakeView{}
|
||||
if !platform.All()(cmd) {
|
||||
t.Errorf("All() must match every command")
|
||||
}
|
||||
if platform.None()(cmd) {
|
||||
t.Errorf("None() must match no command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestByDomain(t *testing.T) {
|
||||
sel := platform.ByDomain("docs", "im")
|
||||
if !sel(fakeView{domain: "docs"}) {
|
||||
t.Errorf("docs should match")
|
||||
}
|
||||
if sel(fakeView{domain: "vc"}) {
|
||||
t.Errorf("vc must not match docs/im selector")
|
||||
}
|
||||
// Unknown domain (empty) must not match.
|
||||
if sel(fakeView{domain: ""}) {
|
||||
t.Errorf("unknown domain must not match ByDomain (use ByDomainOrUnknown style if desired)")
|
||||
}
|
||||
}
|
||||
|
||||
// Risk-based selectors match only against the closed taxonomy
|
||||
// (read / write / high-risk-write). Commands without a risk annotation
|
||||
// never match; the policy engine guarantees such commands cannot reach
|
||||
// hook dispatch when a Rule without AllowUnannotated=true is registered.
|
||||
func TestByExactRisk_unknownDoesNotMatch(t *testing.T) {
|
||||
sel := platform.ByExactRisk("write")
|
||||
if !sel(fakeView{risk: "write", riskOK: true}) {
|
||||
t.Errorf("exact write should match")
|
||||
}
|
||||
if sel(fakeView{riskOK: false}) {
|
||||
t.Errorf("unknown must not match ByExactRisk")
|
||||
}
|
||||
if sel(fakeView{risk: "read", riskOK: true}) {
|
||||
t.Errorf("read must not match ByExactRisk(write)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestByWrite_byReadOnly(t *testing.T) {
|
||||
if !platform.ByWrite()(fakeView{risk: "write", riskOK: true}) {
|
||||
t.Errorf("write should match ByWrite")
|
||||
}
|
||||
if !platform.ByWrite()(fakeView{risk: "high-risk-write", riskOK: true}) {
|
||||
t.Errorf("high-risk-write should match ByWrite")
|
||||
}
|
||||
if platform.ByWrite()(fakeView{risk: "read", riskOK: true}) {
|
||||
t.Errorf("read must not match ByWrite")
|
||||
}
|
||||
if platform.ByWrite()(fakeView{riskOK: false}) {
|
||||
t.Errorf("unknown must not match ByWrite")
|
||||
}
|
||||
if !platform.ByReadOnly()(fakeView{risk: "read", riskOK: true}) {
|
||||
t.Errorf("read should match ByReadOnly")
|
||||
}
|
||||
if platform.ByReadOnly()(fakeView{riskOK: false}) {
|
||||
t.Errorf("unknown must not match ByReadOnly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestByCommandPath(t *testing.T) {
|
||||
sel := platform.ByCommandPath("docs/**", "im/+send")
|
||||
if !sel(fakeView{path: "docs/+update"}) {
|
||||
t.Errorf("docs/+update should match docs/**")
|
||||
}
|
||||
if !sel(fakeView{path: "im/+send"}) {
|
||||
t.Errorf("im/+send should match")
|
||||
}
|
||||
if sel(fakeView{path: "contact/+search"}) {
|
||||
t.Errorf("contact/+search must not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestByIdentity(t *testing.T) {
|
||||
sel := platform.ByIdentity("bot")
|
||||
if !sel(fakeView{identities: []string{"user", "bot"}}) {
|
||||
t.Errorf("ids containing bot should match")
|
||||
}
|
||||
if sel(fakeView{identities: []string{"user"}}) {
|
||||
t.Errorf("user-only ids must not match bot selector")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelector_AndOrNot(t *testing.T) {
|
||||
docsAndWrite := platform.ByDomain("docs").And(platform.ByExactRisk("write"))
|
||||
if !docsAndWrite(fakeView{domain: "docs", risk: "write", riskOK: true}) {
|
||||
t.Errorf("AND of matching selectors should match")
|
||||
}
|
||||
if docsAndWrite(fakeView{domain: "docs", risk: "read", riskOK: true}) {
|
||||
t.Errorf("AND fails when one side fails")
|
||||
}
|
||||
|
||||
docsOrIm := platform.ByDomain("docs").Or(platform.ByDomain("im"))
|
||||
if !docsOrIm(fakeView{domain: "im"}) {
|
||||
t.Errorf("OR should match either side")
|
||||
}
|
||||
|
||||
notRead := platform.ByReadOnly().Not()
|
||||
if notRead(fakeView{risk: "read", riskOK: true}) {
|
||||
t.Errorf("Not(ByReadOnly) must reject read commands")
|
||||
}
|
||||
if !notRead(fakeView{risk: "write", riskOK: true}) {
|
||||
t.Errorf("Not(ByReadOnly) should match write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelector_NilSafeWhenComposed(t *testing.T) {
|
||||
// A nil Selector is equivalent to None() per the Selector godoc.
|
||||
// Composition must honour that contract: the resulting selector
|
||||
// must not panic when invoked and must produce the documented
|
||||
// boolean outcome (nil-as-None propagates through AND/OR/NOT).
|
||||
var s platform.Selector
|
||||
cmd := fakeView{domain: "docs"}
|
||||
|
||||
if got := s.And(platform.All())(cmd); got {
|
||||
t.Errorf("nil.And(All) should match None semantics (false), got true")
|
||||
}
|
||||
if got := s.Or(platform.All())(cmd); !got {
|
||||
t.Errorf("nil.Or(All) should match (true), got false")
|
||||
}
|
||||
if got := platform.All().And(s)(cmd); got {
|
||||
t.Errorf("All.And(nil) should be None (false), got true")
|
||||
}
|
||||
if got := s.Not()(cmd); !got {
|
||||
t.Errorf("(nil).Not() should be Not(None) = true, got false")
|
||||
}
|
||||
}
|
||||
48
extension/platform/view.go
Normal file
48
extension/platform/view.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
// CommandView is the read-only view of a cobra.Command exposed to plugins
|
||||
// and the policy engine. *cobra.Command is deliberately NOT reachable
|
||||
// through this interface -- a plugin should never mutate the command tree.
|
||||
//
|
||||
// View semantics:
|
||||
//
|
||||
// - The view is a live proxy over the underlying *cobra.Command and its
|
||||
// annotation chain. Strict-mode replaces nodes via RemoveCommand+
|
||||
// AddCommand; the replacement stub explicitly carries the original
|
||||
// command's annotations and help text forward so audit / compliance
|
||||
// observers still see Risk / Identities / Domain after a denial.
|
||||
// User-layer policy mutates in place, so its denyStubs preserve the
|
||||
// original metadata by construction.
|
||||
//
|
||||
// - Path() is the canonical slash form ("docs/+fetch"), matching the
|
||||
// doublestar glob semantics used by Rule.Allow / Rule.Deny.
|
||||
//
|
||||
// - Risk() returns ok=false when the command is unannotated. The policy
|
||||
// engine treats an unannotated command as implicit deny whenever any
|
||||
// Rule without AllowUnannotated=true is registered, so risk-based
|
||||
// Selectors never see unannotated commands during normal hook dispatch
|
||||
// under that configuration.
|
||||
type CommandView interface {
|
||||
// Path is the canonical slash-separated path, rootless ("docs/+update").
|
||||
Path() string
|
||||
|
||||
// Domain returns the business domain ("docs", "im", "") inherited from
|
||||
// the nearest ancestor with a cmdmeta.domain annotation. Empty string
|
||||
// when no ancestor declares one.
|
||||
Domain() string
|
||||
|
||||
// Risk returns the static risk level. ok=false signals "no risk_level
|
||||
// annotation found in the parent chain" (unknown).
|
||||
Risk() (level Risk, ok bool)
|
||||
|
||||
// Identities returns the supported identities. nil signals "no
|
||||
// supportedIdentities annotation in the parent chain".
|
||||
Identities() []Identity
|
||||
|
||||
// Annotation exposes the raw cobra annotation map for plugins that
|
||||
// need a tag the framework does not surface.
|
||||
Annotation(key string) (string, bool)
|
||||
}
|
||||
3
go.mod
3
go.mod
@@ -4,6 +4,7 @@ go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/gofrs/flock v0.8.1
|
||||
@@ -21,6 +22,7 @@ require (
|
||||
golang.org/x/sys v0.33.0
|
||||
golang.org/x/term v0.27.0
|
||||
golang.org/x/text v0.23.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -60,5 +62,4 @@ require (
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
2
go.sum
2
go.sum
@@ -8,6 +8,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
||||
|
||||
137
internal/cmdmeta/meta.go
Normal file
137
internal/cmdmeta/meta.go
Normal file
@@ -0,0 +1,137 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package cmdmeta is the single source of truth for command metadata that the
|
||||
// policy engine and the hook selector both consume. It wraps the existing
|
||||
// cmdutil annotations (risk_level, supportedIdentities) and adds the
|
||||
// "domain" axis that the hook selector and Rule path globs need.
|
||||
//
|
||||
// Three axes:
|
||||
//
|
||||
// - Domain - business domain ("im", "docs", "contact", ...). Inherited
|
||||
// from the nearest ancestor when not set on the command
|
||||
// itself. Stored on a new annotation key (the cmdutil
|
||||
// risk_level / supportedIdentities keys are left untouched
|
||||
// for backward compatibility).
|
||||
// - Risk - "read" | "write" | "high-risk-write". Inherited like
|
||||
// Domain. Reuses cmdutil.SetRisk / GetRisk under the hood.
|
||||
// - Identities - allowed identity set. Child explicit override semantics:
|
||||
// the first ancestor (including self) with a non-nil set
|
||||
// wins. Reuses cmdutil.SetSupportedIdentities /
|
||||
// GetSupportedIdentities.
|
||||
//
|
||||
// Missing values are returned as the zero value with ok=false (where the
|
||||
// signature exposes it). Interpretation is up to the consumer: the policy
|
||||
// engine treats a missing risk as fail-closed when a Rule is registered
|
||||
// without AllowUnannotated=true, and as allow otherwise. Identities still
|
||||
// defaults to ALLOW. Do not synthesise defaults here -- let each consumer
|
||||
// decide.
|
||||
package cmdmeta
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// domainAnnotationKey is the cobra Annotation key for the business domain.
|
||||
// Kept distinct from cmdutil.* keys so this package can evolve without
|
||||
// disturbing existing readers.
|
||||
const domainAnnotationKey = "cmdmeta.domain"
|
||||
|
||||
// Meta groups the three command-level metadata axes consumed by the policy
|
||||
// engine and hook selectors.
|
||||
type Meta struct {
|
||||
Domain string
|
||||
Risk string
|
||||
Identities []string
|
||||
}
|
||||
|
||||
// Apply writes metadata onto a cobra command. Empty fields are skipped: pass
|
||||
// the value via the underlying cmdutil setter if you need to write an empty
|
||||
// string / empty slice explicitly.
|
||||
func Apply(cmd *cobra.Command, m Meta) {
|
||||
if m.Domain != "" {
|
||||
SetDomain(cmd, m.Domain)
|
||||
}
|
||||
if m.Risk != "" {
|
||||
cmdutil.SetRisk(cmd, m.Risk)
|
||||
}
|
||||
if m.Identities != nil {
|
||||
cmdutil.SetSupportedIdentities(cmd, m.Identities)
|
||||
}
|
||||
}
|
||||
|
||||
// Get resolves the effective metadata for a command, walking up the parent
|
||||
// chain for Domain, Risk, and Identities. All three axes use the same
|
||||
// nearest-ancestor-wins rule.
|
||||
//
|
||||
// Identities note: cmdutil.GetSupportedIdentities collapses both the
|
||||
// "annotation absent" and "annotation set to empty string" cases to nil.
|
||||
// A child cannot therefore express "deny inheritance" with an empty
|
||||
// annotation; the walk simply continues up the parent chain when nil is
|
||||
// returned. To override a parent, the child must set a non-empty slice
|
||||
// (e.g. ["bot"]).
|
||||
func Get(cmd *cobra.Command) Meta {
|
||||
risk, _ := Risk(cmd)
|
||||
return Meta{
|
||||
Domain: Domain(cmd),
|
||||
Risk: risk,
|
||||
Identities: Identities(cmd),
|
||||
}
|
||||
}
|
||||
|
||||
// SetDomain stores the domain annotation on a single command (no
|
||||
// inheritance is performed on write).
|
||||
func SetDomain(cmd *cobra.Command, domain string) {
|
||||
if domain == "" {
|
||||
return
|
||||
}
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[domainAnnotationKey] = domain
|
||||
}
|
||||
|
||||
// Domain returns the nearest-ancestor domain for the command. Empty string
|
||||
// when no ancestor has the annotation -- this is the "unknown" state the
|
||||
// policy engine must treat as ALLOW.
|
||||
func Domain(cmd *cobra.Command) string {
|
||||
for c := cmd; c != nil; c = c.Parent() {
|
||||
if c.Annotations == nil {
|
||||
continue
|
||||
}
|
||||
if v, ok := c.Annotations[domainAnnotationKey]; ok && v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Risk returns the nearest-ancestor risk level (via cmdutil.GetRisk).
|
||||
// ok=false signals "unknown" -- the policy engine treats this as
|
||||
// fail-closed (deny with risk_not_annotated) whenever a Rule without
|
||||
// AllowUnannotated=true is active, and as allow otherwise.
|
||||
func Risk(cmd *cobra.Command) (level string, ok bool) {
|
||||
for c := cmd; c != nil; c = c.Parent() {
|
||||
if level, ok = cmdutil.GetRisk(c); ok {
|
||||
return level, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Identities returns the first non-nil identity set found while walking up
|
||||
// the parent chain. nil signals "unknown" -- the policy engine treats this
|
||||
// as ALLOW.
|
||||
//
|
||||
// cmdutil.GetSupportedIdentities returns nil when the annotation is absent
|
||||
// or empty; an explicit non-empty set (even ["user"] alone) stops the walk.
|
||||
func Identities(cmd *cobra.Command) []string {
|
||||
for c := cmd; c != nil; c = c.Parent() {
|
||||
if ids := cmdutil.GetSupportedIdentities(c); ids != nil {
|
||||
return ids
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
143
internal/cmdmeta/meta_test.go
Normal file
143
internal/cmdmeta/meta_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdmeta_test
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
func TestApply_writesAllFields(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "fetch"}
|
||||
cmdmeta.Apply(cmd, cmdmeta.Meta{
|
||||
Domain: "docs",
|
||||
Risk: "write",
|
||||
Identities: []string{"user", "bot"},
|
||||
})
|
||||
|
||||
if got := cmdmeta.Domain(cmd); got != "docs" {
|
||||
t.Fatalf("Domain = %q, want %q", got, "docs")
|
||||
}
|
||||
if got, ok := cmdmeta.Risk(cmd); !ok || got != "write" {
|
||||
t.Fatalf("Risk = (%q,%v), want (%q,true)", got, ok, "write")
|
||||
}
|
||||
if got := cmdmeta.Identities(cmd); !reflect.DeepEqual(got, []string{"user", "bot"}) {
|
||||
t.Fatalf("Identities = %v, want [user bot]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_emptyFieldsSkipped(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "fetch"}
|
||||
cmdmeta.Apply(cmd, cmdmeta.Meta{}) // nothing
|
||||
if got := cmdmeta.Domain(cmd); got != "" {
|
||||
t.Fatalf("Domain expected unset, got %q", got)
|
||||
}
|
||||
if _, ok := cmdmeta.Risk(cmd); ok {
|
||||
t.Fatalf("Risk expected unset")
|
||||
}
|
||||
if got := cmdmeta.Identities(cmd); got != nil {
|
||||
t.Fatalf("Identities expected nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Domain inherits from the nearest ancestor; risk and identities behave the
|
||||
// same way. We verify each axis with a 3-level tree:
|
||||
//
|
||||
// root (domain=docs, risk=read, identities=[user])
|
||||
// group
|
||||
// leaf
|
||||
func TestGet_inheritsFromAncestor(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
group := &cobra.Command{Use: "docs"}
|
||||
leaf := &cobra.Command{Use: "fetch"}
|
||||
root.AddCommand(group)
|
||||
group.AddCommand(leaf)
|
||||
|
||||
cmdmeta.Apply(root, cmdmeta.Meta{
|
||||
Domain: "docs",
|
||||
Risk: "read",
|
||||
Identities: []string{"user"},
|
||||
})
|
||||
|
||||
got := cmdmeta.Get(leaf)
|
||||
want := cmdmeta.Meta{
|
||||
Domain: "docs",
|
||||
Risk: "read",
|
||||
Identities: []string{"user"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("Get(leaf) = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Closest ancestor wins -- a mid-level override is preferred over root.
|
||||
func TestGet_nearestAncestorWins(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
group := &cobra.Command{Use: "docs"}
|
||||
leaf := &cobra.Command{Use: "fetch"}
|
||||
root.AddCommand(group)
|
||||
group.AddCommand(leaf)
|
||||
|
||||
cmdmeta.SetDomain(root, "docs")
|
||||
cmdmeta.SetDomain(group, "docs-override")
|
||||
cmdutil.SetRisk(root, "read")
|
||||
cmdutil.SetRisk(group, "high-risk-write")
|
||||
|
||||
if got := cmdmeta.Domain(leaf); got != "docs-override" {
|
||||
t.Fatalf("Domain = %q, want docs-override (nearest)", got)
|
||||
}
|
||||
if got, _ := cmdmeta.Risk(leaf); got != "high-risk-write" {
|
||||
t.Fatalf("Risk = %q, want high-risk-write (nearest)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown axes return zero / nil so the policy engine can apply the
|
||||
// "unknown => ALLOW" contract.
|
||||
func TestGet_unknownReturnsZero(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "orphan"}
|
||||
if got := cmdmeta.Domain(cmd); got != "" {
|
||||
t.Fatalf("Domain = %q, want empty for unknown", got)
|
||||
}
|
||||
if level, ok := cmdmeta.Risk(cmd); ok || level != "" {
|
||||
t.Fatalf("Risk = (%q,%v), want empty / false for unknown", level, ok)
|
||||
}
|
||||
if ids := cmdmeta.Identities(cmd); ids != nil {
|
||||
t.Fatalf("Identities = %v, want nil for unknown", ids)
|
||||
}
|
||||
}
|
||||
|
||||
// Child explicitly overriding identities stops the parent walk.
|
||||
func TestIdentities_childOverridesParent(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "docs"}
|
||||
child := &cobra.Command{Use: "preview"}
|
||||
parent.AddCommand(child)
|
||||
|
||||
cmdutil.SetSupportedIdentities(parent, []string{"user", "bot"})
|
||||
cmdutil.SetSupportedIdentities(child, []string{"bot"})
|
||||
|
||||
got := cmdmeta.Identities(child)
|
||||
if !reflect.DeepEqual(got, []string{"bot"}) {
|
||||
t.Fatalf("Identities(child) = %v, want [bot]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// SetDomain with empty value is a no-op (no annotation written, so a
|
||||
// later inherited read still works).
|
||||
func TestSetDomain_emptyIsNoop(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "docs"}
|
||||
cmdmeta.SetDomain(parent, "docs")
|
||||
|
||||
child := &cobra.Command{Use: "fetch"}
|
||||
parent.AddCommand(child)
|
||||
|
||||
cmdmeta.SetDomain(child, "") // no-op
|
||||
if got := cmdmeta.Domain(child); got != "docs" {
|
||||
t.Fatalf("Domain(child) = %q, want inherited 'docs'", got)
|
||||
}
|
||||
}
|
||||
83
internal/cmdpolicy/active.go
Normal file
83
internal/cmdpolicy/active.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdpolicy
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
// ActivePolicy is the resolved user-layer policy after applyUserPolicyPruning
|
||||
// has run during bootstrap. `lark-cli config policy show` reads this to
|
||||
// answer "what rule is currently in effect, and how many commands does
|
||||
// it hide?".
|
||||
//
|
||||
// Set once at bootstrap time; consumed read-only thereafter.
|
||||
type ActivePolicy struct {
|
||||
Rule *platform.Rule
|
||||
Source ResolveSource
|
||||
DeniedPaths int // number of commands the engine marked as denied (post-aggregation)
|
||||
}
|
||||
|
||||
var (
|
||||
activeMu sync.RWMutex
|
||||
activePolicy *ActivePolicy
|
||||
)
|
||||
|
||||
// SetActive records the policy that ends up applied. Called exactly once
|
||||
// per process from cmd/policy.go::applyUserPolicyPruning. The mutex is
|
||||
// belt-and-braces in case future test paths interleave with bootstrap.
|
||||
//
|
||||
// A deep copy is taken so the snapshot is immune to later mutations of
|
||||
// the input by the caller (a plugin-supplied *Rule could otherwise
|
||||
// mutate the embedded Allow/Deny/Identities slices after we stored it).
|
||||
func SetActive(p *ActivePolicy) {
|
||||
activeMu.Lock()
|
||||
defer activeMu.Unlock()
|
||||
if p == nil {
|
||||
activePolicy = nil
|
||||
return
|
||||
}
|
||||
activePolicy = cloneActivePolicy(p)
|
||||
}
|
||||
|
||||
// GetActive returns a deep copy of the recorded policy, or nil if
|
||||
// bootstrap has not finished or no rule applied. Callers can freely
|
||||
// mutate the result — including the embedded Rule slices — without
|
||||
// affecting the stored global.
|
||||
func GetActive() *ActivePolicy {
|
||||
activeMu.RLock()
|
||||
defer activeMu.RUnlock()
|
||||
if activePolicy == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneActivePolicy(activePolicy)
|
||||
}
|
||||
|
||||
// cloneActivePolicy deep-copies the top-level struct plus the embedded
|
||||
// Rule's slice fields. Other fields (Source, DeniedPaths) are value
|
||||
// types so the struct copy already disjoints them.
|
||||
func cloneActivePolicy(in *ActivePolicy) *ActivePolicy {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *in
|
||||
if in.Rule != nil {
|
||||
rule := *in.Rule
|
||||
rule.Allow = append([]string(nil), in.Rule.Allow...)
|
||||
rule.Deny = append([]string(nil), in.Rule.Deny...)
|
||||
rule.Identities = append([]platform.Identity(nil), in.Rule.Identities...)
|
||||
cp.Rule = &rule
|
||||
}
|
||||
return &cp
|
||||
}
|
||||
|
||||
// ResetActiveForTesting clears the recorded policy. Tests must call this
|
||||
// in t.Cleanup when they exercise the bootstrap path.
|
||||
func ResetActiveForTesting() {
|
||||
activeMu.Lock()
|
||||
defer activeMu.Unlock()
|
||||
activePolicy = nil
|
||||
}
|
||||
364
internal/cmdpolicy/aggregation_test.go
Normal file
364
internal/cmdpolicy/aggregation_test.go
Normal file
@@ -0,0 +1,364 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdpolicy_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// EvaluateAll must skip non-runnable parent groups (their decision is
|
||||
// derived in the aggregation pass). The previous regression: an
|
||||
// Allow:["docs/**"] rule incorrectly denied the parent "docs" group too,
|
||||
// because the parent's own path "docs" did not match "docs/**".
|
||||
func TestEvaluateAll_skipsPureGroups(t *testing.T) {
|
||||
root := buildTree() // docs and im are pure groups, +fetch / +update / +send are leaves
|
||||
e := cmdpolicy.New(&platform.Rule{Allow: []string{"docs/**"}})
|
||||
got := e.EvaluateAll(root)
|
||||
|
||||
if _, present := got["docs"]; present {
|
||||
t.Errorf("parent group 'docs' should not appear in Decisions (Allow=docs/**)")
|
||||
}
|
||||
if _, present := got["im"]; present {
|
||||
t.Errorf("parent group 'im' should not appear in Decisions")
|
||||
}
|
||||
|
||||
// Children still evaluated normally.
|
||||
if !got["docs/+fetch"].Allowed {
|
||||
t.Errorf("docs/+fetch should still be allowed by docs/**")
|
||||
}
|
||||
}
|
||||
|
||||
// BuildDeniedByPath must aggregate: a parent group whose every runnable
|
||||
// child is denied must itself get an aggregated Denial in the map.
|
||||
func TestBuildDeniedByPath_parentAggregationAllChildrenDenied(t *testing.T) {
|
||||
// Custom tree where ALL children of "im" will be denied.
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
im := &cobra.Command{Use: "im"}
|
||||
root.AddCommand(im)
|
||||
send := &cobra.Command{Use: "+send", RunE: noop}
|
||||
cmdutil.SetRisk(send, "write")
|
||||
im.AddCommand(send)
|
||||
search := &cobra.Command{Use: "+search", RunE: noop}
|
||||
cmdutil.SetRisk(search, "read")
|
||||
im.AddCommand(search)
|
||||
|
||||
// Risk is set on both leaves so the rejection comes from the Allow
|
||||
// axis (the contract this test pins), not from the risk gate.
|
||||
e := cmdpolicy.New(&platform.Rule{Allow: []string{"docs/**"}}) // none of im/* matches
|
||||
decisions := e.EvaluateAll(root)
|
||||
|
||||
// Pin the rejection axis: both leaves are rejected by Allow miss,
|
||||
// NOT by the risk_not_annotated gate. If a future edit drops the
|
||||
// SetRisk lines above, this assertion fails and the test stops
|
||||
// silently testing the wrong axis.
|
||||
if rc := decisions["im/+send"].ReasonCode; rc != "domain_not_allowed" {
|
||||
t.Errorf("im/+send ReasonCode = %q, want domain_not_allowed", rc)
|
||||
}
|
||||
if rc := decisions["im/+search"].ReasonCode; rc != "domain_not_allowed" {
|
||||
t.Errorf("im/+search ReasonCode = %q, want domain_not_allowed", rc)
|
||||
}
|
||||
|
||||
denied := cmdpolicy.BuildDeniedByPath(root, decisions,
|
||||
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: "/policy.yml"}, "agent")
|
||||
|
||||
// Both leaves denied.
|
||||
if _, ok := denied["im/+send"]; !ok {
|
||||
t.Errorf("im/+send should be in denied map")
|
||||
}
|
||||
if _, ok := denied["im/+search"]; !ok {
|
||||
t.Errorf("im/+search should be in denied map")
|
||||
}
|
||||
// Parent must be aggregated.
|
||||
parent, ok := denied["im"]
|
||||
if !ok {
|
||||
t.Fatalf("parent 'im' should be aggregated into denied map")
|
||||
}
|
||||
if parent.Layer != "policy" {
|
||||
t.Errorf("parent.Layer = %q, want pruning", parent.Layer)
|
||||
}
|
||||
}
|
||||
|
||||
// Partial children-denied means parent stays UN-denied. This is the
|
||||
// counter-case to the previous regression: docs/** allowed children stays
|
||||
// alive even if some siblings are denied.
|
||||
func TestBuildDeniedByPath_partialDenialKeepsParent(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
docs := &cobra.Command{Use: "docs"}
|
||||
root.AddCommand(docs)
|
||||
|
||||
fetch := &cobra.Command{Use: "+fetch", RunE: noop}
|
||||
cmdutil.SetRisk(fetch, "read")
|
||||
docs.AddCommand(fetch) // allowed
|
||||
|
||||
delete := &cobra.Command{Use: "+delete", RunE: noop}
|
||||
cmdutil.SetRisk(delete, "high-risk-write")
|
||||
docs.AddCommand(delete) // denied by Deny
|
||||
|
||||
e := cmdpolicy.New(&platform.Rule{
|
||||
Allow: []string{"docs/**"},
|
||||
Deny: []string{"docs/+delete"},
|
||||
})
|
||||
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
|
||||
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourcePlugin, Name: "secaudit"}, "secaudit-policy")
|
||||
|
||||
if _, ok := denied["docs"]; ok {
|
||||
t.Errorf("parent 'docs' must NOT be denied when some children are allowed")
|
||||
}
|
||||
if _, ok := denied["docs/+fetch"]; ok {
|
||||
t.Errorf("docs/+fetch should not be in denied map (it's allowed)")
|
||||
}
|
||||
if _, ok := denied["docs/+delete"]; !ok {
|
||||
t.Errorf("docs/+delete should be denied (in Deny)")
|
||||
}
|
||||
}
|
||||
|
||||
// The binary root is never installed with a denyStub even when all its
|
||||
// descendants are denied -- the entry point must remain dispatchable.
|
||||
func TestBuildDeniedByPath_rootNeverDenied(t *testing.T) {
|
||||
root := buildTree()
|
||||
e := cmdpolicy.New(&platform.Rule{Allow: []string{"nonexistent/**"}})
|
||||
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
|
||||
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: "/p.yml"}, "")
|
||||
|
||||
// Every leaf should be denied. We do not assert on the root entry
|
||||
// because Apply skips the root regardless; the contract is "root
|
||||
// stays dispatchable".
|
||||
if _, ok := denied["lark-cli"]; ok {
|
||||
t.Errorf("root should not be in denied map")
|
||||
}
|
||||
}
|
||||
|
||||
// Hybrid command: a parent with its own RunE plus children. Aggregation
|
||||
// requires both own RunE denied AND all children denied for the parent
|
||||
// itself to be marked denied.
|
||||
func TestBuildDeniedByPath_hybridParentOwnAllowedKeepsAlive(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
docs := &cobra.Command{Use: "docs", RunE: noop} // hybrid: own RunE + subs
|
||||
cmdutil.SetRisk(docs, "read")
|
||||
root.AddCommand(docs)
|
||||
delete := &cobra.Command{Use: "+delete", RunE: noop}
|
||||
cmdutil.SetRisk(delete, "high-risk-write")
|
||||
docs.AddCommand(delete)
|
||||
|
||||
// Allow "docs" (parent) but deny "+delete" child.
|
||||
e := cmdpolicy.New(&platform.Rule{
|
||||
Allow: []string{"docs"},
|
||||
})
|
||||
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
|
||||
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: ""}, "")
|
||||
|
||||
// docs/+delete denied (path doesn't match Allow=["docs"]).
|
||||
if _, ok := denied["docs/+delete"]; !ok {
|
||||
t.Errorf("docs/+delete should be denied")
|
||||
}
|
||||
// docs itself allowed (path matches Allow=["docs"] exactly).
|
||||
if _, ok := denied["docs"]; ok {
|
||||
t.Errorf("docs (hybrid) should NOT be denied -- own RunE is allowed")
|
||||
}
|
||||
}
|
||||
|
||||
// Apply with the wrapped *output.ExitError exposes BOTH paths consumers
|
||||
// rely on:
|
||||
// 1. cmd/root.go's envelope writer (errors.As on *output.ExitError)
|
||||
// 2. in-process consumers extracting the platform.CommandDeniedError
|
||||
func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
|
||||
root := buildTree()
|
||||
denied := map[string]cmdpolicy.Denial{
|
||||
"docs/+update": {
|
||||
Layer: "policy",
|
||||
PolicySource: "plugin:secaudit",
|
||||
RuleName: "secaudit-policy",
|
||||
ReasonCode: "write_not_allowed",
|
||||
Reason: "write disabled",
|
||||
},
|
||||
}
|
||||
cmdpolicy.Apply(root, denied)
|
||||
update := findChild(t, root, "docs", "+update")
|
||||
|
||||
err := update.RunE(update, []string{})
|
||||
if err == nil {
|
||||
t.Fatalf("denied command should return error")
|
||||
}
|
||||
|
||||
// Path 1: envelope-writer view.
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error chain must contain *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatalf("ExitError.Detail required for envelope to render")
|
||||
}
|
||||
if exitErr.Detail.Type != "command_denied" {
|
||||
t.Errorf("envelope error.type = %q, want command_denied", exitErr.Detail.Type)
|
||||
}
|
||||
// JSON envelope shape: detail.reason_code must be present and
|
||||
// match the closed enum.
|
||||
detailMap, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("envelope detail should be map[string]any, got %T", exitErr.Detail.Detail)
|
||||
}
|
||||
if detailMap["reason_code"] != "write_not_allowed" {
|
||||
t.Errorf("detail.reason_code = %v, want write_not_allowed", detailMap["reason_code"])
|
||||
}
|
||||
if detailMap["policy_source"] != "plugin:secaudit" {
|
||||
t.Errorf("detail.policy_source = %v, want plugin:secaudit", detailMap["policy_source"])
|
||||
}
|
||||
|
||||
// Path 2: in-process typed-error view.
|
||||
var cd *platform.CommandDeniedError
|
||||
if !errors.As(err, &cd) {
|
||||
t.Fatalf("error chain must expose *platform.CommandDeniedError")
|
||||
}
|
||||
if cd.Path != "docs/+update" || cd.ReasonCode != "write_not_allowed" {
|
||||
t.Errorf("CommandDeniedError = %+v", cd)
|
||||
}
|
||||
|
||||
// Envelope round-trip sanity (the actual JSON cmd/root.go would emit).
|
||||
var buf strings.Builder
|
||||
output.WriteErrorEnvelope(&buf, exitErr, "user")
|
||||
if !strings.Contains(buf.String(), `"type": "command_denied"`) {
|
||||
t.Errorf("envelope JSON missing type=command_denied, got:\n%s", buf.String())
|
||||
}
|
||||
if !strings.Contains(buf.String(), `"reason_code": "write_not_allowed"`) {
|
||||
t.Errorf("envelope JSON missing reason_code, got:\n%s", buf.String())
|
||||
}
|
||||
// Round-trip parse to verify it's well-formed JSON.
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(buf.String()), &parsed); err != nil {
|
||||
t.Fatalf("envelope JSON malformed: %v\n%s", err, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: a pure parent group carrying AnnotationPureGroup must be
|
||||
// skipped by both EvaluateAll and aggregateParents. Without the skip,
|
||||
// the cmd.installUnknownSubcommandGuard pass (which attaches a RunE to
|
||||
// every group for cobra's silent-help fallback) would flip Runnable()
|
||||
// to true for `docs`, `drive`, etc., and a yaml rule like
|
||||
// `max_risk: read` would deny every `<group> --help` invocation with
|
||||
// reason_code = risk_not_annotated.
|
||||
func TestEvaluateAll_skipsAnnotatedPureGroup(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
drive := &cobra.Command{
|
||||
Use: "drive",
|
||||
RunE: func(*cobra.Command, []string) error { return nil }, // emulate guard injection
|
||||
Annotations: map[string]string{
|
||||
cmdpolicy.AnnotationPureGroup: "true",
|
||||
},
|
||||
}
|
||||
root.AddCommand(drive)
|
||||
pull := &cobra.Command{Use: "+pull", RunE: noop}
|
||||
cmdutil.SetRisk(pull, "read")
|
||||
drive.AddCommand(pull)
|
||||
|
||||
e := cmdpolicy.New(&platform.Rule{MaxRisk: "read"})
|
||||
got := e.EvaluateAll(root)
|
||||
|
||||
if d, present := got["drive"]; present {
|
||||
t.Errorf("annotated pure group should not appear in Decisions; got %+v", d)
|
||||
}
|
||||
if !got["drive/+pull"].Allowed {
|
||||
t.Errorf("leaf under pure group must still be evaluated; got %+v", got["drive/+pull"])
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: hasRunnableDescendant must also treat
|
||||
// AnnotationPureGroup-tagged commands as non-runnable. Without the
|
||||
// skip, an entire branch consisting of a pure-group placeholder + a
|
||||
// single pure-group leaf would advertise itself as a "live" subtree
|
||||
// and the parent aggregation pass would refuse to install a deny stub
|
||||
// (allLiveChildrenDenied flips to false because the pure group is
|
||||
// neither runnable nor in `denied`).
|
||||
func TestHasRunnableDescendant_ignoresAnnotatedPureGroup(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
docs := &cobra.Command{Use: "docs"}
|
||||
root.AddCommand(docs)
|
||||
|
||||
// A pure-group sibling of a real leaf. The parent must still
|
||||
// aggregate based on the real leaf alone.
|
||||
placeholder := &cobra.Command{
|
||||
Use: "placeholder",
|
||||
RunE: func(*cobra.Command, []string) error { return nil },
|
||||
Annotations: map[string]string{
|
||||
cmdpolicy.AnnotationPureGroup: "true",
|
||||
},
|
||||
}
|
||||
docs.AddCommand(placeholder)
|
||||
noChild := &cobra.Command{
|
||||
Use: "+ghost",
|
||||
RunE: func(*cobra.Command, []string) error { return nil },
|
||||
Annotations: map[string]string{
|
||||
cmdpolicy.AnnotationPureGroup: "true",
|
||||
},
|
||||
}
|
||||
placeholder.AddCommand(noChild)
|
||||
|
||||
fetch := &cobra.Command{Use: "+fetch", RunE: noop}
|
||||
cmdutil.SetRisk(fetch, "write")
|
||||
docs.AddCommand(fetch)
|
||||
|
||||
e := cmdpolicy.New(&platform.Rule{MaxRisk: "read"})
|
||||
decisions := e.EvaluateAll(root)
|
||||
denied := cmdpolicy.BuildDeniedByPath(root, decisions, cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML}, "")
|
||||
|
||||
if _, ok := denied["docs"]; !ok {
|
||||
t.Fatalf("docs should be aggregated as fully denied (pure-group children excluded from live count); map=%+v", denied)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: aggregateParents must treat an AnnotationPureGroup-tagged
|
||||
// command exactly like a parent-only group. With cmdRunnable accidentally
|
||||
// true (RunE attached by the guard), the aggregator would otherwise look
|
||||
// for an own-RunE denial entry and skip aggregation, leaving `<group>
|
||||
// --help` reachable even when every live child is denied.
|
||||
func TestBuildDeniedByPath_aggregatesAnnotatedPureGroup(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
drive := &cobra.Command{
|
||||
Use: "drive",
|
||||
RunE: func(*cobra.Command, []string) error { return nil },
|
||||
Annotations: map[string]string{
|
||||
cmdpolicy.AnnotationPureGroup: "true",
|
||||
},
|
||||
}
|
||||
root.AddCommand(drive)
|
||||
push := &cobra.Command{Use: "+push", RunE: noop}
|
||||
cmdutil.SetRisk(push, "write")
|
||||
drive.AddCommand(push)
|
||||
pull := &cobra.Command{Use: "+pull", RunE: noop}
|
||||
cmdutil.SetRisk(pull, "write")
|
||||
drive.AddCommand(pull)
|
||||
|
||||
e := cmdpolicy.New(&platform.Rule{MaxRisk: "read"})
|
||||
decisions := e.EvaluateAll(root)
|
||||
denied := cmdpolicy.BuildDeniedByPath(root, decisions, cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML}, "")
|
||||
|
||||
if _, ok := denied["drive"]; !ok {
|
||||
t.Fatalf("aggregator must install drive denial when all children denied; map=%+v", denied)
|
||||
}
|
||||
}
|
||||
|
||||
// The binary root must never receive a denyStub even if every descendant
|
||||
// is denied. cobra still needs root to dispatch help / completion.
|
||||
func TestApply_neverInstallsOnRoot(t *testing.T) {
|
||||
root := buildTree()
|
||||
denied := map[string]cmdpolicy.Denial{
|
||||
"lark-cli": {Layer: "policy", ReasonCode: "all_children_denied"},
|
||||
}
|
||||
cmdpolicy.Apply(root, denied)
|
||||
if root.RunE != nil {
|
||||
t.Errorf("root.RunE should remain nil; got a denyStub installed")
|
||||
}
|
||||
if root.Hidden {
|
||||
t.Errorf("root must stay visible")
|
||||
}
|
||||
}
|
||||
227
internal/cmdpolicy/apply.go
Normal file
227
internal/cmdpolicy/apply.go
Normal file
@@ -0,0 +1,227 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdpolicy
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// Apply walks the command tree and installs denyStubs for every path in
|
||||
// deniedByPath whose Denial.Layer == "policy". It is the user-layer
|
||||
// counterpart to applyStrictModeDenials in cmd/prune.go; both consume the
|
||||
// same deniedByPath map produced by the bootstrap pipeline, neither
|
||||
// re-evaluates rules.
|
||||
//
|
||||
// Three things must happen for every denied command (hard-constraints 1-4
|
||||
// in the tech doc):
|
||||
//
|
||||
// 1. cmd.Hidden = true -- removes from help / completion
|
||||
// 2. cmd.DisableFlagParsing = true -- denial-wins invariant; otherwise
|
||||
// cobra would intercept the call
|
||||
// with "missing required flag"
|
||||
// before we can return our error
|
||||
// 3. cmd.RunE = denyStub(denial) -- returns *output.ExitError so
|
||||
// cmd/root.go's envelope writer
|
||||
// emits structured JSON (with
|
||||
// error.type = denial.Layer and
|
||||
// detail.reason_code = ReasonCode);
|
||||
// the wrapped error chain still
|
||||
// exposes *platform.CommandDeniedError
|
||||
// via errors.As for in-process
|
||||
// consumers
|
||||
//
|
||||
// Apply must be called once during the Bootstrap pipeline BEFORE
|
||||
// cobra.Execute. It mutates the command tree in place and is not safe to
|
||||
// call concurrently with command dispatch. Returns the number of commands
|
||||
// modified.
|
||||
func Apply(root *cobra.Command, deniedByPath map[string]Denial) int {
|
||||
if root == nil || len(deniedByPath) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
count := 0
|
||||
walkTree(root, func(c *cobra.Command) {
|
||||
// Never install a denyStub on the binary root itself. Even if the
|
||||
// aggregation pass somehow marked it (e.g. all-children-denied at
|
||||
// the top), the binary entry point must remain dispatchable so
|
||||
// cobra's own help / completion paths still work.
|
||||
if !c.HasParent() {
|
||||
return
|
||||
}
|
||||
path := CanonicalPath(c)
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
d, ok := deniedByPath[path]
|
||||
if !ok || d.Layer != LayerPolicy {
|
||||
return
|
||||
}
|
||||
if installDenyStub(c, path, d) {
|
||||
count++
|
||||
}
|
||||
})
|
||||
return count
|
||||
}
|
||||
|
||||
// AnnotationDenialLayer / AnnotationDenialSource carry the denial
|
||||
// signal to internal/hook through cobra annotations, avoiding an
|
||||
// import cycle between hook and cmdpolicy.
|
||||
const (
|
||||
AnnotationDenialLayer = "lark:policy_denied_layer"
|
||||
AnnotationDenialSource = "lark:policy_denied_source"
|
||||
|
||||
// AnnotationPureGroup marks a cobra.Command that is logically a
|
||||
// parent-only group but had a RunE attached by the bootstrap-time
|
||||
// unknown-subcommand guard. The engine treats annotated commands
|
||||
// the same as un-annotated parent groups (no RunE): they are not
|
||||
// evaluated against the Rule, and aggregateParents does not treat
|
||||
// them as hybrids.
|
||||
//
|
||||
// Without this signal, a user enabling a policy.yml with
|
||||
// max_risk: read would see every group (`lark-cli drive --help`,
|
||||
// `lark-cli docs --help`) return exit 2 + risk_not_annotated,
|
||||
// because the guard's RunE flips Runnable()=true and the engine
|
||||
// then demands a risk_level annotation on the group itself.
|
||||
AnnotationPureGroup = "lark:cmd_pure_group"
|
||||
)
|
||||
|
||||
// IsPureGroup reports whether cmd carries the AnnotationPureGroup marker.
|
||||
// Used by the engine to skip evaluation and by the aggregator to treat the
|
||||
// command as a parent-only group regardless of cobra's Runnable() answer.
|
||||
func IsPureGroup(cmd *cobra.Command) bool {
|
||||
if cmd == nil || cmd.Annotations == nil {
|
||||
return false
|
||||
}
|
||||
return cmd.Annotations[AnnotationPureGroup] == "true"
|
||||
}
|
||||
|
||||
// CommandDeniedFromDenial materialises the wrapped error type carried
|
||||
// on ExitError.Err so errors.As works for in-process consumers.
|
||||
func CommandDeniedFromDenial(path string, d Denial) *platform.CommandDeniedError {
|
||||
return &platform.CommandDeniedError{
|
||||
Path: path,
|
||||
Layer: d.Layer,
|
||||
PolicySource: d.PolicySource,
|
||||
RuleName: d.RuleName,
|
||||
ReasonCode: d.ReasonCode,
|
||||
Reason: d.Reason,
|
||||
}
|
||||
}
|
||||
|
||||
// DenialDetailMap is the canonical detail.* shape every `command_denied`
|
||||
// envelope shares (see docs/extension/reason-codes.md). Use it as
|
||||
// ErrDetail.Detail when constructing an envelope outside BuildDenialError.
|
||||
func DenialDetailMap(cd *platform.CommandDeniedError) map[string]any {
|
||||
return map[string]any{
|
||||
"path": cd.Path,
|
||||
"layer": cd.Layer,
|
||||
"policy_source": cd.PolicySource,
|
||||
"rule_name": cd.RuleName,
|
||||
"reason_code": cd.ReasonCode,
|
||||
"reason": cd.Reason,
|
||||
}
|
||||
}
|
||||
|
||||
// BuildDenialError is the default envelope for user-layer denials:
|
||||
// Message comes from CommandDeniedError.Error(), no Hint. Callers that
|
||||
// need a custom Message or an independent Hint (strict-mode) should
|
||||
// compose CommandDeniedFromDenial + DenialDetailMap themselves.
|
||||
func BuildDenialError(path string, d Denial) *output.ExitError {
|
||||
cd := CommandDeniedFromDenial(path, d)
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "command_denied",
|
||||
Message: cd.Error(),
|
||||
Detail: DenialDetailMap(cd),
|
||||
},
|
||||
Err: cd,
|
||||
}
|
||||
}
|
||||
|
||||
// installDenyStub mutates a cobra.Command in place. Unlike cmd/prune.go
|
||||
// which does RemoveCommand+AddCommand (changing the pointer), we modify
|
||||
// the existing node so any external reference (snapshots, alias targets)
|
||||
// continues to point at the same cmd.
|
||||
//
|
||||
// Help fields (cmd.Short / cmd.Long / cmd.Flags()) are deliberately
|
||||
// preserved so `--help` on a denied command still describes what the
|
||||
// command was intended to do.
|
||||
//
|
||||
// Two cobra Annotations are set as a denial signal that internal/hook
|
||||
// reads (without taking a dependency on this package):
|
||||
//
|
||||
// - AnnotationDenialLayer -> "policy" or "strict_mode"
|
||||
// - AnnotationDenialSource -> the PolicySource ("yaml", "plugin:foo", ...)
|
||||
//
|
||||
// Returns true when the stub was actually installed and false on the
|
||||
// strict-mode early-return so callers can compute an accurate "commands
|
||||
// modified" count.
|
||||
func installDenyStub(cmd *cobra.Command, path string, d Denial) bool {
|
||||
// strict-mode wins over user-layer pruning. If the command was
|
||||
// already replaced by a strict-mode stub (cmd/prune.go::strictModeStubFrom
|
||||
// writes layer=strict_mode), do NOT overwrite -- the user-layer
|
||||
// rule cannot relax or relabel a credential-hard boundary.
|
||||
//
|
||||
// Behaviour without this guard (pre-fix): a user yaml rule matching
|
||||
// a strict-mode stub's path would replace the RunE with the pruning
|
||||
// denyStub, hiding the original strict-mode error message AND
|
||||
// re-labelling detail.layer from "strict_mode" to "policy".
|
||||
if cmd.Annotations != nil &&
|
||||
cmd.Annotations[AnnotationDenialLayer] == LayerStrictMode {
|
||||
return false
|
||||
}
|
||||
cmd.Hidden = true
|
||||
cmd.DisableFlagParsing = true
|
||||
|
||||
// Bypass cobra's pre-RunE gates that would otherwise short-circuit
|
||||
// before the wrapped RunE (= where observers + denial guard live):
|
||||
//
|
||||
// 1. Args validator: original commands often declare cobra.NoArgs
|
||||
// or a custom Args function. With DisableFlagParsing=true,
|
||||
// `--doc xxx` looks like positional args; cobra.ValidateArgs
|
||||
// fires BEFORE PersistentPreRunE / PreRunE / RunE and would
|
||||
// surface a Cobra usage error instead of our pruning envelope.
|
||||
// ArbitraryArgs accepts everything.
|
||||
//
|
||||
// 2. Parent's PersistentPreRunE: cobra's "first PersistentPreRunE
|
||||
// wins" walks UP from the leaf. cmd/auth/auth.go declares a
|
||||
// PersistentPreRunE that returns external_provider when env
|
||||
// credentials are set; without our leaf-level override, that
|
||||
// fires before pruning's RunE and the caller sees the wrong
|
||||
// envelope. We set a no-op leaf PersistentPreRunE that just
|
||||
// silences usage and returns nil, so dispatch proceeds to the
|
||||
// wrapped RunE (which produces the real pruning envelope and
|
||||
// lets Before/After observers fire).
|
||||
cmd.Args = cobra.ArbitraryArgs
|
||||
cmd.PersistentPreRunE = func(c *cobra.Command, _ []string) error {
|
||||
c.SilenceUsage = true
|
||||
return nil
|
||||
}
|
||||
cmd.PersistentPreRun = nil
|
||||
cmd.PreRunE = nil
|
||||
cmd.PreRun = nil
|
||||
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[AnnotationDenialLayer] = d.Layer
|
||||
cmd.Annotations[AnnotationDenialSource] = d.PolicySource
|
||||
|
||||
denial := d // capture by value for the closure
|
||||
cmd.RunE = func(c *cobra.Command, args []string) error {
|
||||
// error.type is the user-facing semantic ("a command was denied by
|
||||
// policy"). detail.layer carries the implementation distinction
|
||||
// ("policy" vs "strict_mode") for debugging.
|
||||
return BuildDenialError(path, denial)
|
||||
}
|
||||
// Clear any pre-existing Run hook: cobra prefers RunE when both are
|
||||
// set, but leaving a stale Run around is a foot-gun for future
|
||||
// maintainers.
|
||||
cmd.Run = nil
|
||||
return true
|
||||
}
|
||||
130
internal/cmdpolicy/denial.go
Normal file
130
internal/cmdpolicy/denial.go
Normal file
@@ -0,0 +1,130 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdpolicy
|
||||
|
||||
import "sort"
|
||||
|
||||
// Layer values match CommandDeniedError.Layer and the detail.layer
|
||||
// field of the JSON envelope (under error.type = "command_denied").
|
||||
const (
|
||||
LayerStrictMode = "strict_mode"
|
||||
// LayerPolicy is the user-layer enforcement label. The string value
|
||||
// is "policy" — the package name "cmdpolicy" matches it. This
|
||||
// replaces the older "pruning" label.
|
||||
LayerPolicy = "policy"
|
||||
)
|
||||
|
||||
// Denial is the merged record for a single rejected command path. It
|
||||
// is distinct from the user-layer-only Decision type: Denial only
|
||||
// exists when the command is rejected (the Allowed bool would be
|
||||
// wasted here, hence not reusing Decision).
|
||||
type Denial struct {
|
||||
Layer string // "strict_mode" | "policy"
|
||||
PolicySource string // "plugin:secaudit" | "yaml:mywork" | "strict-mode" | ""
|
||||
RuleName string // matched Rule.Name (if any)
|
||||
ReasonCode string // closed enum, see docs/extension/reason-codes.md
|
||||
Reason string // human-readable
|
||||
}
|
||||
|
||||
// ChildDenial is what AggregateChildren consumes — it pairs a Denial
|
||||
// with the child command's path so the aggregate can carry that
|
||||
// breakdown for envelope.detail.children_denied.
|
||||
type ChildDenial struct {
|
||||
Path string
|
||||
Denial Denial
|
||||
}
|
||||
|
||||
// AggregateChildren produces the parent-group Denial when every child
|
||||
// of a command group is itself denied. The rules:
|
||||
//
|
||||
// - all children share Layer "strict_mode" → parent Layer =
|
||||
// strict_mode, parent ReasonCode = single child's ReasonCode (if
|
||||
// consistent) or "mixed_children_strict_mode" otherwise.
|
||||
// - all children share Layer "policy" → parent Layer = policy,
|
||||
// ReasonCode behaves analogously.
|
||||
// - mixed layers across children → parent Layer = "policy",
|
||||
// ReasonCode = "all_children_denied", PolicySource = "mixed".
|
||||
//
|
||||
// Calling with an empty slice returns a zero Denial — callers should
|
||||
// treat this as "no aggregation needed".
|
||||
func AggregateChildren(children []ChildDenial) Denial {
|
||||
if len(children) == 0 {
|
||||
return Denial{}
|
||||
}
|
||||
|
||||
layers := map[string]struct{}{}
|
||||
reasonCodes := map[string]struct{}{}
|
||||
sources := map[string]struct{}{}
|
||||
ruleNames := map[string]struct{}{}
|
||||
for _, c := range children {
|
||||
layers[c.Denial.Layer] = struct{}{}
|
||||
reasonCodes[c.Denial.ReasonCode] = struct{}{}
|
||||
if c.Denial.PolicySource != "" {
|
||||
sources[c.Denial.PolicySource] = struct{}{}
|
||||
}
|
||||
if c.Denial.RuleName != "" {
|
||||
ruleNames[c.Denial.RuleName] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// Mixed: layers differ across children. Parent goes to Layer=policy
|
||||
// (the more "user-recoverable" of the two — swapping policy can
|
||||
// flip children, swapping credential cannot).
|
||||
if len(layers) > 1 {
|
||||
return Denial{
|
||||
Layer: LayerPolicy,
|
||||
PolicySource: "mixed",
|
||||
ReasonCode: "all_children_denied",
|
||||
Reason: "all child commands are denied (mixed reasons)",
|
||||
}
|
||||
}
|
||||
|
||||
var layer string
|
||||
for l := range layers {
|
||||
layer = l
|
||||
}
|
||||
|
||||
d := Denial{Layer: layer}
|
||||
|
||||
switch len(reasonCodes) {
|
||||
case 1:
|
||||
for rc := range reasonCodes {
|
||||
d.ReasonCode = rc
|
||||
}
|
||||
default:
|
||||
switch layer {
|
||||
case LayerStrictMode:
|
||||
d.ReasonCode = "mixed_children_strict_mode"
|
||||
default:
|
||||
d.ReasonCode = "mixed_children_policy"
|
||||
}
|
||||
}
|
||||
|
||||
if len(sources) == 1 {
|
||||
for s := range sources {
|
||||
d.PolicySource = s
|
||||
}
|
||||
}
|
||||
if layer == LayerStrictMode {
|
||||
d.PolicySource = "strict-mode"
|
||||
}
|
||||
|
||||
if len(ruleNames) == 1 {
|
||||
for n := range ruleNames {
|
||||
d.RuleName = n
|
||||
}
|
||||
}
|
||||
|
||||
d.Reason = "all child commands are denied"
|
||||
return d
|
||||
}
|
||||
|
||||
// SortChildren orders children by Path. The aggregate output of
|
||||
// AggregateChildren is deterministic regardless of slice order, but
|
||||
// tests and the envelope's children_denied list want a stable order.
|
||||
func SortChildren(children []ChildDenial) {
|
||||
sort.Slice(children, func(i, j int) bool {
|
||||
return children[i].Path < children[j].Path
|
||||
})
|
||||
}
|
||||
98
internal/cmdpolicy/denial_test.go
Normal file
98
internal/cmdpolicy/denial_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdpolicy_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
)
|
||||
|
||||
func TestAggregateChildren_allSameLayerAndReason(t *testing.T) {
|
||||
got := cmdpolicy.AggregateChildren([]cmdpolicy.ChildDenial{
|
||||
{Path: "docs/+update", Denial: cmdpolicy.Denial{
|
||||
Layer: cmdpolicy.LayerPolicy, PolicySource: "yaml:agent",
|
||||
ReasonCode: "write_not_allowed", RuleName: "agent-policy",
|
||||
}},
|
||||
{Path: "docs/+delete", Denial: cmdpolicy.Denial{
|
||||
Layer: cmdpolicy.LayerPolicy, PolicySource: "yaml:agent",
|
||||
ReasonCode: "write_not_allowed", RuleName: "agent-policy",
|
||||
}},
|
||||
})
|
||||
if got.Layer != cmdpolicy.LayerPolicy || got.ReasonCode != "write_not_allowed" {
|
||||
t.Fatalf("got %+v, want layer=policy reason=write_not_allowed", got)
|
||||
}
|
||||
if got.PolicySource != "yaml:agent" || got.RuleName != "agent-policy" {
|
||||
t.Fatalf("Source / RuleName should propagate when consistent, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateChildren_sameLayerMixedReasons(t *testing.T) {
|
||||
got := cmdpolicy.AggregateChildren([]cmdpolicy.ChildDenial{
|
||||
{Denial: cmdpolicy.Denial{Layer: cmdpolicy.LayerPolicy, ReasonCode: "write_not_allowed"}},
|
||||
{Denial: cmdpolicy.Denial{Layer: cmdpolicy.LayerPolicy, ReasonCode: "domain_not_allowed"}},
|
||||
})
|
||||
if got.Layer != cmdpolicy.LayerPolicy || got.ReasonCode != "mixed_children_policy" {
|
||||
t.Fatalf("got %+v, want layer=policy reason=mixed_children_policy", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateChildren_strictModeBranch(t *testing.T) {
|
||||
got := cmdpolicy.AggregateChildren([]cmdpolicy.ChildDenial{
|
||||
{Denial: cmdpolicy.Denial{Layer: cmdpolicy.LayerStrictMode, ReasonCode: "identity_not_supported"}},
|
||||
{Denial: cmdpolicy.Denial{Layer: cmdpolicy.LayerStrictMode, ReasonCode: "identity_not_supported"}},
|
||||
})
|
||||
if got.Layer != cmdpolicy.LayerStrictMode || got.ReasonCode != "identity_not_supported" {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
if got.PolicySource != "strict-mode" {
|
||||
t.Fatalf("PolicySource = %q, want strict-mode", got.PolicySource)
|
||||
}
|
||||
}
|
||||
|
||||
// Mixed layers (some strict_mode, some policy) collapse to Layer=policy
|
||||
// per the design rule — a parent group failing for "both" reasons is
|
||||
// most actionable framed as a user-policy issue (swappable) rather than
|
||||
// a credential capability one (not swappable).
|
||||
func TestAggregateChildren_mixedLayersFallsToPolicy(t *testing.T) {
|
||||
got := cmdpolicy.AggregateChildren([]cmdpolicy.ChildDenial{
|
||||
{Path: "docs/+update", Denial: cmdpolicy.Denial{
|
||||
Layer: cmdpolicy.LayerStrictMode, ReasonCode: "identity_not_supported",
|
||||
}},
|
||||
{Path: "docs/+fetch", Denial: cmdpolicy.Denial{
|
||||
Layer: cmdpolicy.LayerPolicy, ReasonCode: "domain_not_allowed",
|
||||
}},
|
||||
})
|
||||
if got.Layer != cmdpolicy.LayerPolicy {
|
||||
t.Fatalf("Layer = %q, want policy (mixed-children rule)", got.Layer)
|
||||
}
|
||||
if got.ReasonCode != "all_children_denied" {
|
||||
t.Fatalf("ReasonCode = %q, want all_children_denied", got.ReasonCode)
|
||||
}
|
||||
if got.PolicySource != "mixed" {
|
||||
t.Fatalf("PolicySource = %q, want mixed", got.PolicySource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateChildren_emptySlice(t *testing.T) {
|
||||
got := cmdpolicy.AggregateChildren(nil)
|
||||
if (got != cmdpolicy.Denial{}) {
|
||||
t.Fatalf("empty slice should produce zero Denial, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortChildren_stableOrder(t *testing.T) {
|
||||
children := []cmdpolicy.ChildDenial{
|
||||
{Path: "docs/+update"},
|
||||
{Path: "docs/+delete"},
|
||||
{Path: "docs/+create"},
|
||||
}
|
||||
cmdpolicy.SortChildren(children)
|
||||
want := []string{"docs/+create", "docs/+delete", "docs/+update"}
|
||||
for i, c := range children {
|
||||
if c.Path != want[i] {
|
||||
t.Fatalf("children[%d].Path = %q, want %q", i, c.Path, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
29
internal/cmdpolicy/diagnostic.go
Normal file
29
internal/cmdpolicy/diagnostic.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdpolicy
|
||||
|
||||
// diagnosticPaths lists command paths that are unconditionally allowed,
|
||||
// regardless of any user-layer Rule. Entries must satisfy two properties:
|
||||
//
|
||||
// 1. Read-only. The command performs no I/O outside the local process
|
||||
// and never mutates remote state.
|
||||
// 2. Self-reflective. Denying the command would produce a UX dead-end
|
||||
// where the operator can no longer inspect / validate the policy
|
||||
// that is locking them out.
|
||||
//
|
||||
// Today this is `config policy show` and `config plugins show` --
|
||||
// both purely local introspection over the resolved policy. Keep the
|
||||
// list small and audited: every entry is a permanent hole in the
|
||||
// fail-closed boundary.
|
||||
var diagnosticPaths = map[string]bool{
|
||||
"config/policy/show": true,
|
||||
"config/plugins/show": true,
|
||||
}
|
||||
|
||||
// IsDiagnosticPath reports whether the given canonical command path is
|
||||
// exempt from user-layer pruning. Exported for test packages; callers
|
||||
// inside this package use the unexported helper.
|
||||
func IsDiagnosticPath(path string) bool {
|
||||
return diagnosticPaths[path]
|
||||
}
|
||||
86
internal/cmdpolicy/diagnostic_test.go
Normal file
86
internal/cmdpolicy/diagnostic_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdpolicy_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
)
|
||||
|
||||
// configPolicyTree builds the minimal slice of the real command tree
|
||||
// where diagnostic exemption applies: root -> config -> policy -> show.
|
||||
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})
|
||||
// Plus an unrelated command that the Rule will deny, to anchor the
|
||||
// "everything except diagnostics" check.
|
||||
im := &cobra.Command{Use: "im"}
|
||||
root.AddCommand(im)
|
||||
im.AddCommand(&cobra.Command{Use: "+send", RunE: noop})
|
||||
return root
|
||||
}
|
||||
|
||||
func TestEvaluate_diagnosticAllowedDespiteStrictAllow(t *testing.T) {
|
||||
root := configPolicyTree()
|
||||
// Rule that allows ONLY docs/** -- normally locks out everything else.
|
||||
e := cmdpolicy.New(&platform.Rule{
|
||||
Allow: []string{"docs/**"},
|
||||
})
|
||||
got := e.EvaluateAll(root)
|
||||
|
||||
if !got["config/policy/show"].Allowed {
|
||||
t.Errorf("config/policy/show must be unconditionally allowed; got Allowed=false reason=%q",
|
||||
got["config/policy/show"].ReasonCode)
|
||||
}
|
||||
// Sanity: a non-diagnostic command is still denied so we know the
|
||||
// rule itself is active.
|
||||
if got["im/+send"].Allowed {
|
||||
t.Errorf("im/+send should be denied by Allow=[docs/**]; got Allowed=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_diagnosticAllowedDespiteExplicitDeny(t *testing.T) {
|
||||
// Even a Rule that explicitly Denies the path must not lock the
|
||||
// operator out -- diagnostic is a permanent hole. If a security-
|
||||
// sensitive deployment needs to block introspection, they should
|
||||
// strip the binary, not rely on Rule.
|
||||
root := configPolicyTree()
|
||||
e := cmdpolicy.New(&platform.Rule{
|
||||
Allow: []string{"**"},
|
||||
Deny: []string{"config/policy/**"},
|
||||
})
|
||||
got := e.EvaluateAll(root)
|
||||
|
||||
if !got["config/policy/show"].Allowed {
|
||||
t.Errorf("config/policy/show must override explicit Deny; got Allowed=false reason=%q",
|
||||
got["config/policy/show"].ReasonCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDiagnosticPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{"config/policy/show", true},
|
||||
{"config/plugins/show", true},
|
||||
{"config/policy", false}, // parent group itself is not exempt
|
||||
{"config/plugins", false}, // parent group itself is not exempt
|
||||
{"docs/+fetch", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := cmdpolicy.IsDiagnosticPath(tc.path); got != tc.want {
|
||||
t.Errorf("IsDiagnosticPath(%q) = %v, want %v", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
392
internal/cmdpolicy/engine.go
Normal file
392
internal/cmdpolicy/engine.go
Normal file
@@ -0,0 +1,392 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package cmdpolicy is the user-layer command policy engine. It consumes a
|
||||
// platform.Rule and the cobra command tree, evaluates each runnable command
|
||||
// against the rule's four-axis filter (Allow / Deny / MaxRisk / Identities),
|
||||
// and produces a path -> Decision map. A separate BuildDeniedByPath step
|
||||
// converts those leaf decisions into a deniedByPath map (with parent-group
|
||||
// aggregation), which the Apply step consumes to install denyStubs.
|
||||
//
|
||||
// This package only implements the user-layer half. Strict-mode is handled
|
||||
// by cmd/prune.go, which produces command_denied envelopes of the same
|
||||
// shape via BuildDenialError so external agents can dispatch on
|
||||
// detail.layer / reason_code uniformly regardless of which layer rejected
|
||||
// the call.
|
||||
package cmdpolicy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/bmatcuk/doublestar/v4"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
)
|
||||
|
||||
// Decision is the user-layer single-rule evaluation result. Distinct from
|
||||
// Denial: Decision carries Allowed=true/false and the
|
||||
// rejection reason when Allowed=false; Denial only ever exists when the
|
||||
// command is rejected. Keeping them separate avoids a perpetually-false
|
||||
// Allowed field on Denial.
|
||||
type Decision struct {
|
||||
Allowed bool
|
||||
ReasonCode string // "" when Allowed=true
|
||||
Reason string // human-readable
|
||||
}
|
||||
|
||||
// Engine evaluates a Rule against the command tree. It is stateless except
|
||||
// for the Rule snapshot it was constructed with.
|
||||
type Engine struct {
|
||||
rule *platform.Rule
|
||||
}
|
||||
|
||||
// New returns an Engine bound to a Rule. A nil Rule means "no user-layer
|
||||
// restriction" -- EvaluateOne always returns Allowed=true.
|
||||
func New(rule *platform.Rule) *Engine {
|
||||
return &Engine{rule: rule}
|
||||
}
|
||||
|
||||
// EvaluateAll walks the command tree and evaluates every **runnable**
|
||||
// command against the Rule. Pure parent groups (no RunE) are deliberately
|
||||
// skipped here: their decision is derived from children by
|
||||
// BuildDeniedByPath. Evaluating groups directly would incorrectly deny
|
||||
// "docs" under an Allow:["docs/**"] rule (the group's own path "docs"
|
||||
// does not match the "**"-requiring glob).
|
||||
//
|
||||
// Hybrid commands (own RunE plus children) are evaluated as ordinary
|
||||
// leaves here; the aggregation pass treats them specially.
|
||||
func (e *Engine) EvaluateAll(root *cobra.Command) map[string]Decision {
|
||||
out := map[string]Decision{}
|
||||
walkTree(root, func(c *cobra.Command) {
|
||||
if !c.Runnable() {
|
||||
return
|
||||
}
|
||||
// Pure parent groups carrying the AnnotationPureGroup marker
|
||||
// (installed by cmd.installUnknownSubcommandGuard) look
|
||||
// Runnable to cobra but are not a real leaf: skip them just
|
||||
// like cobra-native parent groups, so a user-level Rule does
|
||||
// not block `<group> --help` discovery.
|
||||
if IsPureGroup(c) {
|
||||
return
|
||||
}
|
||||
path := CanonicalPath(c)
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
out[path] = e.EvaluateOne(c)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// EvaluateOne returns the user-layer decision for a single command. Always
|
||||
// Allowed=true when the engine has no Rule.
|
||||
func (e *Engine) EvaluateOne(cmd *cobra.Command) Decision {
|
||||
if e.rule == nil {
|
||||
return Decision{Allowed: true}
|
||||
}
|
||||
r := e.rule
|
||||
path := CanonicalPath(cmd)
|
||||
|
||||
if IsDiagnosticPath(path) {
|
||||
return Decision{Allowed: true}
|
||||
}
|
||||
|
||||
// A registered Rule expresses intent over the closed risk taxonomy
|
||||
// (read / write / high-risk-write). Two ways a command can fall
|
||||
// outside that taxonomy:
|
||||
//
|
||||
// - "absent" (no risk_level annotation) — fail-closed by default,
|
||||
// but Rule.AllowUnannotated=true opts out for gradual adoption.
|
||||
// - "invalid" (annotation exists but is a typo / not in the
|
||||
// closed enum) — always fail-closed regardless of
|
||||
// AllowUnannotated. Typo is a code bug, not a migration phase.
|
||||
cmdRiskStr, hasRisk := cmdmeta.Risk(cmd)
|
||||
cmdRisk := platform.Risk(cmdRiskStr)
|
||||
var (
|
||||
cmdRank int
|
||||
cmdRankOk bool
|
||||
)
|
||||
if hasRisk {
|
||||
cmdRank, cmdRankOk = cmdRisk.Rank()
|
||||
if !cmdRankOk {
|
||||
return Decision{
|
||||
Allowed: false,
|
||||
ReasonCode: "risk_invalid",
|
||||
Reason: fmt.Sprintf("invalid risk %q; did you mean %q?", cmdRiskStr, suggestRisk(cmdRiskStr)),
|
||||
}
|
||||
}
|
||||
} else if !r.AllowUnannotated {
|
||||
return Decision{
|
||||
Allowed: false,
|
||||
ReasonCode: "risk_not_annotated",
|
||||
Reason: "command has no risk_level annotation; rule denies unannotated commands",
|
||||
}
|
||||
}
|
||||
|
||||
// Axis 1: Deny has priority.
|
||||
if matched, ok := firstMatch(r.Deny, path); ok {
|
||||
return Decision{
|
||||
Allowed: false,
|
||||
ReasonCode: "command_denylisted",
|
||||
Reason: fmt.Sprintf("command path %q matched deny pattern %q", path, matched),
|
||||
}
|
||||
}
|
||||
|
||||
// Axis 2: Allow gate (empty allow means "no restriction").
|
||||
if len(r.Allow) > 0 && !matchesAny(r.Allow, path) {
|
||||
return Decision{
|
||||
Allowed: false,
|
||||
ReasonCode: "domain_not_allowed",
|
||||
Reason: fmt.Sprintf("command path %q not in allow list %v", path, r.Allow),
|
||||
}
|
||||
}
|
||||
|
||||
// Axis 3: MaxRisk. Skipped when cmd risk is absent + AllowUnannotated:
|
||||
// the engine has no rank to compare against, and AllowUnannotated
|
||||
// is the explicit "allow this through" opt-in.
|
||||
if r.MaxRisk != "" && cmdRankOk {
|
||||
if limit, limitOk := r.MaxRisk.Rank(); limitOk && cmdRank > limit {
|
||||
return Decision{
|
||||
Allowed: false,
|
||||
ReasonCode: reasonCodeForRisk(cmdRisk),
|
||||
Reason: fmt.Sprintf("command risk %q exceeds rule max_risk %q", cmdRisk, r.MaxRisk),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Axis 4: Identities. Unknown command identities is treated as ALLOW.
|
||||
if len(r.Identities) > 0 {
|
||||
cmdIdents := cmdmeta.Identities(cmd)
|
||||
if cmdIdents != nil && !hasIdentityIntersection(r.Identities, cmdIdents) {
|
||||
return Decision{
|
||||
Allowed: false,
|
||||
ReasonCode: "identity_mismatch",
|
||||
Reason: fmt.Sprintf("command supports identities %v; rule allows %v", cmdIdents, r.Identities),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Decision{Allowed: true}
|
||||
}
|
||||
|
||||
// BuildDeniedByPath converts engine Decisions to a deniedByPath map keyed
|
||||
// by canonical path. It performs the parent-group aggregation defined in
|
||||
// the tech doc: a non-runnable parent whose every runnable descendant is
|
||||
// denied gets an aggregate denial (via AggregateChildren);
|
||||
// hybrid commands (own RunE + children) get one only when both their own
|
||||
// RunE and all children are denied.
|
||||
//
|
||||
// The root command (no parent) is never installed with a denyStub even if
|
||||
// every child is denied -- the binary entry point must remain dispatchable
|
||||
// so `--help` and similar remain available.
|
||||
//
|
||||
// source / ruleName populate PolicySource and RuleName on the produced
|
||||
// Denial values, so envelope output can attribute denials.
|
||||
func BuildDeniedByPath(root *cobra.Command, decisions map[string]Decision, source ResolveSource, ruleName string) map[string]Denial {
|
||||
out := map[string]Denial{}
|
||||
|
||||
sourceLabel := policySourceLabel(source)
|
||||
for path, d := range decisions {
|
||||
if !d.Allowed {
|
||||
out[path] = Denial{
|
||||
Layer: LayerPolicy,
|
||||
PolicySource: sourceLabel,
|
||||
RuleName: ruleName,
|
||||
ReasonCode: d.ReasonCode,
|
||||
Reason: d.Reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
aggregateParents(root, out)
|
||||
return out
|
||||
}
|
||||
|
||||
// aggregateParents recursively examines each parent group. Returns true
|
||||
// when every runnable descendant beneath cmd (including cmd itself when
|
||||
// runnable) is denied; in that case the function also inserts an aggregate
|
||||
// Denial for cmd, unless cmd is the binary root or cmd is already in the
|
||||
// map (own RunE denial preserved).
|
||||
//
|
||||
// "Live" children are those with at least one runnable descendant; pure
|
||||
// non-runnable placeholders neither count toward "all denied" nor block
|
||||
// the aggregation.
|
||||
func aggregateParents(cmd *cobra.Command, denied map[string]Denial) bool {
|
||||
if cmd == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
children := cmd.Commands()
|
||||
// A pure parent group decorated with the unknown-subcommand guard
|
||||
// looks Runnable() to cobra but is not a true hybrid: treat it
|
||||
// exactly like cobra-native parent groups so the aggregation pass
|
||||
// can still install an aggregate deny stub when every live child
|
||||
// is denied.
|
||||
cmdRunnable := cmd.Runnable() && !IsPureGroup(cmd)
|
||||
cmdPath := CanonicalPath(cmd)
|
||||
|
||||
// Pure leaf
|
||||
if len(children) == 0 {
|
||||
if !cmdRunnable {
|
||||
return false // placeholder, doesn't contribute
|
||||
}
|
||||
_, ok := denied[cmdPath]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Has children: recurse first, collect direct-child denials for the
|
||||
// aggregation message.
|
||||
childDenials := make([]ChildDenial, 0, len(children))
|
||||
liveChildSeen := false
|
||||
allLiveChildrenDenied := true
|
||||
for _, child := range children {
|
||||
childDenied := aggregateParents(child, denied)
|
||||
if hasRunnableDescendant(child) {
|
||||
liveChildSeen = true
|
||||
if !childDenied {
|
||||
allLiveChildrenDenied = false
|
||||
}
|
||||
}
|
||||
if cp := CanonicalPath(child); cp != "" {
|
||||
if d, ok := denied[cp]; ok {
|
||||
childDenials = append(childDenials, ChildDenial{Path: cp, Denial: d})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !liveChildSeen {
|
||||
// No reachable runnable descendant in children, but cmd itself
|
||||
// may still be a runnable hybrid (own RunE + placeholder
|
||||
// children). The contract is "every runnable descendant
|
||||
// beneath cmd (including cmd itself when runnable) is denied",
|
||||
// so when cmd is runnable, the answer depends on whether cmd
|
||||
// itself was denied. Returning false unconditionally here lost
|
||||
// that signal and blocked aggregation up the chain.
|
||||
if cmdRunnable {
|
||||
_, ownDenied := denied[cmdPath]
|
||||
return ownDenied
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Hybrid: own RunE must also be denied for the group to count as denied.
|
||||
if cmdRunnable {
|
||||
if _, ownDenied := denied[cmdPath]; !ownDenied {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if !allLiveChildrenDenied {
|
||||
return false
|
||||
}
|
||||
|
||||
// Everything reachable below this command is denied. Install the
|
||||
// aggregate denyStub if there isn't already an own denial here, and
|
||||
// skip the binary root.
|
||||
if cmd.HasParent() && cmdPath != "" {
|
||||
if _, exists := denied[cmdPath]; !exists {
|
||||
SortChildren(childDenials)
|
||||
denied[cmdPath] = AggregateChildren(childDenials)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// hasRunnableDescendant reports whether cmd or any descendant has RunE.
|
||||
// We use it to ignore pure placeholder branches when aggregating.
|
||||
func hasRunnableDescendant(cmd *cobra.Command) bool {
|
||||
if cmd == nil {
|
||||
return false
|
||||
}
|
||||
if cmd.Runnable() && !IsPureGroup(cmd) {
|
||||
return true
|
||||
}
|
||||
for _, c := range cmd.Commands() {
|
||||
if hasRunnableDescendant(c) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// policySourceLabel produces the "plugin:foo" / "yaml" / "" label that goes
|
||||
// into CommandDeniedError.PolicySource and envelope.detail.policy_source.
|
||||
//
|
||||
// **Plugin name is included** because plugins live inside the binary and
|
||||
// their names are part of the implementation contract; an integrator
|
||||
// debugging a denial wants to know which plugin's Restrict() fired.
|
||||
//
|
||||
// **YAML file path is deliberately omitted** -- the envelope is observable
|
||||
// by agents, CI logs, and other downstream systems, and the path leaks
|
||||
// the user's home directory (e.g. /Users/alice/.lark-cli/policy.yml).
|
||||
// The Denial.RuleName field already carries the human-identifier the user
|
||||
// chose for their rule (yaml's "name:" field), which suffices for
|
||||
// disambiguation. Use `config policy show` if the absolute path matters
|
||||
// for a local debugging session.
|
||||
func policySourceLabel(s ResolveSource) string {
|
||||
switch s.Kind {
|
||||
case SourcePlugin:
|
||||
return "plugin:" + s.Name
|
||||
case SourceYAML:
|
||||
return "yaml"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// reasonCodeForRisk picks the canonical reason_code for an exceeds-max-risk
|
||||
// rejection.
|
||||
func reasonCodeForRisk(risk platform.Risk) string {
|
||||
if risk == platform.RiskWrite || risk == platform.RiskHighRiskWrite {
|
||||
return "write_not_allowed"
|
||||
}
|
||||
return "risk_too_high"
|
||||
}
|
||||
|
||||
// matchesAny reports whether path matches any of the doublestar globs.
|
||||
// Invalid globs are skipped here -- they're rejected upstream by
|
||||
// ValidateRule when the rule first enters the system.
|
||||
func matchesAny(globs []string, path string) bool {
|
||||
_, ok := firstMatch(globs, path)
|
||||
return ok
|
||||
}
|
||||
|
||||
// firstMatch returns the first glob in globs that matches path. Used by
|
||||
// command_denylisted so the envelope can name the specific deny pattern
|
||||
// that fired.
|
||||
func firstMatch(globs []string, path string) (string, bool) {
|
||||
for _, g := range globs {
|
||||
if ok, err := doublestar.Match(g, path); err == nil && ok {
|
||||
return g, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// hasIdentityIntersection reports whether the rule's typed identities
|
||||
// share any value with the command's raw identity strings. Both slices
|
||||
// are short (usually 1-2 identities) so a nested loop beats allocating
|
||||
// a set.
|
||||
func hasIdentityIntersection(rule []platform.Identity, cmd []string) bool {
|
||||
for _, x := range rule {
|
||||
for _, y := range cmd {
|
||||
if string(x) == y {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// walkTree applies fn to every command in the tree, depth-first. Hidden
|
||||
// commands are visited too -- they can still be invoked.
|
||||
func walkTree(root *cobra.Command, fn func(*cobra.Command)) {
|
||||
if root == nil {
|
||||
return
|
||||
}
|
||||
fn(root)
|
||||
for _, c := range root.Commands() {
|
||||
walkTree(c, fn)
|
||||
}
|
||||
}
|
||||
505
internal/cmdpolicy/engine_test.go
Normal file
505
internal/cmdpolicy/engine_test.go
Normal file
@@ -0,0 +1,505 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdpolicy_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// buildTree assembles a tiny realistic tree for engine tests:
|
||||
//
|
||||
// lark-cli (root)
|
||||
// ├── docs
|
||||
// │ ├── +fetch risk=read identities=[user,bot]
|
||||
// │ ├── +update risk=write identities=[user]
|
||||
// │ └── +delete-doc risk=high-risk-write
|
||||
// └── im
|
||||
// └── +send risk=write identities=[bot]
|
||||
func buildTree() *cobra.Command {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
|
||||
docs := &cobra.Command{Use: "docs"}
|
||||
cmdmeta.SetDomain(docs, "docs")
|
||||
root.AddCommand(docs)
|
||||
|
||||
fetch := &cobra.Command{Use: "+fetch", RunE: noop}
|
||||
cmdutil.SetRisk(fetch, "read")
|
||||
cmdutil.SetSupportedIdentities(fetch, []string{"user", "bot"})
|
||||
docs.AddCommand(fetch)
|
||||
|
||||
update := &cobra.Command{Use: "+update", RunE: noop}
|
||||
cmdutil.SetRisk(update, "write")
|
||||
cmdutil.SetSupportedIdentities(update, []string{"user"})
|
||||
docs.AddCommand(update)
|
||||
|
||||
deleteDoc := &cobra.Command{Use: "+delete-doc", RunE: noop}
|
||||
cmdutil.SetRisk(deleteDoc, "high-risk-write")
|
||||
docs.AddCommand(deleteDoc)
|
||||
|
||||
im := &cobra.Command{Use: "im"}
|
||||
cmdmeta.SetDomain(im, "im")
|
||||
root.AddCommand(im)
|
||||
|
||||
send := &cobra.Command{Use: "+send", RunE: noop}
|
||||
cmdutil.SetRisk(send, "write")
|
||||
cmdutil.SetSupportedIdentities(send, []string{"bot"})
|
||||
im.AddCommand(send)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
func noop(*cobra.Command, []string) error { return nil }
|
||||
|
||||
func TestEvaluate_nilRuleAllowsAll(t *testing.T) {
|
||||
root := buildTree()
|
||||
got := cmdpolicy.New(nil).EvaluateAll(root)
|
||||
for path, d := range got {
|
||||
if !d.Allowed {
|
||||
t.Fatalf("nil rule should allow all, got Allowed=false for %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_allowGlob(t *testing.T) {
|
||||
root := buildTree()
|
||||
e := cmdpolicy.New(&platform.Rule{
|
||||
Allow: []string{"docs/**"},
|
||||
})
|
||||
got := e.EvaluateAll(root)
|
||||
|
||||
if !got["docs/+fetch"].Allowed {
|
||||
t.Errorf("docs/+fetch should be allowed by docs/** glob")
|
||||
}
|
||||
if got["im/+send"].Allowed {
|
||||
t.Errorf("im/+send should NOT be allowed when Allow=docs/**")
|
||||
}
|
||||
if got["im/+send"].ReasonCode != "domain_not_allowed" {
|
||||
t.Errorf("im/+send ReasonCode = %q, want domain_not_allowed",
|
||||
got["im/+send"].ReasonCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_denyTakesPriorityOverAllow(t *testing.T) {
|
||||
root := buildTree()
|
||||
e := cmdpolicy.New(&platform.Rule{
|
||||
Allow: []string{"docs/**"},
|
||||
Deny: []string{"docs/+delete-doc"},
|
||||
})
|
||||
got := e.EvaluateAll(root)
|
||||
|
||||
if got["docs/+delete-doc"].Allowed {
|
||||
t.Errorf("docs/+delete-doc should be denied by Deny rule")
|
||||
}
|
||||
if got["docs/+delete-doc"].ReasonCode != "command_denylisted" {
|
||||
t.Errorf("ReasonCode = %q, want command_denylisted",
|
||||
got["docs/+delete-doc"].ReasonCode)
|
||||
}
|
||||
if !got["docs/+fetch"].Allowed {
|
||||
t.Errorf("docs/+fetch should still be allowed (not in Deny)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_maxRiskCutoff(t *testing.T) {
|
||||
root := buildTree()
|
||||
e := cmdpolicy.New(&platform.Rule{
|
||||
MaxRisk: "write", // allow read+write, deny high-risk-write
|
||||
})
|
||||
got := e.EvaluateAll(root)
|
||||
|
||||
if !got["docs/+update"].Allowed {
|
||||
t.Errorf("+update (risk=write) should pass MaxRisk=write")
|
||||
}
|
||||
if !got["docs/+fetch"].Allowed {
|
||||
t.Errorf("+fetch (risk=read) should pass MaxRisk=write")
|
||||
}
|
||||
if got["docs/+delete-doc"].Allowed {
|
||||
t.Errorf("+delete-doc (risk=high-risk-write) should fail MaxRisk=write")
|
||||
}
|
||||
if rc := got["docs/+delete-doc"].ReasonCode; rc != "write_not_allowed" {
|
||||
t.Errorf("ReasonCode = %q, want write_not_allowed", rc)
|
||||
}
|
||||
}
|
||||
|
||||
// Unannotated commands are implicit-deny when any Rule is registered.
|
||||
// The closed risk taxonomy (read / write / high-risk-write) is the only
|
||||
// vocabulary a Rule can reason about; an unannotated command falls
|
||||
// outside that vocabulary and is denied with reason_code
|
||||
// "risk_not_annotated", regardless of whether the rule sets MaxRisk.
|
||||
func TestEvaluate_unannotatedRiskIsDeny(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
docs := &cobra.Command{Use: "docs"}
|
||||
root.AddCommand(docs)
|
||||
// Note: no SetRisk on this command -> unannotated
|
||||
orphan := &cobra.Command{Use: "+orphan", RunE: noop}
|
||||
docs.AddCommand(orphan)
|
||||
|
||||
// Rule without MaxRisk still triggers the implicit deny.
|
||||
e := cmdpolicy.New(&platform.Rule{Allow: []string{"docs/**"}})
|
||||
got := e.EvaluateAll(root)
|
||||
if got["docs/+orphan"].Allowed {
|
||||
t.Fatalf("unannotated risk must be denied when a Rule is registered")
|
||||
}
|
||||
if got["docs/+orphan"].ReasonCode != "risk_not_annotated" {
|
||||
t.Errorf("ReasonCode = %q, want risk_not_annotated", got["docs/+orphan"].ReasonCode)
|
||||
}
|
||||
|
||||
// And with MaxRisk it still uses risk_not_annotated (the missing-
|
||||
// annotation gate runs before the MaxRisk axis).
|
||||
e = cmdpolicy.New(&platform.Rule{MaxRisk: "read"})
|
||||
got = e.EvaluateAll(root)
|
||||
if got["docs/+orphan"].ReasonCode != "risk_not_annotated" {
|
||||
t.Errorf("ReasonCode under MaxRisk = %q, want risk_not_annotated", got["docs/+orphan"].ReasonCode)
|
||||
}
|
||||
|
||||
// An empty Rule{} (no Allow / Deny / MaxRisk / Identities) still
|
||||
// triggers the implicit deny. "any registered Rule = enter the safety
|
||||
// boundary" is the design contract; pin it so future edits cannot
|
||||
// silently weaken it.
|
||||
e = cmdpolicy.New(&platform.Rule{})
|
||||
got = e.EvaluateAll(root)
|
||||
if got["docs/+orphan"].Allowed {
|
||||
t.Fatalf("empty Rule{} must still deny unannotated commands")
|
||||
}
|
||||
if got["docs/+orphan"].ReasonCode != "risk_not_annotated" {
|
||||
t.Errorf("empty Rule{} ReasonCode = %q, want risk_not_annotated", got["docs/+orphan"].ReasonCode)
|
||||
}
|
||||
|
||||
// Without any Rule, unannotated commands are still allowed (no
|
||||
// policy engine is invoked when no plugin registers a Rule).
|
||||
e = cmdpolicy.New(nil)
|
||||
got = e.EvaluateAll(root)
|
||||
if !got["docs/+orphan"].Allowed {
|
||||
t.Fatalf("nil Rule must allow unannotated commands (no main-flow impact)")
|
||||
}
|
||||
}
|
||||
|
||||
// AllowUnannotated=true opts out of the "unannotated = deny" rule for
|
||||
// gradual adoption. The flag does NOT loosen any other axis: Deny still
|
||||
// rejects, MaxRisk is skipped (no rank to compare), Allow/Identities still
|
||||
// apply.
|
||||
func TestEvaluate_allowUnannotatedOptsOutOfDeny(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
docs := &cobra.Command{Use: "docs"}
|
||||
root.AddCommand(docs)
|
||||
orphan := &cobra.Command{Use: "+orphan", RunE: noop}
|
||||
docs.AddCommand(orphan)
|
||||
|
||||
// Without opt-in: still denied
|
||||
e := cmdpolicy.New(&platform.Rule{Allow: []string{"docs/**"}})
|
||||
if got := e.EvaluateAll(root); got["docs/+orphan"].Allowed {
|
||||
t.Fatalf("default behaviour must deny unannotated; AllowUnannotated should be opt-in")
|
||||
}
|
||||
|
||||
// With opt-in: allowed
|
||||
e = cmdpolicy.New(&platform.Rule{
|
||||
Allow: []string{"docs/**"},
|
||||
AllowUnannotated: true,
|
||||
})
|
||||
got := e.EvaluateAll(root)
|
||||
if !got["docs/+orphan"].Allowed {
|
||||
t.Fatalf("AllowUnannotated=true must allow unannotated commands; got %+v", got["docs/+orphan"])
|
||||
}
|
||||
|
||||
// AllowUnannotated does NOT bypass Deny: an unannotated command
|
||||
// hitting a Deny glob is still rejected.
|
||||
e = cmdpolicy.New(&platform.Rule{
|
||||
Deny: []string{"docs/+orphan"},
|
||||
AllowUnannotated: true,
|
||||
})
|
||||
got = e.EvaluateAll(root)
|
||||
if got["docs/+orphan"].Allowed {
|
||||
t.Fatalf("AllowUnannotated must not bypass Deny; got %+v", got["docs/+orphan"])
|
||||
}
|
||||
if got["docs/+orphan"].ReasonCode != "command_denylisted" {
|
||||
t.Errorf("ReasonCode under Deny+AllowUnannotated = %q, want command_denylisted",
|
||||
got["docs/+orphan"].ReasonCode)
|
||||
}
|
||||
}
|
||||
|
||||
// risk_invalid (typo) is unaffected by AllowUnannotated and emits a
|
||||
// "did you mean" suggestion in the reason text.
|
||||
func TestEvaluate_invalidRiskAlwaysDeny_andSuggests(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
docs := &cobra.Command{Use: "docs"}
|
||||
root.AddCommand(docs)
|
||||
typo := &cobra.Command{Use: "+typo", RunE: noop}
|
||||
cmdutil.SetRisk(typo, "wrtie")
|
||||
docs.AddCommand(typo)
|
||||
|
||||
// AllowUnannotated=true must NOT bypass risk_invalid — typo is a
|
||||
// code bug, not a missing annotation.
|
||||
e := cmdpolicy.New(&platform.Rule{
|
||||
MaxRisk: "read",
|
||||
AllowUnannotated: true,
|
||||
})
|
||||
got := e.EvaluateAll(root)
|
||||
if got["docs/+typo"].Allowed {
|
||||
t.Fatalf("AllowUnannotated must not bypass risk_invalid; got %+v", got["docs/+typo"])
|
||||
}
|
||||
if got["docs/+typo"].ReasonCode != "risk_invalid" {
|
||||
t.Errorf("ReasonCode = %q, want risk_invalid", got["docs/+typo"].ReasonCode)
|
||||
}
|
||||
if !strings.Contains(got["docs/+typo"].Reason, "write") {
|
||||
t.Errorf("Reason should contain suggestion 'write', got %q", got["docs/+typo"].Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// Invalid risk annotations (typos like "wrtie" or anything outside the
|
||||
// read|write|high-risk-write taxonomy) are denied with reason_code
|
||||
// "risk_invalid". Without this gate they used to pass the MaxRisk axis
|
||||
// because RiskRank returned ok=false and the comparison was skipped --
|
||||
// a typo SetRisk would silently slip past an "agent read-only" rule.
|
||||
func TestEvaluate_invalidRiskIsDeny(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
docs := &cobra.Command{Use: "docs"}
|
||||
root.AddCommand(docs)
|
||||
typo := &cobra.Command{Use: "+typo", RunE: noop}
|
||||
cmdutil.SetRisk(typo, "wrtie") // typo for "write"
|
||||
docs.AddCommand(typo)
|
||||
|
||||
// Even under MaxRisk=read the typo command must not slip through.
|
||||
e := cmdpolicy.New(&platform.Rule{MaxRisk: "read"})
|
||||
got := e.EvaluateAll(root)
|
||||
if got["docs/+typo"].Allowed {
|
||||
t.Fatalf("invalid risk must be denied under MaxRisk=read, got allowed")
|
||||
}
|
||||
if got["docs/+typo"].ReasonCode != "risk_invalid" {
|
||||
t.Errorf("ReasonCode = %q, want risk_invalid", got["docs/+typo"].ReasonCode)
|
||||
}
|
||||
|
||||
// Same when no MaxRisk is set -- the taxonomy check runs unconditionally
|
||||
// once a Rule is present.
|
||||
e = cmdpolicy.New(&platform.Rule{Allow: []string{"docs/**"}})
|
||||
got = e.EvaluateAll(root)
|
||||
if got["docs/+typo"].ReasonCode != "risk_invalid" {
|
||||
t.Errorf("ReasonCode without MaxRisk = %q, want risk_invalid", got["docs/+typo"].ReasonCode)
|
||||
}
|
||||
|
||||
// The risk_invalid gate must fire BEFORE Deny matching, otherwise a
|
||||
// typo command landing in the deny list would surface as
|
||||
// command_denylisted and mask the underlying taxonomy violation.
|
||||
e = cmdpolicy.New(&platform.Rule{Deny: []string{"docs/+typo"}})
|
||||
got = e.EvaluateAll(root)
|
||||
if got["docs/+typo"].ReasonCode != "risk_invalid" {
|
||||
t.Errorf("ReasonCode under Deny match = %q, want risk_invalid (taxonomy gate must precede Deny)", got["docs/+typo"].ReasonCode)
|
||||
}
|
||||
|
||||
// Without any Rule, invalid risk is not policed (same main-flow
|
||||
// no-impact rule as risk_not_annotated).
|
||||
e = cmdpolicy.New(nil)
|
||||
got = e.EvaluateAll(root)
|
||||
if !got["docs/+typo"].Allowed {
|
||||
t.Fatalf("nil Rule must allow invalid risk (no main-flow impact)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_identitiesIntersection(t *testing.T) {
|
||||
root := buildTree()
|
||||
e := cmdpolicy.New(&platform.Rule{
|
||||
Identities: []platform.Identity{"bot"}, // bot-only rule
|
||||
})
|
||||
got := e.EvaluateAll(root)
|
||||
|
||||
// docs/+fetch has [user, bot] -- intersection includes bot -> ALLOW
|
||||
if !got["docs/+fetch"].Allowed {
|
||||
t.Errorf("+fetch (identities=user,bot) should intersect bot rule")
|
||||
}
|
||||
// docs/+update has [user] -- no intersection with bot -> DENY
|
||||
if got["docs/+update"].Allowed {
|
||||
t.Errorf("+update (identities=user) should fail bot-only rule")
|
||||
}
|
||||
if got["docs/+update"].ReasonCode != "identity_mismatch" {
|
||||
t.Errorf("ReasonCode = %q, want identity_mismatch",
|
||||
got["docs/+update"].ReasonCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Reason strings must carry both the attempted value and the rule's
|
||||
// constraint so the envelope is self-contained for AI consumers.
|
||||
// Asserting on substrings (not exact match) leaves room for minor wording
|
||||
// tweaks while pinning the value-carrying behaviour.
|
||||
func TestEvaluate_reasonCarriesAttemptAndConstraint(t *testing.T) {
|
||||
root := buildTree()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
rule *platform.Rule
|
||||
path string
|
||||
wantInReason []string
|
||||
}{
|
||||
{
|
||||
name: "identity_mismatch surfaces both identity sets",
|
||||
rule: &platform.Rule{Identities: []platform.Identity{"bot"}},
|
||||
path: "docs/+update", // identities=[user]
|
||||
wantInReason: []string{"[user]", "[bot]"},
|
||||
},
|
||||
{
|
||||
name: "domain_not_allowed surfaces path and allow list",
|
||||
rule: &platform.Rule{Allow: []string{"docs/**"}},
|
||||
path: "im/+send",
|
||||
wantInReason: []string{`"im/+send"`, "docs/**"},
|
||||
},
|
||||
{
|
||||
name: "command_denylisted surfaces matched deny pattern",
|
||||
rule: &platform.Rule{Deny: []string{"docs/+delete-*"}},
|
||||
path: "docs/+delete-doc",
|
||||
wantInReason: []string{`"docs/+delete-doc"`, `"docs/+delete-*"`},
|
||||
},
|
||||
{
|
||||
name: "risk_too_high surfaces cmd risk and max_risk",
|
||||
rule: &platform.Rule{MaxRisk: "write"},
|
||||
path: "docs/+delete-doc", // risk=high-risk-write
|
||||
wantInReason: []string{`"high-risk-write"`, `"write"`},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := cmdpolicy.New(tc.rule).EvaluateAll(root)
|
||||
d, ok := got[tc.path]
|
||||
if !ok {
|
||||
t.Fatalf("no decision for %q", tc.path)
|
||||
}
|
||||
if d.Allowed {
|
||||
t.Fatalf("%q should have been denied", tc.path)
|
||||
}
|
||||
for _, sub := range tc.wantInReason {
|
||||
if !strings.Contains(d.Reason, sub) {
|
||||
t.Errorf("reason %q missing %q", d.Reason, sub)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown identities defaults to ALLOW. A command with risk annotated
|
||||
// but without supportedIdentities passes any identity filter.
|
||||
func TestEvaluate_unknownIdentitiesIsAllow(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
cmd := &cobra.Command{Use: "+x", RunE: noop}
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
root.AddCommand(cmd)
|
||||
// no SetSupportedIdentities
|
||||
|
||||
e := cmdpolicy.New(&platform.Rule{Identities: []platform.Identity{"bot"}})
|
||||
got := e.EvaluateAll(root)
|
||||
if !got["+x"].Allowed {
|
||||
t.Fatalf("unknown identities must pass any identity rule")
|
||||
}
|
||||
}
|
||||
|
||||
// Apply must install denyStubs only on Layer="policy" entries. A
|
||||
// "strict_mode" denial in the same map must be left for
|
||||
// applyStrictModeDenials in cmd/.
|
||||
func TestApply_onlyTouchesPruningLayer(t *testing.T) {
|
||||
root := buildTree()
|
||||
denied := map[string]cmdpolicy.Denial{
|
||||
"docs/+update": {Layer: "policy", ReasonCode: "write_not_allowed"},
|
||||
"docs/+fetch": {Layer: "strict_mode", ReasonCode: "identity_not_supported"},
|
||||
}
|
||||
|
||||
count := cmdpolicy.Apply(root, denied)
|
||||
if count != 1 {
|
||||
t.Fatalf("Apply count = %d, want 1 (only pruning-layer entries)", count)
|
||||
}
|
||||
|
||||
update := findChild(t, root, "docs", "+update")
|
||||
if !update.Hidden {
|
||||
t.Errorf("+update should be Hidden after Apply")
|
||||
}
|
||||
if !update.DisableFlagParsing {
|
||||
t.Errorf("+update should have DisableFlagParsing=true (constraint #4)")
|
||||
}
|
||||
|
||||
// strict-mode entry must NOT have been touched here.
|
||||
fetch := findChild(t, root, "docs", "+fetch")
|
||||
if fetch.Hidden || fetch.DisableFlagParsing {
|
||||
t.Errorf("+fetch (strict_mode layer) should NOT be touched by cmdpolicy.Apply")
|
||||
}
|
||||
}
|
||||
|
||||
// Calling the denied RunE must produce a typed CommandDeniedError with the
|
||||
// right Layer/ReasonCode. This is the contract every external consumer
|
||||
// (agent, integration) depends on.
|
||||
func TestApply_runEReturnsTypedError(t *testing.T) {
|
||||
root := buildTree()
|
||||
cmdpolicy.Apply(root, map[string]cmdpolicy.Denial{
|
||||
"docs/+update": {
|
||||
Layer: "policy",
|
||||
PolicySource: "plugin:secaudit",
|
||||
RuleName: "secaudit-policy",
|
||||
ReasonCode: "write_not_allowed",
|
||||
Reason: "write disabled",
|
||||
},
|
||||
})
|
||||
|
||||
update := findChild(t, root, "docs", "+update")
|
||||
err := update.RunE(update, []string{})
|
||||
if err == nil {
|
||||
t.Fatalf("denied command should return error")
|
||||
}
|
||||
var denied *platform.CommandDeniedError
|
||||
if !errors.As(err, &denied) {
|
||||
t.Fatalf("error should be *platform.CommandDeniedError, got %T", err)
|
||||
}
|
||||
if denied.Layer != "policy" || denied.ReasonCode != "write_not_allowed" {
|
||||
t.Errorf("denial = %+v, want layer=pruning code=write_not_allowed", denied)
|
||||
}
|
||||
if denied.Path != "docs/+update" {
|
||||
t.Errorf("Path = %q, want docs/+update", denied.Path)
|
||||
}
|
||||
if denied.PolicySource != "plugin:secaudit" || denied.RuleName != "secaudit-policy" {
|
||||
t.Errorf("policy source / rule name lost in stub: %+v", denied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_emptyMapNoop(t *testing.T) {
|
||||
root := buildTree()
|
||||
if got := cmdpolicy.Apply(root, nil); got != 0 {
|
||||
t.Fatalf("nil deniedByPath should yield count=0, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// CanonicalPath strips the root and joins with slashes -- the form
|
||||
// doublestar globs need to work.
|
||||
func TestCanonicalPath(t *testing.T) {
|
||||
root := buildTree()
|
||||
update := findChild(t, root, "docs", "+update")
|
||||
if got := cmdpolicy.CanonicalPath(update); got != "docs/+update" {
|
||||
t.Fatalf("CanonicalPath = %q, want docs/+update", got)
|
||||
}
|
||||
if got := cmdpolicy.CanonicalPath(root); got != "lark-cli" {
|
||||
t.Fatalf("CanonicalPath(root) = %q, want lark-cli (orphan fallback)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// findChild is a test helper: descend a path of cmd.Use names through the
|
||||
// tree, failing the test if any step is missing.
|
||||
func findChild(t *testing.T, parent *cobra.Command, names ...string) *cobra.Command {
|
||||
t.Helper()
|
||||
cur := parent
|
||||
for _, n := range names {
|
||||
var next *cobra.Command
|
||||
for _, c := range cur.Commands() {
|
||||
if c.Use == n {
|
||||
next = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
t.Fatalf("child %q not found under %q", n, cur.Use)
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
return cur
|
||||
}
|
||||
39
internal/cmdpolicy/path.go
Normal file
39
internal/cmdpolicy/path.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdpolicy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// CanonicalPath returns the rootless slash-separated path used everywhere in
|
||||
// the pruning framework. Cobra's CommandPath() yields space-separated
|
||||
// segments ("lark-cli docs +update"); doublestar globs ("docs/**") require
|
||||
// slashes, so all internal lookups go through this conversion.
|
||||
func CanonicalPath(cmd *cobra.Command) string {
|
||||
if cmd == nil {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, 4)
|
||||
for c := cmd; c != nil && c.HasParent(); c = c.Parent() {
|
||||
parts = append(parts, useName(c))
|
||||
}
|
||||
for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 {
|
||||
parts[i], parts[j] = parts[j], parts[i]
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return useName(cmd)
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
func useName(cmd *cobra.Command) string {
|
||||
name := cmd.Use
|
||||
if i := strings.IndexByte(name, ' '); i >= 0 {
|
||||
name = name[:i]
|
||||
}
|
||||
return name
|
||||
}
|
||||
92
internal/cmdpolicy/resolver.go
Normal file
92
internal/cmdpolicy/resolver.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdpolicy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
pyaml "github.com/larksuite/cli/internal/cmdpolicy/yaml"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type SourceKind string
|
||||
|
||||
const (
|
||||
SourcePlugin SourceKind = "plugin"
|
||||
SourceYAML SourceKind = "yaml"
|
||||
SourceNone SourceKind = "none"
|
||||
)
|
||||
|
||||
type ResolveSource struct {
|
||||
Kind SourceKind
|
||||
Name string
|
||||
}
|
||||
|
||||
type PluginRule struct {
|
||||
PluginName string
|
||||
Rule *platform.Rule
|
||||
}
|
||||
|
||||
type Sources struct {
|
||||
PluginRules []PluginRule
|
||||
YAMLRule *platform.Rule
|
||||
YAMLPath string
|
||||
}
|
||||
|
||||
var ErrMultipleRestricts = errors.New("multiple plugins called Restrict; only one is permitted")
|
||||
|
||||
// Resolve picks by precedence: plugin > yaml > none. Pure function; load
|
||||
// yaml via LoadYAMLPolicy first. Winner is validated.
|
||||
func Resolve(s Sources) (*platform.Rule, ResolveSource, error) {
|
||||
if len(s.PluginRules) > 1 {
|
||||
names := make([]string, len(s.PluginRules))
|
||||
for i, pr := range s.PluginRules {
|
||||
names[i] = pr.PluginName
|
||||
}
|
||||
return nil, ResolveSource{}, fmt.Errorf("%w: %v", ErrMultipleRestricts, names)
|
||||
}
|
||||
|
||||
if len(s.PluginRules) == 1 {
|
||||
pr := s.PluginRules[0]
|
||||
if err := ValidateRule(pr.Rule); err != nil {
|
||||
return nil, ResolveSource{}, fmt.Errorf("plugin %q rule invalid: %w", pr.PluginName, err)
|
||||
}
|
||||
return pr.Rule, ResolveSource{Kind: SourcePlugin, Name: pr.PluginName}, nil
|
||||
}
|
||||
|
||||
if s.YAMLRule != nil {
|
||||
if err := ValidateRule(s.YAMLRule); err != nil {
|
||||
return nil, ResolveSource{}, fmt.Errorf("policy yaml %q: %w", s.YAMLPath, err)
|
||||
}
|
||||
return s.YAMLRule, ResolveSource{Kind: SourceYAML, Name: s.YAMLPath}, nil
|
||||
}
|
||||
|
||||
return nil, ResolveSource{Kind: SourceNone}, nil
|
||||
}
|
||||
|
||||
// LoadYAMLPolicy returns (nil, nil) when path is empty or file is absent,
|
||||
// so callers can pass the result straight into Sources.YAMLRule.
|
||||
func LoadYAMLPolicy(path string) (*platform.Rule, error) {
|
||||
if path == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if _, err := vfs.Stat(path); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("stat policy yaml %q: %w", path, err)
|
||||
}
|
||||
data, err := vfs.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read policy yaml %q: %w", path, err)
|
||||
}
|
||||
rule, err := pyaml.Parse(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("policy yaml %q: %w", path, err)
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
123
internal/cmdpolicy/resolver_test.go
Normal file
123
internal/cmdpolicy/resolver_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdpolicy_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
)
|
||||
|
||||
func TestResolve_singlePluginWins(t *testing.T) {
|
||||
rule := &platform.Rule{Name: "secaudit"}
|
||||
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
||||
PluginRules: []cmdpolicy.PluginRule{{PluginName: "secaudit", Rule: rule}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve err: %v", err)
|
||||
}
|
||||
if got != rule || src.Kind != cmdpolicy.SourcePlugin || src.Name != "secaudit" {
|
||||
t.Fatalf("Resolve = (%v, %+v)", got, src)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_pluginShadowsYaml(t *testing.T) {
|
||||
pluginRule := &platform.Rule{Name: "from-plugin"}
|
||||
yamlRule := &platform.Rule{Name: "from-yaml"}
|
||||
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
||||
PluginRules: []cmdpolicy.PluginRule{{PluginName: "secaudit", Rule: pluginRule}},
|
||||
YAMLRule: yamlRule,
|
||||
YAMLPath: "/some/policy.yml",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve err: %v", err)
|
||||
}
|
||||
if got.Name != "from-plugin" || src.Kind != cmdpolicy.SourcePlugin {
|
||||
t.Fatalf("plugin should shadow yaml, got %+v / %+v", got, src)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_yamlWhenNoPlugin(t *testing.T) {
|
||||
yamlRule := &platform.Rule{Name: "from-yaml", MaxRisk: "read"}
|
||||
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
||||
YAMLRule: yamlRule,
|
||||
YAMLPath: "/some/policy.yml",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve err: %v", err)
|
||||
}
|
||||
if got.Name != "from-yaml" || src.Kind != cmdpolicy.SourceYAML {
|
||||
t.Fatalf("yaml should win when no plugin, got %+v / %+v", got, src)
|
||||
}
|
||||
if src.Name != "/some/policy.yml" {
|
||||
t.Errorf("yaml source Name should carry path, got %q", src.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_emptyEverythingIsNone(t *testing.T) {
|
||||
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve err: %v", err)
|
||||
}
|
||||
if got != nil || src.Kind != cmdpolicy.SourceNone {
|
||||
t.Fatalf("expected (nil, SourceNone), got (%v, %+v)", got, src)
|
||||
}
|
||||
}
|
||||
|
||||
// Two plugins both contributing a Rule must produce the typed error so
|
||||
// the bootstrap pipeline aborts (hard-constraint #7).
|
||||
func TestResolve_multipleRestrictIsError(t *testing.T) {
|
||||
_, _, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
||||
PluginRules: []cmdpolicy.PluginRule{
|
||||
{PluginName: "a", Rule: &platform.Rule{Name: "a"}},
|
||||
{PluginName: "b", Rule: &platform.Rule{Name: "b"}},
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, cmdpolicy.ErrMultipleRestricts) {
|
||||
t.Fatalf("err = %v, want ErrMultipleRestricts", err)
|
||||
}
|
||||
}
|
||||
|
||||
// LoadYAMLPolicy: missing file returns (nil, nil) silently so callers
|
||||
// can pass the result straight into Sources.YAMLRule without special-
|
||||
// casing not-exist.
|
||||
func TestLoadYAMLPolicy_missingIsSilent(t *testing.T) {
|
||||
missing := filepath.Join(t.TempDir(), "absent-policy.yml")
|
||||
rule, err := cmdpolicy.LoadYAMLPolicy(missing)
|
||||
if err != nil {
|
||||
t.Fatalf("missing yaml should not error, got %v", err)
|
||||
}
|
||||
if rule != nil {
|
||||
t.Fatalf("missing yaml should return nil rule, got %+v", rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadYAMLPolicy_emptyPathIsNoop(t *testing.T) {
|
||||
rule, err := cmdpolicy.LoadYAMLPolicy("")
|
||||
if err != nil {
|
||||
t.Fatalf("empty path should not error, got %v", err)
|
||||
}
|
||||
if rule != nil {
|
||||
t.Fatalf("empty path should return nil rule, got %+v", rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadYAMLPolicy_parsesValid(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
yamlPath := filepath.Join(dir, "policy.yml")
|
||||
if err := os.WriteFile(yamlPath, []byte("name: from-yaml\nmax_risk: read\n"), 0o644); err != nil {
|
||||
t.Fatalf("write yaml: %v", err)
|
||||
}
|
||||
rule, err := cmdpolicy.LoadYAMLPolicy(yamlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadYAMLPolicy err: %v", err)
|
||||
}
|
||||
if rule == nil || rule.Name != "from-yaml" {
|
||||
t.Fatalf("expected rule with name=from-yaml, got %+v", rule)
|
||||
}
|
||||
}
|
||||
96
internal/cmdpolicy/source_label_test.go
Normal file
96
internal/cmdpolicy/source_label_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdpolicy_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// The envelope's policy_source must never leak the absolute home path.
|
||||
// "yaml:/Users/alice/.lark-cli/policy.yml" would expose Alice's username
|
||||
// to any agent or log consumer; the contract is to emit just "yaml" and
|
||||
// rely on rule_name (from the yaml's "name:" field) for disambiguation.
|
||||
func TestEnvelope_yamlPolicySourceDoesNotLeakHomePath(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
docs := &cobra.Command{Use: "docs"}
|
||||
root.AddCommand(docs)
|
||||
leaf := &cobra.Command{Use: "+write", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
docs.AddCommand(leaf)
|
||||
|
||||
e := cmdpolicy.New(&platform.Rule{
|
||||
Name: "my-readonly-rule",
|
||||
Allow: []string{"contact/**"}, // docs/* falls outside, denied
|
||||
})
|
||||
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
|
||||
cmdpolicy.ResolveSource{
|
||||
Kind: cmdpolicy.SourceYAML,
|
||||
Name: "/Users/alice/.lark-cli/policy.yml", // simulate an absolute path
|
||||
}, "my-readonly-rule")
|
||||
|
||||
cmdpolicy.Apply(root, denied)
|
||||
err := leaf.RunE(leaf, nil)
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected denial ExitError, got %v", err)
|
||||
}
|
||||
detail := exitErr.Detail.Detail.(map[string]any)
|
||||
src, _ := detail["policy_source"].(string)
|
||||
if src != "yaml" {
|
||||
t.Errorf("policy_source = %q, want %q (no path leak)", src, "yaml")
|
||||
}
|
||||
// rule_name carries the disambiguating identifier.
|
||||
if detail["rule_name"] != "my-readonly-rule" {
|
||||
t.Errorf("rule_name = %v, want my-readonly-rule", detail["rule_name"])
|
||||
}
|
||||
// Direct probe: the absolute path must not appear anywhere in the
|
||||
// envelope detail (key OR value).
|
||||
for k, v := range detail {
|
||||
if strings.Contains(k, "/Users/alice") || strings.Contains(asString(v), "/Users/alice") {
|
||||
t.Errorf("envelope detail must not leak '/Users/alice', found in %s = %v", k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin name IS allowed in policy_source because plugins are in-binary
|
||||
// and their names are part of the contract (an integrator debugging a
|
||||
// denial wants to know which plugin fired). This test pins that intent
|
||||
// so a future change does not silently strip the plugin name too.
|
||||
func TestEnvelope_pluginPolicySourceCarriesName(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
leaf := &cobra.Command{Use: "+block", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
root.AddCommand(leaf)
|
||||
|
||||
e := cmdpolicy.New(&platform.Rule{
|
||||
Name: "secaudit-policy",
|
||||
Deny: []string{"+block"},
|
||||
})
|
||||
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
|
||||
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourcePlugin, Name: "secaudit"},
|
||||
"secaudit-policy")
|
||||
cmdpolicy.Apply(root, denied)
|
||||
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError")
|
||||
}
|
||||
detail := exitErr.Detail.Detail.(map[string]any)
|
||||
if detail["policy_source"] != "plugin:secaudit" {
|
||||
t.Errorf("policy_source = %v, want plugin:secaudit", detail["policy_source"])
|
||||
}
|
||||
}
|
||||
|
||||
func asString(v any) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
163
internal/cmdpolicy/strict_mode_skip_test.go
Normal file
163
internal/cmdpolicy/strict_mode_skip_test.go
Normal file
@@ -0,0 +1,163 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdpolicy_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
)
|
||||
|
||||
// cmdpolicy.Apply MUST NOT overwrite the denial annotation on a command
|
||||
// already marked as strict-mode denied. strict-mode is a hard boundary
|
||||
// (credential-derived); a user-layer rule cannot relabel or replace
|
||||
// the error path.
|
||||
//
|
||||
// Without this invariant: when a user yaml rule happened to match the
|
||||
// path of a strict-mode stub, Apply would change layer=strict_mode to
|
||||
// layer=pruning, and the user-visible error would say "denied by yaml"
|
||||
// instead of "strict mode". The hard-boundary contract demands
|
||||
// strict_mode wins.
|
||||
func TestApply_PreservesStrictModeAnnotation(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
stub := &cobra.Command{
|
||||
Use: "victim",
|
||||
Hidden: true,
|
||||
Annotations: map[string]string{
|
||||
cmdpolicy.AnnotationDenialLayer: cmdpolicy.LayerStrictMode,
|
||||
cmdpolicy.AnnotationDenialSource: "strict-mode",
|
||||
},
|
||||
RunE: func(*cobra.Command, []string) error { return nil },
|
||||
}
|
||||
root.AddCommand(stub)
|
||||
|
||||
// User-layer pruning denies the same path.
|
||||
denied := map[string]cmdpolicy.Denial{
|
||||
"victim": {
|
||||
Layer: cmdpolicy.LayerPolicy,
|
||||
PolicySource: "yaml",
|
||||
Reason: "denied by user yaml",
|
||||
ReasonCode: "command_denylisted",
|
||||
},
|
||||
}
|
||||
cmdpolicy.Apply(root, denied)
|
||||
|
||||
if got := stub.Annotations[cmdpolicy.AnnotationDenialLayer]; got != cmdpolicy.LayerStrictMode {
|
||||
t.Errorf("strict-mode layer overwritten by pruning: got %q want %q",
|
||||
got, cmdpolicy.LayerStrictMode)
|
||||
}
|
||||
if got := stub.Annotations[cmdpolicy.AnnotationDenialSource]; got != "strict-mode" {
|
||||
t.Errorf("strict-mode source overwritten: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for codex H13 / C6: a denied command that carries
|
||||
// flag-like positional args (because DisableFlagParsing=true makes
|
||||
// every `--doc xxx` look positional) MUST surface the pruning
|
||||
// envelope, not a cobra usage error. Pre-fix, the original command's
|
||||
// Args validator (e.g. cobra.NoArgs from shortcut registration) would
|
||||
// fire BEFORE PersistentPreRunE / RunE and produce
|
||||
// "Error: positional arguments are not supported".
|
||||
//
|
||||
// Fix: installDenyStub sets Args=ArbitraryArgs so cobra's validate
|
||||
// step always passes, letting dispatch reach the wrapped RunE.
|
||||
func TestApply_DenyStubBypassesArgsValidator(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
leaf := &cobra.Command{
|
||||
Use: "+update",
|
||||
Args: cobra.NoArgs, // shortcut style: refuse all positional args
|
||||
RunE: func(*cobra.Command, []string) error { return nil },
|
||||
}
|
||||
root.AddCommand(leaf)
|
||||
|
||||
denied := map[string]cmdpolicy.Denial{
|
||||
"+update": {
|
||||
Layer: cmdpolicy.LayerPolicy,
|
||||
PolicySource: "yaml",
|
||||
ReasonCode: "command_denylisted",
|
||||
Reason: "denied by user yaml",
|
||||
},
|
||||
}
|
||||
cmdpolicy.Apply(root, denied)
|
||||
|
||||
if leaf.Args == nil {
|
||||
t.Fatal("denied command must have non-nil Args validator after Apply")
|
||||
}
|
||||
// ArbitraryArgs returns nil for every input -> Args validation no-ops.
|
||||
if err := leaf.Args(leaf, []string{"--doc", "xxx", "--mode", "append"}); err != nil {
|
||||
t.Errorf("denied command Args validator should accept any input, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for codex C11 / C13: a denied command whose PARENT
|
||||
// declares a PersistentPreRunE (e.g. cmd/auth/auth.go's
|
||||
// external_provider check) MUST surface the pruning envelope, not
|
||||
// the parent's error. Cobra's "first PersistentPreRunE walking up
|
||||
// from leaf wins" semantics will pick the parent's PersistentPreRunE
|
||||
// unless the denied leaf carries its own.
|
||||
//
|
||||
// Fix: installDenyStub installs a no-op PersistentPreRunE on the leaf
|
||||
// so cobra stops there and proceeds to the wrapped RunE (which holds
|
||||
// the real pruning envelope).
|
||||
func TestApply_DenyStubBypassesParentPersistentPreRunE(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
parent := &cobra.Command{
|
||||
Use: "auth",
|
||||
PersistentPreRunE: func(*cobra.Command, []string) error {
|
||||
return errors.New("parent PersistentPreRunE fired (would mask pruning)")
|
||||
},
|
||||
}
|
||||
root.AddCommand(parent)
|
||||
leaf := &cobra.Command{
|
||||
Use: "login",
|
||||
RunE: func(*cobra.Command, []string) error { return nil },
|
||||
}
|
||||
parent.AddCommand(leaf)
|
||||
|
||||
denied := map[string]cmdpolicy.Denial{
|
||||
"auth/login": {
|
||||
Layer: cmdpolicy.LayerPolicy,
|
||||
PolicySource: "yaml",
|
||||
ReasonCode: "identity_mismatch",
|
||||
Reason: "denied",
|
||||
},
|
||||
}
|
||||
cmdpolicy.Apply(root, denied)
|
||||
|
||||
if leaf.PersistentPreRunE == nil {
|
||||
t.Fatal("denied command must have leaf-level PersistentPreRunE")
|
||||
}
|
||||
// Our PersistentPreRunE must NOT propagate the parent's error.
|
||||
if err := leaf.PersistentPreRunE(leaf, nil); err != nil {
|
||||
t.Errorf("denied command leaf PersistentPreRunE should be no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: a normal command (no prior annotation) still gets the
|
||||
// pruning denial annotations after Apply.
|
||||
func TestApply_NonStrictCommandStillGetsPruningAnnotation(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
leaf := &cobra.Command{
|
||||
Use: "normal",
|
||||
RunE: func(*cobra.Command, []string) error { return nil },
|
||||
}
|
||||
root.AddCommand(leaf)
|
||||
|
||||
denied := map[string]cmdpolicy.Denial{
|
||||
"normal": {
|
||||
Layer: cmdpolicy.LayerPolicy,
|
||||
PolicySource: "yaml",
|
||||
Reason: "denied",
|
||||
ReasonCode: "command_denylisted",
|
||||
},
|
||||
}
|
||||
cmdpolicy.Apply(root, denied)
|
||||
|
||||
if got := leaf.Annotations[cmdpolicy.AnnotationDenialLayer]; got != cmdpolicy.LayerPolicy {
|
||||
t.Errorf("expected pruning layer annotation, got %q", got)
|
||||
}
|
||||
}
|
||||
86
internal/cmdpolicy/suggest.go
Normal file
86
internal/cmdpolicy/suggest.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdpolicy
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
// suggestRisk returns the closest valid Risk literal by edit distance
|
||||
// for risk_invalid diagnostics; input is never silently substituted.
|
||||
// Case-insensitive ("WRITE" → "write"); empty in, empty out (the
|
||||
// absent-annotation case goes to risk_not_annotated, not here).
|
||||
func suggestRisk(bad string) string {
|
||||
if bad == "" {
|
||||
return ""
|
||||
}
|
||||
lowered := toLower(bad)
|
||||
candidates := []platform.Risk{
|
||||
platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite,
|
||||
}
|
||||
best := string(candidates[0])
|
||||
bestDist := levenshtein(lowered, best)
|
||||
for _, c := range candidates[1:] {
|
||||
if d := levenshtein(lowered, string(c)); d < bestDist {
|
||||
bestDist, best = d, string(c)
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// toLower is an ASCII-only lowercase. Risk taxonomy values are
|
||||
// ASCII; pulling in unicode here would be overkill.
|
||||
func toLower(s string) string {
|
||||
b := []byte(s)
|
||||
for i, c := range b {
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
b[i] = c + ('a' - 'A')
|
||||
}
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// levenshtein computes the classic edit distance between two strings.
|
||||
// O(len(a)*len(b)) time, O(min(a,b)) space. Three-element string set
|
||||
// makes raw performance irrelevant — clarity beats trickiness here.
|
||||
func levenshtein(a, b string) int {
|
||||
if len(a) == 0 {
|
||||
return len(b)
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return len(a)
|
||||
}
|
||||
prev := make([]int, len(b)+1)
|
||||
curr := make([]int, len(b)+1)
|
||||
for j := 0; j <= len(b); j++ {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(a); i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= len(b); j++ {
|
||||
cost := 1
|
||||
if a[i-1] == b[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[j] = min3(
|
||||
prev[j]+1, // deletion
|
||||
curr[j-1]+1, // insertion
|
||||
prev[j-1]+cost, // substitution
|
||||
)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[len(b)]
|
||||
}
|
||||
|
||||
func min3(a, b, c int) int {
|
||||
m := a
|
||||
if b < m {
|
||||
m = b
|
||||
}
|
||||
if c < m {
|
||||
m = c
|
||||
}
|
||||
return m
|
||||
}
|
||||
51
internal/cmdpolicy/suggest_test.go
Normal file
51
internal/cmdpolicy/suggest_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdpolicy
|
||||
|
||||
import "testing"
|
||||
|
||||
// suggest is unexported, so the test lives in the same package.
|
||||
|
||||
func TestSuggestRisk(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"wrtie", "write"},
|
||||
{"WRITE", "write"},
|
||||
{"reed", "read"},
|
||||
{"rad", "read"},
|
||||
{"high-rik-write", "high-risk-write"},
|
||||
// "highrisk" is genuinely ambiguous between "write" and
|
||||
// "high-risk-write" — not testing it.
|
||||
{"", ""}, // empty input has no meaningful suggestion; the engine
|
||||
// routes the absent case to risk_not_annotated, not risk_invalid.
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := suggestRisk(c.input)
|
||||
if got != c.want {
|
||||
t.Errorf("suggestRisk(%q) = %q, want %q", c.input, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevenshtein(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"", "", 0},
|
||||
{"", "abc", 3},
|
||||
{"abc", "", 3},
|
||||
{"abc", "abc", 0},
|
||||
{"wrtie", "write", 2},
|
||||
{"kitten", "sitting", 3},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := levenshtein(c.a, c.b)
|
||||
if got != c.want {
|
||||
t.Errorf("levenshtein(%q,%q) = %d, want %d", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
75
internal/cmdpolicy/validate.go
Normal file
75
internal/cmdpolicy/validate.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdpolicy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/bmatcuk/doublestar/v4"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
// ValidateRule is the single Rule-validation entry point. It runs from
|
||||
// every source: yaml file load, Plugin.Restrict (once the Hook surface
|
||||
// lands), and the policy CLI's validate subcommand. Catching invalid
|
||||
// rules HERE rather than during evaluation prevents silent fail-open
|
||||
// scenarios:
|
||||
//
|
||||
// - bad MaxRisk string ("readd") would skip the risk check entirely
|
||||
// - malformed doublestar pattern ("docs/[abc") never matches, so a
|
||||
// plugin that meant to allow "docs/*" silently allows nothing,
|
||||
// and a deny list with the same typo silently denies nothing
|
||||
//
|
||||
// A typo in either field by a plugin author or admin must abort the load
|
||||
// rather than continue with a degraded rule (hard-constraint #6 / #11
|
||||
// safety contract).
|
||||
//
|
||||
// A nil rule is a no-op (treated as "no restriction" everywhere -- not an
|
||||
// error).
|
||||
func ValidateRule(r *platform.Rule) error {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if r.MaxRisk != "" {
|
||||
if !r.MaxRisk.IsValid() {
|
||||
return fmt.Errorf("invalid max_risk %q: must be one of read|write|high-risk-write", r.MaxRisk)
|
||||
}
|
||||
}
|
||||
|
||||
for _, id := range r.Identities {
|
||||
if !id.IsValid() {
|
||||
return fmt.Errorf("invalid identities entry %q: must be 'user' or 'bot'", id)
|
||||
}
|
||||
}
|
||||
|
||||
for _, g := range r.Allow {
|
||||
if err := validateGlob(g); err != nil {
|
||||
return fmt.Errorf("invalid allow glob %q: %w", g, err)
|
||||
}
|
||||
}
|
||||
for _, g := range r.Deny {
|
||||
if err := validateGlob(g); err != nil {
|
||||
return fmt.Errorf("invalid deny glob %q: %w", g, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateGlob rejects malformed doublestar patterns. doublestar.Match
|
||||
// returns an error for unbalanced brackets / bad escape sequences; that
|
||||
// error path is the canonical signal for "this pattern is not valid".
|
||||
//
|
||||
// We probe with an empty string -- the goal is to exercise the parser,
|
||||
// not to compute a match.
|
||||
func validateGlob(g string) error {
|
||||
if g == "" {
|
||||
return fmt.Errorf("empty pattern")
|
||||
}
|
||||
if _, err := doublestar.Match(g, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
97
internal/cmdpolicy/validate_test.go
Normal file
97
internal/cmdpolicy/validate_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdpolicy_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
)
|
||||
|
||||
// nil rule is "no restriction" everywhere -- validation must agree.
|
||||
func TestValidateRule_nilIsOk(t *testing.T) {
|
||||
if err := cmdpolicy.ValidateRule(nil); err != nil {
|
||||
t.Fatalf("nil rule should validate, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRule_validRule(t *testing.T) {
|
||||
r := &platform.Rule{
|
||||
Allow: []string{"docs/**", "contact/+search-*"},
|
||||
Deny: []string{"docs/+delete-doc"},
|
||||
MaxRisk: "write",
|
||||
Identities: []platform.Identity{"user", "bot"},
|
||||
}
|
||||
if err := cmdpolicy.ValidateRule(r); err != nil {
|
||||
t.Fatalf("valid rule rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A typo in MaxRisk must abort the load; otherwise the engine would skip
|
||||
// the risk check entirely and let high-risk-write commands pass under
|
||||
// what the operator thought was a "read" cap.
|
||||
func TestValidateRule_badMaxRisk(t *testing.T) {
|
||||
cases := []string{"readd", "Read", "high_risk_write", "anything"}
|
||||
for _, bad := range cases {
|
||||
r := &platform.Rule{MaxRisk: platform.Risk(bad)}
|
||||
err := cmdpolicy.ValidateRule(r)
|
||||
if err == nil {
|
||||
t.Errorf("ValidateRule should reject MaxRisk=%q", bad)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), "max_risk") {
|
||||
t.Errorf("error should mention max_risk for MaxRisk=%q, got %v", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Identities must come from the closed taxonomy {"user","bot"}. A typo
|
||||
// like "users" would silently lock out everyone (no command intersects
|
||||
// the typo), so it must abort.
|
||||
func TestValidateRule_badIdentity(t *testing.T) {
|
||||
r := &platform.Rule{Identities: []platform.Identity{"user", "admin"}}
|
||||
err := cmdpolicy.ValidateRule(r)
|
||||
if err == nil {
|
||||
t.Fatalf("ValidateRule should reject identity 'admin'")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "identities") {
|
||||
t.Fatalf("error should mention identities, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Malformed doublestar globs are silent fail-open if not caught here
|
||||
// (doublestar.Match returns an error which matchesAny() ignores).
|
||||
func TestValidateRule_malformedGlob(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rule *platform.Rule
|
||||
}{
|
||||
{"bad allow", &platform.Rule{Allow: []string{"docs/[abc"}}},
|
||||
{"bad deny", &platform.Rule{Deny: []string{"docs/[abc"}}},
|
||||
{"empty allow entry", &platform.Rule{Allow: []string{"", "docs/**"}}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := cmdpolicy.ValidateRule(c.rule)
|
||||
if err == nil {
|
||||
t.Fatalf("ValidateRule should reject %+v", c.rule)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Empty MaxRisk and Empty Identities slices are both "no restriction" --
|
||||
// not an error.
|
||||
func TestValidateRule_emptyFieldsAreOk(t *testing.T) {
|
||||
r := &platform.Rule{
|
||||
Allow: []string{"docs/**"},
|
||||
MaxRisk: "",
|
||||
Identities: nil,
|
||||
}
|
||||
if err := cmdpolicy.ValidateRule(r); err != nil {
|
||||
t.Fatalf("empty optional fields should validate, got %v", err)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user