diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ff298f7..2cc63f6 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,16 +4,18 @@ ## User value - + ## Plugin submission checklist - [ ] Plugin lives at `plugins//`. - [ ] `plugin.json` name matches the Plugin directory. -- [ ] `README.md` includes a real example prompt and expected result. +- [ ] Plugin exposes at least one valid Skill, MCP server, or MiniMax Code Hook. +- [ ] `README.md` includes a real example prompt or lifecycle reproduction and expected result. - [ ] `LICENSE` and `plugin.json` declare an open-source license. - [ ] Required executables, accounts, paid services, and supported platforms are disclosed. - [ ] Network destinations and data handled by the plugin are disclosed. +- [ ] Hook events, commands, implicit side effects, and stored data locations are disclosed. - [ ] No credentials, private endpoints, hidden telemetry, installers, symlinks, or native binaries are included. - [ ] Every scaffold `TODO` has been replaced. - [ ] `npm run check` passes. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e06db84..6c805bf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,15 +14,21 @@ npm run create -- / The command creates `plugins//` with a portable `plugin.json`, README, Apache-2.0 license, and starter Skill. +For a Hook-only Plugin, start from [`examples/hello-mcode-hooks`](examples/hello-mcode-hooks/) and +keep the same hosted README, LICENSE, and manifest requirements. + ## 2. Make it real Replace every scaffold `TODO`. Your Plugin must: -- expose at least one Skill or MCP server; +- expose at least one Skill, MCP server, or MiniMax Code Hook; - use the supported package shape in [`docs/plugin-compatibility.md`](docs/plugin-compatibility.md); +- follow the versioned [`Hooks 0.1 contract`](docs/hooks.md) for every Hook; - include `README.md`, `LICENSE`, and a matching open-source license in `plugin.json`; -- explain the user problem, an example prompt, and the expected result; +- explain the user problem, a copyable prompt or lifecycle reproduction, and the expected result; - disclose required executables, accounts, paid services, platforms, network destinations, and data; +- for Hooks, disclose every event, command, implicit side effect, received data category, and stored + data location; - contain no credentials, private endpoints, hidden telemetry, installers, native binaries, or symlinks. Keep source and docs inside your Plugin directory. Do not edit another contributor's Plugin in the @@ -34,23 +40,26 @@ same pull request. npm run check ``` -The validator checks the hosted directory, Manifest, Skills, MCP transports, required docs, -placeholders, and path safety. CI runs the same command. +The validator checks the hosted directory, Manifest, Skills, MCP transports, Hooks, required docs, +placeholders, and path safety. CI parses Hook configuration and source as data and never executes a +contributed Hook command. ## 4. Open the pull request Include: - the problem your Plugin solves; -- a copyable example prompt; +- a copyable example prompt or lifecycle reproduction; - the expected result; - dependencies and supported platforms; - network and data behavior; - automated and manual test evidence. -Review covers usefulness, reproducibility, clear ownership, data flow, dependency risk, and obvious -supply-chain issues. Acceptance means “available as community software”; it is not a MiniMax -endorsement or a complete security audit. +Review covers usefulness, reproducibility, clear ownership, implicit execution, data flow, +dependency risk, and obvious supply-chain issues. Acceptance means “available as community +software”; it is not a MiniMax endorsement or a complete security audit. Hook acceptance confirms +the staged registry declaration only. It does not establish runtime availability until this +repository links a compatible MiniMax Code implementation and end-to-end conformance evidence. ## Update or remove a Plugin diff --git a/README.md b/README.md index 37e4c6c..4dca694 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,9 @@ ## One folder is the release -MiniMax Code Plugins is the community home for Agent Plugins that run in MiniMax Code. Put a -portable Plugin under `plugins//`, open a pull request, and let CI check -the package users will actually install. +MiniMax Code Plugins is the community home for Agent Plugins that run in MiniMax Code. Put a Plugin +under `plugins//`, open a pull request, and let CI check the package users +will actually install. ```text fork → create → build → check → pull request → discover @@ -71,15 +71,24 @@ good prompt pattern to a capability anyone can install. Connect MiniMax Code to local tools or remote services with `stdio`, `streamable-http`, or `sse`. Dependencies, accounts, network destinations, and data handling must be visible before install. -### Both +### Hooks -Use a Skill to teach the workflow and MCP to provide the tools. The portable package stays small: +Declare observe-only commands for six MiniMax Code lifecycle points with the staged, versioned +[`io.minimax.mcode` Hooks 0.1 client extension](docs/hooks.md). The registry validates explicit +commands, arguments, and disclosures. Runtime execution remains unavailable as a documented public +capability until a compatible MiniMax Code build and end-to-end evidence are linked. + +### Combine them + +Use Skills to teach the workflow, MCP to provide tools, and Hooks for bounded lifecycle side effects: ```text plugin-root/ ├── plugin.json ├── mcp.json # optional -└── skills/ # optional +├── skills/ # optional +└── io.minimax.mcode/ # optional MiniMax Code extension + └── hooks/hooks.json ``` This repository is for **Agent capabilities**. TUI Extensions are a separate system and are not @@ -91,7 +100,7 @@ A contribution must: - live at `plugins//`; - include `plugin.json`, `README.md`, and `LICENSE`; -- expose at least one valid Skill or MCP server; +- expose at least one valid Skill, MCP server, or MiniMax Code Hook; - document a copyable example, requirements, network access, and data use; - contain no secrets, private endpoints, hidden telemetry, native binaries, or symlinks; - pass `npm run check` and human review. @@ -104,16 +113,19 @@ or a complete security audit. Read the source and requested capabilities before - [`plugins/`](plugins/) — community Plugin source - [`examples/hello-mcode`](examples/hello-mcode/) — smallest Skill Plugin - [`examples/hello-mcode-mcp`](examples/hello-mcode-mcp/) — dependency-free stdio MCP +- [`examples/hello-mcode-hooks`](examples/hello-mcode-hooks/) — smallest Hook-only Plugin - [`docs/plugin-compatibility.md`](docs/plugin-compatibility.md) — exact supported contract +- [`docs/hooks.md`](docs/hooks.md) — MiniMax Code Hooks 0.1 author and runtime contract - [`docs/security-model.md`](docs/security-model.md) — validation and trust model - [`docs/architecture.md`](docs/architecture.md) — hosted contribution architecture - [`GOVERNANCE.md`](GOVERNANCE.md) — decisions and maintainer responsibilities ## Community preview -The contract is intentionally narrow while MiniMax Code's public Plugin surface stabilizes. Hooks, -custom Agents, Commands, LSP, Apps, generic OAuth, and TUI Extensions are not advertised as current -Agent Plugin capabilities. +The contract is intentionally narrow while MiniMax Code's public Plugin surface stabilizes. Hooks +0.1 is accepted as a staged registry declaration; this repository does not yet certify a runtime +build that executes it. Custom Agents, Commands, LSP, Apps, generic OAuth, blocking Hooks, and TUI +Extensions are not advertised as current Agent Plugin capabilities. Bring one useful capability. Make the example undeniable. Ship it in one pull request. diff --git a/README.zh-CN.md b/README.zh-CN.md index b77b9a4..0ef4a86 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -69,15 +69,23 @@ npm run check 通过 `stdio`、`streamable-http` 或 `sse` 连接本地工具和远程服务。依赖、账号、网络目标和数据处理必须 在安装前说清楚。 -### Skill + MCP +### Hooks -Skill 教会 Agent 怎么做,MCP 给它真正的工具。可移植包结构保持简单: +通过分阶段、版本化的 [`io.minimax.mcode` Hooks 0.1 客户端扩展](docs/hooks.md),声明 MiniMax Code +六个生命周期点的只观察命令。仓库会校验 command、args 和披露内容;在链接兼容的 MiniMax Code 构建及 +端到端证据前,不把运行时执行宣传为已公开可用能力。 + +### 组合使用 + +Skill 教会 Agent 怎么做,MCP 提供工具,Hook 负责有边界的生命周期副作用: ```text plugin-root/ ├── plugin.json ├── mcp.json # 可选 -└── skills/ # 可选 +├── skills/ # 可选 +└── io.minimax.mcode/ # 可选的 MiniMax Code 扩展 + └── hooks/hooks.json ``` 这个仓库只承接 **Agent 能力**。TUI Extension 是另一套独立扩展体系,不使用这里的包格式和加载流程。 @@ -88,7 +96,7 @@ plugin-root/ - 位于 `plugins//`; - 包含 `plugin.json`、`README.md` 和 `LICENSE`; -- 至少提供一个有效的 Skill 或 MCP Server; +- 至少提供一个有效的 Skill、MCP Server 或 MiniMax Code Hook; - 写清示例、依赖、网络访问和数据用途; - 不包含密钥、私有地址、隐藏遥测、原生二进制或 symlink; - 通过 `npm run check` 和人工 Review。 @@ -101,15 +109,18 @@ plugin-root/ - [`plugins/`](plugins/):社区 Plugin 源码 - [`examples/hello-mcode`](examples/hello-mcode/):最小 Skill Plugin - [`examples/hello-mcode-mcp`](examples/hello-mcode-mcp/):零依赖 stdio MCP +- [`examples/hello-mcode-hooks`](examples/hello-mcode-hooks/):最小 Hook-only Plugin - [`docs/plugin-compatibility.md`](docs/plugin-compatibility.md):当前支持的精确契约 +- [`docs/hooks.md`](docs/hooks.md):MiniMax Code Hooks 0.1 作者与运行时契约 - [`docs/security-model.md`](docs/security-model.md):校验与信任模型 - [`docs/architecture.md`](docs/architecture.md):中央托管架构 - [`GOVERNANCE.md`](GOVERNANCE.md):决策与维护者职责 ## Community Preview -MiniMax Code 的公开 Plugin 能力仍在稳定中,所以首版契约刻意保持克制。Hooks、自定义 Agent、Commands、 -LSP、Apps、通用 OAuth 和 TUI Extension 暂不作为当前 Agent Plugin 能力宣传。 +MiniMax Code 的公开 Plugin 能力仍在稳定中,所以契约刻意保持克制。仓库接受 Hooks 0.1 的分阶段声明, +但尚未认证并链接可以执行它的运行时构建。自定义 Agent、Commands、LSP、Apps、通用 OAuth、阻断型 +Hooks 和 TUI Extension 暂不作为当前 Agent Plugin 能力宣传。 带来一个真的有用的能力,给出一个无法误解的示例,然后用一个 PR 把它发布出来。 diff --git a/docs/architecture.md b/docs/architecture.md index e44624e..b7c79b4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,7 +4,7 @@ MiniMax Code Plugins keeps contribution and review in one repository. ```text plugins// - plugin.json + README + LICENSE + Skills and/or MCP + plugin.json + README + LICENSE + Skills, MCP, and/or Hooks | v local validation -> pull request CI -> human review @@ -16,9 +16,10 @@ main branch -> catalog consumers / MiniMax Code discovery The hosted directory is the publication unit. Reviewers inspect the exact files that enter `main`; contributors do not create a second repository or maintain a separate catalog record. -Static validation reads package metadata and text contracts without executing Plugin code. Human -review covers usefulness, dependencies, data flow, and reproducibility. MCP runtime behavior and -external service quality still require explicit test evidence. +Static validation reads package metadata and text contracts without executing Plugin code. In +particular, CI never executes contributed Hook commands. Human review covers usefulness, +dependencies, implicit execution, data flow, and reproducibility. MCP and Hook runtime behavior and +external service quality still require explicit test evidence from a compatible client. The first version intentionally optimizes for a low-friction community path. External source registries, release mirroring, and Marketplace publishing interfaces can be proposed later without diff --git a/docs/hooks.md b/docs/hooks.md new file mode 100644 index 0000000..6b8ca3d --- /dev/null +++ b/docs/hooks.md @@ -0,0 +1,210 @@ +# MiniMax Code Hooks 0.1 + +MiniMax Code Hooks 0.1 is a staged, versioned client-extension contract for Plugin authors who need +to declare observe-only commands at common agent lifecycle points. This community registry accepts +and statically validates the declaration alongside Agent Skills and MCP servers. + +Hooks 0.1 is not an Agent Plugins 1.0 portable component. Other Agent Plugins clients may ignore it, +and a MiniMax Code build must advertise support for the exact Hooks schema before it executes the +extension. Registry acceptance validates the package contract; it does not prove that every released +client build implements it. + +This repository does not yet link a MiniMax Code runtime implementation and end-to-end conformance +fixture for Hooks 0.1. Until it does, this is a registry-supported declaration format, not a claim +that a current MiniMax Code release executes Hooks. + +## Package layout + +Hooks use the Agent Plugins client-extension namespace `io.minimax.mcode`: + +```text +plugin-root/ +├── plugin.json +└── io.minimax.mcode/ + └── hooks/ + ├── hooks.json + └── scripts/ + └── record.mjs +``` + +The root `plugin.json` continues to target Agent Plugins 1.0. Do not add a root `hooks` field. The +fixed Hooks configuration path is: + +```text +io.minimax.mcode/hooks/hooks.json +``` + +`hooks.json` must target the published 0.1.0 schema: + +```json +"$schema": "https://raw.githubusercontent.com/MiniMax-AI/MiniMax-Code-Plugins/main/schemas/io.minimax.mcode/hooks/0.1.0.schema.json" +``` + +Clients select supported behavior from that exact identifier using a local schema table. They must +not fetch a schema while loading a Plugin. The versioned schema file is immutable; any structural or +behavioral change requires a new identifier and file. + +This document is the human-readable contract. If it conflicts with the published schema, this +document governs and the mismatch is a specification defect that must be corrected in a new pull +request without reassigning a published schema identifier. + +## Configuration + +This example records two event types with a bundled, dependency-free Node.js script: + +```json +{ + "$schema": "https://raw.githubusercontent.com/MiniMax-AI/MiniMax-Code-Plugins/main/schemas/io.minimax.mcode/hooks/0.1.0.schema.json", + "hooks": { + "pre-tool-use": [ + { + "command": "node", + "args": [ + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs", + "pre-tool-use" + ] + } + ], + "post-tool-use": [ + { + "command": "node", + "args": [ + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs", + "post-tool-use" + ], + "cwd": "${PLUGIN_DATA}" + } + ] + } +} +``` + +The document and every handler are closed objects. Unknown fields fail community-registry +validation. + +### Events + +Hooks 0.1 defines six exact event names: + +| Event | Trigger | +| --- | --- | +| `session-start` | A supporting runtime attaches to a newly created or resumed top-level session. | +| `turn-start` | The runtime accepts user input and creates a new top-level agent turn. | +| `pre-tool-use` | One finalized top-level tool attempt exists, before permission evaluation or execution. | +| `post-tool-use` | The matching tool attempt reaches a terminal outcome, including success, failure, denial, timeout, or cancellation. | +| `turn-end` | The accepted top-level turn reaches completion, failure, or cancellation; transient idle and permission waits are not terminal. | +| `session-end` | The supporting runtime intentionally detaches from or closes the top-level session. | + +Every event is observe-only. A Hook cannot block or approve an action, rewrite tool input or output, +inject model context, or change agent control flow. Those behaviors require a future versioned +contract. + +MiniMax Code supports at most eight handlers for one event and 32 handlers across one Plugin. A +Hooks document must contain at least one event and every declared event must contain at least one +handler. + +### Occurrence and delivery semantics + +- A resume emits one new `session-start` for that runtime attachment, but does not replay events + already emitted by an earlier attachment. +- Every top-level tool attempt emits one `pre-tool-use` and one matching `post-tool-use`. Permission + denial, spawn failure, timeout, and cancellation still produce the matching terminal event. A + retry is a new attempt and therefore a new pair. +- Parallel tool attempts each receive their own pair. Their events may interleave; no global order + across attempts is defined. +- Subagent turns and tools do not emit Hooks 0.1 events. Background or remote execution emits events + only when that execution is itself the top-level session in a runtime advertising this schema. +- For each emitted event occurrence, the runtime makes exactly one invocation attempt for every + configured handler. Delivery is not durable and failed or interrupted handlers are not retried. + An abrupt runtime or machine failure can prevent an attempt. `session-end` emission itself is best + effort; its handlers are attempted in declaration order only while the shutdown budget remains, + so later attempts may be omitted when shutdown wins. + +### Handler fields + +| Field | Required | Contract | +| --- | --- | --- | +| `command` | yes | One bare executable token or one contained Plugin-relative path beginning with `./`. Never a shell command string. | +| `args` | no | At most 64 string arguments, passed as distinct process arguments without shell parsing. | +| `env` | no | At most 64 portable environment names with string values. `PLUGIN_ROOT` and `PLUGIN_DATA` are reserved case-insensitively. | +| `cwd` | no | A contained `./` path, `${PLUGIN_ROOT}` path, or `${PLUGIN_DATA}` path. Defaults to the Plugin root. | + +NUL bytes are invalid in process fields. A Plugin-relative executable, explicit working directory, +or bundled script must remain inside its filesystem-resolved root. Hosted Plugins cannot contain +symlinks. + +## Runtime process contract + +A compatible MiniMax Code build: + +1. resolves `command` as one executable token and never sends it through a shell; +2. expands `${PLUGIN_ROOT}` and `${PLUGIN_DATA}` once and non-recursively in `args`, `env` values, + and `cwd`, but not in `command` or environment names; unknown placeholder-like text remains + literal and no other environment expansion occurs; +3. supplies absolute `PLUGIN_ROOT` and per-installed-instance `PLUGIN_DATA` values after configured + environment overlays, so a Plugin cannot override them; +4. uses the Plugin root as the default working directory; +5. sanitizes the base environment instead of exposing ambient credentials by default; and +6. enforces client-owned limits for time, stdin, stdout, stderr, process-tree lifetime, invocation + count, and concurrency. + +The client may send a MiniMax-native event object as one UTF-8 JSON document on stdin followed by +EOF. Hooks 0.1 does not standardize that payload. Plugin behavior intended to work across MiniMax +Code versions should use explicit arguments where possible and tolerate missing or unknown stdin +fields. A Hook must not persist prompt, transcript, tool input, or tool output unless its README +discloses that data flow. + +Stdout, stderr, exit status, timeout, cancellation, and spawn failure are diagnostic-only. They +cannot change agent or permission behavior. `session-end` is best effort and cannot delay client +shutdown beyond the client's own budget. + +Handlers within one Plugin run in declaration order. Plugins must not depend on ordering relative to +other Plugins. + +The published JSON Schema enforces document shape and per-field/per-event limits. Standard JSON +Schema cannot express the sum of array lengths across six event properties, so the 32-handler total +is an explicit registry-validator and runtime requirement in addition to the schema. + +## Validation and failure boundaries + +Community CI validates every declared Hook entry and rejects an invalid contribution. It parses +configuration and source as data and never executes contributed Hook code. + +Runtime implementations use narrower failure boundaries: + +1. an invalid root `plugin.json` rejects the Plugin under Agent Plugins core rules; +2. an escaped or invalid `io.minimax.mcode` directory disables that extension; +3. invalid JSON, an unsupported schema, or an invalid Hooks document disables Hooks for that Plugin + while valid Skills and MCP servers continue loading; +4. an invalid handler skips that handler without disabling valid siblings; and +5. one command failure does not disable later Hooks or independent components. + +A client that does not implement the exact schema ignores the extension and should report that it is +unsupported. It must not guess compatibility from a similar version. + +## Author checklist + +Before submitting a Hook Plugin: + +- use the fixed namespace path and exact schema identifier; +- keep `command` and `args` separate and avoid shell parsing in wrapper scripts; +- bundle inspectable source instead of installers or native binaries; +- document every event, executable, platform requirement, file write, network destination, and data + category the Hook can receive or persist; +- provide a copyable lifecycle reproduction and expected result; +- make repeated execution safe and bound all stored data; +- do not include secrets, private endpoints, hidden telemetry, or personal data; and +- run `npm run check` without executing the Hook itself. + +See [`examples/hello-mcode-hooks`](../examples/hello-mcode-hooks/) for a minimal Hook-only package. + +## Portability and future versions + +The six events and command declaration align with the active upstream +[Portable Hooks Component Type discussion](https://github.com/agentplugins/agent-plugins-spec/discussions/54). +That discussion is not a published Agent Plugins release. If Agent Plugins standardizes root +`hooks.json`, this extension can migrate only through an explicit new schema and documented package +change. + +Blocking policy, context injection, portable payloads, subagent lifecycle, additional events, and +HTTP or model-backed handlers remain unsupported in 0.1. diff --git a/docs/plugin-compatibility.md b/docs/plugin-compatibility.md index b7fcf0a..a891363 100644 --- a/docs/plugin-compatibility.md +++ b/docs/plugin-compatibility.md @@ -1,20 +1,34 @@ # MiniMax Code plugin compatibility -## Portable package +## Package contract -MiniMax Code reads the portable subset of Agent Plugins 1.0: +MiniMax Code Plugins accepts the portable Agent Plugins 1.0 subset plus the versioned MiniMax Code +Hooks 0.1 client extension: ```text plugin-root/ -├── README.md # required by this community repository -├── LICENSE # required by this community repository +├── README.md # required by this community repository +├── LICENSE # required by this community repository ├── plugin.json -├── mcp.json # optional -└── skills/ - └── / - └── SKILL.md +├── mcp.json # optional Agent Plugins component +├── skills/ # optional Agent Plugins component +│ └── / +│ └── SKILL.md +└── io.minimax.mcode/ # optional MiniMax Code client extension + └── hooks/ + └── hooks.json ``` +A hosted contribution must expose at least one valid Skill, MCP server, or MiniMax Code Hook. +Hook-only packages are accepted, but Agent Plugins clients that do not implement the MiniMax +extension may load no usable component from them. + +Hooks 0.1 is currently a staged registry declaration. This repository does not yet link a MiniMax +Code runtime build and end-to-end fixture certified to execute it, so package acceptance must not be +presented as current runtime availability. + +## Manifest + `plugin.json` must target: ```json @@ -22,8 +36,13 @@ plugin-root/ ``` The only required manifest fields are `$schema` and `name`. Adding `version`, `description`, -`author`, `homepage`, `repository`, `license`, and `keywords` improves catalog quality. Client -`extensions` may be present but are ignored by MiniMax Code. +`author`, `homepage`, `repository`, `license`, and `keywords` improves catalog quality. The community +repository additionally requires a declared license for hosted Plugins. + +Do not add a root `hooks` field. Agent Plugins 1.0 client-specific files belong under their stable +reverse-domain directory. The registry recognizes `io.minimax.mcode/hooks/hooks.json`, and only a +compatible runtime advertising its exact schema may interpret it. Other extension namespaces remain +ignored unless separately documented. ## Skills @@ -47,24 +66,62 @@ Supported transports are: - `streamable-http`, using an HTTP(S) URL and optional headers; and - `sse`, retained for compatible legacy HTTP+SSE servers. -MiniMax Code reserves `PLUGIN_ROOT` and `PLUGIN_DATA`. A plugin must not set those variables itself. +MiniMax Code reserves `PLUGIN_ROOT` and `PLUGIN_DATA`. A Plugin must not set those variables itself. Do not embed tokens in environment values or headers. Generic OAuth configuration is not part of this portable subset. -## Limits and unsupported capabilities +## Hooks + +MiniMax Code Hooks is a client extension, not an Agent Plugins 1.0 portable component. The fixed +configuration path is: + +```text +io.minimax.mcode/hooks/hooks.json +``` + +The document must target the exact 0.1.0 schema: + +```json +"$schema": "https://raw.githubusercontent.com/MiniMax-AI/MiniMax-Code-Plugins/main/schemas/io.minimax.mcode/hooks/0.1.0.schema.json" +``` + +Hooks 0.1 supports six observe-only lifecycle events and command handlers with optional `args`, +`env`, and `cwd`. It does not support blocking, approvals, tool rewriting, model-context injection, +or portable event payloads. Read the complete [`Hooks 0.1 contract`](hooks.md) before contributing. + +Community validation recognizes the package and rejects every invalid declared handler. A MiniMax +Code build executes the extension only when it advertises support for this exact schema. Registry +acceptance does not imply that every current or older client build can run it. + +The schema covers document shape and per-event limits. Because JSON Schema cannot sum array lengths +across event properties, the repository validator separately enforces the normative 32-handler +Plugin total. + +## Limits and failure boundaries + +MiniMax Code accepts at most: + +- 64 Skill directories per Plugin; +- 8 MCP servers per Plugin; +- 8 Hook handlers for one event; and +- 32 Hook handlers across one Plugin. + +Community CI rejects invalid declared components so broken packages do not enter the hosted +catalog. Runtime implementations use narrower failure boundaries: invalid Skills, MCP entries, and +Hook handlers are omitted with diagnostics while independent valid components continue loading. An +invalid root manifest rejects the package. -The runtime accepts at most 64 Skill directories and 8 MCP servers per Agent Plugin. Invalid Skills -or MCP entries are omitted with diagnostics; an invalid root manifest rejects the package. +## Unsupported capabilities -The following are not currently public MCode Plugin capabilities: +The following are not current MiniMax Code Agent Plugin capabilities: -- Hooks and lifecycle scripts -- custom Agents and Commands -- LSP configuration -- Apps or UI extensions -- generic OAuth setup -- host-specific fields hidden in `extensions` +- blocking or context-producing Hooks and lifecycle scripts; +- custom Agents and Commands; +- LSP configuration; +- Apps or UI extensions; +- generic OAuth setup; and +- undocumented host-specific fields hidden in `extensions`. -Hosted contributions may contain extra assets, but documentation must not imply that MiniMax Code -loads unsupported components. TUI Extensions are a separate product extension system, not an Agent -Plugin capability. +Hosted contributions may contain assets used by supported components, but documentation must not +imply that MiniMax Code loads unsupported content. TUI Extensions are a separate product extension +system, not an Agent Plugin capability. diff --git a/docs/security-model.md b/docs/security-model.md index f7b2649..7d78fcf 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -2,15 +2,28 @@ Hosted Plugins use three layers of evidence: -1. **Package validation** checks the owner/name layout, Manifest, Skill and MCP contracts, required - documentation, unfinished placeholders, and symlinks without executing Plugin code. +1. **Package validation** checks the owner/name layout, Manifest, Skill, MCP, and Hooks contracts, + required documentation, unfinished placeholders, and symlinks without executing Plugin code. 2. **Pull request evidence** makes every source change reviewable before it reaches `main`. 3. **Human review** evaluates usefulness, dependencies, data flow, suspicious code, maintenance ownership, and reproducible test evidence. These checks reduce ambiguity but are not a sandbox or full audit. `stdio` MCP servers execute local programs with the user's permissions. Remote MCP servers send data to configured destinations. -Skills can instruct an agent to use tools and change files. +Skills can instruct an agent to use tools and change files. Hooks can execute local commands +implicitly at lifecycle points and may receive prompt or tool data, so they remain disabled until a +compatible client obtains user or organization authorization. + +Hook authors must disclose every event, executable, platform requirement, file write, network +destination, and data category the Hook can receive or persist. Hooks 0.1 is observe-only: output, +failure, or timeout cannot approve, deny, rewrite, or otherwise change agent behavior. Clients must +sanitize ambient environment values, isolate `PLUGIN_DATA`, bound process resources and diagnostics, +and provide global and per-Plugin disable controls. + +Static registry validation parses Hooks as data and never executes commands from hosted +contributions under `plugins/`. Repository tests may execute explicitly reviewed, repository-owned +example fixtures with a minimal environment. Adding a Hook, changing its executable, or expanding +its data access is a capability change that runtime clients should present for authorization again. Never commit credentials, private endpoints, or personal data. Use runtime-supported secret and environment mechanisms. Maintainers may quarantine or remove a Plugin while a security report is diff --git a/examples/hello-mcode-hooks/README.md b/examples/hello-mcode-hooks/README.md new file mode 100644 index 0000000..a0e9fc2 --- /dev/null +++ b/examples/hello-mcode-hooks/README.md @@ -0,0 +1,33 @@ +# Hello MiniMax Code Hooks + +This Hook-only example records `pre-tool-use` and `post-tool-use` event names in +`${PLUGIN_DATA}/events/`. It consumes but never stores the client-native event payload and retains +at most 128 atomic JSON records, pruning the oldest records after concurrent invocations. + +## Requirements + +- a MiniMax Code build that advertises the MiniMax Code Hooks 0.1 schema; +- Node.js 22 or newer on `PATH`; and +- user or organization approval to execute the bundled Hook script. + +It requires no account or paid service, makes no network requests, and stores only an event name and +timestamp in the Plugin's isolated data directory. + +This repository does not yet link a MiniMax Code runtime build and end-to-end fixture certified for +Hooks 0.1. The package is therefore a declaration and validation example, not evidence that a +currently released client will execute it. + +## Try it + +After installing the Plugin into a compatible MiniMax Code build, ask: + +```text +List the files in this workspace. +``` + +Expected result: MiniMax Code performs its normal tool work, while the Hook appends bounded event +records to `${PLUGIN_DATA}/events/`. The Hook cannot block or modify the tool call. + +Run `npm run check` from the repository root. Static Plugin validation does not execute Hook +commands; the test suite separately executes this repository-owned example with a minimal +environment to verify bounded concurrent writes. diff --git a/examples/hello-mcode-hooks/io.minimax.mcode/hooks/hooks.json b/examples/hello-mcode-hooks/io.minimax.mcode/hooks/hooks.json new file mode 100644 index 0000000..7736130 --- /dev/null +++ b/examples/hello-mcode-hooks/io.minimax.mcode/hooks/hooks.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://raw.githubusercontent.com/MiniMax-AI/MiniMax-Code-Plugins/main/schemas/io.minimax.mcode/hooks/0.1.0.schema.json", + "hooks": { + "pre-tool-use": [ + { + "command": "node", + "args": [ + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs", + "pre-tool-use" + ] + } + ], + "post-tool-use": [ + { + "command": "node", + "args": [ + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs", + "post-tool-use" + ] + } + ] + } +} diff --git a/examples/hello-mcode-hooks/io.minimax.mcode/hooks/scripts/record.mjs b/examples/hello-mcode-hooks/io.minimax.mcode/hooks/scripts/record.mjs new file mode 100644 index 0000000..17bf406 --- /dev/null +++ b/examples/hello-mcode-hooks/io.minimax.mcode/hooks/scripts/record.mjs @@ -0,0 +1,55 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, readdir, rename, unlink, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +const MAX_RECORDS = 128; +const STALE_TEMP_MS = 5 * 60 * 1_000; +const TEMP_RECORD = /^(?\d{13})-\d{20}-[0-9a-f-]{36}\.tmp$/u; + +const event = process.argv[2]; +const supportedEvents = new Set(['pre-tool-use', 'post-tool-use']); +if (!supportedEvents.has(event)) throw new Error('expected a configured Hook event name'); + +let inputBytes = 0; +for await (const chunk of process.stdin) { + inputBytes += chunk.length; + if (inputBytes > 64 * 1024) throw new Error('Hook input exceeds 64 KiB'); +} + +const dataRoot = process.env.PLUGIN_DATA; +if (!dataRoot || !path.isAbsolute(dataRoot)) throw new Error('PLUGIN_DATA must be an absolute path'); + +const recordsRoot = path.join(dataRoot, 'events'); +await mkdir(recordsRoot, { recursive: true }); +const timestamp = Date.now(); +const recordStem = `${timestamp.toString().padStart(13, '0')}-${process.hrtime.bigint().toString().padStart(20, '0')}-${randomUUID()}`; +const temporary = path.join(recordsRoot, `${recordStem}.tmp`); +const published = path.join(recordsRoot, `${recordStem}.json`); +await writeFile( + temporary, + `${JSON.stringify({ event, timestamp: new Date().toISOString() })}\n`, + { encoding: 'utf8', flag: 'wx', mode: 0o600 }, +); +await rename(temporary, published); + +const entries = await readdir(recordsRoot, { withFileTypes: true }); +const records = entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) + .map((entry) => entry.name) + .sort(); +for (const expired of records.slice(0, Math.max(0, records.length - MAX_RECORDS))) { + try { + await unlink(path.join(recordsRoot, expired)); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } +} +for (const entry of entries.filter((item) => item.isFile())) { + const match = TEMP_RECORD.exec(entry.name); + if (!match || timestamp - Number(match.groups.timestamp) <= STALE_TEMP_MS) continue; + try { + await unlink(path.join(recordsRoot, entry.name)); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } +} diff --git a/examples/hello-mcode-hooks/plugin.json b/examples/hello-mcode-hooks/plugin.json new file mode 100644 index 0000000..b0bee3b --- /dev/null +++ b/examples/hello-mcode-hooks/plugin.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "hello-mcode-hooks", + "version": "0.1.0", + "description": "Record MiniMax Code Hook event names locally without retaining event payloads.", + "license": "Apache-2.0" +} diff --git a/package-lock.json b/package-lock.json index 497688a..3e30ee3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,70 @@ "name": "minimax-code-plugins", "version": "0.1.0", "license": "Apache-2.0", + "devDependencies": { + "ajv": "^8.17.1" + }, "engines": { "node": ">=22" } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } } } } diff --git a/package.json b/package.json index 77520b5..2d25648 100644 --- a/package.json +++ b/package.json @@ -13,5 +13,8 @@ "test": "node --test", "validate": "node scripts/validate.mjs" }, + "devDependencies": { + "ajv": "^8.17.1" + }, "license": "Apache-2.0" } diff --git a/plugins/README.md b/plugins/README.md index f05168d..595bb43 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -10,7 +10,9 @@ plugins/ ├── README.md ├── LICENSE ├── mcp.json # optional - └── skills/ # optional + ├── skills/ # optional + └── io.minimax.mcode/ # optional MiniMax Code Hooks extension + └── hooks/hooks.json ``` Create a contribution from the repository root: @@ -21,3 +23,8 @@ npm run create -- / Replace every `TODO`, run `npm run check`, and open one pull request for one Plugin. See [`CONTRIBUTING.md`](../CONTRIBUTING.md) for review and security requirements. + +A Plugin must contain at least one valid Skill, MCP server, or MiniMax Code Hook. Hook authors should +start with [`examples/hello-mcode-hooks`](../examples/hello-mcode-hooks/) and read the complete +[`Hooks 0.1 contract`](../docs/hooks.md). Hooks are currently a staged registry declaration; this +repository does not yet certify a MiniMax Code runtime build that executes them. diff --git a/proposals/README.md b/proposals/README.md index ee8f249..c39e33a 100644 --- a/proposals/README.md +++ b/proposals/README.md @@ -1,11 +1,16 @@ # Capability proposals This directory records evidence and design proposals for future Agent capabilities outside the -portable Agent Plugins 1.0 surface, such as Hooks, custom Agents, Commands, LSP, and OAuth. +current package contract, such as custom Agents, Commands, LSP, OAuth, and Hooks behavior beyond the +observe-only 0.1 contract. A proposal is not a supported plugin capability. It must identify real user workflows, define host ownership and security boundaries, explain cross-client portability, and link to implementation and conformance evidence before documentation can present it as available. +The staged Hooks 0.1 declaration contract lives in [`docs/hooks.md`](../docs/hooks.md). Static +registry acceptance does not promote Hooks to an available runtime capability; that promotion still +requires a linked MiniMax Code implementation and end-to-end conformance evidence under this rule. + TUI Extensions are a separate product extension system. They do not enter this proposal or package contract. diff --git a/schemas/io.minimax.mcode/hooks/0.1.0.schema.json b/schemas/io.minimax.mcode/hooks/0.1.0.schema.json new file mode 100644 index 0000000..112bbc3 --- /dev/null +++ b/schemas/io.minimax.mcode/hooks/0.1.0.schema.json @@ -0,0 +1,88 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/MiniMax-AI/MiniMax-Code-Plugins/main/schemas/io.minimax.mcode/hooks/0.1.0.schema.json", + "title": "MiniMax Code Hooks client extension 0.1.0", + "description": "Observe-only lifecycle Hook declarations for the io.minimax.mcode Agent Plugins client extension.", + "type": "object", + "additionalProperties": false, + "required": ["$schema", "hooks"], + "properties": { + "$schema": { + "const": "https://raw.githubusercontent.com/MiniMax-AI/MiniMax-Code-Plugins/main/schemas/io.minimax.mcode/hooks/0.1.0.schema.json" + }, + "hooks": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "$comment": "JSON Schema cannot express the cross-property sum. The community registry validator additionally limits the combined total to 32 handlers per plugin.", + "properties": { + "session-start": { "$ref": "#/$defs/handlers" }, + "turn-start": { "$ref": "#/$defs/handlers" }, + "pre-tool-use": { "$ref": "#/$defs/handlers" }, + "post-tool-use": { "$ref": "#/$defs/handlers" }, + "turn-end": { "$ref": "#/$defs/handlers" }, + "session-end": { "$ref": "#/$defs/handlers" } + } + } + }, + "$defs": { + "handlers": { + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": { "$ref": "#/$defs/handler" } + }, + "handler": { + "type": "object", + "additionalProperties": false, + "required": ["command"], + "properties": { + "command": { + "type": "string", + "anyOf": [ + { + "description": "A single bare executable token.", + "pattern": "^[^\\s/\\\\\\u0000]+$" + }, + { + "description": "A contained plugin-relative executable path.", + "pattern": "^\\./(?!(?:\\.\\.(?:/|$)|.*\\/\\.\\.(?:/|$)))[^\\\\\\u0000]+$" + } + ] + }, + "args": { + "type": "array", + "maxItems": 64, + "items": { "type": "string", "pattern": "^[^\\u0000]*$" } + }, + "env": { + "type": "object", + "maxProperties": 64, + "propertyNames": { + "allOf": [ + { "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" }, + { + "not": { + "pattern": "^(?:[Pp][Ll][Uu][Gg][Ii][Nn]_[Rr][Oo][Oo][Tt]|[Pp][Ll][Uu][Gg][Ii][Nn]_[Dd][Aa][Tt][Aa])$" + } + } + ] + }, + "additionalProperties": { "type": "string", "pattern": "^[^\\u0000]*$" }, + "$comment": "PLUGIN_ROOT and PLUGIN_DATA are reserved case-insensitively." + }, + "cwd": { + "type": "string", + "anyOf": [ + { + "pattern": "^\\./(?!(?:\\.\\.(?:/|$)|.*\\/\\.\\.(?:/|$)))[^\\\\\\u0000]*$" + }, + { + "pattern": "^\\$\\{PLUGIN_(?:ROOT|DATA)\\}(?:/(?!(?:\\.\\.(?:/|$)|.*\\/\\.\\.(?:/|$)))[^\\\\\\u0000]*)?$" + } + ] + } + } + } + } +} diff --git a/scripts/lib/validation.mjs b/scripts/lib/validation.mjs index cc2a324..333c273 100644 --- a/scripts/lib/validation.mjs +++ b/scripts/lib/validation.mjs @@ -3,10 +3,23 @@ import path from 'node:path'; export const PLUGIN_SCHEMA = 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json'; export const MCP_SCHEMA = 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json'; +export const HOOKS_NAMESPACE = 'io.minimax.mcode'; +export const HOOKS_SCHEMA = 'https://raw.githubusercontent.com/MiniMax-AI/MiniMax-Code-Plugins/main/schemas/io.minimax.mcode/hooks/0.1.0.schema.json'; +export const HOOK_EVENTS = Object.freeze([ + 'session-start', + 'turn-start', + 'pre-tool-use', + 'post-tool-use', + 'turn-end', + 'session-end', +]); const PLUGIN_NAME = /^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u; const OWNER_NAME = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/u; const SKILL_NAME = /^(?!.*--)[a-z0-9]+(?:-[a-z0-9]+)*$/u; +const HOOK_EVENT_SET = new Set(HOOK_EVENTS); +const HOOK_HANDLER_FIELDS = new Set(['command', 'args', 'env', 'cwd']); +const RESERVED_PLUGIN_ENV = new Set(['PLUGIN_ROOT', 'PLUGIN_DATA']); const PLUGIN_FIELDS = new Set([ '$schema', 'name', @@ -102,14 +115,95 @@ export function validateMcp(value, label = 'mcp.json') { return entries.map(([name]) => name).sort(); } +export function validateHooks(value, label = 'hooks.json') { + assert(isRecord(value), `${label}: root must be an object`); + assert(value.$schema === HOOKS_SCHEMA, `${label}: unsupported $schema`); + assert(Object.keys(value).every((key) => ['$schema', 'hooks'].includes(key)), `${label}: unknown root field`); + assert(isRecord(value.hooks), `${label}: hooks must be an object`); + + const events = Object.keys(value.hooks); + assert(events.length > 0, `${label}: hooks must contain at least one event`); + let handlerCount = 0; + for (const event of events) { + assert(HOOK_EVENT_SET.has(event), `${label}: unsupported event ${event}`); + const handlers = value.hooks[event]; + assert(Array.isArray(handlers) && handlers.length > 0, `${label}: ${event} must contain at least one handler`); + assert(handlers.length <= 8, `${label}: ${event} supports at most 8 handlers`); + handlerCount += handlers.length; + for (const [index, handler] of handlers.entries()) { + const handlerLabel = `${event}[${index}]`; + assert(isRecord(handler), `${label}: ${handlerLabel} must be an object`); + assert(Object.keys(handler).every((key) => HOOK_HANDLER_FIELDS.has(key)), `${label}: ${handlerLabel} has unsupported fields`); + assert( + typeof handler.command === 'string' + && (isBareExecutable(handler.command) || isContainedRelativeExecutable(handler.command)), + `${label}: ${handlerLabel}.command must be a single bare executable or contained ./ path`, + ); + assert( + handler.args === undefined + || (Array.isArray(handler.args) + && handler.args.length <= 64 + && handler.args.every((item) => typeof item === 'string' && !item.includes('\0'))), + `${label}: ${handlerLabel}.args must be strings with at most 64 entries`, + ); + assert( + handler.env === undefined + || (isRecord(handler.env) + && Object.keys(handler.env).length <= 64 + && Object.entries(handler.env).every(([key, item]) => ( + /^[A-Za-z_][A-Za-z0-9_]*$/u.test(key) + && !RESERVED_PLUGIN_ENV.has(key.toUpperCase()) + && typeof item === 'string' + && !item.includes('\0') + ))), + `${label}: ${handlerLabel}.env is invalid`, + ); + assert( + handler.cwd === undefined || (typeof handler.cwd === 'string' && isContainedWorkingDirectory(handler.cwd)), + `${label}: ${handlerLabel}.cwd is invalid`, + ); + } + } + assert(handlerCount <= 32, `${label}: MiniMax Code supports at most 32 handlers per plugin`); + return { + events: HOOK_EVENTS.filter((event) => Object.hasOwn(value.hooks, event)), + handlerCount, + }; +} + function isBareCommand(value) { return !/[\\/]/u.test(value); } +function isBareExecutable(value) { + return value.length > 0 && !value.includes('\0') && !/[\s\\/]/u.test(value); +} + function isContainedRelativePath(value) { return value.startsWith('./') && !value.split('/').includes('..') && !value.includes('\\'); } +function isContainedRelativeExecutable(value) { + return value.startsWith('./') && isContainedPathSuffix(value.slice(2)); +} + +function isContainedWorkingDirectory(value) { + if (value === './') return true; + if (value.startsWith('./')) return isContainedPathSuffix(value.slice(2)); + for (const placeholder of ['${PLUGIN_ROOT}', '${PLUGIN_DATA}']) { + if (value === placeholder) return true; + if (value.startsWith(`${placeholder}/`)) { + const suffix = value.slice(placeholder.length + 1); + return suffix.length === 0 || isContainedPathSuffix(suffix); + } + } + return false; +} + +function isContainedPathSuffix(value) { + return value.length > 0 && !value.includes('\0') && !value.includes('\\') && !value.split('/').includes('..'); +} + function isSafeRemoteUrl(value) { try { const url = new URL(value); @@ -126,6 +220,13 @@ function isSafeRemoteUrl(value) { export async function validatePluginDirectory(root) { const manifestPath = path.join(root, 'plugin.json'); const manifest = validatePluginManifest(parseJson(await readFile(manifestPath, 'utf8'), manifestPath), manifestPath); + const unsupportedRootHooksPath = path.join(root, 'hooks.json'); + try { + await readFile(unsupportedRootHooksPath, 'utf8'); + throw new Error(`${unsupportedRootHooksPath}: MiniMax Code Hooks must use ${HOOKS_NAMESPACE}/hooks/hooks.json`); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } const skills = []; const skillsRoot = path.join(root, 'skills'); let children = []; @@ -147,8 +248,21 @@ export async function validatePluginDirectory(root) { } catch (error) { if (error.code !== 'ENOENT') throw error; } - assert(skills.length + mcpServers.length > 0, `${root}: plugin must expose at least one Skill or MCP server`); - return { manifest, skills: skills.sort(), mcpServers }; + let hookEvents = []; + let hookHandlers = 0; + const hooksPath = path.join(root, HOOKS_NAMESPACE, 'hooks', 'hooks.json'); + try { + const hooks = validateHooks(parseJson(await readFile(hooksPath, 'utf8'), hooksPath), hooksPath); + hookEvents = hooks.events; + hookHandlers = hooks.handlerCount; + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + assert( + skills.length + mcpServers.length + hookHandlers > 0, + `${root}: plugin must expose at least one Skill, MCP server, or MiniMax Code Hook`, + ); + return { manifest, skills: skills.sort(), mcpServers, hookEvents, hookHandlers }; } export async function validateHostedPluginDirectory(root, { owner, pluginName }) { @@ -182,7 +296,7 @@ async function listTextContractFiles(root) { for (const child of await readdir(root, { withFileTypes: true })) { const file = path.join(root, child.name); if (child.isDirectory()) files.push(...await listTextContractFiles(file)); - else if (child.isFile() && (child.name.endsWith('.md') || ['plugin.json', 'mcp.json'].includes(child.name))) files.push(file); + else if (child.isFile() && (child.name.endsWith('.md') || ['plugin.json', 'mcp.json', 'hooks.json'].includes(child.name))) files.push(file); } return files; } diff --git a/test/hooks-example.test.mjs b/test/hooks-example.test.mjs new file mode 100644 index 0000000..833ba39 --- /dev/null +++ b/test/hooks-example.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const script = path.join( + repositoryRoot, + 'examples', + 'hello-mcode-hooks', + 'io.minimax.mcode', + 'hooks', + 'scripts', + 'record.mjs', +); + +function runHook(dataRoot, event) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [script, event], { + env: { PLUGIN_DATA: dataRoot }, + stdio: 'ignore', + }); + child.once('error', reject); + child.once('exit', (code, signal) => { + if (code === 0) resolve(); + else reject(new Error(`Hook exited with code ${String(code)} and signal ${String(signal)}`)); + }); + }); +} + +test('Hook example retains concurrent records within its storage bound', async (context) => { + const { mkdtemp, rm } = await import('node:fs/promises'); + const dataRoot = await mkdtemp(path.join(os.tmpdir(), 'minimax-code-hooks-example-')); + context.after(() => rm(dataRoot, { recursive: true, force: true })); + const recordsRoot = path.join(dataRoot, 'events'); + await mkdir(recordsRoot); + const activeTemporary = `${Date.now().toString().padStart(13, '0')}-00000000000000000000-00000000-0000-4000-8000-000000000000.tmp`; + const staleTemporary = '0000000000000-00000000000000000000-00000000-0000-4000-8000-000000000001.tmp'; + await Promise.all([ + writeFile(path.join(recordsRoot, activeTemporary), '{"partial":', 'utf8'), + writeFile(path.join(recordsRoot, staleTemporary), '{"abandoned":', 'utf8'), + ]); + + const invocationCount = 140; + await Promise.all(Array.from( + { length: invocationCount }, + (_, index) => runHook(dataRoot, index % 2 === 0 ? 'pre-tool-use' : 'post-tool-use'), + )); + + const entries = await readdir(recordsRoot); + const records = entries.filter((entry) => entry.endsWith('.json')).sort(); + assert.equal(records.length, 128); + assert.equal(entries.includes(activeTemporary), true); + assert.equal(entries.includes(staleTemporary), false); + const values = await Promise.all(records.map(async (record) => ( + JSON.parse(await readFile(path.join(recordsRoot, record), 'utf8')) + ))); + assert.ok(values.every(({ event }) => ['pre-tool-use', 'post-tool-use'].includes(event))); +}); diff --git a/test/hooks-schema.test.mjs b/test/hooks-schema.test.mjs new file mode 100644 index 0000000..2875544 --- /dev/null +++ b/test/hooks-schema.test.mjs @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import Ajv2020 from 'ajv/dist/2020.js'; + +import { HOOK_EVENTS, HOOKS_SCHEMA, validateHooks } from '../scripts/lib/validation.mjs'; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +test('published Hooks schema encodes the field-level validator contract', async () => { + const schema = JSON.parse(await readFile( + path.join(repositoryRoot, 'schemas', 'io.minimax.mcode', 'hooks', '0.1.0.schema.json'), + 'utf8', + )); + + assert.equal(schema.$id, HOOKS_SCHEMA); + assert.equal(schema.properties.$schema.const, HOOKS_SCHEMA); + assert.deepEqual(Object.keys(schema.properties.hooks.properties), HOOK_EVENTS); + assert.equal(schema.properties.hooks.additionalProperties, false); + assert.equal(schema.$defs.handlers.maxItems, 8); + assert.equal(schema.$defs.handler.additionalProperties, false); + const commandVariants = schema.$defs.handler.properties.command.anyOf; + for (const variant of commandVariants) { + assert.doesNotThrow(() => new RegExp(variant.pattern, 'u')); + } + const cwdVariants = schema.$defs.handler.properties.cwd.anyOf; + for (const variant of cwdVariants) { + assert.doesNotThrow(() => new RegExp(variant.pattern, 'u')); + } + + const matchesAny = (variants, value) => variants.some(({ pattern }) => new RegExp(pattern, 'u').test(value)); + assert.equal(matchesAny(commandVariants, 'node'), true); + assert.equal(matchesAny(commandVariants, './scripts/record.mjs'), true); + assert.equal(matchesAny(commandVariants, 'node script.mjs'), false); + assert.equal(matchesAny(commandVariants, './scripts/../record.mjs'), false); + assert.equal(matchesAny(commandVariants, 'node\0'), false); + assert.equal(matchesAny(cwdVariants, './'), true); + assert.equal(matchesAny(cwdVariants, '${PLUGIN_DATA}/logs'), true); + assert.equal(matchesAny(cwdVariants, '${PLUGIN_ROOT}/../outside'), false); + assert.equal(matchesAny(cwdVariants, '/tmp'), false); + + const argumentPattern = new RegExp(schema.$defs.handler.properties.args.items.pattern, 'u'); + assert.equal(argumentPattern.test('ordinary argument'), true); + assert.equal(argumentPattern.test('bad\0argument'), false); +}); + +test('published schema and registry validator agree except for the documented aggregate limit', async () => { + const schema = JSON.parse(await readFile( + path.join(repositoryRoot, 'schemas', 'io.minimax.mcode', 'hooks', '0.1.0.schema.json'), + 'utf8', + )); + const validateSchema = new Ajv2020({ strict: true }).compile(schema); + const document = (hooks) => ({ $schema: HOOKS_SCHEMA, hooks }); + const valid = document({ + 'pre-tool-use': [{ + command: 'node', + args: ['${PLUGIN_ROOT}/record.mjs'], + env: { OUTPUT: '${PLUGIN_DATA}/events.jsonl' }, + cwd: '${PLUGIN_DATA}', + }], + }); + assert.equal(validateSchema(valid), true, JSON.stringify(validateSchema.errors)); + assert.doesNotThrow(() => validateHooks(valid)); + + for (const reservedName of ['PLUGIN_ROOT', 'plugin_data']) { + const reserved = document({ 'turn-end': [{ command: 'node', env: { [reservedName]: 'override' } }] }); + assert.equal(validateSchema(reserved), false); + assert.throws(() => validateHooks(reserved), /env is invalid/u); + } + + const sixHandlers = Array.from({ length: 6 }, () => ({ command: 'node' })); + const aggregateOnly = document({ + 'session-start': sixHandlers, + 'turn-start': sixHandlers, + 'pre-tool-use': sixHandlers, + 'post-tool-use': sixHandlers, + 'turn-end': sixHandlers, + 'session-end': sixHandlers, + }); + assert.equal(validateSchema(aggregateOnly), true, 'JSON Schema cannot sum handlers across event properties'); + assert.throws(() => validateHooks(aggregateOnly), /at most 32 handlers/u); +}); diff --git a/test/hosted-plugins.test.mjs b/test/hosted-plugins.test.mjs index 4e58656..dfaa0a9 100644 --- a/test/hosted-plugins.test.mjs +++ b/test/hosted-plugins.test.mjs @@ -7,7 +7,7 @@ import { promisify } from 'node:util'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; -import { validateHostedPluginDirectory } from '../scripts/lib/validation.mjs'; +import { HOOKS_SCHEMA, validateHostedPluginDirectory } from '../scripts/lib/validation.mjs'; const execFileAsync = promisify(execFile); const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -69,6 +69,102 @@ test('hosted Plugin is valid when its package and contribution docs are complete assert.deepEqual(result.skills, ['hello-world']); }); +test('hosted Plugin is valid with only a MiniMax Code Hook', async (context) => { + const workspace = await mkdtemp(path.join(os.tmpdir(), 'minimax-code-hooks-validation-')); + context.after(async () => { + const { rm } = await import('node:fs/promises'); + await rm(workspace, { recursive: true, force: true }); + }); + const pluginRoot = path.join(workspace, 'plugins', 'alice', 'event-recorder'); + const hooksRoot = path.join(pluginRoot, 'io.minimax.mcode', 'hooks'); + await mkdir(hooksRoot, { recursive: true }); + await Promise.all([ + writeFile(path.join(pluginRoot, 'plugin.json'), `${JSON.stringify({ + $schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json', + name: 'event-recorder', + version: '1.0.0', + description: 'Records bounded MiniMax Code lifecycle events locally.', + license: 'Apache-2.0', + })}\n`), + writeFile(path.join(pluginRoot, 'README.md'), '# Event Recorder\n\nRecords lifecycle event names locally.\n'), + writeFile(path.join(pluginRoot, 'LICENSE'), 'Apache License\nVersion 2.0\n'), + writeFile(path.join(hooksRoot, 'hooks.json'), `${JSON.stringify({ + $schema: HOOKS_SCHEMA, + hooks: { + 'session-start': [{ + command: 'node', + args: ['${PLUGIN_ROOT}/io.minimax.mcode/hooks/record.mjs'], + }], + }, + })}\n`), + ]); + + const result = await validateHostedPluginDirectory(pluginRoot, { + owner: 'alice', + pluginName: 'event-recorder', + }); + + assert.deepEqual(result.hookEvents, ['session-start']); + assert.equal(result.hookHandlers, 1); + assert.deepEqual(result.skills, []); + assert.deepEqual(result.mcpServers, []); +}); + +test('hosted Plugin rejects a root hooks.json instead of silently ignoring it', async (context) => { + const workspace = await mkdtemp(path.join(os.tmpdir(), 'minimax-code-root-hooks-')); + context.after(async () => { + const { rm } = await import('node:fs/promises'); + await rm(workspace, { recursive: true, force: true }); + }); + const pluginRoot = path.join(workspace, 'plugins', 'alice', 'wrong-hooks-path'); + await mkdir(path.join(pluginRoot, 'skills', 'hello'), { recursive: true }); + await Promise.all([ + writeFile(path.join(pluginRoot, 'plugin.json'), `${JSON.stringify({ + $schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json', + name: 'wrong-hooks-path', + license: 'Apache-2.0', + })}\n`), + writeFile(path.join(pluginRoot, 'README.md'), '# Wrong Hooks Path\n'), + writeFile(path.join(pluginRoot, 'LICENSE'), 'Apache License\nVersion 2.0\n'), + writeFile(path.join(pluginRoot, 'hooks.json'), '{}\n'), + writeFile(path.join(pluginRoot, 'skills', 'hello', 'SKILL.md'), '---\nname: hello\ndescription: Explain the wrong Hooks path when asked.\n---\n\nExplain the package.\n'), + ]); + + await assert.rejects( + validateHostedPluginDirectory(pluginRoot, { owner: 'alice', pluginName: 'wrong-hooks-path' }), + /Hooks must use io\.minimax\.mcode\/hooks\/hooks\.json/u, + ); +}); + +test('hosted Plugin rejects unfinished placeholders in hooks.json', async (context) => { + const workspace = await mkdtemp(path.join(os.tmpdir(), 'minimax-code-hooks-todo-')); + context.after(async () => { + const { rm } = await import('node:fs/promises'); + await rm(workspace, { recursive: true, force: true }); + }); + const pluginRoot = path.join(workspace, 'plugins', 'alice', 'unfinished-hook'); + const hooksRoot = path.join(pluginRoot, 'io.minimax.mcode', 'hooks'); + await mkdir(hooksRoot, { recursive: true }); + await Promise.all([ + writeFile(path.join(pluginRoot, 'plugin.json'), `${JSON.stringify({ + $schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json', + name: 'unfinished-hook', + license: 'Apache-2.0', + })}\n`), + writeFile(path.join(pluginRoot, 'README.md'), '# Unfinished Hook\n'), + writeFile(path.join(pluginRoot, 'LICENSE'), 'Apache License\nVersion 2.0\n'), + writeFile(path.join(hooksRoot, 'hooks.json'), `${JSON.stringify({ + $schema: HOOKS_SCHEMA, + hooks: { 'turn-end': [{ command: 'node', args: ['TODO'] }] }, + })}\n`), + ]); + + await assert.rejects( + validateHostedPluginDirectory(pluginRoot, { owner: 'alice', pluginName: 'unfinished-hook' }), + /replace every TODO/u, + ); +}); + test('scaffold stays review-incomplete until contributor replaces every TODO', async (context) => { const workspace = await mkdtemp(path.join(os.tmpdir(), 'minimax-code-plugin-todo-')); context.after(async () => { diff --git a/test/validation.test.mjs b/test/validation.test.mjs index c7dfce6..d664d59 100644 --- a/test/validation.test.mjs +++ b/test/validation.test.mjs @@ -1,7 +1,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { validateMcp, validatePluginManifest, validateSkillText } from '../scripts/lib/validation.mjs'; +import { + HOOKS_SCHEMA, + validateHooks, + validateMcp, + validatePluginManifest, + validateSkillText, +} from '../scripts/lib/validation.mjs'; test('accepts the portable Agent Plugins manifest', () => { const value = validatePluginManifest({ @@ -42,3 +48,95 @@ test('validates supported MCP transports and reserved environment variables', () /env is invalid/u, ); }); + +test('validates the MiniMax Code Hooks 0.1 client extension', () => { + const result = validateHooks({ + $schema: HOOKS_SCHEMA, + hooks: { + 'pre-tool-use': [{ + command: 'node', + args: ['${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs'], + env: { OUTPUT: '${PLUGIN_DATA}/events.jsonl' }, + cwd: '${PLUGIN_DATA}', + }], + 'turn-end': [{ command: './io.minimax.mcode/hooks/scripts/record.mjs' }], + }, + }); + + assert.deepEqual(result, { + events: ['pre-tool-use', 'turn-end'], + handlerCount: 2, + }); +}); + +test('rejects unsupported Hook events and unsafe handler configuration', () => { + const document = (hooks) => ({ $schema: HOOKS_SCHEMA, hooks }); + + assert.throws(() => validateHooks(document({})), /at least one event/u); + assert.throws( + () => validateHooks(document({ Notification: [{ command: 'node' }] })), + /unsupported event Notification/u, + ); + assert.throws( + () => validateHooks(document({ 'pre-tool-use': [{ command: 'node script.mjs' }] })), + /single bare executable or contained \.\/ path/u, + ); + assert.throws( + () => validateHooks(document({ 'pre-tool-use': [{ command: './../outside.sh' }] })), + /single bare executable or contained \.\/ path/u, + ); + assert.throws( + () => validateHooks(document({ 'pre-tool-use': [{ command: './' }] })), + /single bare executable or contained \.\/ path/u, + ); + assert.throws( + () => validateHooks(document({ 'pre-tool-use': [{ command: 'node', args: ['ok', 1] }] })), + /args must be strings/u, + ); + assert.throws( + () => validateHooks(document({ 'pre-tool-use': [{ command: 'node', args: ['bad\0value'] }] })), + /args must be strings/u, + ); + assert.throws( + () => validateHooks(document({ 'pre-tool-use': [{ command: 'node', env: { plugin_root: 'bad' } }] })), + /env is invalid/u, + ); + assert.throws( + () => validateHooks(document({ 'pre-tool-use': [{ command: 'node', cwd: '${PLUGIN_ROOT}/../outside' }] })), + /cwd is invalid/u, + ); + assert.throws( + () => validateHooks(document({ 'pre-tool-use': [{ command: 'node', type: 'command' }] })), + /unsupported fields/u, + ); + assert.throws( + () => validateHooks(document({ 'pre-tool-use': Array.from({ length: 9 }, () => ({ command: 'node' })) })), + /at most 8 handlers/u, + ); +}); + +test('rejects unsupported Hooks schema versions and excessive total handlers', () => { + assert.throws( + () => validateHooks({ + $schema: 'https://raw.githubusercontent.com/MiniMax-AI/MiniMax-Code-Plugins/main/schemas/io.minimax.mcode/hooks/9.9.9.schema.json', + hooks: { 'session-start': [{ command: 'node' }] }, + }), + /unsupported \$schema/u, + ); + + const sixHandlers = Array.from({ length: 6 }, () => ({ command: 'node' })); + assert.throws( + () => validateHooks({ + $schema: HOOKS_SCHEMA, + hooks: { + 'session-start': sixHandlers, + 'turn-start': sixHandlers, + 'pre-tool-use': sixHandlers, + 'post-tool-use': sixHandlers, + 'turn-end': sixHandlers, + 'session-end': sixHandlers, + }, + }), + /at most 32 handlers/u, + ); +});