Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions examples/hello-mcode-hooks/LICENSE
Original file line number Diff line number Diff line change
@@ -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.
61 changes: 61 additions & 0 deletions examples/hello-mcode-hooks/README.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 48 additions & 0 deletions examples/hello-mcode-hooks/io.minimax.mcode/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
}
232 changes: 232 additions & 0 deletions examples/hello-mcode-hooks/io.minimax.mcode/hooks/scripts/record.mjs
Original file line number Diff line number Diff line change
@@ -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();
11 changes: 11 additions & 0 deletions examples/hello-mcode-hooks/plugin.json
Original file line number Diff line number Diff line change
@@ -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"]
}
Loading