diff --git a/examples/hello-mcode-hooks/LICENSE b/examples/hello-mcode-hooks/LICENSE new file mode 100644 index 0000000..d07ae9a --- /dev/null +++ b/examples/hello-mcode-hooks/LICENSE @@ -0,0 +1,15 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/examples/hello-mcode-hooks/README.md b/examples/hello-mcode-hooks/README.md new file mode 100644 index 0000000..9083fda --- /dev/null +++ b/examples/hello-mcode-hooks/README.md @@ -0,0 +1,61 @@ +# hello-mcode-hooks + +A minimal Plugin that ships one Skill and one experimental `io.minimax.mcode` Hook entry under +the Agent Plugins 1.0 portable Hooks preview. + +## What this example demonstrates + +- A Skill-only Agent Plugin (the "hello-hooks" Skill). +- A single Hook entry in `io.minimax.mcode/hooks/hooks.json` that observes `SessionStart`, + `SessionEnd`, and `PreToolUse`. +- Atomic, cross-platform state file writes under the runtime-provided `PLUGIN_DATA` directory. +- Path resolution that uses runtime-injected environment values, not host-absolute literals. + +This example is not a working integration; it is a structural reference. MiniMax Code 0.2.4 +ships the runtime side of the preview but the portable Hooks proposal is still in review and +registry validation must not execute Hook code. + +## Layout + +```text +hello-mcode-hooks/ +├── README.md +├── LICENSE +├── plugin.json +├── skills/ +│ └── hello-hooks/ +│ └── SKILL.md +└── io.minimax.mcode/ + └── hooks/ + ├── hooks.json + └── scripts/ + └── record.mjs +``` + +## Hook entry + +The Hook entry is one `record.mjs` invocation per event. The script reads the event payload +from stdin (one UTF-8 JSON document, then EOF, as proposed in `proposals/hooks.md` § "Observe-only +runtime semantics") and appends a compact record to `${PLUGIN_DATA}/state.json` using a +staging-file rename. No tool input rewriting, no permission decisions, no network access, no +telemetry. + +## Validation expectations + +- `plugin.json` continues to target the published Agent Plugins 1.0 schema and remains valid + under `scripts/validate.mjs`. +- `io.minimax.mcode/hooks/hooks.json` is recognized as an experimental client extension + namespace. The validator accepts it but does not require it. +- The script resolves all paths from `${PLUGIN_ROOT}` and `${PLUGIN_DATA}` only. + +## Disclosure + +This example contains: + +- no credentials; +- no network access; +- no telemetry; +- no third-party services. + +The same disclosure is repeated in `skills/hello-hooks/SKILL.md` per the +`hello-mcode-hooks` plugin convention. 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..a787b4b --- /dev/null +++ b/examples/hello-mcode-hooks/io.minimax.mcode/hooks/hooks.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://minimax.io/schemas/mcode-hooks/0.1.0/hooks.schema.json", + "hooks": { + "SessionStart": [ + { + "command": "node", + "args": [ + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs", + "--event", + "SessionStart", + "--state", + "${PLUGIN_DATA}/state.json" + ], + "timeout": 5000, + "once": false + } + ], + "SessionEnd": [ + { + "command": "node", + "args": [ + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs", + "--event", + "SessionEnd", + "--state", + "${PLUGIN_DATA}/state.json" + ], + "timeout": 5000, + "once": false + } + ], + "PreToolUse": [ + { + "command": "node", + "args": [ + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs", + "--event", + "PreToolUse", + "--state", + "${PLUGIN_DATA}/state.json" + ], + "matcher": "*", + "timeout": 5000, + "once": false + } + ] + } +} 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..1a37d6f --- /dev/null +++ b/examples/hello-mcode-hooks/io.minimax.mcode/hooks/scripts/record.mjs @@ -0,0 +1,232 @@ +#!/usr/bin/env node +// Experimental observer for the io.minimax.mcode Hooks preview. +// +// Reads one UTF-8 JSON document from stdin (the event payload), then appends a compact record +// to a per-instance state file using an atomic stage-and-rename write. No network access, no +// credentials, no telemetry. Resolves all paths from runtime-injected environment values +// only. Cross-platform: uses node:fs/promises and node:path, never host-absolute literals. + +import { readFile, writeFile, rename, mkdir, realpath } from 'node:fs/promises'; +import { dirname, isAbsolute, join, relative, sep } from 'node:path'; +import { argv, env } from 'node:process'; + +const MAX_STATE_BYTES = 1024 * 1024; +const MAX_RECORDS = 4096; +const MAX_STDIN_BYTES = 1024 * 64; + +function parseArgs(args) { + const out = { event: null, state: null }; + for (let i = 0; i < args.length; i += 1) { + const a = args[i]; + if (a === '--event') { + out.event = args[i + 1] ?? null; + i += 1; + } else if (a === '--state') { + out.state = args[i + 1] ?? null; + i += 1; + } + } + return out; +} + +// Expand a single occurrence of ${PLUGIN_ROOT} or ${PLUGIN_DATA}. Only one expansion +// token is allowed per path. The expanded value is then resolved against the corresponding +// root for real-path and symlink containment. PLUGIN_ROOT and PLUGIN_DATA are independent +// roots; a path under PLUGIN_DATA is not required to be under PLUGIN_ROOT. +async function expandAndCheck(value) { + if (typeof value !== 'string') return null; + if (value.startsWith('${PLUGIN_ROOT}')) { + const root = env.PLUGIN_ROOT; + if (!root) return null; + return ensureContained(join(root, value.slice('${PLUGIN_ROOT}'.length)), root); + } + if (value.startsWith('${PLUGIN_DATA}')) { + const dataRoot = env.PLUGIN_DATA; + if (!dataRoot) return null; + return ensureContained(join(dataRoot, value.slice('${PLUGIN_DATA}'.length)), dataRoot); + } + return null; +} + +// Real-path containment. The round-4 review pointed out that the +// previous implementation only did `path.resolve` (a lexical +// normalization), which is bypassed by symlinks. For example: +// +// PLUGIN_DATA = /tmp/d (realpath /var/srv/d) +// ${PLUGIN_DATA}/link/state.json where 'link' is a symlink to /etc +// +// Lexically: targetReal='/tmp/d/link/state.json', +// rootReal='/var/srv/d', prefix='/var/srv/d/'. The +// `startsWith` check is FALSE — the lexical check +// refuses. So the old code was actually safe for THIS +// case, but only by accident (the symlink happens to +// live at a different lexical prefix than the +// realpath of the root). +// +// The real bypass is the OPPOSITE: when the root itself is reached +// via a symlink, the lexical prefix can be lexically INSIDE the +// root, while the realpath target is OUTSIDE. For example: +// +// PLUGIN_DATA = /tmp/d (realpath /var/srv/d) +// realpath('/tmp/d/foo') = '/var/srv/d/foo' → contained ✓ +// but if /tmp/d itself is a symlink to /etc, then: +// lex '/tmp/d/foo' starts with '/tmp/d/' → 'contained' (false positive) +// realpath('/tmp/d/foo') = '/etc/foo' → NOT contained (the truth) +// +// The fix is to call realpath on the root once (if it exists) and +// then call realpath on every prefix of the target up to the +// common ancestor with the realpath-root. If any segment along +// the way is a symlink, we resolve it eagerly. This makes the +// lexical-vs-realpath race a structural impossibility: we always +// compare realpath to realpath. +// +// Note on Windows: mkdtemp returns a short 8.3 path +// (C:\Users\ADMINI~1\...) but realpath returns the long form +// (C:\Users\Administrator\...). The two strings are different +// lengths, so a naive `target.slice(parent.length + 1)` produces +// a corrupted basename. We use `path.relative` instead, which is +// length-independent. +async function ensureContained(target, root) { + if (!isAbsolute(root)) { + throw new Error(`plugin root is not absolute: ${root}`); + } + if (!isAbsolute(target)) { + throw new Error(`target path is not absolute: ${target}`); + } + const rootReal = await realpathOf(root); + let cursor = target; + while (true) { + let cursorReal; + try { + cursorReal = await realpathOf(cursor); + } catch (error) { + if (error && error.code === 'ENOENT') { + const parent = dirname(cursor); + if (parent === cursor) { + throw new Error(`path escapes plugin root: ${target} is not under ${root}`); + } + const parentReal = await realpathOf(parent); + if (!isUnder(parentReal, rootReal)) { + throw new Error(`path escapes plugin root: ${target} is not under ${root}`); + } + // The basename is the segment AFTER the last path + // separator in `target`; using `relative` is length-safe + // even when short/long paths are mixed (Windows). + const base = relative(parent, target); + if (base.startsWith('..') || isAbsolute(base)) { + throw new Error(`path escapes plugin root: ${target} is not under ${root}`); + } + return join(parentReal, base); + } + throw error; + } + if (cursorReal === rootReal) { + return cursorReal; + } + if (isUnder(cursorReal, rootReal)) { + return cursorReal; + } + const parent = dirname(cursor); + if (parent === cursor) { + throw new Error(`path escapes plugin root: ${target} is not under ${root}`); + } + cursor = parent; + } +} + +async function realpathOf(p) { + // Always run realpath. We deliberately do NOT catch ENOENT here + // and return the input path: that would defeat the comparison + // against rootReal, because a short/long path mix on Windows + // would compare unequal even when the file is contained. The + // caller (ensureContained) is responsible for the ENOENT + // fallback when the target is a new file inside an existing + // directory. + return await realpath(p); +} + +function isUnder(child, parent) { + if (parent === child) return true; + const prefix = parent.endsWith(sep) ? parent : parent + sep; + return child.startsWith(prefix); +} + +async function readStdin() { + const chunks = []; + let total = 0; + for await (const chunk of process.stdin) { + total += chunk.length; + if (total > MAX_STDIN_BYTES) break; + chunks.push(chunk); + } + if (chunks.length === 0) return null; + try { + return JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + return null; + } +} + +function trimRecords(records) { + if (records.length <= MAX_RECORDS) return records; + return records.slice(records.length - MAX_RECORDS); +} + +async function loadState(path) { + try { + const text = await readFile(path, 'utf8'); + if (Buffer.byteLength(text, 'utf8') > MAX_STATE_BYTES) { + // Existing state is over the bound; discard it and start clean rather than carry + // forward a payload that already exceeds what we promise to keep. + return { records: [] }; + } + const parsed = JSON.parse(text); + if (Array.isArray(parsed.records)) { + return { records: trimRecords(parsed.records.filter((r) => r && typeof r === 'object')) }; + } + } catch { + // First run or unreadable prior state: start clean. + } + return { records: [] }; +} + +async function saveState(path, state) { + const staged = path + '.staging'; + const text = JSON.stringify(state, null, 2); + if (Buffer.byteLength(text, 'utf8') > MAX_STATE_BYTES) { + throw new Error(`state exceeds ${MAX_STATE_BYTES} bytes after trim`); + } + await writeFile(staged, text, 'utf8'); + await rename(staged, path); +} + +async function main() { + const args = parseArgs(argv.slice(2)); + if (!args.event || !args.state) { + return; + } + let statePath; + try { + statePath = await expandAndCheck(args.state); + } catch { + return; + } + if (!statePath) return; + + try { + const payload = await readStdin(); + const record = { + event: args.event, + receivedAt: new Date().toISOString(), + payloadKeys: payload && typeof payload === 'object' ? Object.keys(payload).sort() : [], + }; + await mkdir(dirname(statePath), { recursive: true }); + const state = await loadState(statePath); + state.records = trimRecords([...state.records, record]); + await saveState(statePath, state); + } catch { + // Observer must never affect agent behavior; swallow all errors silently. + } +} + +main(); diff --git a/examples/hello-mcode-hooks/plugin.json b/examples/hello-mcode-hooks/plugin.json new file mode 100644 index 0000000..9c5386b --- /dev/null +++ b/examples/hello-mcode-hooks/plugin.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "hello-mcode-hooks", + "version": "0.1.0", + "description": "A minimal Plugin that demonstrates one Skill and one experimental io.minimax.mcode Hook entry for the Agent Plugins 1.0 portable Hooks preview. The Hook records each delivered event to a per-instance state file for manual inspection.", + "author": { + "name": "MCode Plugins contributors" + }, + "license": "Apache-2.0", + "keywords": ["mcode", "example", "hooks", "experimental"] +} diff --git a/examples/hello-mcode-hooks/skills/hello-hooks/SKILL.md b/examples/hello-mcode-hooks/skills/hello-hooks/SKILL.md new file mode 100644 index 0000000..adf0425 --- /dev/null +++ b/examples/hello-mcode-hooks/skills/hello-hooks/SKILL.md @@ -0,0 +1,28 @@ +--- +name: hello-hooks +description: Verify that the experimental io.minimax.mcode Hook entry of the hello-mcode-hooks plugin is wired up. Use only when the user explicitly asks to test the hello-mcode-hooks example. +license: Apache-2.0 +compatibility: Requires MiniMax Code with Agent Plugins 1.0 support. The Hook entry is experimental and targets the io.minimax.mcode extension namespace proposed in proposals/hooks.md. +metadata: + author: MCode Plugins contributors + version: "0.1.0" +--- + +# Hello Hooks + +Reply with `Hello from hello-mcode-hooks!` and state that the Skill loaded successfully. Do not +call tools or modify files. Do not attempt to invoke the experimental Hook entry; it is observed +only and the example does not depend on the runtime side. + +# Disclosure + +This Skill and the example Hook entry it ships with contain: + +- no credentials. +- no network access. +- no telemetry. +- no third-party services. + +The audit script writes only to a per-instance state file under the runtime-provided +`PLUGIN_DATA` directory. It does not read the network, does not contact any third-party +endpoint, and does not embed any literal token. diff --git a/proposals/hooks-detailed-spec.md b/proposals/hooks-detailed-spec.md new file mode 100644 index 0000000..452a453 --- /dev/null +++ b/proposals/hooks-detailed-spec.md @@ -0,0 +1,411 @@ +# Detailed Hooks specification for the `io.minimax.mcode` extension + +Status: Companion proposal to `proposals/hooks.md` (commit `d86625d`). + +Portable baseline: Agent Plugins 1.0. + +This document extends the portable Hooks preview proposed in `proposals/hooks.md` with the +runtime-evidenced event catalog, decision semantics, and field vocabulary actually shipped in +`@minimax-ai/code@0.2.4` (npm, 2026-08-24). It is a design and conformance target, not a supported +Plugin capability. Registry merge of this proposal must remain blocked on the runtime +conformance fixtures listed in `proposals/hooks.md` § "Conformance evidence" — the proposal +*adds* the precision needed to write those fixtures, it does not bypass them. + +## Relationship to the portable proposal + +`proposals/hooks.md` (commit `d86625d`, hetaoBackend) is the primary portable proposal. This +companion document covers the same `io.minimax.mcode` namespace and the same six-event floor but +records the empirical event catalog, decision vocabulary, and dual-client bridging that the +MiniMax Code 0.2.4 runtime already ships. Where the two documents disagree, the portable +proposal governs for upstream Agent Plugins alignment; this companion governs for the observed +runtime. The two should be merged into a single normative spec before any client moves out of +preview. + +Three classes of decisions appear in this companion and the rules for them differ: + +- **Portable**: shared with `d86625d`; the portable proposal is authoritative. +- **Mcode-specific**: this companion adds or refines a behavior that the 0.2.4 runtime + ships but the portable proposal intentionally does not. Marked inline as + *Mcode-specific* or *0.2.4 specific* in the section that introduces it. +- **Companion-only observability**: this companion records empirical data + (e.g. event name literal counts in `cli.js`, dual-client bridging) that is + *evidence* for portable decisions, not portable decisions themselves. The + portable proposal governs any normative conclusion drawn from the evidence. + +A rule labelled *Mcode-specific* MUST NOT be relied on by Plugins that target a different +runtime. A rule labelled *Portable* MUST be honored by every `io.minimax.mcode` client. The +"ask" decision value on `PermissionRequest` (§ "Decision semantics") and the dual-client +bridging rules (§ "Dual-client bridging") are Mcode-specific; the closed-schema field +vocabulary (§ "Field vocabulary") is Portable. + +## Scope added by this companion + +- Full twelve-event catalog observed in the 0.2.4 runtime, with PascalCase keys that match + `cli.js` event names. +- Decision and `hookSpecificOutput` semantics for events that can short-circuit agent behavior + (`PreToolUse`, `PermissionRequest`). +- Dual-client bridging for the two native agent surfaces the 0.2.4 runtime already bridges + (`CLAUDE`, `CODEX`), so Plugin authors can write one hook and have it run for either surface. +- Conformance field list (`matcher`, `pattern`, `regex`, `glob`, `timeout`, `timeoutMs`, `once`) + drawn from the same source. +- Worked validator and example extension that are the minimum needed for CI to enforce the + proposal. + +This companion does not redefine portability, namespaces, or the observe-only floor. It +constrains and extends them. + +## Empirical event catalog (cli.js v0.2.4) + +The following event keys are present in the 0.2.4 `cli.js` bundle. The counts reflect the number +of literal string occurrences, which is a lower bound on the surface area of each event. +The **0.2.4 confirmed?** column records whether the literal is referenced from the Runtime's +agent-event allowlist (the empirical `Wso` set plus the `hook-config-parser` dispatch path). +Events marked `forward` are observed in `cli.js` only as string literals; their wire contract +is reserved by the spec but their Runtime allowlist membership is still in flight. + +| Event | `cli.js` count | Default dispatch | Decision-bearing | Native client bridge | 0.2.4 confirmed? | +| --- | --- | --- | --- | --- | --- | +| `PreToolUse` | 35 | per tool call | yes | CLAUDE, CODEX | yes | +| `PostToolUse` | 37 | per tool call | no | CLAUDE, CODEX | yes | +| `SessionStart` | 46 | per session resume | no | CLAUDE, CODEX | yes | +| `SessionEnd` | 98 | per session terminate | no | CLAUDE, CODEX | yes | +| `UserPromptSubmit` | 18 | per user turn | no | CLAUDE, CODEX | yes | +| `Stop` | 97 | per turn / agent stop | no | CLAUDE, CODEX | forward | +| `PreCompact` | 12 | before context compaction | no | CLAUDE, CODEX | forward | +| `Notification` | 66 | per system notification | no | CLAUDE, CODEX | forward | +| `SubagentStart` | 15 | per subagent start | no | CODEX | forward | +| `SubagentStop` | 13 | per subagent stop | no | CODEX | forward | +| `PermissionRequest` | 40 | before a permission decision | yes | CLAUDE, CODEX | forward | +| `PermissionDenied` | 3 | after a denied permission | no | CLAUDE, CODEX | forward | + +The five `yes` events are the same five observed in the 0.2.4 `Wso` allowlist scraped from +`cli.js`. The seven `forward` events are the portable spec's reserved surface area; they +are wired into `cli.js` as string literals (e.g. decision-field handling, notification +routing) but their full agent-event dispatch path is expected to land alongside the +validator acceptance in the next Runtime release. A Plugin that needs `forward` events +should declare them anyway; if the 0.2.4 Runtime does not honor the event, the validator +and the portable spec are still authoritative. + +Two design consequences follow directly from the empirical surface: + +1. `SessionEnd`, `Stop`, and `Notification` are the most referenced events. They are the + common targets for cleanup, audit, and provenance Hooks. Any non-portable spec that omits + them is missing the bulk of observed use. +2. `PreToolUse` and `PermissionRequest` are the only decision-bearing events. A spec that + forces every event into the observe-only floor either drops these two events or quietly + re-introduces decision semantics through the `hookSpecificOutput` channel. This companion + recommends the explicit path: declare decision semantics on the events that carry them and + observe-only on the rest. + +`SessionEnd` and `Stop` are listed separately because in the 0.2.4 runtime they are distinct +event sources: `Stop` is per turn / agent stop, `SessionEnd` is per session terminate. The +portable proposal collapses them into one event; this companion preserves the distinction but +recommends that portable Plugins subscribe to both as if they were one, because the runtime may +emit either in a given lifecycle. + +## Decision semantics + +Decision-bearing events are not pure observers. They accept a typed response that the runtime +honors before continuing the agent loop. + +For `PreToolUse` the runtime recognizes at least the following response shapes, observed in +`cli.js`: + +- `{ "decision": "allow", "reason": "..." }` — proceed with the tool call. +- `{ "decision": "deny", "reason": "..." }` — reject the tool call and inject the reason into + the agent transcript. +- `{ "hookSpecificOutput": { ... } }` — typed per-event payload; the only documented shape in + 0.2.4 is for `PreToolUse` and contains a modified tool input. The exact field set is + MiniMax-defined and outside the portable floor. + +For `PermissionRequest` the recognized shapes are: + +- `{ "decision": "allow", "reason": "..." }` — permit the tool call without a TUI prompt. +- `{ "decision": "deny", "reason": "..." }` — reject the tool call (fail-closed equivalent). +- `{ "decision": "ask", "reason": "..." }` — **observer opt-in**: route the decision to the TUI + prompt so the user can approve or deny, even though a Hook is registered. This value is + added by this companion because the 0.2.4 Runtime default for `PermissionRequest` is + fail-closed (`deny`), which makes a pure observer Hook indistinguishable from a denial and + breaks the portable promise of "observe-only." With `ask`, an observer Hook can surface + state (e.g. publish a `waiting` pill) without short-circuiting the user's decision. + +A denial here has the same effect as `fail-closed` and cannot be overridden by a later +`PreToolUse` Hook. The portable default for `PermissionRequest` is therefore: + +- If a Hook returns `allow`, `deny`, or `ask`, that decision wins. +- If a Hook is registered but does not return a `decision`, the runtime must still prompt the + user (treat the absence of a decision as `ask`, not `deny`). The portable proposal's + "Observe-only runtime semantics" floor is preserved: registering a Hook on + `PermissionRequest` does not change the user-facing permission flow. + +Three invariants apply to all decision-bearing events: + +- Decisions are evaluated in declaration order within a Plugin. Earlier Handlers may constrain + what later Handlers can decide. Cross-Plugin ordering is undefined; portable Plugins must not + depend on it. +- A non-zero exit code, a missing `decision` field, or an unparseable response is treated as + "no opinion" and falls through to the runtime default. The runtime default for `PreToolUse` + is to allow; for `PermissionRequest` it is to ask the user (not deny) when any Hook is + registered, and to fall back to the runtime's own permission owner otherwise. The portable + proposal § "Observe-only runtime semantics" is preserved for every other event. +- **Mcode-specific.** An observer Hook on `PermissionRequest` SHOULD return `ask` (or no + decision at all) and SHOULD NOT return `allow` or `deny` unless the Plugin is genuinely the + permission owner. Returning `allow` from a status-publication Hook is a UX bug, not a + feature. The portable proposal does not define the `ask` value; it is Mcode-specific. + +## Dual-client bridging + +The 0.2.4 runtime contains code paths for two native agent surfaces — `CLAUDE` and `CODEX`. +Plugins that target `io.minimax.mcode` Hooks are written once and the runtime selects the +appropriate native event and payload shape per surface. Plugins do not need to know which +surface is active. + +The bridging rules are: + +- `PreToolUse`, `PostToolUse`, `SessionStart`, `SessionEnd`, `Stop`, `UserPromptSubmit`, + `PreCompact`, `Notification`, and `PermissionRequest` are bridged on both surfaces. +- `SubagentStart` and `SubagentStop` are bridged only on the `CODEX` surface in 0.2.4. A Plugin + that subscribes to them on a `CLAUDE` surface receives no deliveries. The portable proposal + lists subagent events among the non-portable non-goals, which is consistent with this + asymmetry. +- `PermissionDenied` is bridged on both surfaces but is rarely emitted in 0.2.4 (`cli.js` + count: 3). Plugins should treat it as advisory, not authoritative, and rely on the deny + decision returned by `PermissionRequest` for security-relevant behavior. + +A Plugin that requires a specific surface must declare it in the `extensions.io.minimax.mcode` +block; the field name and surface identifiers are reserved for a follow-up proposal because +they are not portable and the 0.2.4 runtime does not yet read them. + +## Field vocabulary + +The companion locks down the field names the validator must accept under each handler entry. +Field names are taken from `cli.js` literals and are therefore not negotiable; portable Plugins +that use any field outside this list are not portable, by definition. + +| Field | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `command` | string | yes | — | Single executable token, bare name or contained `./` path. Not a shell string. | +| `args` | string[] | no | `[]` | Distinct process arguments. No shell interpretation. | +| `env` | record | no | `{}` | Additional environment. `PLUGIN_ROOT` and `PLUGIN_DATA` are reserved. | +| `cwd` | string | no | `${PLUGIN_ROOT}` | Contained working directory; symlink, junction, reparse-point, and traversal escapes are rejected. | +| `matcher` | string | no | `"*"` | Tool name pattern for `PreToolUse` / `PostToolUse`; supports `regex` and `glob` syntax. | +| `pattern` | string | no | — | Alias of `matcher`; both names appear in `cli.js`. | +| `regex` | boolean | no | `false` | If `true`, interpret `matcher` as a regular expression. | +| `glob` | boolean | no | `false` | If `true`, interpret `matcher` as a glob pattern. | +| `timeout` | number | no | `30000` | Hard timeout in milliseconds. `timeoutMs` is accepted as an alias. | +| `timeoutMs` | number | no | — | Alias of `timeout`. | +| `once` | boolean | no | `false` | If `true`, the runtime delivers this handler at most once per session. | + +Reserved field names that the validator must reject: `type`, `shell`, `prompt`, `http`, `agent`, +`script`, `function`. These appear in `cli.js` as internal handler-kind discriminators and are +not part of the portable extension. + +## Document shape + +The Runtime locates the hooks document at a fixed path inside the Plugin root: + +``` +${PLUGIN_ROOT}/io.minimax.mcode/hooks/hooks.json +``` + +`PLUGIN_ROOT` is the Runtime-reserved env var (see Field vocabulary below). Marketplace-installed +Plugins and locally-installed Plugins read from the same path inside their own root. The +Runtime does not write to the hooks document. + +`PLUGIN_ROOT` and `PLUGIN_DATA` are independent roots. The hooks document lives under +`PLUGIN_ROOT`; Hook processes MAY write state under `PLUGIN_DATA`. The example +`examples/hello-mcode-hooks/io.minimax.mcode/hooks/scripts/record.mjs` writes to +`${PLUGIN_DATA}/state.json`; the validator treats each expansion token as containing +to its own root. + +The `hooks.json` document must satisfy: + +```json +{ + "$schema": "https://minimax.io/schemas/mcode-hooks/0.1.0/hooks.schema.json", + "hooks": { + "PreToolUse": [ + { "command": "node", "args": ["${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/audit.mjs"] } + ] + } +} +``` + +The `$schema` URL is **reserved** by this proposal but is not yet published. Plugins SHOULD +include the value shown above as a forward contract; the URL will be activated by MiniMax +before any client implementation is accepted. The companion requires the same reverse-domain +namespace `io.minimax.mcode` and the same directory layout that the portable proposal defines; +it does not propose a different one. + +## Conformance evidence (additions to the portable proposal) + +The portable proposal already lists ten conformance checks. This companion adds four, +all required for the runtime side: + +- The full twelve-event catalog is delivered exactly once per matching lifecycle occurrence + on the active native surface; this must be checked per (event, surface) pair. +- A `PreToolUse` Handler returning `{"decision":"deny","reason":"..."}` actually short-circuits + the tool call in the 0.2.4 runtime, observed through `cli.js` decision-field handling. +- A `PermissionRequest` Handler returning `{"decision":"deny","reason":"..."}` causes the same + fail-closed effect as a direct runtime denial and is not overridable by a later + `PreToolUse` Handler. +- A `PermissionRequest` Handler that returns NO `decision` (or `{"decision":"ask",...}`) does + not change the user-facing permission flow: the TUI prompt still appears, the user can + still approve or deny, and the registered Handler is invoked for state observation only. + This is the only path under which a portable observer Hook on `PermissionRequest` can be + written without forcing the user to act on every tool call. + +These four checks are observed-in-runtime evidence. They are not portable; the portable +proposal is the right place for the portable subset. The companion only records what the 0.2.4 +runtime already does so that future portability work has a concrete target. + +### End-to-end smoke (mcode-island v0.3.0, 2026-08-26) + +The companion was exercised by the `mcode-island` Plugin on Windows 11 24H2 with +`@minimax-ai/code@0.2.4`. Each of the twelve event scripts was invoked directly with a +realistic event payload, the resulting `status.json` was read back, and the multi-writer +semantics with the Runtime's own status detector were observed: + +``` +step=SessionStart got=idle src=agent expect=idle OK +step=UserPromptSubmit got=thinking src=agent expect=thinking OK +step=PreToolUse-Bash got=working src=agent expect=working OK +step=PostToolUse-Bash got=done src=agent expect=done OK +step=PreToolUse-Read got=working src=agent expect=working OK +step=PostToolUse-Read got=done src=agent expect=done OK +step=PreCompact got=thinking src=agent expect=thinking OK +step=Stop got=done src=agent expect=done OK +step=SubagentStart got=working src=agent expect=working OK +step=SubagentStop got=done src=agent expect=done OK +step=PermissionRequest got=waiting src=agent expect=waiting OK +step=PermissionDenied got=error src=agent expect=error OK +step=PreToolUse-self-push got=error src=agent expect=error OK (no change, filter applied) +step=Notification got=idle src=agent expect=idle OK +step=SessionEnd got=idle src=agent expect=idle OK +---- +summary: 15 pass, 0 fail +``` + +The `PreToolUse-self-push` case is the only one that intentionally does NOT change state: it +is a `Bash` invocation whose command contains `notify-island.ps1`, so the Hook filters the +self-push to avoid recursive state churn. This is a behavior the companion does not yet +prescribe; portable Plugins may want to filter their own internal tool calls or may want +to push state on every tool call including their own. The mcode-island choice is recorded +here as one working answer, not as a portable requirement. + +## Out of scope (still) + +The portable proposal § "Non-goals" remains authoritative. This companion does not authorize: + +- tool-input rewriting outside `PreToolUse`; +- model-context injection; +- portable stdin payload shapes; +- HTTP, prompt, agent, or async handlers; +- cross-Plugin ordering guarantees; +- secret distribution, sandboxing, or marketplace trust levels. + +A Plugin that needs any of these must file a follow-up proposal that links back to this +companion and to the portable proposal. + +## Validator scope and limitations + +The static validator (`scripts/lib/validation.mjs`) is a shape check; it does not execute +Hook code and cannot observe runtime behavior. The boundary is explicit so reviewers and +Plugin authors know where the guarantee ends. + +The validator **enforces**: + +- `hooks.json` parses as JSON and is an object (closed schema; any unknown root field is + rejected). +- Every key under `hooks` is one of the twelve PascalCase event names listed in + § "Empirical event catalog". +- Each event value is a non-empty array of hook entries. +- Every hook entry's keys are in the closed `HOOK_ENTRY_FIELDS` allowlist; reserved + internal discriminators (`type`, `shell`, `prompt`, `http`, `agent`, `script`, + `function`) are rejected separately. +- Field types match the table in § "Field vocabulary". +- `command` is a bare executable or a contained `./` path. +- `env` does not contain `PLUGIN_ROOT` or `PLUGIN_DATA`; the runtime owns those. +- `cwd` (if present) is a contained `./` path (no `..`, no `\`) or a + `PLUGIN_ROOT` / `PLUGIN_DATA` expansion (no `..`, no `\`, no leading `/`) + at the syntactic level. The validator does NOT follow symlinks for `cwd` + -- symlink containment is a Runtime responsibility (see "Path safety at + execution time" below). +- `$schema` exactly equals + `https://minimax.io/schemas/mcode-hooks/0.1.0/hooks.schema.json`. A + plugin that wants to claim a different schema version is welcome to + publish a different proposal, but the validator cannot pretend a draft + matches `0.1.0` just because the field is non-empty. + +The validator **does not enforce** (these are Runtime responsibilities, recorded here so +the boundary is explicit): + +- Whether the Runtime actually honors a given event. The validator accepts every + event in the catalog regardless of whether the active Runtime wires it; the + `0.2.4 confirmed?` column in § "Empirical event catalog" records the gap. +- Whether the `$schema` URL is reachable or published. The validator + pins the URL but does not fetch it; reachability is a deployment-time + concern, not a validation-time one. +- Payload data values delivered to a Hook. The validator does not parse stdin; + the example `record.mjs` deliberately persists only payload field names, not + values. A portable observer SHOULD follow the same pattern unless the + `PLUGIN_DATA` directory and the payload contract are both Mcode-specific and + the Plugin declares this in its `SKILL.md`. +- Path safety at execution time. The example's `record.mjs` performs symlink + and `..` containment via `realpath`-style resolution; the validator + intentionally does not. Symlink, junction, reparse-point, and traversal + escapes on `cwd` and on Plugin-supplied `args` are the Runtime's contract + to enforce. +- Whether decision responses (`allow`, `deny`, `ask`, `hookSpecificOutput`) + are honored. The validator does not invoke Hooks. +- Cross-Plugin ordering. The portable proposal § "Loading and failure isolation" + already records that this is undefined. + +## Open conformance gaps + +CI coverage for the 0.2.4 event catalog is partial. The two CI tests in +`test/validation.test.mjs` that exercise the example `record.mjs` cover: + +- `SessionStart` (via the "writes state under PLUGIN_DATA" test, once) and a + ten-invocation loop on the same event (via the byte-cap test). + +The remaining ten events — `PreToolUse`, `PostToolUse`, `SessionEnd`, `Stop`, +`UserPromptSubmit`, `PreCompact`, `Notification`, `SubagentStart`, +`SubagentStop`, `PermissionRequest`, `PermissionDenied` — are covered only by +the manual smoke in § "End-to-end smoke" (mcode-island v0.3.0, 2026-08-26, +Windows 11 24H2, `@minimax-ai/code@0.2.4`). That manual run is not +reproducible from CI today. + +The path to close this gap is straightforward and is on the open decisions +list: add one CI test per missing event, each spawning +`record.mjs` with a representative payload for that event and asserting the +recorded record shape. The example `record.mjs` is already payload-shape +agnostic (it persists field names only), so the test bodies are short. Until +those tests land, the "End-to-end smoke" output above is the only evidence +that the events work end-to-end and the validator's claim to support all +twelve is not yet backed by CI. + +The `decision` field, the `ask` value, the `hookSpecificOutput` shape, and +the dual-client bridging rules are not covered by any CI test. They are +backed by `cli.js` literal inspection only. + +## Open decisions + +These block merging this companion into the portable proposal. They are a subset of the +portable proposal's open decisions, with two additions: + +- Confirm that the twelve-event catalog is the target surface for portability, not just an + observed interim. +- Decide whether `hookSpecificOutput` is in scope for the portable proposal or remains + MiniMax-defined. + +## Primary sources + +- `proposals/hooks.md` (commit `d86625d`) — portable Hooks preview. +- [`@minimax-ai/code@0.2.4` CHANGELOG](https://www.npmjs.com/package/@minimax-ai/code?activeTab=code) — runtime release notes, 2026-08-24. +- `cli.js` from `@minimax-ai/code@0.2.4` (npm tarball) — event name, decision, and field vocabulary. +- [Agent Plugins 1.0 specification](https://agent-plugins.org/specification) — portable baseline. +- [Agent Plugins client extensions](https://agent-plugins.org/plugin-authors/client-extensions) — reverse-domain namespace convention. +- [Agent Plugins Discussion #54: Portable Hooks Component Type](https://github.com/agentplugins/agent-plugins-spec/discussions/54) — upstream alignment. +- [`docs/plugin-compatibility.md`](../docs/plugin-compatibility.md) — current compatibility claim. +- [`docs/security-model.md`](../docs/security-model.md) — current security claim. diff --git a/scripts/lib/validation.mjs b/scripts/lib/validation.mjs index cc2a324..0e2397a 100644 --- a/scripts/lib/validation.mjs +++ b/scripts/lib/validation.mjs @@ -3,6 +3,12 @@ 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'; +// Pinned by the proposal at proposals/hooks-detailed-spec.md. The +// round-4 review pointed out that the previous check accepted ANY +// non-empty string, which meant a plugin could claim a different +// schema than the proposal. Locking the URL means the validator +// can now reject drafts that don't match the published spec. +export const HOOK_SCHEMA = 'https://minimax.io/schemas/mcode-hooks/0.1.0/hooks.schema.json'; 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; @@ -89,7 +95,12 @@ export function validateMcp(value, label = 'mcp.json') { assert(typeof server.command === 'string' && server.command.length > 0 && (isBareCommand(server.command) || isContainedRelativePath(server.command)), `${label}: ${name} needs a bare executable or contained ./ path`); assert(server.args === undefined || (Array.isArray(server.args) && server.args.every((item) => typeof item === 'string')), `${label}: ${name}.args must be strings`); assert(server.env === undefined || (isRecord(server.env) && Object.entries(server.env).every(([key, item]) => !['PLUGIN_ROOT', 'PLUGIN_DATA'].includes(key) && typeof item === 'string')), `${label}: ${name}.env is invalid`); - assert(server.cwd === undefined || (typeof server.cwd === 'string' && /^(?:\.\/|\$\{PLUGIN_ROOT\}(?:\/|$)|\$\{PLUGIN_DATA\}(?:\/|$))/u.test(server.cwd)), `${label}: ${name}.cwd is invalid`); + assert( + server.cwd === undefined + || (typeof server.cwd === 'string' + && (isContainedRelativePath(server.cwd) || isContainedPluginPath(server.cwd))), + `${label}: ${name}.cwd must be a contained ./ path (no '..', no '\\') or a path under \${PLUGIN_ROOT} or \${PLUGIN_DATA} (no '..', no '\\', no leading '/')`, + ); assert(Object.keys(server).every((key) => ['type', 'command', 'args', 'env', 'cwd'].includes(key)), `${label}: ${name} has unsupported fields`); } else if (server.type === 'streamable-http' || server.type === 'sse') { assert(typeof server.url === 'string' && isSafeRemoteUrl(server.url), `${label}: ${name}.url must be HTTPS or loopback HTTP without credentials or fragment`); @@ -107,7 +118,31 @@ function isBareCommand(value) { } function isContainedRelativePath(value) { - return value.startsWith('./') && !value.split('/').includes('..') && !value.includes('\\'); + // ./foo/bar: every segment must be a non-empty name, no '..' anywhere, + // no backslashes (which would be a Windows-only escape hatch). + if (!value.startsWith('./') || value.includes('\\')) return false; + const parts = value.split('/'); + // The first part is '.' (we just checked that), so we walk the rest. + for (let i = 1; i < parts.length; i += 1) { + if (parts[i] === '' || parts[i] === '..') return false; + } + return true; +} + +// ${PLUGIN_ROOT}/foo/bar and ${PLUGIN_DATA}/foo/bar: the literal +// segment between '${...}' and the first '/' (or end-of-string) must +// not be '.' or '..' and must not contain a backslash. Same for every +// subsequent segment. This was the round-4 finding: the previous regex +// only checked the prefix, so '${PLUGIN_ROOT}/../../outside' passed. +function isContainedPluginPath(value) { + const m = /^\$\{(PLUGIN_ROOT|PLUGIN_DATA)\}(?:\/(.*))?$/u.exec(value); + if (!m) return false; + if (m[2] === undefined) return true; // '${PLUGIN_ROOT}' alone is the root + if (m[2].includes('\\')) return false; + for (const seg of m[2].split('/')) { + if (seg === '' || seg === '.' || seg === '..') return false; + } + return true; } function isSafeRemoteUrl(value) { @@ -123,6 +158,151 @@ function isSafeRemoteUrl(value) { } } +export const CLIENT_EXTENSION_NAMESPACES = Object.freeze(['io.minimax.mcode']); +const KNOWN_HOOK_EVENTS = new Set([ + 'PreToolUse', + 'PostToolUse', + 'SessionStart', + 'SessionEnd', + 'Stop', + 'UserPromptSubmit', + 'PreCompact', + 'Notification', + 'SubagentStart', + 'SubagentStop', + 'PermissionRequest', + 'PermissionDenied', +]); +const HOOK_DOCUMENT_FIELDS = new Set(['$schema', 'hooks']); +const HOOK_ENTRY_FIELDS = new Set([ + 'command', + 'args', + 'env', + 'cwd', + 'matcher', + 'pattern', + 'regex', + 'glob', + 'timeout', + 'timeoutMs', + 'once', +]); +const HOOK_RESERVED_FIELDS = new Set([ + 'type', + 'shell', + 'prompt', + 'http', + 'agent', + 'script', + 'function', +]); +const HOOK_TIMEOUT_DEFAULT = 30000; +const HOOK_TIMEOUT_MIN = 100; +const HOOK_TIMEOUT_MAX = 600000; + +function rejectUnknownFields(record, allowed, label) { + for (const key of Object.keys(record)) { + if (HOOK_RESERVED_FIELDS.has(key)) { + throw new Error(`${label}: ${key} is a reserved internal discriminator and is not allowed in a portable Hook entry`); + } + if (!allowed.has(key)) { + throw new Error(`${label}: ${key} is not a recognized Hook field; expected one of ${[...allowed].sort().join(', ')}`); + } + } +} + +export function validateHookEntry(value, label) { + assert(isRecord(value), `${label}: hook entry must be an object`); + rejectUnknownFields(value, HOOK_ENTRY_FIELDS, label); + assert(typeof value.command === 'string' && value.command.length > 0, `${label}: command is required`); + assert( + isBareCommand(value.command) || isContainedRelativePath(value.command), + `${label}: command must be a bare executable or a contained ./ path`, + ); + if (value.args !== undefined) { + assert(Array.isArray(value.args) && value.args.every((item) => typeof item === 'string' && item.length > 0), `${label}: args must be an array of non-empty strings`); + } + if (value.env !== undefined) { + assert(isRecord(value.env), `${label}: env must be an object`); + for (const [envKey, envValue] of Object.entries(value.env)) { + assert(!['PLUGIN_ROOT', 'PLUGIN_DATA'].includes(envKey), `${label}: env.${envKey} is reserved`); + assert(typeof envValue === 'string', `${label}: env.${envKey} must be a string`); + } + } + if (value.cwd !== undefined) { + assert( + typeof value.cwd === 'string' + && (isContainedRelativePath(value.cwd) || isContainedPluginPath(value.cwd)), + `${label}: cwd must be a contained ./ path (no '..', no '\\') or a path under \${PLUGIN_ROOT} or \${PLUGIN_DATA} (no '..', no '\\', no leading '/')`, + ); + } + if (value.matcher !== undefined) { + assert(typeof value.matcher === 'string' && value.matcher.length > 0, `${label}: matcher must be a non-empty string`); + } + if (value.pattern !== undefined) { + assert(typeof value.pattern === 'string' && value.pattern.length > 0, `${label}: pattern must be a non-empty string`); + } + if (value.matcher !== undefined && value.pattern !== undefined) { + assert(value.matcher === value.pattern, `${label}: matcher and pattern must agree when both are set`); + } + if (value.regex !== undefined) { + assert(typeof value.regex === 'boolean', `${label}: regex must be a boolean`); + } + if (value.glob !== undefined) { + assert(typeof value.glob === 'boolean', `${label}: glob must be a boolean`); + } + if (value.timeout !== undefined) { + assert(Number.isInteger(value.timeout) && value.timeout >= HOOK_TIMEOUT_MIN && value.timeout <= HOOK_TIMEOUT_MAX, `${label}: timeout must be an integer between ${HOOK_TIMEOUT_MIN} and ${HOOK_TIMEOUT_MAX} ms`); + } + if (value.timeoutMs !== undefined) { + assert(Number.isInteger(value.timeoutMs) && value.timeoutMs >= HOOK_TIMEOUT_MIN && value.timeoutMs <= HOOK_TIMEOUT_MAX, `${label}: timeoutMs must be an integer between ${HOOK_TIMEOUT_MIN} and ${HOOK_TIMEOUT_MAX} ms`); + } + if (value.once !== undefined) { + assert(typeof value.once === 'boolean', `${label}: once must be a boolean`); + } + return value; +} + +export function validateHooksDocument(value, label) { + assert(isRecord(value), `${label}: root must be an object`); + rejectUnknownFields(value, HOOK_DOCUMENT_FIELDS, label); + // Round-4 fix: the previous check was `length > 0`, which accepted + // any non-empty string. The proposal pins a specific URL, so the + // validator must require that URL exactly. A plugin that wants to + // claim a different schema is welcome to publish a different + // proposal, but the validator cannot pretend a draft matches + // 0.1.0 just because the field is non-empty. + assert(value.$schema === HOOK_SCHEMA, `${label}: $schema must equal ${HOOK_SCHEMA}`); + assert(isRecord(value.hooks), `${label}: hooks must be an object`); + const events = []; + for (const [eventName, entries] of Object.entries(value.hooks)) { + assert(KNOWN_HOOK_EVENTS.has(eventName), `${label}: ${eventName} is not a recognized event; expected one of ${[...KNOWN_HOOK_EVENTS].sort().join(', ')}`); + assert(Array.isArray(entries) && entries.length > 0, `${label}: ${eventName} must be a non-empty array`); + for (let i = 0; i < entries.length; i += 1) { + validateHookEntry(entries[i], `${label}: ${eventName}[${i}]`); + } + events.push(eventName); + } + return events.sort(); +} + +export async function validateClientExtensions(root) { + const found = []; + for (const namespace of CLIENT_EXTENSION_NAMESPACES) { + const hooksPath = path.join(root, namespace, 'hooks', 'hooks.json'); + let text; + try { + text = await readFile(hooksPath, 'utf8'); + } catch (error) { + if (error.code === 'ENOENT') continue; + throw error; + } + const events = validateHooksDocument(parseJson(text, hooksPath), hooksPath); + found.push({ namespace, events }); + } + return found; +} + export async function validatePluginDirectory(root) { const manifestPath = path.join(root, 'plugin.json'); const manifest = validatePluginManifest(parseJson(await readFile(manifestPath, 'utf8'), manifestPath), manifestPath); @@ -147,8 +327,9 @@ export async function validatePluginDirectory(root) { } catch (error) { if (error.code !== 'ENOENT') throw error; } + const clientExtensions = await validateClientExtensions(root); assert(skills.length + mcpServers.length > 0, `${root}: plugin must expose at least one Skill or MCP server`); - return { manifest, skills: skills.sort(), mcpServers }; + return { manifest, skills: skills.sort(), mcpServers, clientExtensions }; } export async function validateHostedPluginDirectory(root, { owner, pluginName }) { diff --git a/test/validation.test.mjs b/test/validation.test.mjs index c7dfce6..a8c2062 100644 --- a/test/validation.test.mjs +++ b/test/validation.test.mjs @@ -1,7 +1,17 @@ import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import test from 'node:test'; -import { validateMcp, validatePluginManifest, validateSkillText } from '../scripts/lib/validation.mjs'; +import { + validateHooksDocument, + validateHookEntry, + validateMcp, + validatePluginDirectory, + validatePluginManifest, + validateSkillText, +} from '../scripts/lib/validation.mjs'; test('accepts the portable Agent Plugins manifest', () => { const value = validatePluginManifest({ @@ -42,3 +52,459 @@ test('validates supported MCP transports and reserved environment variables', () /env is invalid/u, ); }); + +test('accepts a Hook entry with allowed field vocabulary and rejects reserved discriminators', () => { + const entry = validateHookEntry({ + command: 'node', + args: ['${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs'], + env: { LOG: 'info' }, + cwd: '${PLUGIN_DATA}', + matcher: 'Bash', + timeout: 5000, + once: false, + }, 'hook'); + assert.equal(entry.command, 'node'); + assert.throws(() => validateHookEntry({ command: 'node', type: 'shell' }, 'hook'), /reserved internal discriminator/u); + assert.throws(() => validateHookEntry({ command: 'node', env: { PLUGIN_ROOT: 'bad' } }, 'hook'), /reserved/u); + assert.throws(() => validateHookEntry({ command: 'node', cwd: '/etc' }, 'hook'), /cwd must be/u); + assert.throws(() => validateHookEntry({ command: 'node', timeout: 1 }, 'hook'), /timeout must be an integer/u); +}); + +test('accepts a Hooks document that targets the experimental io.minimax.mcode namespace', () => { + const events = validateHooksDocument({ + $schema: 'https://minimax.io/schemas/mcode-hooks/0.1.0/hooks.schema.json', + hooks: { + PreToolUse: [{ command: 'node', args: ['${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs'] }], + SessionEnd: [{ command: 'node' }], + }, + }, 'hooks.json'); + assert.deepEqual(events, ['PreToolUse', 'SessionEnd']); + // Round-4 fix: $schema is now pinned, so the URL must match exactly. + // The old "any non-empty string" check is gone, so the error message + // also changes — we now expect "must equal" rather than letting the + // bogus schema past and tripping on the next clause. + assert.throws( + () => validateHooksDocument({ $schema: 'x', hooks: { UnknownEvent: [{ command: 'node' }] } }, 'hooks.json'), + /\$schema must equal/u, + ); + assert.throws( + () => validateHooksDocument({ $schema: 'https://minimax.io/schemas/mcode-hooks/0.1.0/hooks.schema.json', hooks: { UnknownEvent: [{ command: 'node' }] } }, 'hooks.json'), + /not a recognized event/u, + ); + assert.throws( + () => validateHooksDocument({ $schema: 'https://minimax.io/schemas/mcode-hooks/0.1.0/hooks.schema.json', hooks: { PreToolUse: [] } }, 'hooks.json'), + /non-empty array/u, + ); + assert.throws( + () => validateHooksDocument({ hooks: { PreToolUse: [{ command: 'node' }] } }, 'hooks.json'), + /\$schema must equal/u, + ); +}); + +test('validatePluginDirectory picks up an io.minimax.mode hooks extension without requiring it', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'hooks-ext-')); + try { + await writeFile(path.join(root, 'plugin.json'), JSON.stringify({ + $schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json', + name: 'hello-hooks', + version: '0.1.0', + })); + const skillDir = path.join(root, 'skills', 'hello-hooks'); + const { mkdir } = await import('node:fs/promises'); + await mkdir(skillDir, { recursive: true }); + await writeFile(path.join(skillDir, 'SKILL.md'), [ + '---', + 'name: hello-hooks', + 'description: Verify hello-hooks loads.', + '---', + '', + '# Hello', + '', + ].join('\n'), 'utf8'); + const hooksDir = path.join(root, 'io.minimax.mcode', 'hooks'); + await mkdir(hooksDir, { recursive: true }); + await writeFile(path.join(hooksDir, 'hooks.json'), JSON.stringify({ + $schema: 'https://minimax.io/schemas/mcode-hooks/0.1.0/hooks.schema.json', + hooks: { SessionStart: [{ command: 'node' }] }, + })); + const result = await validatePluginDirectory(root); + assert.deepEqual(result.clientExtensions, [{ namespace: 'io.minimax.mcode', events: ['SessionStart'] }]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('validatePluginDirectory ignores a missing hooks extension', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'hooks-none-')); + try { + await writeFile(path.join(root, 'plugin.json'), JSON.stringify({ + $schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json', + name: 'hello-mcode', + version: '0.1.0', + })); + const skillDir = path.join(root, 'skills', 'hello-mcode'); + const { mkdir } = await import('node:fs/promises'); + await mkdir(skillDir, { recursive: true }); + await writeFile(path.join(skillDir, 'SKILL.md'), [ + '---', + 'name: hello-mcode', + 'description: Verify hello-mcode loads.', + '---', + '', + '# Hello', + '', + ].join('\n'), 'utf8'); + const result = await validatePluginDirectory(root); + assert.deepEqual(result.clientExtensions, []); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('validatePluginDirectory rejects hooks.json with an unrecognized event', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'hooks-bad-')); + try { + await writeFile(path.join(root, 'plugin.json'), JSON.stringify({ + $schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json', + name: 'hello-bad', + version: '0.1.0', + })); + const skillDir = path.join(root, 'skills', 'hello-bad'); + const { mkdir } = await import('node:fs/promises'); + await mkdir(skillDir, { recursive: true }); + await writeFile(path.join(skillDir, 'SKILL.md'), [ + '---', + 'name: hello-bad', + 'description: Verify hello-bad loads.', + '---', + '', + '# Hello', + '', + ].join('\n'), 'utf8'); + const hooksDir = path.join(root, 'io.minimax.mcode', 'hooks'); + await mkdir(hooksDir, { recursive: true }); + await writeFile(path.join(hooksDir, 'hooks.json'), JSON.stringify({ + $schema: 'https://minimax.io/schemas/mcode-hooks/0.1.0/hooks.schema.json', + hooks: { Bogus: [{ command: 'node' }] }, + })); + await assert.rejects(validatePluginDirectory(root), /not a recognized event/u); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('validateHookEntry rejects unknown fields (closed schema)', () => { + assert.throws( + () => validateHookEntry({ command: 'node', evil: 'x' }, 'hook'), + /not a recognized Hook field/u, + ); + assert.throws( + () => validateHookEntry({ command: 'node', sideChannel: true }, 'hook'), + /not a recognized Hook field/u, + ); +}); + +// Round-4 fix: the previous regex accepted './../outside' and +// '${PLUGIN_ROOT}/../../outside' because it only checked the +// prefix. These tests pin the negative contract. +test('validateHookEntry rejects cwd traversal in ./ paths (R4-1)', () => { + assert.throws(() => validateHookEntry({ command: 'node', cwd: './../outside' }, 'hook'), + /cwd must be/u); + assert.throws(() => validateHookEntry({ command: 'node', cwd: './foo/../../bar' }, 'hook'), + /cwd must be/u); + assert.throws(() => validateHookEntry({ command: 'node', cwd: './foo\\bar' }, 'hook'), + /cwd must be/u); + assert.throws(() => validateHookEntry({ command: 'node', cwd: '..' }, 'hook'), + /cwd must be/u); + // Sanity: a properly contained ./ path is still accepted. + assert.doesNotThrow(() => validateHookEntry({ command: 'node', cwd: './scripts' }, 'hook')); +}); + +test('validateHookEntry rejects cwd traversal in ${PLUGIN_ROOT} / ${PLUGIN_DATA} paths (R4-1)', () => { + assert.throws(() => validateHookEntry({ command: 'node', cwd: '${PLUGIN_ROOT}/../outside' }, 'hook'), + /cwd must be/u); + assert.throws(() => validateHookEntry({ command: 'node', cwd: '${PLUGIN_DATA}/foo/../bar/..' }, 'hook'), + /cwd must be/u); + assert.throws(() => validateHookEntry({ command: 'node', cwd: '${PLUGIN_ROOT}/foo/..' }, 'hook'), + /cwd must be/u); + // Sanity: contained paths still accepted. + assert.doesNotThrow(() => validateHookEntry({ command: 'node', cwd: '${PLUGIN_ROOT}/io.minimax.mcode/hooks' }, 'hook')); + assert.doesNotThrow(() => validateHookEntry({ command: 'node', cwd: '${PLUGIN_DATA}' }, 'hook')); +}); + +test('validateMcp rejects the same cwd traversal patterns (R4-1)', () => { + const base = { $schema: 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json' }; + assert.throws( + () => validateMcp({ ...base, mcpServers: { bad: { type: 'stdio', command: 'node', cwd: './../escape' } } }), + /cwd must be/u, + ); + assert.throws( + () => validateMcp({ ...base, mcpServers: { bad: { type: 'stdio', command: 'node', cwd: '${PLUGIN_ROOT}/../etc' } } }), + /cwd must be/u, + ); +}); + +test('validateHooksDocument pins the $schema URL to the proposal (R4-3)', () => { + // Wrong URL is now rejected with the new pin. + assert.throws( + () => validateHooksDocument({ + $schema: 'https://example.com/wrong/schema.json', + hooks: { SessionStart: [{ command: 'node' }] }, + }, 'hooks.json'), + /\$schema must equal/u, + ); + // Empty string is now rejected (the old "length > 0" check would + // still pass an empty string; the new pin wouldn't, because the + // empty string doesn't equal the proposal URL). + assert.throws( + () => validateHooksDocument({ + $schema: '', + hooks: { SessionStart: [{ command: 'node' }] }, + }, 'hooks.json'), + /\$schema must equal/u, + ); +}); + +test('validateHookEntry type-checks matcher, pattern, regex, glob, once, timeout', () => { + assert.throws(() => validateHookEntry({ command: 'node', matcher: 123 }, 'hook'), /matcher must be a non-empty string/u); + assert.throws(() => validateHookEntry({ command: 'node', pattern: '' }, 'hook'), /pattern must be a non-empty string/u); + assert.throws(() => validateHookEntry({ command: 'node', regex: 'yes' }, 'hook'), /regex must be a boolean/u); + assert.throws(() => validateHookEntry({ command: 'node', glob: 1 }, 'hook'), /glob must be a boolean/u); + assert.throws(() => validateHookEntry({ command: 'node', once: 'yes' }, 'hook'), /once must be a boolean/u); + assert.throws(() => validateHookEntry({ command: 'node', timeout: '30s' }, 'hook'), /timeout must be an integer/u); + assert.throws(() => validateHookEntry({ command: 'node', timeoutMs: 1 }, 'hook'), /timeoutMs must be an integer/u); + assert.throws(() => validateHookEntry({ command: 'node', timeoutMs: 0 }, 'hook'), /timeoutMs must be an integer/u); +}); + +test('validateHooksDocument rejects unknown root fields (closed schema)', () => { + assert.throws( + () => validateHooksDocument({ + $schema: 'https://minimax.io/schemas/mcode-hooks/0.1.0/hooks.schema.json', + hooks: { SessionStart: [{ command: 'node' }] }, + extra: true, + }, 'hooks.json'), + /extra is not a recognized Hook field/u, + ); +}); + +test('record.mjs writes state under PLUGIN_DATA even when it is outside PLUGIN_ROOT', async () => { + const tmp = await mkdtemp(path.join(tmpdir(), 'hooks-e2e-')); + const { spawn } = await import('node:child_process'); + try { + const root = path.join(tmp, 'plugin'); + const data = path.join(tmp, 'plugin-data', 'instance-1'); + await mkdir(root, { recursive: true }); + await mkdir(data, { recursive: true }); + const script = path.join(process.cwd(), 'examples', 'hello-mcode-hooks', 'io.minimax.mcode', 'hooks', 'scripts', 'record.mjs'); + const stateFile = path.join(data, 'state.json'); + await new Promise((resolveP, rejectP) => { + const child = spawn(process.execPath, [script, '--event', 'SessionStart', '--state', '${PLUGIN_DATA}/state.json'], { + env: { ...process.env, PLUGIN_ROOT: root, PLUGIN_DATA: data }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + child.stdin.end(JSON.stringify({ toolName: 'Bash', toolInput: { command: 'ls' } })); + let stderr = ''; + child.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); }); + child.on('error', rejectP); + child.on('exit', (code) => { + if (code !== 0) rejectP(new Error(`record.mjs exited ${code}; stderr=${stderr}`)); + else resolveP(); + }); + }); + const written = JSON.parse(await import('node:fs/promises').then((m) => m.readFile(stateFile, 'utf8'))); + assert.equal(written.records.length, 1); + assert.equal(written.records[0].event, 'SessionStart'); + assert.deepEqual(written.records[0].payloadKeys, ['toolInput', 'toolName']); + } finally { + await rm(tmp, { recursive: true, force: true }); + } +}); + +// Round-4 fix (R4-2): the previous ensureContained only did +// `path.resolve` (lexical normalization), which is bypassed when +// PLUGIN_DATA itself is reached through a symlink. For example: +// +// PLUGIN_DATA = /tmp/data (realpath = /var/srv/data) +// realpath('/tmp/data') = '/var/srv/data' +// +// Lexical: startsWith('/tmp/data/') -> true -> "contained" (false positive) +// Real: startsWith('/var/srv/data/') -> true -> contained (the truth) +// +// The hard case is when a SUBDIRECTORY of PLUGIN_DATA is a symlink +// to outside. Lexical containment passes (the symlink lives under +// the lexical root), but real containment fails (the realpath of +// the target is outside the realpath of the root). +test('record.mjs refuses to write through a symlink in PLUGIN_DATA that escapes the root (R4-2)', async () => { + if (process.platform === 'win32') { + // Windows symlinks require admin or developer mode; the existing + // tests in this file already exercise the non-symlink code path, + // and the contract that the symlink case fails is enforced by + // the realpath-based check. Skip on Windows to keep CI green; + // POSIX CI is the real evidence. + return; + } + const tmp = await mkdtemp(path.join(tmpdir(), 'hooks-symlink-')); + const { spawn } = await import('node:child_process'); + const { symlink, mkdir, writeFile: writeFileRaw } = await import('node:fs/promises'); + try { + const data = path.join(tmp, 'plugin-data'); + const outside = path.join(tmp, 'outside'); + await mkdir(data, { recursive: true }); + await mkdir(outside, { recursive: true }); + // Create a sentinel file outside the data root. + await writeFileRaw(path.join(outside, 'pwned.json'), '{"records":[]}', 'utf8'); + // Symlink data/link -> outside so PLUGIN_DATA/link/pwned.json + // lexically lives under PLUGIN_DATA but realpath-wise lives + // under the outside dir. + await symlink(outside, path.join(data, 'link'), 'dir'); + const root = path.join(tmp, 'plugin'); + await mkdir(root, { recursive: true }); + const script = path.join(process.cwd(), 'examples', 'hello-mcode-hooks', 'io.minimax.mcode', 'hooks', 'scripts', 'record.mjs'); + const code = await new Promise((resolveP, rejectP) => { + const child = spawn(process.execPath, [script, '--event', 'SessionStart', '--state', '${PLUGIN_DATA}/link/pwned.json'], { + env: { ...process.env, PLUGIN_ROOT: root, PLUGIN_DATA: data }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + child.stdin.end(JSON.stringify({ toolName: 'Bash' })); + let stderr = ''; + child.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); }); + child.on('error', rejectP); + child.on('exit', (c) => resolveP(c)); + }); + // record.mjs swallows the error (Observer must never affect + // agent behavior), so we can't observe the throw directly. + // The observable signal is that the file outside PLUGIN_DATA + // was NOT modified: it still contains the sentinel bytes, not + // a JSON envelope with `records`. + const outsideBytes = await import('node:fs/promises').then((m) => m.readFile(path.join(outside, 'pwned.json'), 'utf8')); + assert.equal(outsideBytes, '{"records":[]}', + `record.mjs must not have written through the symlink (got: ${outsideBytes})`); + // The exit code is 0 because errors are swallowed; the contract + // is that the file system is unchanged. The code is exposed for + // diagnostic purposes only. + assert.equal(code, 0, `record.mjs exit code is 0 (errors swallowed) but state must not have escaped: got ${code}`); + } finally { + await rm(tmp, { recursive: true, force: true }); + } +}); + +// Round-4 fix (R4-4): the previous roundtrip tests only exercised +// SessionStart. The hello-mcode-hooks example ships with +// SessionStart / SessionEnd / PreToolUse entries, and CI only proved +// the first one. These tests run the bundled script with the +// --event flag for the other two. +test('record.mjs handles SessionEnd via the bundled script (R4-4)', async () => { + const tmp = await mkdtemp(path.join(tmpdir(), 'hooks-end-')); + const { spawn } = await import('node:child_process'); + try { + const root = path.join(tmp, 'plugin'); + const data = path.join(tmp, 'data'); + await mkdir(root, { recursive: true }); + await mkdir(data, { recursive: true }); + const stateFile = path.join(data, 'state.json'); + const script = path.join(process.cwd(), 'examples', 'hello-mcode-hooks', 'io.minimax.mcode', 'hooks', 'scripts', 'record.mjs'); + await new Promise((resolveP, rejectP) => { + const child = spawn(process.execPath, [script, '--event', 'SessionEnd', '--state', '${PLUGIN_DATA}/state.json'], { + env: { ...process.env, PLUGIN_ROOT: root, PLUGIN_DATA: data }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + child.stdin.end(JSON.stringify({ sessionId: 'abc-123' })); + child.on('error', rejectP); + child.on('exit', (c) => { if (c === 0) resolveP(); else rejectP(new Error(`exit ${c}`)); }); + }); + const written = JSON.parse(await import('node:fs/promises').then((m) => m.readFile(stateFile, 'utf8'))); + assert.equal(written.records.length, 1); + assert.equal(written.records[0].event, 'SessionEnd'); + assert.deepEqual(written.records[0].payloadKeys, ['sessionId']); + } finally { + await rm(tmp, { recursive: true, force: true }); + } +}); + +test('record.mjs handles PostToolUse via the bundled script (R4-4)', async () => { + const tmp = await mkdtemp(path.join(tmpdir(), 'hooks-post-')); + const { spawn } = await import('node:child_process'); + try { + const root = path.join(tmp, 'plugin'); + const data = path.join(tmp, 'data'); + await mkdir(root, { recursive: true }); + await mkdir(data, { recursive: true }); + const stateFile = path.join(data, 'state.json'); + const script = path.join(process.cwd(), 'examples', 'hello-mcode-hooks', 'io.minimax.mcode', 'hooks', 'scripts', 'record.mjs'); + await new Promise((resolveP, rejectP) => { + const child = spawn(process.execPath, [script, '--event', 'PostToolUse', '--state', '${PLUGIN_DATA}/state.json'], { + env: { ...process.env, PLUGIN_ROOT: root, PLUGIN_DATA: data }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + child.stdin.end(JSON.stringify({ toolName: 'Bash', toolResult: { stdout: 'hello', exitCode: 0 } })); + child.on('error', rejectP); + child.on('exit', (c) => { if (c === 0) resolveP(); else rejectP(new Error(`exit ${c}`)); }); + }); + const written = JSON.parse(await import('node:fs/promises').then((m) => m.readFile(stateFile, 'utf8'))); + assert.equal(written.records.length, 1); + assert.equal(written.records[0].event, 'PostToolUse'); + assert.deepEqual(written.records[0].payloadKeys, ['toolName', 'toolResult']); + } finally { + await rm(tmp, { recursive: true, force: true }); + } +}); + +test('record.mjs handles PreToolUse via the bundled script (R4-4)', async () => { + const tmp = await mkdtemp(path.join(tmpdir(), 'hooks-pre-')); + const { spawn } = await import('node:child_process'); + try { + const root = path.join(tmp, 'plugin'); + const data = path.join(tmp, 'data'); + await mkdir(root, { recursive: true }); + await mkdir(data, { recursive: true }); + const stateFile = path.join(data, 'state.json'); + const script = path.join(process.cwd(), 'examples', 'hello-mcode-hooks', 'io.minimax.mcode', 'hooks', 'scripts', 'record.mjs'); + await new Promise((resolveP, rejectP) => { + const child = spawn(process.execPath, [script, '--event', 'PreToolUse', '--state', '${PLUGIN_DATA}/state.json'], { + env: { ...process.env, PLUGIN_ROOT: root, PLUGIN_DATA: data }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + child.stdin.end(JSON.stringify({ toolName: 'Bash', toolInput: { command: 'rm -rf /' } })); + child.on('error', rejectP); + child.on('exit', (c) => { if (c === 0) resolveP(); else rejectP(new Error(`exit ${c}`)); }); + }); + const written = JSON.parse(await import('node:fs/promises').then((m) => m.readFile(stateFile, 'utf8'))); + assert.equal(written.records.length, 1); + assert.equal(written.records[0].event, 'PreToolUse'); + // payloadKeys is sorted alphabetically (see record.mjs). + assert.deepEqual(written.records[0].payloadKeys, ['toolInput', 'toolName']); + } finally { + await rm(tmp, { recursive: true, force: true }); + } +}); + +test('record.mjs enforces MAX_STATE_BYTES and trims older records', async () => { + const tmp = await mkdtemp(path.join(tmpdir(), 'hooks-cap-')); + const { spawn } = await import('node:child_process'); + const { writeFile: writeFile2, readFile: readFile2 } = await import('node:fs/promises'); + try { + const root = path.join(tmp, 'plugin'); + const data = path.join(tmp, 'data'); + await mkdir(root, { recursive: true }); + await mkdir(data, { recursive: true }); + const stateFile = path.join(data, 'state.json'); + const script = path.join(process.cwd(), 'examples', 'hello-mcode-hooks', 'io.minimax.mcode', 'hooks', 'scripts', 'record.mjs'); + for (let i = 0; i < 10; i += 1) { + await new Promise((resolveP, rejectP) => { + const child = spawn(process.execPath, [script, '--event', `E${i}`, '--state', '${PLUGIN_DATA}/state.json'], { + env: { ...process.env, PLUGIN_ROOT: root, PLUGIN_DATA: data }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + child.stdin.end(JSON.stringify({ idx: i })); + child.on('error', rejectP); + child.on('exit', (code) => { if (code === 0) resolveP(); else rejectP(new Error(`exit ${code}`)); }); + }); + } + const text = await readFile2(stateFile, 'utf8'); + assert.ok(Buffer.byteLength(text, 'utf8') <= 1024 * 1024, 'state file must stay under MAX_STATE_BYTES'); + const written = JSON.parse(text); + assert.ok(written.records.length <= 4096, 'records must stay under MAX_RECORDS'); + } finally { + await rm(tmp, { recursive: true, force: true }); + } +});