20 Commits

Author SHA1 Message Date
liangshuo-1 8493800ebf feat(config): support keychain-backed tenant access tokens (#2488) 2026-08-25 21:39:50 +08:00
sang-neo03 6952d3aa7f feat(extension): add business command extension v1 (#2308)
* feat(shortcuts): import typed shortcut framework from c07b64621

* feat(extension): add public command contract

* feat(extension): compile and register business commands

* feat(cmd): assemble business command sets

* fix(auth): derive login domains from shortcuts

* test(extension): add business command test runtime

* ci: verify generated Go sources

* test(auth): match help and interactive domains

* fix(extension): validate command path segments

* fix(extension): satisfy generator and error guards

* fix(extension): follow generated domain naming contract

* fix(command): complete extension runtime contracts

* test(command): cover public extension surface

* fix(command): remove unused typed runtime APIs

* fix(command): address follow-up review findings

* fix(command): enforce extension v1 contracts

* fix(command): remove unreachable compatibility wrappers

* fix(auth): keep scope-less domains addressable via --domain, matching main

Move the declared-scope filter from allKnownDomains to the interactive
selector only. On main, a scope-less shortcut domain (event) passes
--domain validation and fails later with "no matching scopes found";
the previous unified filter changed that to "unknown domain" and
dropped it from the --help list. The interactive picker still hides
scope-less domains — selecting one can only fail.

* feat(command): add PathSegment for user-provided path values

Business code concatenates IDs into request paths but had no public
escape helper (internal/validate.EncodePathSegment is unreachable from
extension/command). Mirror its url.PathEscape semantics, use it in the
business command examples, and pin the traversal defense: the validator
decodes percent-encoding before the canonical check, so both raw and
escaped dot sequences fail same-origin validation.

* docs(command): add runnable chat-brief distribution example

Mirror the audit-observer precedent: a buildable wrapper main under
examples/ showing WithCommandSets against the real distribution shape
(plugins, strict mode, and service commands stay enabled). Covers the
single-read command (Validate, shared DryRun request, CallJSON,
PathSegment, Tips) and a Page[T] list command whose pagination flags
come from the compiler. The testdata/wrapper fixture stays test-only.

Verified offline: --help renders tag-driven parameters, +chat-brief-list
exposes --page-all/--page-limit/--page-delay, and --dry-run previews the
request with a fake env token and no network access.

* fix(command): normalize page envelopes and bound CollectAllPages

Two pagination contract fixes from the extension design (owner plan §8.3):

Page decoding accepted only a literal "items" array, so endpoints that
spell their list field differently (drive uses files, some responses use
records) walked every page while decoding nothing — CollectAllPages then
returned an empty set marked complete, and downstream writes ran against
it. Each page now normalizes its single top-level array field into
Page.Items; zero or multiple array fields fail closed with a typed
invalid-response error.

CollectAllPages previously reused the user-facing --page-limit maximum
(1000) as its walk bound. A complete-set collection holds every page in
memory before the workflow's writes run, so it now uses the design's
dedicated workflow bound of 100 pages.

* feat(commandhost): note bounded repetition in Page dry-run previews

A Page[T] command's dry-run can only show the first request; fabricating
response-dependent page tokens is forbidden. Append the bounded-repeat
explanation to the previewed request (preserving any business
description), matching the design's dry-run contract.

Also give the example's list command a --page-token resume flag seeded
into the request, documenting the resume convention: the framework owns
--page-all/--page-limit/--page-delay while the starting cursor is a
business-declared input, independent of --page-all.

* docs(command): generate domain constants with English comments

The generator emitted Chinese titles while the rest of the public
extension packages document in English. Switch the generator to the
"en" service title and regenerate. The generator already rejects a
domain missing either locale, so the switch keeps its own guard.

* docs(command): mark the host adapter read surface

The Host* types, InspectCommand, InspectDomain and CloneSets exist for
lark-cli's host adapter, not for business commands, but nothing said so
at the symbols themselves. They cannot move to a subpackage: a Command
holds its declaration unexported, so a sibling package has no way to
reach it, and moving the wire types to internal/ would cycle back
through CommandMetadata and CommandContext.

Also correct HostPagination, which is not adapter-only -- ContextOptions
and commandtest both carry it.

* refactor(command): type CommandMetadata.Service as DomainName

A set already declares its domain through ExtendDomain(DomainIm), yet
every command repeated the same domain as a bare string that only the
host compiler checked. Typing the field points authors at the generated
enum and forces an explicit conversion when the value comes from a
string variable.

This does not make a mistyped literal a compile error -- an untyped
constant still converts to DomainName -- so the mismatch check in
CompileSets stays the actual net. The example and the wrapper fixture
now declare command.DomainIm.

The chat-brief example was also not gofmt-clean, which the CI format
gate would have caught.

* refactor(command): extract the command-set assembly steps

buildInternalWithConfig is an orchestrator, and the business command
sets were compiled inline inside it. Move that step to
resolveShortcutSnapshot so the entry point reads as one call and the
built-in/external merge has a name.

newCommand carried six near-identical blocks that each nil-checked a
hook and wrapped it in the same type assertion. Split them into one
binder per hook shape; Normalize and Validate now share bindArgsHook
since their signatures match. The behaviour is unchanged: an undeclared
hook still erases to nil, and an empty renderer map still yields nil.

* perf(shortcuts): stop re-cloning an already-isolated snapshot

AllShortcuts deep-copies because a Shortcut carries slice fields whose
backing arrays a shallow copy would share: an external distribution
mutating registered[0].Flags[0] would corrupt the process-global list.
That copy is worth its ~165us over 500+ shortcuts.

Paying it four times per startup is not. auth, schema and the mount path
each cloned the snapshot again, but they receive it from
AllShortcutsWithExternal with no third-party code in between, and nothing
in this repository mutates a shortcut element -- mountDeclarative takes a
value receiver and only replaces slice headers. Drop those three copies
and document the boundary on AllShortcuts so the next reader does not
reintroduce them.

Startup drops from four full clones to one. Benchmarks pin the remaining
cost so a regression points at a new clone rather than at growth in the
shortcut set.

* fix(command): align the commandtest page bound and escape wrapper paths

Two review findings, both of which let a business command pass its tests
and then misbehave in production.

The commandtest recorder walked 1000 pages for a complete-set collection
while the host adapter stops at 100, so a command tested against 300
pages of fixtures would fail its first real --page-all run with
PaginationLimitError. The bound now lives in internal/pagination, which
both sides already import, and the hard-limit test scripts itself from
that constant instead of restating 1000 -- the literal was what let the
two drift apart.

The testdata wrapper concatenated args.ID straight into the request path,
contradicting PathSegment's own documented rule and the chat-brief
example. ValidateRequestView does not cover this: "abc/other-users-file"
cleans to itself, so an unescaped separator silently retargets the
request. Since testdata is what an integrator copies first, route both
call sites through one readRequest helper, mirroring chat-brief.

The e2e assertion could not have caught it either -- PathSegment("chat_1")
is "chat_1", so the check passed with or without the call. It now sends
"chat/1" and asserts %2F reaches the wire; removing PathSegment fails it.

* fix(command): deny network to the pre-confirmation hooks and four review findings

Normalize and Validate run before the high-risk confirmation gate, and
both received the full CommandContext, so a high-risk business command
could POST or DELETE from Validate and leave remote side effects behind
before the user was ever asked to confirm. Moving the gate earlier would
contradict the documented hook order and would also make --dry-run
require --yes. The design already forbids this from the other side --
Validate is specified as parameter checking that issues no request -- so
enforce that instead: Normalize and Validate get a context whose CallJSON
and CollectPages refuse, while PreflightScopes stays available. The guard
sits in CommandContext rather than in the wiring, so a future adapter
that wires the callbacks anyway still cannot reach the API. commandtest
mirrors it, otherwise a command would pass its tests and fail only in
production.

Page.Items now starts non-nil. It is declared required;nonnullable, but a
zero-item collection encoded as {"items":null}, which a caller generating
types from the published schema would reject.

NewCmdAuthWithRecovery and NewCmdSchemaWithVisibility are restored as
wrappers. Both were dropped for shortcut-aware variants, and both are
reachable from outside this module: CommandVisibility is an ordinary
exported func type, and *recovery.Projector cannot be named by an outside
caller but can be passed as nil. A signature test now pins them.

The path-traversal fixture said "../../secret", which the deterministic
gate rejects as a generic credential assignment -- the reason CI is
currently red. The filename carries no meaning; it is now "../../outside".

* test(cmd): exercise the retained constructors instead of naming them

The compatibility wrappers restored for outside callers are unreachable
from inside this repository by construction, so the incremental dead-code
gate rejected them. A signature-only assertion did not help: taking a
function value and discarding it leaves the body unreachable, and it
proved nothing about whether the wrapper still builds a working command.

Call each one and assert the command it returns. NewCmdAuthWithRecovery
is called with a nil projector, which is the exact call an outside module
can make and the reason the wrapper has to keep compiling.

Verified with the same deadcode version CI runs: neither function is
reported, and no other function in this branch's files is either.

* docs(command): document the hook contract and pin dry-run note idempotency

Hooks is the first type a business author reads and carried no field
documentation, so the rules lived only in the design doc: which of DryRun
and DryRunE to set, that setting both fails to compile, that Execute owns
the API call and must not write stdout, and that Normalize and Validate
run before the confirmation gate and therefore get no network.

The choice between DryRun and DryRunE is not old-versus-new -- neither is
legacy. It follows from whether building the preview can fail, which is
now what the field docs say.

Also pin the dry-run note as idempotent. convertDryRun writes the
bounded-repeat note into the projection it builds, never back into the
hook's *DryRun, and DryRunAPI.Desc assigns rather than appends, so a hook
that caches and returns the same preview cannot accumulate the note. Both
properties were true and neither was tested.

* refactor(command): settle the dry-run constructor, tips, and domain enum

Three narrowings of the V1 business-command contract, none of which has a
published compatibility surface: extension/command does not exist on main.

Preview and NewDryRun were the same constructor twice -- one empty, one
seeded with requests. Fold them into a variadic NewDryRun. Every existing
NewDryRun() call keeps compiling, and the domain word in the contract is
now spelled one way. The type DryRun already owns that identifier in this
package, so naming the constructor DryRun outright cannot compile.

Drop Metadata.Tips. It was pure passthrough into common.Shortcut.Tips and
nothing in the execution path read it, so business commands lose only the
ability to declare help tips; the repository's own typed shortcuts keep
theirs. The mount test asserted a tip reached the rendered help as proof
that metadata survives the extension -> commandhost -> common.Shortcut ->
help conversion; it now asserts the risk line, which travels the same path.

Hand-write the domain enumeration and delete the generator. Generating
from shortcuts.AllShortcuts silently omitted approval, attendance and
mindnotes: all three are published under `lark-cli --help` and served by
typed and raw API commands, they just own no shortcut. The enum is now
the 23 domains the CLI actually exposes.

Those three would otherwise have been constants that compile and always
fail, because CompileSets derived its mountable domains from the same
shortcut list. It now reads the service registry, and shortcuts/register.go
already creates a domain command group on demand when no built-in occupies
it, so a business command can mount under a shortcut-less domain.

* ci: stop generating extension/command

The domain enumeration is hand-written now and extension/command holds no
go:generate directive, so the path was a no-op that still read as if the
package carried generated files.

* refactor(command): drop DryRunE and let the preview render like a built-in

DryRunE has no counterpart in the shipped CLI: `git show
main:shortcuts/common/types.go` has no such field, it arrived with the
typed-shortcut framework this branch imported, and no shortcut in the
repository sets one. Business commands get the single DryRun hook that
built-in shortcuts have. Validate already runs before it and owns the
error channel, so a preview that cannot be built still fails there with a
typed error -- which is what the repointed tests now assert, end to end
through --dry-run and through commandtest.Preview.

Also stop appending the bounded-repeat note. convertDryRun added "with
--page-all, repeats with the returned page_token until exhaustion or
--page-limit" to every Page[T] preview, so an external command's dry-run
carried a sentence its author never wrote. Built-in paginated shortcuts
say this themselves when they want it (im_chat_members_list.go calls
dry.Desc), and external commands now do the same: the framework renders
the description it was given and nothing else.

The dry-run context keeps refusing requests -- runner.go does the same for
built-ins, so removing that would be the divergence, not the alignment.
Only the word changes: "offline" was our own vocabulary for what the rest
of the CLI calls dry-run.

* refactor(command): drop the partial-failure outcome from the contract

Business commands now return Success only. Partial, OutcomeDefinition,
PartialFailureDefinition and FailedItemDefinition leave the public surface
along with Execution.Partial and the host adapter's receipt conversion.

Result keeps its outcome field. It is no longer a choice -- Success is the
only value -- but it is also how the host tells a returned Result apart
from the zero value that accompanies an error, which is the check
commandtest.Execute makes before reporting "returned both Result and
error". Collapsing it to nothing would delete that signal.

The exemplar commands that returned Partial keep their scenarios: a
best-effort scope failure still marks every item failed and appends the
snapshot, and the multi-call audit still records the owner it could not
resolve. That information lives in the command's own Data (Items[].State
plus Failures), not in the outcome, so the tests assert the same facts and
only the outcome assertion is gone. The deep-copy test moved its nested
JSON exemplar from FailedValues to InputDefault.Value, keeping
cloneJSONValue covered.

shortcuts/common still defines PartialFailure for built-in typed
shortcuts. That is the imported framework, untouched here.

* refactor(shortcuts): walk pages once for built-in and external commands

Commit 4d0c6ea61 added internal/pagination, moved PaginateInto onto it,
and then wrote a second caller for externally declared commands. Both
assembled the same Walk options, cloned the same params, read the same
cursor and mapped the same walk error; only the policy source, the call
path and the accumulator ever differed.

Those three now parameterize one pageWalk. PaginateInto keeps calling
through the RuntimeContext and keeps its per-page progress line; external
commands keep CallTypedAPI, the walker's context and their undecoded
pages, which the public contract needs because it decodes them into its
own Page[T]. Behavior is unchanged on both sides -- the external walk
still leaves Wait nil, which internal/pagination fills with WaitContext,
so --page-delay works exactly as before.

pageWalk is deliberately generic-free so one struct serves both callers;
the typed half of the built-in path moved to addDecodedPage.

* refactor(shortcuts): make the external page walk PaginateInto's twin

CollectCommandPages now differs from PaginateInto only where the context
type forces it. It takes the same PageAccumulator, decodes each page into
T through the same addDecodedPage, returns the same *output.PaginationMeta
and reads the same state out of the same walk, in the same order.

What is left is what the interface cannot supply. An externally declared
command compiles in the business module, so it holds a CommandContext
rather than a *RuntimeContext: the context arrives as a parameter because
the interface carries none, the call goes through CallTypedAPI, and there
is no progress line because deciding to print one needs StderrIsTerminal,
JqExpr and Format, none of which the interface exposes.

The all parameter stays. It is the complete-set policy CollectAllPages
depends on -- collect to exhaustion under the hard page bound instead of
obeying --page-all and --page-limit -- and PaginateInto has no way to
express it, since resolvePaginationPolicy only ever reads flags. Dropping
it would quietly turn a command that must see the whole set into one a
user can truncate with --page-limit 1.

CommandPageCollection is gone with it: pages accumulate in commandhost's
own accumulator, the way every built-in shortcut already accumulates its
own. One consequence of sharing the decode: a page whose response carries
no data object is now an error on this path too, as it always was for
built-ins.

* fix(shortcuts): reject recursive Data and Args types during compilation

shapeForType and compileStructShape called each other without recording the
Go types already being walked, so a self-referential type recursed forever.
The walk ran during command registration and ended in a stack overflow --
a fatal runtime error rather than a panic, so no recover boundary could
contain it and one extension command took the whole CLI down before --help,
schema, or any unrelated command could run. That also broke
CompileErasedDefinition's documented promise to compile without panic.

Thread the struct types open on the current recursion path through both
functions and return a compile error on a repeat visit, pointing at the
explicit Shape escape hatch. Membership is scoped to the path, not the whole
walk, so a type reused as a sibling or at another depth stays legal.

Covers self-reference through a slice and through a pointer, mutual
recursion through two types, the JSON-encoded Args path, and the public
CompileErasedDefinition contract.

* fix(command): share one result protocol between the host and commandtest

commandtest.Execute only checked that Data carried the expected type. Generic
erasure leaves a correctly typed zero Data behind, so a business command that
returned Result{} instead of Success(data) passed the type assertion and the
test reported success. Production rejects the same result, which left
extension authors with green tests and a command that failed on every real
invocation -- exactly the guarantee commandtest exists to provide.

Add ValidateHostResult in extension/command and call it from both
commandtest.Execute and the host adapter's execute hook, so the two surfaces
cannot drift. It rejects an empty or unsupported outcome and mirrors the
pagination receipt checks the host already applies: declared Page output,
pages of at least one, non-negative items, and next-token state consistent
with completeness.

RunWithFlags is covered because it delegates to Execute.

* feat(command): expose reusable download capabilities

* test(command): make the chat-brief example testable and cover its hooks

The example declared both commands as inline Definition literals handed
straight to Define. Define erases the type parameters and returns an opaque
Command that cannot hand its Definition back, while commandtest.Execute takes
the Definition -- so the shape the example demonstrated could not be unit
tested at all. Neither shipped example had a test file, so nobody had walked
the copy-the-example-then-add-a-test path.

Lift both declarations into Definition-returning functions, the shape the
repository's own commandtest suites already use, and keep the compiled
Commands as package vars so main is unchanged. The configuration bodies are
untouched.

Add the tests that shape exists for: the single-read projection and its
Validate rejection, plus the Page[T] contract for default single-page reads
and a --page-all walk through RunWithFlags. All four run offline through the
commandtest recorder.

* test(commandtest): cover the ordered URL download script

Recorder.ReplyURL shipped without a caller, so the incremental dead code
gate flagged it as new unreachable code and blocked the branch. The existing
URL download test uses the unordered RespondFile constructor, which never
exercises the URL assertion ReplyURL exists for.

Mirror the ReplyJSON pair: one test walks two scripted URLs in order and
checks the recorded source URLs, content types, and artifacts; the other
points DownloadURL at an unscripted URL and expects the mismatch error.

* feat(command): accept @file input for external commands

V1 rejected the file value source, leaving external commands with inline
flags and stdin only. A process has one stdin, so a command whose body is
too large or too quoted for the shell -- an XML document update is the case
that surfaced this -- had no second way to receive it, and the caller had to
fall back to shell escaping.

Nothing downstream was missing: resolveInputFlags already resolves @path and
the @@ escape through the invocation's FileIO, help renders the "@file"
affordance, and legacyInputSources maps the source onto the compiled flag.
The gap was the public constant and the host allow-list.

Export SourceFile and let it through compilation. Unknown sources still fail
the same way, which the rewritten host test now pins alongside the compiled
flag actually carrying both extra sources -- silently dropping a declared
source is the regression worth catching.

Give the wrapper fixture a command whose content flag declares all three
sources, and drive it end to end: @file and stdin both reach the request
body, and the help text advertises them.

* revert(content): drop the default content exports from the extension surface

Exporting the repository's embedded skills and affordance trees turned two
content directories into Go packages, because go:embed cannot reach up out of
a package directory and the repository root is package main. That cost two
things: a .go file living inside an authored-content tree, where a future
content type is silently omitted until someone edits the glob, and an embed
directive per tree where the root previously covered both in one.

The need it served does not exist. The distribution driving this work ships
its own skills and does not consume the official set, and a wrapper that does
want them can supply a tree through cmd.SetEmbeddedSkillContent, which is what
extension/platform already documents.

Restore content_embed.go, the SkillsOverlay comments, and the platform README
to their main state, and delete the two exporting packages. The example and
fixture wrappers now ship no embedded content, which is what a wrapper that
does not compile the repository root actually gets; the e2e assertion that
depended on inheriting lark-doc goes with it.

* fix(command): close the review findings on API surface and storage commit

Five findings from the extension-v1 review, each verified by a test that fails
against the previous implementation.

Source compatibility of auth.LoginOptions. The shortcut snapshot was an
unexported field on a struct that appears in the exported runF signature, which
ends positional literals for every caller outside this module. It becomes a
closure capture plus an explicit authLoginRun parameter, and the six domain
helpers collapse into domainResolver methods, removing five xxxWithShortcuts
twins that only tests reached. common.Shortcut.DryRunE goes too: it was a new
exported field with no production caller. The unexported typed field stays, so
Shortcut itself is still not positional-literal compatible -- that is a
deliberate remaining gap, since relocating it would need a global mutable map or
a wider signature change.

One canonical wire projection. queryValues stringified and dropped nils for the
live call while the dry-run preview and the pagination walk forwarded raw values,
so a preview could describe a request the runtime would never send. canonicalQuery
is now the only projection and all three consumers derive from it. Dry-run output
for numeric parameters therefore reads "20" instead of 20, matching what the
query string actually carries.

No-clobber as a storage guarantee. IfExistsFail checked existence, downloaded,
then committed with a rename that replaces unconditionally, so a target created
during the transfer was silently overwritten. The commit step is now an optional
ExclusiveFileIO capability: content lands in a temp file and is published with
Link, which refuses an existing target and never exposes a partial file. A
provider without the capability is refused rather than served a guarantee it
cannot keep.

V1 public surface. Removes NewDomain and its options (host compilation rejected
them), HostDomain.IsNew, reservedRootNames, and the unproducible
ResultMetaDefinition.Count; narrows Hooks.Renderers to a single PrettyRenderer,
since pretty was the only key the compiler accepted; and demotes the generic
authoring layer in shortcuts/common to unexported, as no production code outside
that package used it.

commandtest runs the production compiler. Execute, RunWithFlags and Preview now
share compileForTest, so a wrong tag, Shape or relation fails in the unit test
instead of at CLI startup. Applying this surfaced two long-standing contract
violations in the package's own fixture.

* refactor(command): keep a single authoring contract

* revert(schema): keep the schema command blind to shortcuts

The schema command serves the generated API catalog only, matching main.
Drop the shortcut contract lookup and completion from cmd/schema and
restore the constructor surface. ShortcutSchema stays on the sealed
commandbridge surface, where the host compiler tests assert the contract;
the CLI itself no longer consumes it. The surface scenario and wrapper
e2e pin the boundary from the other side: schema resolution and
completion must not see mounted shortcuts.

---------

Co-authored-by: sang-neo03 <266690410+sang-neo03@users.noreply.github.com>
Co-authored-by: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com>
2026-08-25 20:22:13 +08:00
xiahjay cd1c2a3c8f docs: make enterprise/embedded integration discoverable from the repo (#2066)
The embedding guide (extension mechanism: Credential / Transport /
Restrict / Observer / Wrap / On) lives on the Open Platform doc site,
but the repo itself never mentions it — enterprise IT scanning GitHub
cannot discover the centralized-integration path at all.

- README(.zh): add a 'Personal or Enterprise?' routing table right
  after 'Why lark-cli?', linking the official embedding guide and
  extension/; add 'Enterprise' to the nav bar; document the '.md'
  raw-Markdown trick for AI Agents
- extension/README.md: thin index mapping packages to extension
  points, pointing to platform/README.md and the official guide
2026-08-12 11:13:39 +08:00
SunPeiYang996 a6f3e635d4 feat(docs): add local authoring and resource workflows (#1921)
* feat(docs): add local authoring and resource workflows

Add docs +script workflows for isolated draft initialization and tolerant XML/Markdown profiling.

Support local and remote document resources across create and update flows with safe, bounded-concurrency uploads, binding verification, and cleanup.

Synchronize shared credential-source selection during concurrent uploads, refresh lark-doc guidance, and expand unit, dry-run, and live E2E coverage.

* docs(lark-doc): clarify genre reference paths

* fix(docs): address PR validation feedback

* docs(lark-doc): clarify remote image handling

* feat: streamline docs draft workflow

* fix(docs): clarify script input and resource cleanup

* fix(docs): align script dry-run test with auth flow

* fix(docs): authenticate local script e2e test

* fix(docs): refine script diagnostics and image preflight

* fix(docs): align draft workspace cleanup with VFS

* fix(docs): route workspace cleanup through FileIO

* docs(lark-doc): simplify profile check guidance
2026-08-07 18:15:28 +08:00
evandance 09feefe96b fix: make agent recovery and concealment reliable (#2189) 2026-08-05 19:44:11 +08:00
evandance ebdeda854d feat(extension): present restricted commands as absent and trim skills (#1837) 2026-08-05 01:44:50 +08:00
evandance 40a0a9de66 feat(enhancement): centralize HTTP transport policies (#2021) 2026-08-02 14:55:05 +08:00
evandance 4847f06ca8 fix: route brand-sensitive endpoints through the resolver (#1836) 2026-07-11 20:02:06 +08:00
evandance c5b5aece33 refactor: retire legacy error envelopes and enforce typed contract (#1449)
* refactor: retire legacy error envelopes and enforce typed contract

Consolidate all command error reporting onto the typed errs.* contract, remove
the legacy error surface that predated it, and tighten the lint guards so the
contract holds across the whole repository going forward.

Every failure now reaches stderr as one envelope shape: a category, an
optional subtype, a human- and agent-readable message, and a recovery hint,
with invalid parameters listed under `params`. The legacy ExitError envelope,
its constructors, and the boundary bridge that promoted untyped config and
authorization errors are deleted, leaving a single path from error to wire.
Predicate commands keep their silent-exit behavior through a dedicated signal
that carries only an exit code.

Infrastructure paths that still emitted ad-hoc envelopes — flag parsing,
unknown commands and subcommands, plugin and policy guards, confirmation
prompts, and auth/config failures — now classify into the same taxonomy.
Business, API, auth, and config exit codes are preserved; the one behavioral
change is that Cobra usage failures (missing required flag, unknown command,
bad arguments) now emit the typed validation envelope and exit 2, matching the
explicit flag and subcommand guards, instead of Cobra's plain-text exit 1.

Enforcement is repo-wide rather than per-path:
- The errscontract guards run by default everywhere instead of through a
  migration allowlist, so legacy envelopes cannot be reintroduced anywhere.
- errorlint runs across the whole repository: every error wrap must use %w and
  every comparison must use errors.Is/errors.As, so interior wraps stay legal
  but can no longer break the chain the typed boundary relies on.
- The errs-no-bare-wrap guard is keyed by structural prefix instead of an
  explicit per-domain allowlist, so new shortcut domains are covered without
  editing a list. It runs where forbidigo is enabled (the shortcut domains and
  the auth/config/service command groups); repo-wide chain integrity for the
  remaining command paths is carried by errorlint above.

* test: align cli_e2e success assertions to the ok envelope

The api and service success path now emits the {"ok":true} envelope, so the
cli_e2e workflow assertions that still expected the old {"code":0} shape via
AssertStdoutStatus(t, 0) fail once they run with live credentials. Switch those
workflow assertions to AssertStdoutStatus(t, true); the fake-payload helper test
in core_test.go keeps its code-shape assertion.
2026-06-17 19:42:38 +08:00
sang-neo03 50b3f0a2af feat(platform): support multiple policy rules per plugin (#1182)
* feat(platform): support multiple policy rules per plugin

Extend the command policy framework from single-Rule to multi-Rule
semantics. A plugin (or policy.yml) may now contribute several scoped
Rules; the engine combines them with OR -- a command is allowed when it
satisfies every axis of at least one rule. This lets one integration
apply different risk ceilings and identity restrictions to different
command groups.

The cross-plugin fail-closed boundary is preserved: two distinct plugins
both calling Restrict still aborts startup (multiple_restrict_plugins).
Single-Rule behaviour is fully backward compatible -- the rejection
reason_code / rule_name / envelope shape are byte-for-byte unchanged;
multi-rule rejection surfaces the aggregate reason_code no_matching_rule.

- engine: New keeps single-rule compat, add NewSet for OR over rules
- resolver: dedupe by owner (one plugin may contribute many rules),
  return []*Rule; yaml gains a top-level rules: list
- registrar/builder/staging: Restrict may be called more than once;
  retire the double_restrict error
- config policy show / config plugins show: emit a rules array
- inventory: PluginEntry.Rules is now a slice (fixes last-rule-wins
  overwrite when a plugin contributes multiple rules)

* fix(platform): clone rules in Builder.Restrict and inventory snapshot

Address review feedback. Builder.Restrict stored the caller's *Rule
directly, so reusing and mutating one Rule object across multiple
Restrict calls collapsed entries to the last mutation; clone the rule and
its slices on append, mirroring the staging registrar.

BuildInventory likewise reused the source Allow/Deny/Identities slices;
copy them when building the RuleView snapshot instead of relying on
cloneInventory downstream.

Add a regression test: reusing and mutating one Rule across two Restrict
calls now yields two independent rules.

* fix(platform): skip yaml when a plugin owns policy; reject empty rules list

Two policy-config robustness fixes from review:

- A malformed ~/.lark-cli/policy.yml could abort a plugin-governed
  binary. applyUserPolicyPruning read yaml before resolving, and
  build.go fail-closes on any policy error when a plugin is present.
  Plugin rules shadow yaml anyway, so skip reading yaml entirely when a
  plugin contributed rules -- an unrelated broken file on the user's
  machine can no longer lock the CLI.

- A present-but-empty "rules: []" collapsed to a single all-zero Rule
  that allows every annotated command ("looks like policy, enforces
  almost nothing"). yaml.Parse now distinguishes absent from
  present-but-empty (Rules is a pointer) and rejects the empty list.

Add regression tests for both.
2026-05-30 17:05:33 +08:00
sang-neo03 33c292c05e 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>
2026-05-18 15:25:02 +08:00
MaxHuang22 600fa50517 feat: add configurable content-safety scanning (#606)
* feat(contentsafety): add extension interface layer with Provider, Alert, and registry

Change-Id: Ibeac6366c7201293057bc3b063f75ac34565bcd5

* feat(contentsafety): add normalize utility for JSON type conversion

Change-Id: I7d4729a5ddcab2553abc110f8f6ecc88435ae921

* feat(contentsafety): add tree walker and regex scanner

Change-Id: I215dad7cf3072711d05e45f7d384162e1f8752d4

* feat(contentsafety): add config loading with lazy creation, default rules, and allowlist matching

Change-Id: I75e10df28f1f8d4f433cb2b469a0ff317af3bf70

* feat(contentsafety): add regex provider with config-driven scanning and allowlist

Change-Id: I658889b3647cbbbde6881e0c5f7c13887a1eb1d4

* feat(contentsafety): add output core with mode parsing, path normalization, and scan orchestration

Change-Id: I1cb9df75f1a4d176d660e2e7a9561314c3787191

* feat(contentsafety): add ScanForSafety entry point and Envelope alert field

Change-Id: I5fdb311e1c8d983a35a58667970b9fd3ac729a5c

* feat(contentsafety): integrate scanning into shortcut Out() and OutFormat()

Change-Id: I33eef1dba14c8a9bd1998857311bdd611f33b916

* feat(contentsafety): integrate scanning into API/service output paths and register provider

Change-Id: Ic3981db6c546a19eadea095d82175f92f4783bec

* fix(contentsafety): emit stderr notice when lazy-creating default config

Change-Id: Ia2491f7a17caceea3125ff9fb58d750dc196d7e7

* style: gofmt factory_default and exitcode

Change-Id: I86c5afdfbbdb68d8137f0ca09ef3b5a1139f4b4e

* fix(contentsafety): vfs for config I/O, mutex for lazy-create, sort matched rules, emit warn on --output path

Change-Id: Ib4982cd54e1bfe0580a0eb03368e6ca818304e1b

* fix(contentsafety): isolate scan goroutine errOut to prevent race on timeout

Change-Id: Ia5a770d7387ba6d3b7fa318fc5f1384214ea10b7

* fix(contentsafety): deep-normalize typed slices so scanner can walk shortcut data

Change-Id: I641e89113d1a2f2285ac6109bd3d7264f5845ea7

* fix(contentsafety): file perms 0600/0700, no result mutation, timeout test, scanTimeout comment

Change-Id: Ie45a2e365ee7098e214e94f8871026cc12029d83
2026-04-23 17:18:29 +08:00
tuxedomm fbed6beac3 refactor: split Execute into Build + Execute with explicit IO and keychain injection (#371)
* refactor(cmd): split Execute into Build with IO/Keychain injection

Introduce a public cmd.Build entry point so external consumers (cli-server,
MCP server, other embedders) can assemble the full CLI command tree without
going through os.Args or the platform keychain. Build takes an
InvocationContext plus functional BuildOptions:

  * WithIO(in, out, errOut) — inject custom streams; terminal detection
    is derived from the input's underlying *os.File when present.
  * WithKeychain(kc)        — swap the credential store.
  * HideProfile(bool)       — registered later in cmd.HideProfile.

The existing Execute() keeps using the internal buildInternal (which
still returns the Factory so error handling can attribute exit codes),
and SetDefaultFS replaces the global VFS implementation at startup.

Hardening applied up front:

  * cmdutil.NewIOStreams(in, out, errOut) centralizes terminal detection
    so SystemIO() and WithIO share one path.
  * cmdutil.NewDefault normalizes partial IOStreams — callers may pass
    &IOStreams{Out: buf} without tripping nil-writer panics in the
    RoundTripper warnings, Cobra, or the credential provider.
  * Build guards against nil functional options.
  * An API contract test (cmd/build_api_test.go) exercises Build +
    WithIO + WithKeychain + HideProfile + SetDefaultFS so the public
    surface is reachable by deadcode analysis.

Change-Id: I7c895e6019817401accbde2db3ef800da40ad319

* feat(schema): filter methods by strict mode in schema output

When strict mode is active, schema output now excludes methods that
are incompatible with the forced identity. This applies to both
pretty and JSON output formats at the resource and method levels.

Change-Id: I39647d5578466c3e23dc545bfb917ae075203ad7

* refactor: centralize strict-mode as flag registration

Change-Id: Iec11151c5002c2f58a8aa067d08747db2e4d2d8c

* fix(cmd): align strict-mode completion and build context; drop dead register shims

Thread a context.Context through RegisterShortcuts, RegisterServiceCommands,
and service.registerService/Resource/Method by introducing explicit
*WithContext variants. Pass that context into NewCmdServiceMethodWithContext
so shortcut and service command construction can honor cancellation and
strict-mode pruning consistently.

Also drop the context-less registerMethod and registerResource shims —
they became unreachable once the WithContext variants took over, and
were the source of new deadcode warnings. registerService is retained
because service_test.go still calls it directly.

Change-Id: I3fe5673aed663c7383bbbc5b0ae94d1f3491f22d

* refactor(cmd): hide --profile in single-app mode via build option

- GlobalOptions gains HideProfile; RegisterGlobalFlags stays pure and reads
  the policy off the struct. No boolean-trap parameter, one call per site.
- buildConfig holds GlobalOptions inline so HideProfile(bool) BuildOption
  mutates it directly. buildInternal stays a pure assembly function and
  requires callers to supply WithIO — no implicit os.Std* fallback.
- Add WithIO BuildOption (wrapping raw io.Reader/Writer with automatic
  *os.File TTY detection); Execute injects streams explicitly and decides
  profile visibility via HideProfile(isSingleAppMode()).
- installTipsHelpFunc force-shows hidden root flags while rendering the
  root command's own help, so single-app users still discover --profile
  via lark-cli --help without it polluting subcommand helps.

Change-Id: I7755387e993992ca969e0a4a6f54441cc1993eef

* feat(transport): extension abort hook and shared base transport

Two transport-layer changes bundled because both reshape the base
round-tripper contract used by the HTTP client, the Lark SDK client,
and the in-process updater.

1. Extension abort hook (PreRoundTripE).

   Extensions implementing exttransport.AbortableInterceptor can now
   return an error from PreRoundTripE to skip the built-in chain. The
   post hook still fires with (nil, reason) so extensions can unwind
   resources. extensionMiddleware captures the provider name so the
   returned *AbortError carries attribution.

2. Shared base transport to stop RPC leak.

   util.NewBaseTransport cloned http.DefaultTransport on every call, so
   each cmdutil.Factory produced a fresh *http.Transport whose
   persistConn readLoop/writeLoop goroutines lingered until
   IdleConnTimeout (~90s). Invisible in a single-process CLI, but the
   fork is consumed by cli-server where each RPC request constructs a
   new Factory, causing linear memory + goroutine growth under load.

   Replace NewBaseTransport with SharedTransport — returns
   http.DefaultTransport (the stdlib-wide singleton) by default, and
   a cached proxy-disabled clone only when LARK_CLI_NO_PROXY is set.
   Return type is http.RoundTripper to discourage in-place mutation of
   the shared instance. FallbackTransport is kept as a thin
   *http.Transport wrapper so existing callers in internal/auth and
   internal/cmdutil transport decorators (which were already on the
   singleton path) do not have to migrate.

   Leak-site migrations: factory_default.go (HTTP + SDK base) and
   update.go now call SharedTransport directly.

Change-Id: Ia82462134c5c5ee838be878b887860f41446a235

* fix: unblock Build() zero-opts path and sidecar demo build

Two regressions surfaced on refactor/build-execute-split:

1. cmd.Build(ctx, inv) without WithIO panicked at rootCmd.SetIn/Out/Err
   because cfg.streams stayed nil — NewDefault normalized internally
   but cmd/build.go never saw the normalized value. Default cfg.streams
   to cmdutil.SystemIO() before the root command wires them, and add a
   TestBuild_NoOptions regression guard.

2. sidecar/server-demo/main.go still called cmdutil.NewDefault(inv),
   so `go build -tags authsidecar_demo ./sidecar/server-demo` failed
   with "not enough arguments". Pass nil for the new streams parameter
   to preserve the prior behavior (NewDefault substitutes SystemIO).

Change-Id: I20227b2355cde7d19e22eba3eb841c6d8611e8a7
2026-04-21 14:48:40 +08:00
sang-neo03 5943a20e2b Feat/auth sidecar proxy (#532)
* feat(sidecar): add sidecar proxy for sandbox credential isolation

Keep real secrets (app_secret, access_token) out of sandbox environments.
CLI instances inside sandboxes connect to a trusted sidecar process via
HTTP; the sidecar verifies HMAC-signed requests and injects real tokens
before forwarding to the Lark API.

Key components:

- `auth proxy` subcommand to start the sidecar server (build tag: authsidecar)
- Noop credential provider returns sentinel tokens in sidecar mode
- Transport interceptor rewrites requests to sidecar with HMAC signature
- Env provider yields to sidecar provider when AUTH_PROXY is set
- Supports both feishu and lark brand endpoints

* feat(sidecar): implement priority ordering for credential providers

* feat(sidecar): strip client-supplied auth headers and improve shutdown logging

* feat(sidecar): buffer request body to prevent HMAC mismatches on read errors

* feat(sidecar): fix CI

* refactor(sidecar): publish protocol package and move server to reference demo

  The sidecar server is no longer shipped as a `lark-cli auth proxy`
  subcommand. Instead, the CLI provides only the standard sidecar *client*
  (via `-tags authsidecar`), while the wire-protocol utilities are exposed
  as a public package for integrators to implement their own server.

  Changes:
  - Move `internal/sidecar/` → `sidecar/` so external integrators can
    import HMAC signing, headers, sentinels and address validators.
  - Remove `cmd/auth/proxy.go`, `proxy_stub.go`, `proxy_test.go` and the
    conditional registration in `cmd/auth/auth.go`.
  - Add `sidecar/server-demo/` — a reference server implementation behind
    the `authsidecar_demo` build tag. It reuses the lark-cli credential
    pipeline for local development; production integrators are expected
    to replace the credential layer with their own secrets source.
  - Update all internal imports from `internal/sidecar` to `sidecar`.

  Rationale:
  - Each integrator has different secrets management / HA / multi-tenant
    requirements, so a one-size-fits-all server doesn't belong in the
    shipped CLI.
  - Keeping the client in-tree guarantees all sandbox-side code stays
    protocol-compatible without a second repo to sync.
  - The public `sidecar/` package pins the wire protocol as a stable
    contract third-party servers must conform to.

  Build matrix after this change:
  - `go build`                         → standard CLI, no sidecar code
  - `go build -tags authsidecar`       → CLI + sidecar client
  - `go build -tags authsidecar_demo \
      ./sidecar/server-demo/`          → reference server binary

  No production users are affected today because the server was not yet
  released; existing sidecar-client users are unchanged.

* feat(sidecar): close 5 pre-release security gaps
  - Server: enforce https-only target (no path/query/userinfo), pin
    forwardURL to https:// — blocks cleartext token leak
  - Protocol v1: canonical now covers version/identity/auth-header,
    blocks identity-flip replay within drift window
  - Client: ValidateProxyAddr requires loopback or same-host alias,
    rejects userinfo and https (interceptor is http-only); cross-machine
    is out of scope
  - Build: non-authsidecar builds exit(2) when AUTH_PROXY is set,
    preventing silent fallback to env credentials
  - Demo: whitelist auth-header to Authorization / X-Lark-MCP-{UAT,TAT},
    blocks token injection into Cookie / UA / X-Forwarded-For exfil paths
2026-04-20 20:24:51 +08:00
tuxedomm 0bf4f80ef4 refactor: migrate drive/doc/sheets shortcuts to FileIO (#339)
* refactor: migrate drive/doc/sheets shortcuts to FileIO

- drive_download/upload/import/export: SafeInputPath/SafeOutputPath +
  vfs.Stat/Open/MkdirAll + AtomicWrite → FileIO.Stat/Open/Save
- doc_media_download/insert/upload: same migration pattern
- sheet_export: same migration pattern
- Add Mode() fs.FileMode to fileio.FileInfo for IsRegular() checks
- Add WrapInputStatError helper to preserve error message fidelity
- Add WrapSaveErrorByCategory for standardized save error mapping
2026-04-09 16:34:59 +08:00
liangshuo-1 cdd9f9ab49 chore: add missing license headers (#352)
Change-Id: Ic26bedcbb111331eb53d695fccdabd0907a6272f
2026-04-08 23:11:01 +08:00
tuxedomm f5a8fbf8f1 refactor: migrate common/client/im to FileIO and add localfileio tests (#322)
* refactor: migrate common/client/im to FileIO and add localfileio tests

- runner resolveInputFlags: replace validate.SafeInputPath + vfs.ReadFile
  with FileIO.Open + io.ReadAll
- SaveResponse: delegate to FileIO.Save + ResolvePath
- cmd/api, cmd/service: pass FileIO to ResponseOptions
- im: replace validate.SafeLocalFlagPath with RuntimeContext.ValidatePath,
  migrate download/upload to FileIO.Save/Open/Stat
- Add path_test.go and atomicwrite_test.go for localfileio
- Add validate_media_test.go for im media flag validation
- Adapt test mocks to fileio.FileInfo interface
2026-04-08 17:31:21 +08:00
tuxedomm 900c12ce8d feat: add FileIO extension for file transfer abstraction (#314)
* feat: add FileIO extension for file transfer abstraction

Introduce extension/fileio package with Provider/FileIO/File interfaces
and a global registry, following the same pattern as extension/credential.

- Add LocalFileIO default implementation with path validation and atomic writes
- Wire FileIOProvider into Factory and resolve at runtime via RuntimeContext.FileIO()
- Factory holds Provider (not resolved instance), deferring resolution to execution time
2026-04-08 14:13:59 +08:00
tuxedomm bb38ecd41a feat: add transport extension with interceptor pre/post hooks (#292)
* feat: add transport extension with interceptor pre/post hooks

Add extension/transport package following the same Provider pattern as
credential and fileio extensions. The Interceptor interface uses a
PreRoundTrip/post-closure design that guarantees built-in transport
decorators (SecurityHeader, SecurityPolicy, Retry) cannot be skipped,
overridden, or tampered with by extensions. The original request context
is restored after PreRoundTrip to prevent context tampering.

Change-Id: I2e51ff67a0e2d8d32944a0565c2a6781110f281f
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 18:21:50 +08:00
liangshuo-1 8db4528269 feat: add strict mode identity filter, profile management and credential extension (#252)
* feat: add strict mode identity filter, profile management and credential extension

Port changes from feat/strict-mode-identity-filter_3 branch:
- Add strict mode for identity filtering and configuration
- Add profile management commands (add/list/remove/rename/use)
- Add credential extension framework (registry, env provider)
- Add VFS abstraction layer
- Refactor factory default and client options
- Update shortcuts to use new credential and validation patterns

Change-Id: I8c104c6b147e1901d94aefcefe35a174932c742b
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: go mod tidy

Change-Id: I0f610ccea6bc874248e84c24770944a3071dcc57
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: fix test failures from credential provider migration

- Remove unused TAT stub registrations in api and service tests
  (CredentialProvider manages tokens, SDK no longer calls TAT endpoint)
- Update strict mode integration test: +chat-create now supports user
  identity, so it should succeed under strict mode user

Change-Id: Iab51c2e12a97995e0b95dcd71df212d2d1f76570
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: migrate remaining os calls to internal/vfs

Replace direct os.Stat/Open/MkdirAll/OpenFile/Remove/ReadDir/UserHomeDir
with vfs equivalents in shortcuts/minutes, shortcuts/drive, and
internal/keychain. Add ReadDir to the vfs interface and OsFs implementation.

Change-Id: I8f97e5fb3e1731b4684d276644fcb10fae823067

* fix: resolve gofmt and goimports formatting issues

Change-Id: If61578631f5698f7ca2d9a946ca59753651463fb

* feat: add Flag.Input support for @file and stdin input sources

Add framework-level support for reading flag values from files (@path)
or stdin (-), solving the fundamental problem of passing complex text
(markdown, multi-line content) via CLI arguments where shell escaping
breaks content. Closes #239, fixes #163.

- Add File/Stdin constants and Input field to Flag struct
- Add resolveInputFlags() in runner pipeline (pre-Validate)
- Support @@ escape for literal @ prefix
- Guard against multiple stdin consumers
- Auto-append "(supports @file, - for stdin)" to help text
- Apply to: docs +create/+update --markdown, im +messages-send/+reply
  --text/--markdown/--content, task +comment --content,
  drive +add-comment --content

Change-Id: I305a326d972417542aeadd70f37b74ea456461ef
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: fix pre-existing test failures in task, minutes, and registry

- task/minutes: remove unused tenant_access_token httpmock stubs
  (TestFactory's testDefaultToken provides tokens directly, so the
  HTTP stub was never consumed and failed verification)
- registry: fix hasEmbeddedData() to check for actual services instead
  of just byte length (meta_data_default.json has empty services array)

Change-Id: Ic7b5fc7f9de09137a7254fe1ddf47d24ade40587
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: suppress nilerr lint for intentional nil returns

Both cases intentionally return nil on error for graceful degradation:
- profile list: show friendly message when config is not initialized
- service: skip scope check when token resolution fails

Change-Id: I7285c37277c9b0361a421ab00359244c2cd150b3
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address CodeRabbit review feedback

- runner.go: fail fast when Input is used on non-string flags
- remote_test.go: rename hasEmbeddedData → hasEmbeddedServices
- profile/list.go: add omitempty to optional JSON fields
- service.go: surface context cancellation errors in scope check

Change-Id: I7072d41f8c711b4b37c542e32dfd8150f42b13c0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: tighten credential resolution and profile flows

Change-Id: I83f6d424540eab9b1708944b9b6e26e8477cc60d

* refactor: centralize identity hint resolution

Change-Id: I38d5f98160b92adb62dc929ae73697ae5b3d64f8

* fix: surface unverified extension identities

Change-Id: Ia86d9bd19add9010176339ec4cc89deb033f5b4f

* fix: honor runtime credential sources in config views

Change-Id: I40b2ffedc5c1db5e08e86b9472ea2b84fa02bb29

* fix: prefer runtime values in config show commands

Change-Id: I5663a53e147577f0f1f533f67d12bea504e6b839

* Revert "fix: prefer runtime values in config show commands"

This reverts commit 4f9db3a227.

* Revert "fix: honor runtime credential sources in config views"

This reverts commit b3bfd526c5.

* fix: harden profile flows and credential boundaries

Change-Id: Ica61cd2730a639f71516cb1b237a639cb6511f7a

* fix: optimize profile and config inspection for agents

Change-Id: I19c368102f19654952638180ab947788a6971563

* refactor: unify credential env contracts

Change-Id: I0ff2c0a650ea53589a0626333e8f6e628ef10a54

* docs: expand AGENTS guidance

Change-Id: I289027dfd364c92205012feef6f05037066c035b

* fix: resolve regression bugs found during PR #252 review

- im: fix double SafeInputPath in resolveLocalMedia → uploadImageToIM/
  uploadFileToIM chain that rejected all local image/file uploads
- credential: stop writing plain-text warnings to stderr, preserving
  JSON envelope contract for AI agent consumers
- profile add: reject duplicate app-id to prevent keychain credential
  collisions across profiles
- profile rename: exclude self when checking name uniqueness so renaming
  to own appId works correctly
- config: replace bare fmt.Errorf with output.Errorf in save-failure
  paths (default_as, strict_mode ×2, profile add)
- factory: remove unused resolveDefaultAs method (lint)

Change-Id: I6aa0d064414016f367f1edb08dd0604adf7bf13d
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove flaky TestColdStart_UsesEmbedded (race in registry)

The test triggers a data race: resetInit() writes package globals while
a background goroutine from a previous test may still be reading them.
The embedded-data path is covered by other tests.

Change-Id: I7a0c3bf85a9fb337b9279c9053697f40a0c0a0d4
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: type-strengthen Brand and DefaultAs across credential chain

Replace raw string fields with typed enums for compile-time safety:
- extension/credential: add Brand and Identity named types
- internal/core: AppConfig.DefaultAs and CliConfig.DefaultAs → Identity
- internal/credential: Account.DefaultAs and IdentityHint.DefaultAs → core.Identity

The full data flow is now typed end-to-end:
  extcred.Brand → core.LarkBrand (named-type cast)
  extcred.Identity → core.Identity (named-type cast)

No string intermediaries, no implicit conversions.

Change-Id: I715b3b3f033fcb624010f1af9619e3562740ef08
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: fix gofmt alignment in extension/credential/types.go

Change-Id: Ibfac0703a5a28f3c6ba4a47bf40696028d0f3b90
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove file/stdin input support from task comment content flag

Change-Id: If49704ca4612465a23bd30b755d6e72a35fc2349
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(cmdutil): remove dead code autoDetectIdentity

autoDetectIdentity() is only called from tests, never from production
code. Remove it along with its 3 test cases to reduce surface area
before the upcoming ctx propagation refactor.

Change-Id: I35a188860f17656f3e1fe9874f87f284985ae196

* refactor(cmdutil): add ctx parameter to resolveIdentityHint

Private method resolveIdentityHint now accepts context.Context and
passes it to CredentialProvider.ResolveIdentityHint instead of using
context.Background(). The caller (ResolveAs) still uses
context.Background() temporarily until its own signature is updated.

Change-Id: I14634a4e0dc1d657d56936ba61a7b7a206da8ac4

* refactor(cmdutil): add ctx parameter to ResolveStrictMode

ResolveStrictMode now accepts context.Context and passes it to
CredentialProvider.ResolveAccount instead of using context.Background().

Callers in cobra RunE pass cmd.Context(); callers outside RunE
(cmd/root.go startup, tests) use context.Background() explicitly.

Change-Id: I31be48e548ac5ac5640a65f3bfdde4a53ed1dc7e

* refactor(cmdutil): add ctx parameter to CheckStrictMode

CheckStrictMode now accepts context.Context and forwards it to
ResolveStrictMode. Callers pass cmd.Context() (cobra RunE) or
opts.Ctx (APIOptions/ServiceMethodOptions).

Change-Id: I47888519d4cae8c94054771c32aff075565a8cdc

* refactor(cmdutil): add ctx parameter to ResolveAs

ResolveAs now accepts context.Context as first parameter and forwards
it to ResolveStrictMode and resolveIdentityHint. This completes the
ctx propagation chain: all Factory methods that call
CredentialProvider now receive ctx from cobra cmd.Context().

No more context.Background() calls remain in factory.go for
credential provider operations.

Change-Id: I6d10b6350e3b149470660de3e7855614314e8b29

* test: fix gofmt in cmdutil factory tests

Change-Id: I4a87d5a815b959f14cc4371b73dee4aae106932f

* fix: remove file/stdin input support from im send/reply and drive comment

The Input (file/stdin) feature is not yet ready for these flags:
- im send/reply: --content, --text, --markdown
- drive add-comment: --content

Retained only in doc create/update where markdown from file is essential.

Change-Id: I582b6349528fccb639ad9edc84650cca3b68535c
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: liushiyao <liushiyao.1206@bytedance.com>
2026-04-07 15:21:14 +08:00