@nanhara/hara 0.125.3 → 0.126.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -1
- package/README.md +10 -4
- package/SECURITY.md +7 -3
- package/dist/agent/limits.js +2 -2
- package/dist/agent/loop.js +270 -98
- package/dist/config.js +172 -4
- package/dist/gateway/flows-pending.js +14 -36
- package/dist/gateway/wecom.js +9 -1
- package/dist/index.js +372 -159
- package/dist/mcp/client.js +2 -0
- package/dist/plugins/manifest.js +317 -0
- package/dist/plugins/plugins.js +476 -139
- package/dist/providers/bounded-turn.js +6 -0
- package/dist/providers/factory.js +54 -0
- package/dist/providers/openai.js +6 -1
- package/dist/providers/registry.js +1 -0
- package/dist/providers/target.js +98 -0
- package/dist/security/guardian.js +6 -1
- package/dist/security/private-state.js +1 -1
- package/dist/security/secrets.js +19 -0
- package/dist/serve/protocol.js +7 -2
- package/dist/serve/server.js +139 -35
- package/dist/serve/sessions.js +11 -2
- package/dist/session/operation-drain.js +45 -0
- package/dist/tools/agent.js +1 -0
- package/dist/tools/all.js +1 -0
- package/dist/tools/ask_user.js +8 -3
- package/dist/tools/builtin.js +19 -1
- package/dist/tools/codebase.js +1 -0
- package/dist/tools/computer.js +1 -0
- package/dist/tools/cron.js +10 -0
- package/dist/tools/external_agent.js +1 -0
- package/dist/tools/memory.js +2 -0
- package/dist/tools/registry.js +95 -4
- package/dist/tools/result-limit.js +172 -2
- package/dist/tools/runtime.js +67 -0
- package/dist/tools/search.js +3 -0
- package/dist/tools/skill.js +1 -0
- package/dist/tools/task.js +5 -0
- package/dist/tools/todo.js +3 -2
- package/dist/tools/web.js +4 -0
- package/dist/tui/App.js +5 -1
- package/package.json +1 -1
package/dist/mcp/client.js
CHANGED
|
@@ -148,6 +148,7 @@ export async function connectMcpServers(servers, log, options = {}) {
|
|
|
148
148
|
transport = new StdioClientTransport({
|
|
149
149
|
command: cfg.command,
|
|
150
150
|
args: cfg.args ?? [],
|
|
151
|
+
...(cfg.cwd ? { cwd: cfg.cwd } : {}),
|
|
151
152
|
// The inherited agent environment is scrubbed; server-specific cfg.env is an explicit grant.
|
|
152
153
|
env: toolSubprocessEnv(process.env, cfg.env ?? {}),
|
|
153
154
|
// The SDK default is "inherit", which would let an external server print credentials or terminal
|
|
@@ -175,6 +176,7 @@ export async function connectMcpServers(servers, log, options = {}) {
|
|
|
175
176
|
description: t.description ?? `${name}/${t.name}`,
|
|
176
177
|
input_schema: schema,
|
|
177
178
|
kind: "exec",
|
|
179
|
+
visibility: "deferred",
|
|
178
180
|
trustBoundary: "external",
|
|
179
181
|
async run(input, ctx) {
|
|
180
182
|
if (!ctx.ask && process.env.HARA_ALLOW_TRUSTED_EXTENSIONS !== "1") {
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import { sensitiveFileReason } from "../security/sensitive-files.js";
|
|
5
|
+
const MANIFEST_PATHS = [".claude-plugin/plugin.json", ".hara-plugin/plugin.json", "plugin.json"];
|
|
6
|
+
const TOP_LEVEL_KEYS = new Set(["name", "version", "description", "skills", "agents", "mcpServers", "hooks", "bin", "panels"]);
|
|
7
|
+
const MCP_KEYS = new Set(["command", "args", "env"]);
|
|
8
|
+
const PANEL_KEYS = new Set(["id", "title", "command", "args", "port", "detect"]);
|
|
9
|
+
const HOOK_KEYS = new Set(["matcher", "command"]);
|
|
10
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
11
|
+
const ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
|
12
|
+
const MAX_MANIFEST_BYTES = 256 * 1024;
|
|
13
|
+
const MAX_PLUGIN_FILES = 20_000;
|
|
14
|
+
const MAX_PLUGIN_BYTES = 512 * 1024 * 1024;
|
|
15
|
+
const MAX_ARRAY_ENTRIES = 256;
|
|
16
|
+
function record(value, label) {
|
|
17
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
18
|
+
throw new Error(`${label} must be an object`);
|
|
19
|
+
const proto = Object.getPrototypeOf(value);
|
|
20
|
+
if (proto !== Object.prototype && proto !== null)
|
|
21
|
+
throw new Error(`${label} must be a plain object`);
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
function knownKeys(value, allowed, label) {
|
|
25
|
+
const unknown = Object.keys(value).filter((key) => !allowed.has(key));
|
|
26
|
+
if (unknown.length)
|
|
27
|
+
throw new Error(`${label} contains unsupported field '${unknown[0]}'`);
|
|
28
|
+
}
|
|
29
|
+
function text(value, label, max = 4096) {
|
|
30
|
+
if (typeof value !== "string")
|
|
31
|
+
throw new Error(`${label} must be a string`);
|
|
32
|
+
if (!value || value.length > max || /[\0-\x1f\x7f]/u.test(value)) {
|
|
33
|
+
throw new Error(`${label} is empty, too long, or contains control characters`);
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
export function safePluginId(value, label = "plugin name") {
|
|
38
|
+
const id = text(value, label, 64);
|
|
39
|
+
if (!ID.test(id))
|
|
40
|
+
throw new Error(`${label} must match ${ID.source}`);
|
|
41
|
+
return id;
|
|
42
|
+
}
|
|
43
|
+
export function safePluginRelativePath(value, label) {
|
|
44
|
+
const input = text(value, label, 512);
|
|
45
|
+
const path = input.startsWith("./") ? input.slice(2) : input;
|
|
46
|
+
if (isAbsolute(path)
|
|
47
|
+
|| path.includes("\\")
|
|
48
|
+
|| path.startsWith("~")
|
|
49
|
+
|| /^[A-Za-z]:/u.test(path)
|
|
50
|
+
|| path.startsWith("//"))
|
|
51
|
+
throw new Error(`${label} must be a portable relative path`);
|
|
52
|
+
const parts = path.split("/");
|
|
53
|
+
if (parts.some((part) => !part || part === "." || part === ".." || /[\0-\x1f\x7f]/u.test(part))) {
|
|
54
|
+
throw new Error(`${label} must not contain empty, current, parent, or control-character components`);
|
|
55
|
+
}
|
|
56
|
+
return parts.join("/");
|
|
57
|
+
}
|
|
58
|
+
function inside(root, candidate) {
|
|
59
|
+
const rel = relative(root, candidate);
|
|
60
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
61
|
+
}
|
|
62
|
+
export function verifiedPluginPath(root, rel, kind, label) {
|
|
63
|
+
const safe = safePluginRelativePath(rel, label);
|
|
64
|
+
const canonicalRoot = realpathSync.native(resolve(root));
|
|
65
|
+
let current = canonicalRoot;
|
|
66
|
+
const parts = safe.split("/");
|
|
67
|
+
for (let index = 0; index < parts.length; index++) {
|
|
68
|
+
current = join(current, parts[index]);
|
|
69
|
+
const info = lstatSync(current);
|
|
70
|
+
if (info.isSymbolicLink())
|
|
71
|
+
throw new Error(`${label} contains a symbolic-link component`);
|
|
72
|
+
if (index < parts.length - 1 && !info.isDirectory())
|
|
73
|
+
throw new Error(`${label} crosses a non-directory component`);
|
|
74
|
+
}
|
|
75
|
+
const info = lstatSync(current);
|
|
76
|
+
if (kind === "file" && (!info.isFile() || info.nlink !== 1))
|
|
77
|
+
throw new Error(`${label} must be a single-link regular file`);
|
|
78
|
+
if (kind === "directory" && !info.isDirectory())
|
|
79
|
+
throw new Error(`${label} must be a directory`);
|
|
80
|
+
const canonical = realpathSync.native(current);
|
|
81
|
+
if (!inside(canonicalRoot, canonical))
|
|
82
|
+
throw new Error(`${label} escapes the immutable plugin root`);
|
|
83
|
+
return canonical;
|
|
84
|
+
}
|
|
85
|
+
function stringArray(value, label, pathValues = false) {
|
|
86
|
+
if (!Array.isArray(value) || value.length > MAX_ARRAY_ENTRIES)
|
|
87
|
+
throw new Error(`${label} must be a bounded array`);
|
|
88
|
+
return value.map((entry, index) => pathValues
|
|
89
|
+
? safePluginRelativePath(entry, `${label}[${index}]`)
|
|
90
|
+
: text(entry, `${label}[${index}]`));
|
|
91
|
+
}
|
|
92
|
+
function validateHooks(value) {
|
|
93
|
+
const raw = record(value, "plugin hooks");
|
|
94
|
+
knownKeys(raw, new Set(["PreToolUse", "PostToolUse"]), "plugin hooks");
|
|
95
|
+
const out = {};
|
|
96
|
+
for (const event of ["PreToolUse", "PostToolUse"]) {
|
|
97
|
+
if (raw[event] === undefined)
|
|
98
|
+
continue;
|
|
99
|
+
if (!Array.isArray(raw[event]) || raw[event].length > 64)
|
|
100
|
+
throw new Error(`plugin hooks.${event} must be a bounded array`);
|
|
101
|
+
out[event] = raw[event].map((entry, index) => {
|
|
102
|
+
const item = record(entry, `plugin hooks.${event}[${index}]`);
|
|
103
|
+
knownKeys(item, HOOK_KEYS, `plugin hooks.${event}[${index}]`);
|
|
104
|
+
const hook = { command: text(item.command, `plugin hooks.${event}[${index}].command`, 16_384) };
|
|
105
|
+
if (item.matcher !== undefined)
|
|
106
|
+
hook.matcher = text(item.matcher, `plugin hooks.${event}[${index}].matcher`, 1024);
|
|
107
|
+
return hook;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
function validateMcpServers(value) {
|
|
113
|
+
const raw = record(value, "plugin mcpServers");
|
|
114
|
+
if (Object.keys(raw).length > 64)
|
|
115
|
+
throw new Error("plugin mcpServers has too many entries");
|
|
116
|
+
const out = {};
|
|
117
|
+
for (const [rawName, rawConfig] of Object.entries(raw)) {
|
|
118
|
+
const name = safePluginId(rawName, "MCP server id");
|
|
119
|
+
const item = record(rawConfig, `MCP server '${name}'`);
|
|
120
|
+
knownKeys(item, MCP_KEYS, `MCP server '${name}'`);
|
|
121
|
+
const command = text(item.command, `MCP server '${name}' command`, 512);
|
|
122
|
+
if (/\s/u.test(command))
|
|
123
|
+
throw new Error(`MCP server '${name}' command must be one executable, not a shell command`);
|
|
124
|
+
const config = { command };
|
|
125
|
+
if (item.args !== undefined)
|
|
126
|
+
config.args = stringArray(item.args, `MCP server '${name}' args`);
|
|
127
|
+
if (item.env !== undefined) {
|
|
128
|
+
const env = record(item.env, `MCP server '${name}' env`);
|
|
129
|
+
if (Object.keys(env).length > 128)
|
|
130
|
+
throw new Error(`MCP server '${name}' env has too many entries`);
|
|
131
|
+
config.env = {};
|
|
132
|
+
for (const [key, rawValue] of Object.entries(env)) {
|
|
133
|
+
if (!ENV_KEY.test(key))
|
|
134
|
+
throw new Error(`MCP server '${name}' env key '${key}' is invalid`);
|
|
135
|
+
config.env[key] = text(rawValue, `MCP server '${name}' env '${key}'`, 65_536);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
out[name] = config;
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
function validateManifest(value, root) {
|
|
143
|
+
const raw = record(value, "plugin manifest");
|
|
144
|
+
knownKeys(raw, TOP_LEVEL_KEYS, "plugin manifest");
|
|
145
|
+
const manifest = { name: safePluginId(raw.name) };
|
|
146
|
+
if (raw.version !== undefined)
|
|
147
|
+
manifest.version = text(raw.version, "plugin version", 128);
|
|
148
|
+
if (raw.description !== undefined)
|
|
149
|
+
manifest.description = text(raw.description, "plugin description", 4096);
|
|
150
|
+
for (const field of ["skills", "agents"]) {
|
|
151
|
+
if (raw[field] === undefined)
|
|
152
|
+
continue;
|
|
153
|
+
const paths = stringArray(raw[field], `plugin ${field}`, true);
|
|
154
|
+
for (let index = 0; index < paths.length; index++) {
|
|
155
|
+
verifiedPluginPath(root, paths[index], "directory", `plugin ${field}[${index}]`);
|
|
156
|
+
}
|
|
157
|
+
manifest[field] = paths;
|
|
158
|
+
}
|
|
159
|
+
if (raw.bin !== undefined) {
|
|
160
|
+
const bins = record(raw.bin, "plugin bin");
|
|
161
|
+
if (Object.keys(bins).length > 128)
|
|
162
|
+
throw new Error("plugin bin has too many entries");
|
|
163
|
+
manifest.bin = {};
|
|
164
|
+
for (const [rawName, rawPath] of Object.entries(bins)) {
|
|
165
|
+
const name = safePluginId(rawName, "plugin command name");
|
|
166
|
+
const rel = safePluginRelativePath(rawPath, `plugin bin '${name}'`);
|
|
167
|
+
verifiedPluginPath(root, rel, "file", `plugin bin '${name}'`);
|
|
168
|
+
manifest.bin[name] = rel;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (raw.mcpServers !== undefined)
|
|
172
|
+
manifest.mcpServers = validateMcpServers(raw.mcpServers);
|
|
173
|
+
if (raw.hooks !== undefined)
|
|
174
|
+
manifest.hooks = validateHooks(raw.hooks);
|
|
175
|
+
if (raw.panels !== undefined) {
|
|
176
|
+
if (!Array.isArray(raw.panels) || raw.panels.length > 64)
|
|
177
|
+
throw new Error("plugin panels must be a bounded array");
|
|
178
|
+
manifest.panels = raw.panels.map((entry, index) => {
|
|
179
|
+
const item = record(entry, `plugin panels[${index}]`);
|
|
180
|
+
knownKeys(item, PANEL_KEYS, `plugin panels[${index}]`);
|
|
181
|
+
const command = safePluginId(item.command, `plugin panels[${index}].command`);
|
|
182
|
+
if (!manifest.bin?.[command]) {
|
|
183
|
+
throw new Error(`plugin panels[${index}].command must reference a declared plugin bin`);
|
|
184
|
+
}
|
|
185
|
+
const panel = {
|
|
186
|
+
id: safePluginId(item.id, `plugin panels[${index}].id`),
|
|
187
|
+
title: text(item.title, `plugin panels[${index}].title`, 256),
|
|
188
|
+
command,
|
|
189
|
+
};
|
|
190
|
+
if (item.args !== undefined)
|
|
191
|
+
panel.args = stringArray(item.args, `plugin panels[${index}].args`);
|
|
192
|
+
if (item.detect !== undefined)
|
|
193
|
+
panel.detect = stringArray(item.detect, `plugin panels[${index}].detect`, true);
|
|
194
|
+
if (item.port !== undefined) {
|
|
195
|
+
if (!Number.isInteger(item.port) || Number(item.port) < 1 || Number(item.port) > 65_535) {
|
|
196
|
+
throw new Error(`plugin panels[${index}].port must be an integer from 1 to 65535`);
|
|
197
|
+
}
|
|
198
|
+
panel.port = Number(item.port);
|
|
199
|
+
}
|
|
200
|
+
return panel;
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
// Validation must fail during staging, not only when a later session starts the server. This also catches
|
|
204
|
+
// `node server.mjs`-style relative entry scripts whose process cwd used to be the user's current project.
|
|
205
|
+
if (manifest.mcpServers)
|
|
206
|
+
bindPluginMcpServers(root, manifest);
|
|
207
|
+
return manifest;
|
|
208
|
+
}
|
|
209
|
+
export function verifyPluginTree(root) {
|
|
210
|
+
const canonicalRoot = realpathSync.native(resolve(root));
|
|
211
|
+
const rootInfo = lstatSync(canonicalRoot);
|
|
212
|
+
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink())
|
|
213
|
+
throw new Error("plugin root must be a real directory");
|
|
214
|
+
const rootDevice = String(rootInfo.dev);
|
|
215
|
+
let files = 0;
|
|
216
|
+
let bytes = 0;
|
|
217
|
+
const stack = [canonicalRoot];
|
|
218
|
+
while (stack.length) {
|
|
219
|
+
const directory = stack.pop();
|
|
220
|
+
for (const name of readdirSync(directory)) {
|
|
221
|
+
files++;
|
|
222
|
+
if (files > MAX_PLUGIN_FILES)
|
|
223
|
+
throw new Error(`plugin contains more than ${MAX_PLUGIN_FILES} filesystem entries`);
|
|
224
|
+
const path = join(directory, name);
|
|
225
|
+
const info = lstatSync(path);
|
|
226
|
+
if (info.isSymbolicLink())
|
|
227
|
+
throw new Error(`plugin packages must not contain symbolic links: '${relative(canonicalRoot, path)}'`);
|
|
228
|
+
if (String(info.dev) !== rootDevice) {
|
|
229
|
+
throw new Error(`plugin packages must not cross filesystem boundaries: '${relative(canonicalRoot, path)}'`);
|
|
230
|
+
}
|
|
231
|
+
if (info.isDirectory()) {
|
|
232
|
+
stack.push(path);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (!info.isFile() || info.nlink !== 1) {
|
|
236
|
+
throw new Error(`plugin packages may contain only directories and single-link regular files: '${relative(canonicalRoot, path)}'`);
|
|
237
|
+
}
|
|
238
|
+
const protectedReason = sensitiveFileReason(path);
|
|
239
|
+
if (protectedReason)
|
|
240
|
+
throw new Error(`plugin package contains protected ${protectedReason}: '${relative(canonicalRoot, path)}'`);
|
|
241
|
+
bytes += info.size;
|
|
242
|
+
if (bytes > MAX_PLUGIN_BYTES)
|
|
243
|
+
throw new Error(`plugin exceeds the ${MAX_PLUGIN_BYTES}-byte unpacked limit`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
export function readVerifiedPluginManifest(root, options = {}) {
|
|
248
|
+
if (options.scanTree !== false)
|
|
249
|
+
verifyPluginTree(root);
|
|
250
|
+
else {
|
|
251
|
+
const info = lstatSync(realpathSync.native(resolve(root)));
|
|
252
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
253
|
+
throw new Error("plugin root must be a real directory");
|
|
254
|
+
}
|
|
255
|
+
const present = [];
|
|
256
|
+
for (const rel of MANIFEST_PATHS) {
|
|
257
|
+
try {
|
|
258
|
+
present.push(verifiedPluginPath(root, rel, "file", "plugin manifest"));
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
if (error?.code !== "ENOENT")
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (!present.length)
|
|
266
|
+
throw new Error("no supported plugin.json at the source root");
|
|
267
|
+
if (present.length > 1)
|
|
268
|
+
throw new Error("plugin package contains multiple ambiguous manifests");
|
|
269
|
+
const path = present[0];
|
|
270
|
+
const info = lstatSync(path);
|
|
271
|
+
if (info.size > MAX_MANIFEST_BYTES)
|
|
272
|
+
throw new Error("plugin manifest is too large");
|
|
273
|
+
const raw = readFileSync(path, "utf8");
|
|
274
|
+
let parsed;
|
|
275
|
+
try {
|
|
276
|
+
parsed = JSON.parse(raw);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
throw new Error("plugin manifest is not valid JSON");
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
manifest: validateManifest(parsed, root),
|
|
283
|
+
path,
|
|
284
|
+
sha256: createHash("sha256").update(raw).digest("hex"),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function pathLikeCommand(command) {
|
|
288
|
+
return command.startsWith(".") || command.includes("/") || command.includes("\\") || isAbsolute(command) || /^[A-Za-z]:/u.test(command);
|
|
289
|
+
}
|
|
290
|
+
/** Bind every plugin-owned MCP process to the installed package root. Bare PATH executables remain
|
|
291
|
+
* allowed, but relative executables and conventional runtime entry scripts become verified absolute files. */
|
|
292
|
+
export function bindPluginMcpServers(root, manifest) {
|
|
293
|
+
const out = {};
|
|
294
|
+
for (const [name, input] of Object.entries(manifest.mcpServers ?? {})) {
|
|
295
|
+
let command = input.command;
|
|
296
|
+
const args = [...(input.args ?? [])];
|
|
297
|
+
if (pathLikeCommand(command)) {
|
|
298
|
+
command = verifiedPluginPath(root, safePluginRelativePath(command, `MCP server '${name}' command`), "file", `MCP server '${name}' command`);
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
const runtime = command.toLowerCase().replace(/\.exe$/u, "");
|
|
302
|
+
if (["node", "bun", "deno", "python", "python3", "pythonw"].includes(runtime)) {
|
|
303
|
+
const scriptIndex = args.findIndex((arg) => !arg.startsWith("-") && /\.(?:[cm]?js|ts|py)$/iu.test(arg));
|
|
304
|
+
if (scriptIndex >= 0) {
|
|
305
|
+
args[scriptIndex] = verifiedPluginPath(root, safePluginRelativePath(args[scriptIndex], `MCP server '${name}' entry script`), "file", `MCP server '${name}' entry script`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
out[name] = {
|
|
310
|
+
command,
|
|
311
|
+
...(args.length ? { args } : {}),
|
|
312
|
+
...(input.env ? { env: { ...input.env } } : {}),
|
|
313
|
+
cwd: realpathSync.native(resolve(root)),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
return out;
|
|
317
|
+
}
|