@nanhara/hara 0.126.0 → 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 +19 -0
- package/README.md +4 -1
- package/SECURITY.md +7 -3
- package/dist/index.js +13 -6
- package/dist/mcp/client.js +1 -0
- package/dist/plugins/manifest.js +317 -0
- package/dist/plugins/plugins.js +476 -139
- package/dist/security/private-state.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,25 @@ All notable changes to `@nanhara/hara`.
|
|
|
5
5
|
> Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
|
|
6
6
|
> **patch** (last) number bumps for **optimizations/fixes of existing features**.
|
|
7
7
|
|
|
8
|
+
## 0.126.1 — 2026-07-19 — verified plugin packages and ownership-safe lifecycle
|
|
9
|
+
|
|
10
|
+
- **Plugin packages now fail closed at staging.** Hara validates a bounded manifest schema, portable IDs and
|
|
11
|
+
relative paths, declared Skills/roles/binaries/panels, and MCP entry points before activation. Complete
|
|
12
|
+
package scans reject symbolic links, hard-linked or special files, protected credential-shaped files,
|
|
13
|
+
filesystem-boundary crossings, and excessive file/byte counts.
|
|
14
|
+
- **Install, update, and removal have an ownership transaction.** A same-filesystem private staging directory
|
|
15
|
+
is atomically activated; command collisions never overwrite foreign entries; and a private receipt binds the
|
|
16
|
+
installed root device/inode, manifest digest, and exact command links. Update rollback preserves the previous
|
|
17
|
+
package and commands, while uninstall refuses a changed root, a foreign command replacement, or an older
|
|
18
|
+
unreceipted install. A legacy plugin remains usable; reinstalling it from the same reviewed source performs
|
|
19
|
+
the one-time receipt migration, after which later updates and removal use the verified ownership path.
|
|
20
|
+
- **Plugin MCP processes are rooted in their installed package.** Relative executables and conventional
|
|
21
|
+
Node/Bun/Deno/Python entry scripts are verified and converted to absolute installed paths, and each server
|
|
22
|
+
receives the immutable plugin root as its working directory instead of the user's project.
|
|
23
|
+
- Install warnings no longer echo manifest-provided MCP arguments or hook command bodies, which could contain
|
|
24
|
+
credentials. They still identify each executable surface so the package source can be reviewed before use.
|
|
25
|
+
- Upgrade with `npm i -g @nanhara/hara@0.126.1`.
|
|
26
|
+
|
|
8
27
|
## 0.126.0 — 2026-07-19 — provider control plane, bounded tools, and honest execution time
|
|
9
28
|
|
|
10
29
|
- **Desktop can configure providers through an authenticated, redacted Serve control plane.** The shared
|
package/README.md
CHANGED
|
@@ -238,7 +238,10 @@ and a near-duplicate is flagged so it updates instead of piling up. `assetCaptur
|
|
|
238
238
|
**Plugins** — bundle skills + roles + MCP servers in one installable unit (Claude-Code-compatible
|
|
239
239
|
`plugin.json` / `.claude-plugin/`). `hara plugin add file:<path> | github:<owner/repo> | git:<url>` installs it;
|
|
240
240
|
`hara plugin` lists; `enable`/`disable`/`remove`. A plugin's skills/roles/MCP auto-contribute (your project &
|
|
241
|
-
global override them).
|
|
241
|
+
global override them). Installation validates the full package in a private stage, activates it atomically,
|
|
242
|
+
and records an owner-only receipt for the exact root and command links. Plugins installed before `0.126.1`
|
|
243
|
+
remain usable; reinstall the same reviewed source once to create the receipt before removing them.
|
|
244
|
+
`.claude/agents/*.md` subagents load as roles too.
|
|
242
245
|
|
|
243
246
|
**Recall** — `hara recall --init` creates a personal `~/.hara/code-assets` library (snippets as `*.md`);
|
|
244
247
|
`hara recall "<query>"` searches it **plus your skills** (one corpus), and `/recall <query>` pulls the best
|
package/SECURITY.md
CHANGED
|
@@ -74,9 +74,13 @@ local user (who already has your shell).
|
|
|
74
74
|
permission and starts one named server only when a task needs it. Their inherited environment is still
|
|
75
75
|
scrubbed, but the extension may use its own credentials or access anything its host process permits.
|
|
76
76
|
- **Plugins are code you trust.** Installing a plugin (`hara plugin add`) grants its author code execution:
|
|
77
|
-
its MCP servers may run on first relevant use and its hooks may run around matching tool calls.
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
its MCP servers may run on first relevant use and its hooks may run around matching tool calls. Installation
|
|
78
|
+
validates the complete package in a private same-filesystem stage, binds executable contributions to portable
|
|
79
|
+
package-relative paths, and records an owner-only root/manifest/command receipt before removal is allowed.
|
|
80
|
+
`hara plugin add` identifies each executable surface without echoing manifest-provided arguments or hook
|
|
81
|
+
bodies, which may contain credentials; review the package source itself, and disable it with
|
|
82
|
+
`hara plugin disable <name>`. Plugins installed before `0.126.1` remain usable; reinstalling one from the same
|
|
83
|
+
reviewed source creates the receipt required for later ownership-safe updates and removal.
|
|
80
84
|
- **Coding-plan keys.** Provider keys you configure are used only to call the model endpoint you set.
|
|
81
85
|
|
|
82
86
|
## What is *not* a security boundary
|
package/dist/index.js
CHANGED
|
@@ -2850,13 +2850,13 @@ pluginCmd
|
|
|
2850
2850
|
if (!onPath)
|
|
2851
2851
|
out(c.yellow(` add to PATH once: echo 'export PATH="$HOME/.hara/bin:$PATH"' >> ~/.zshrc && source ~/.zshrc\n`));
|
|
2852
2852
|
}
|
|
2853
|
-
// Surface the code-execution surface
|
|
2854
|
-
//
|
|
2853
|
+
// Surface the code-execution surface without echoing manifest-provided arguments or hook bodies:
|
|
2854
|
+
// those fields may contain credentials. Installing a plugin means trusting code from its author.
|
|
2855
2855
|
const execs = [];
|
|
2856
2856
|
for (const [name, s] of Object.entries(m.mcpServers ?? {}))
|
|
2857
|
-
execs.push(`mcp ${name}: ${
|
|
2858
|
-
for (const
|
|
2859
|
-
execs.push(
|
|
2857
|
+
execs.push(`mcp ${name}: ${s.command}`);
|
|
2858
|
+
for (const _hook of [...(m.hooks?.PreToolUse ?? []), ...(m.hooks?.PostToolUse ?? [])])
|
|
2859
|
+
execs.push("hook configured (command hidden)");
|
|
2860
2860
|
if (execs.length) {
|
|
2861
2861
|
out(c.yellow(`⚠ ${p.name} will run these commands on every hara launch (a plugin is code you run — review them):\n`) +
|
|
2862
2862
|
execs.map((e) => c.dim(` ${e}`)).join("\n") +
|
|
@@ -2871,7 +2871,14 @@ pluginCmd
|
|
|
2871
2871
|
.command("remove <name>")
|
|
2872
2872
|
.alias("uninstall")
|
|
2873
2873
|
.description("uninstall a plugin")
|
|
2874
|
-
.action((name) =>
|
|
2874
|
+
.action((name) => {
|
|
2875
|
+
try {
|
|
2876
|
+
out(uninstallPlugin(name) ? c.green(`Removed ${name}\n`) : c.dim(`(no plugin '${name}')\n`));
|
|
2877
|
+
}
|
|
2878
|
+
catch (error) {
|
|
2879
|
+
out(c.red(`Remove failed: ${error?.message ?? String(error)}\n`));
|
|
2880
|
+
}
|
|
2881
|
+
});
|
|
2875
2882
|
pluginCmd
|
|
2876
2883
|
.command("enable <name>")
|
|
2877
2884
|
.description("enable an installed plugin")
|
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
|
|
@@ -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
|
+
}
|
package/dist/plugins/plugins.js
CHANGED
|
@@ -1,189 +1,526 @@
|
|
|
1
|
-
// Plugins
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
import { join, resolve, isAbsolute } from "node:path";
|
|
1
|
+
// Plugins are untrusted distribution units. Installation validates every executable contribution,
|
|
2
|
+
// activates a same-filesystem staging directory atomically, and records the exact directory/bin ownership
|
|
3
|
+
// used by uninstall. Runtime loaders re-validate installed manifests instead of trusting installation time.
|
|
4
|
+
import { chmodSync, cpSync, existsSync, lstatSync, readlinkSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, } from "node:fs";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
7
6
|
import { homedir } from "node:os";
|
|
7
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
8
8
|
import { execFileSync } from "node:child_process";
|
|
9
9
|
import { readRawConfig, updateRawConfig } from "../config.js";
|
|
10
|
+
import { bindPrivateHaraStateFile, ensurePrivateStateSubdirectory, readPrivateStateFileSnapshotSync, removePrivateStateFile, writePrivateStateFileSync, } from "../security/private-state.js";
|
|
11
|
+
import { bindPluginMcpServers, readVerifiedPluginManifest, safePluginId, safePluginRelativePath, } from "./manifest.js";
|
|
10
12
|
export function pluginsDir() {
|
|
11
13
|
return join(homedir(), ".hara", "plugins");
|
|
12
14
|
}
|
|
13
|
-
/** Where plugin-contributed CLI commands are symlinked. Add to PATH to use them (e.g. `export PATH="$HOME/.hara/bin:$PATH"`). */
|
|
14
15
|
export function haraBinDir() {
|
|
15
16
|
return join(homedir(), ".hara", "bin");
|
|
16
17
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
18
|
+
function pluginStorage() {
|
|
19
|
+
return ensurePrivateStateSubdirectory(homedir(), [".hara", "plugins"]).path;
|
|
20
|
+
}
|
|
21
|
+
function pluginBinStorage() {
|
|
22
|
+
return ensurePrivateStateSubdirectory(homedir(), [".hara", "bin"]).path;
|
|
23
|
+
}
|
|
24
|
+
function pluginRoot(name) {
|
|
25
|
+
const safe = safePluginId(name);
|
|
26
|
+
const root = join(pluginStorage(), safe);
|
|
27
|
+
if (resolve(root) !== join(pluginStorage(), safe))
|
|
28
|
+
throw new Error("plugin root escaped the Hara plugin directory");
|
|
29
|
+
return root;
|
|
30
|
+
}
|
|
31
|
+
function receiptBinding(name) {
|
|
32
|
+
return bindPrivateHaraStateFile(homedir(), ["plugin-receipts"], `${safePluginId(name)}.json`);
|
|
33
|
+
}
|
|
34
|
+
function rootIdentity(path) {
|
|
35
|
+
const info = lstatSync(path);
|
|
36
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
37
|
+
throw new Error(`plugin root '${path}' is not an owned real directory`);
|
|
38
|
+
return { rootDev: String(info.dev), rootIno: String(info.ino) };
|
|
39
|
+
}
|
|
40
|
+
function pluginBins(manifest) {
|
|
41
|
+
return { ...(manifest.bin ?? {}) };
|
|
42
|
+
}
|
|
43
|
+
function parseReceipt(raw, expectedName) {
|
|
44
|
+
let value;
|
|
45
|
+
try {
|
|
46
|
+
value = JSON.parse(raw);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw new Error(`plugin '${expectedName}' ownership receipt is invalid JSON`);
|
|
50
|
+
}
|
|
51
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
52
|
+
throw new Error(`plugin '${expectedName}' ownership receipt is invalid`);
|
|
53
|
+
}
|
|
54
|
+
const input = value;
|
|
55
|
+
const allowed = new Set(["schemaVersion", "name", "root", "rootDev", "rootIno", "manifestSha256", "bins"]);
|
|
56
|
+
if (Object.keys(input).some((key) => !allowed.has(key)) || input.schemaVersion !== 1) {
|
|
57
|
+
throw new Error(`plugin '${expectedName}' ownership receipt has an unsupported schema`);
|
|
43
58
|
}
|
|
44
|
-
|
|
59
|
+
const name = safePluginId(input.name, "receipt plugin name");
|
|
60
|
+
if (name !== expectedName)
|
|
61
|
+
throw new Error(`plugin '${expectedName}' ownership receipt belongs to '${name}'`);
|
|
62
|
+
if (typeof input.root !== "string"
|
|
63
|
+
|| typeof input.rootDev !== "string"
|
|
64
|
+
|| typeof input.rootIno !== "string"
|
|
65
|
+
|| typeof input.manifestSha256 !== "string"
|
|
66
|
+
|| !/^[a-f0-9]{64}$/u.test(input.manifestSha256)
|
|
67
|
+
|| !input.bins
|
|
68
|
+
|| typeof input.bins !== "object"
|
|
69
|
+
|| Array.isArray(input.bins))
|
|
70
|
+
throw new Error(`plugin '${expectedName}' ownership receipt is incomplete`);
|
|
71
|
+
const bins = {};
|
|
72
|
+
for (const [rawName, rawPath] of Object.entries(input.bins)) {
|
|
73
|
+
const binName = safePluginId(rawName, "receipt command name");
|
|
74
|
+
bins[binName] = safePluginRelativePath(rawPath, `receipt command '${binName}'`);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
schemaVersion: 1,
|
|
78
|
+
name,
|
|
79
|
+
root: resolve(input.root),
|
|
80
|
+
rootDev: input.rootDev,
|
|
81
|
+
rootIno: input.rootIno,
|
|
82
|
+
manifestSha256: input.manifestSha256,
|
|
83
|
+
bins,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function readReceipt(name) {
|
|
87
|
+
const binding = receiptBinding(name);
|
|
88
|
+
const snapshot = readPrivateStateFileSnapshotSync(binding.path, 256 * 1024);
|
|
89
|
+
if (!snapshot)
|
|
90
|
+
return null;
|
|
91
|
+
return { binding, snapshot, raw: snapshot.text, receipt: parseReceipt(snapshot.text, name) };
|
|
92
|
+
}
|
|
93
|
+
function writeReceipt(name, root, verified) {
|
|
94
|
+
const identity = rootIdentity(root);
|
|
95
|
+
const receipt = {
|
|
96
|
+
schemaVersion: 1,
|
|
97
|
+
name,
|
|
98
|
+
root: resolve(root),
|
|
99
|
+
...identity,
|
|
100
|
+
manifestSha256: verified.sha256,
|
|
101
|
+
bins: pluginBins(verified.manifest),
|
|
102
|
+
};
|
|
103
|
+
writePrivateStateFileSync(receiptBinding(name), `${JSON.stringify(receipt, null, 2)}\n`);
|
|
104
|
+
return receipt;
|
|
45
105
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (
|
|
106
|
+
function restoreReceipt(name, raw) {
|
|
107
|
+
const binding = receiptBinding(name);
|
|
108
|
+
if (raw !== null) {
|
|
109
|
+
writePrivateStateFileSync(binding, raw);
|
|
49
110
|
return;
|
|
50
|
-
for (const name of Object.keys(manifest.bin)) {
|
|
51
|
-
try {
|
|
52
|
-
rmSync(join(haraBinDir(), name), { force: true });
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
/* ignore */
|
|
56
|
-
}
|
|
57
111
|
}
|
|
112
|
+
const current = readPrivateStateFileSnapshotSync(binding.path, 256 * 1024);
|
|
113
|
+
if (current)
|
|
114
|
+
removePrivateStateFile(binding.path, current, binding.directory);
|
|
58
115
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
116
|
+
function verifyReceipt(receipt, root, verified) {
|
|
117
|
+
const canonicalRoot = resolve(root);
|
|
118
|
+
const identity = rootIdentity(canonicalRoot);
|
|
119
|
+
if (receipt.root !== canonicalRoot
|
|
120
|
+
|| receipt.rootDev !== identity.rootDev
|
|
121
|
+
|| receipt.rootIno !== identity.rootIno
|
|
122
|
+
|| receipt.manifestSha256 !== verified.sha256)
|
|
123
|
+
throw new Error(`plugin '${receipt.name}' changed after installation; refusing an ownership-sensitive operation`);
|
|
124
|
+
}
|
|
125
|
+
function resolvedLinkTarget(link) {
|
|
126
|
+
const target = readlinkSync(link);
|
|
127
|
+
return resolve(dirname(link), target);
|
|
128
|
+
}
|
|
129
|
+
function entryExists(path) {
|
|
130
|
+
try {
|
|
131
|
+
lstatSync(path);
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
if (error?.code === "ENOENT")
|
|
136
|
+
return false;
|
|
137
|
+
throw error;
|
|
71
138
|
}
|
|
72
|
-
return null;
|
|
73
139
|
}
|
|
74
|
-
|
|
140
|
+
function expectedBinTarget(root, rel) {
|
|
141
|
+
const target = resolve(root, safePluginRelativePath(rel, "plugin command target"));
|
|
142
|
+
const relToRoot = relative(resolve(root), target);
|
|
143
|
+
if (relToRoot.startsWith("..") || isAbsolute(relToRoot))
|
|
144
|
+
throw new Error("plugin command target escaped its package root");
|
|
145
|
+
return target;
|
|
146
|
+
}
|
|
147
|
+
function preflightBinLink(root, name, rel, allowMissing = true) {
|
|
148
|
+
const link = join(pluginBinStorage(), safePluginId(name, "plugin command name"));
|
|
149
|
+
if (!entryExists(link)) {
|
|
150
|
+
if (allowMissing)
|
|
151
|
+
return;
|
|
152
|
+
throw new Error(`plugin command link '${name}' is missing`);
|
|
153
|
+
}
|
|
154
|
+
const info = lstatSync(link);
|
|
155
|
+
if (!info.isSymbolicLink() || resolvedLinkTarget(link) !== expectedBinTarget(root, rel)) {
|
|
156
|
+
throw new Error(`refusing to replace or remove foreign command entry '${link}'`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function unlinkBinLink(root, name, rel) {
|
|
160
|
+
const link = join(pluginBinStorage(), safePluginId(name, "plugin command name"));
|
|
161
|
+
if (!entryExists(link))
|
|
162
|
+
return;
|
|
163
|
+
preflightBinLink(root, name, rel, false);
|
|
164
|
+
rmSync(link);
|
|
165
|
+
}
|
|
166
|
+
function ensureBinLink(root, name, rel) {
|
|
167
|
+
const dir = pluginBinStorage();
|
|
168
|
+
const link = join(dir, safePluginId(name, "plugin command name"));
|
|
169
|
+
const target = expectedBinTarget(root, rel);
|
|
170
|
+
const targetInfo = lstatSync(target);
|
|
171
|
+
if (!targetInfo.isFile() || targetInfo.isSymbolicLink() || targetInfo.nlink !== 1) {
|
|
172
|
+
throw new Error(`plugin command '${name}' is not a verified regular file`);
|
|
173
|
+
}
|
|
174
|
+
if (entryExists(link)) {
|
|
175
|
+
preflightBinLink(root, name, rel, false);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
chmodSync(target, 0o755);
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
if (process.platform !== "win32")
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
symlinkSync(target, link, "file");
|
|
186
|
+
}
|
|
187
|
+
function restoreBinLinks(root, bins) {
|
|
188
|
+
for (const [name, rel] of Object.entries(bins))
|
|
189
|
+
ensureBinLink(root, name, rel);
|
|
190
|
+
}
|
|
191
|
+
function safeTemporaryRemove(path) {
|
|
192
|
+
if (!existsSync(path))
|
|
193
|
+
return;
|
|
194
|
+
const info = lstatSync(path);
|
|
195
|
+
if (info.isSymbolicLink()) {
|
|
196
|
+
rmSync(path);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (!info.isDirectory())
|
|
200
|
+
throw new Error(`refusing to recursively remove unexpected plugin staging entry '${path}'`);
|
|
201
|
+
rmSync(path, { recursive: true, force: true });
|
|
202
|
+
}
|
|
203
|
+
function readInstalled(root, expectedName, options = {}) {
|
|
204
|
+
const info = lstatSync(root);
|
|
205
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
206
|
+
throw new Error(`installed plugin root '${root}' is not a real directory`);
|
|
207
|
+
const verified = readVerifiedPluginManifest(root, options);
|
|
208
|
+
const name = safePluginId(verified.manifest.name);
|
|
209
|
+
if (expectedName && name !== expectedName)
|
|
210
|
+
throw new Error(`installed plugin '${expectedName}' claims the different name '${name}'`);
|
|
211
|
+
return {
|
|
212
|
+
plugin: { name, version: verified.manifest.version || "0.0.0", root: realpathSync.native(root), manifest: verified.manifest },
|
|
213
|
+
verified,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/** Every installed plugin under ~/.hara/plugins (regardless of enabled state). Invalid roots never execute.
|
|
217
|
+
* A structurally valid legacy root remains usable so an explicit reinstall can replace it and create a receipt;
|
|
218
|
+
* ownership-sensitive removal still refuses it until that migration happens. */
|
|
75
219
|
export function listInstalled() {
|
|
76
|
-
const dir =
|
|
77
|
-
if (!existsSync(dir))
|
|
78
|
-
return [];
|
|
220
|
+
const dir = pluginStorage();
|
|
79
221
|
const out = [];
|
|
80
222
|
for (const entry of readdirSync(dir)) {
|
|
81
|
-
|
|
82
|
-
const manifest = readManifest(root);
|
|
83
|
-
if (!manifest)
|
|
223
|
+
if (entry.startsWith(".") || entry.startsWith("_"))
|
|
84
224
|
continue;
|
|
85
|
-
|
|
225
|
+
try {
|
|
226
|
+
const name = safePluginId(entry, "installed plugin directory");
|
|
227
|
+
// Installation and ownership-sensitive operations scan the complete package. Hot-path discovery only
|
|
228
|
+
// revalidates the manifest and every declared skill/agent/bin/MCP entry; unrelated package files cannot
|
|
229
|
+
// become executable contributions and must not add O(package-size) latency to each prompt/turn.
|
|
230
|
+
const { plugin } = readInstalled(join(dir, name), name, { scanTree: false });
|
|
231
|
+
out.push(plugin);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
// Fail closed: a malformed root contributes no code, paths, hooks, MCP servers, or panels.
|
|
235
|
+
}
|
|
86
236
|
}
|
|
87
237
|
return out;
|
|
88
238
|
}
|
|
89
|
-
/** A plugin is active unless explicitly disabled in config (`plugins.enabled[name] === false`). */
|
|
90
239
|
export function enabledPlugins() {
|
|
91
240
|
const enabled = (readRawConfig().plugins?.enabled ?? {});
|
|
92
|
-
return listInstalled().filter((
|
|
241
|
+
return listInstalled().filter((plugin) => enabled[plugin.name] !== false);
|
|
93
242
|
}
|
|
94
|
-
function resolveDirs(
|
|
95
|
-
return (entries ?? [])
|
|
96
|
-
|
|
97
|
-
|
|
243
|
+
function resolveDirs(plugin, entries) {
|
|
244
|
+
return (entries ?? []).map((entry) => {
|
|
245
|
+
const root = realpathSync.native(resolve(plugin.root));
|
|
246
|
+
const requested = resolve(root, safePluginRelativePath(entry, `plugin '${plugin.name}' contribution`));
|
|
247
|
+
const target = realpathSync.native(requested);
|
|
248
|
+
const rel = relative(root, target);
|
|
249
|
+
if (rel.startsWith("..") || isAbsolute(rel))
|
|
250
|
+
throw new Error(`plugin '${plugin.name}' contribution escaped its root`);
|
|
251
|
+
return target;
|
|
252
|
+
});
|
|
98
253
|
}
|
|
99
|
-
// --- Contribution resolvers (consumed by the existing loaders; lowest precedence) ---
|
|
100
|
-
/** Skill search dirs from enabled plugins (each holds <name>/SKILL.md subdirs). */
|
|
101
254
|
export function pluginSkillDirs() {
|
|
102
|
-
return enabledPlugins().flatMap((
|
|
255
|
+
return enabledPlugins().flatMap((plugin) => resolveDirs(plugin, plugin.manifest.skills));
|
|
103
256
|
}
|
|
104
|
-
/** Role/subagent dirs from enabled plugins. */
|
|
105
257
|
export function pluginRoleDirs() {
|
|
106
|
-
return enabledPlugins().flatMap((
|
|
258
|
+
return enabledPlugins().flatMap((plugin) => resolveDirs(plugin, plugin.manifest.agents));
|
|
107
259
|
}
|
|
108
|
-
/** MCP servers contributed by enabled plugins (merged under user config, which wins). */
|
|
109
260
|
export function pluginMcpServers() {
|
|
110
261
|
const out = {};
|
|
111
|
-
for (const
|
|
112
|
-
Object.assign(out,
|
|
262
|
+
for (const plugin of enabledPlugins())
|
|
263
|
+
Object.assign(out, bindPluginMcpServers(plugin.root, plugin.manifest));
|
|
113
264
|
return out;
|
|
114
265
|
}
|
|
115
|
-
/** Lifecycle hooks contributed by enabled plugins (appended after user-config hooks). */
|
|
116
266
|
export function pluginHooks() {
|
|
117
267
|
const out = { PreToolUse: [], PostToolUse: [] };
|
|
118
|
-
for (const
|
|
119
|
-
const
|
|
120
|
-
if (!
|
|
268
|
+
for (const plugin of enabledPlugins()) {
|
|
269
|
+
const hooks = plugin.manifest.hooks;
|
|
270
|
+
if (!hooks)
|
|
121
271
|
continue;
|
|
122
|
-
if (
|
|
123
|
-
out.PreToolUse.push(...
|
|
124
|
-
if (
|
|
125
|
-
out.PostToolUse.push(...
|
|
272
|
+
if (hooks.PreToolUse)
|
|
273
|
+
out.PreToolUse.push(...hooks.PreToolUse);
|
|
274
|
+
if (hooks.PostToolUse)
|
|
275
|
+
out.PostToolUse.push(...hooks.PostToolUse);
|
|
126
276
|
}
|
|
127
277
|
return out;
|
|
128
278
|
}
|
|
129
|
-
|
|
279
|
+
function validGitSource(source) {
|
|
280
|
+
const url = source.slice("git:".length);
|
|
281
|
+
if (!/^(?:https:\/\/|ssh:\/\/|git@)[^\s\0]+$/u.test(url)) {
|
|
282
|
+
throw new Error("git source must use an https://, ssh://, or git@ URL");
|
|
283
|
+
}
|
|
284
|
+
return url;
|
|
285
|
+
}
|
|
286
|
+
function populateStaging(source, staging) {
|
|
287
|
+
if (source.startsWith("file:")) {
|
|
288
|
+
const requested = resolve(source.slice("file:".length));
|
|
289
|
+
const info = lstatSync(requested);
|
|
290
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
291
|
+
throw new Error(`plugin source '${requested}' must be a real directory`);
|
|
292
|
+
const src = realpathSync.native(requested);
|
|
293
|
+
const store = realpathSync.native(pluginStorage());
|
|
294
|
+
const rel = relative(store, src);
|
|
295
|
+
if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) {
|
|
296
|
+
throw new Error("plugin source must not be inside Hara's installed plugin directory");
|
|
297
|
+
}
|
|
298
|
+
cpSync(src, staging, { recursive: true, dereference: false, errorOnExist: true, force: false });
|
|
299
|
+
}
|
|
300
|
+
else if (source.startsWith("github:")) {
|
|
301
|
+
const repo = source.slice("github:".length);
|
|
302
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repo))
|
|
303
|
+
throw new Error("github source must be owner/repository");
|
|
304
|
+
execFileSync("git", ["clone", "--depth", "1", `https://github.com/${repo}.git`, staging], { stdio: "ignore" });
|
|
305
|
+
}
|
|
306
|
+
else if (source.startsWith("git:")) {
|
|
307
|
+
execFileSync("git", ["clone", "--depth", "1", validGitSource(source), staging], { stdio: "ignore" });
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
throw new Error("source must be file:<path>, github:<owner/repo>, or git:<url>");
|
|
311
|
+
}
|
|
312
|
+
safeTemporaryRemove(join(staging, ".git"));
|
|
313
|
+
safeTemporaryRemove(join(staging, ".learnings"));
|
|
314
|
+
}
|
|
315
|
+
/** Install through a validated same-filesystem stage. Replacements preserve the previous directory and
|
|
316
|
+
* receipt until the new manifest, bin links, and ownership receipt have all committed successfully. */
|
|
130
317
|
export function installPlugin(source) {
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
|
|
318
|
+
const store = pluginStorage();
|
|
319
|
+
const staging = join(store, `_install-${process.pid}-${randomUUID()}`);
|
|
320
|
+
const backup = join(store, `_backup-${process.pid}-${randomUUID()}`);
|
|
321
|
+
let destination = "";
|
|
322
|
+
let newActivated = false;
|
|
323
|
+
let oldMoved = false;
|
|
324
|
+
let oldManifest = null;
|
|
325
|
+
let oldReceipt = null;
|
|
326
|
+
let oldBins = {};
|
|
327
|
+
let oldIdentity = null;
|
|
328
|
+
let newManifest = null;
|
|
135
329
|
try {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
330
|
+
populateStaging(source, staging);
|
|
331
|
+
newManifest = readVerifiedPluginManifest(staging);
|
|
332
|
+
const name = newManifest.manifest.name;
|
|
333
|
+
destination = pluginRoot(name);
|
|
334
|
+
if (existsSync(destination)) {
|
|
335
|
+
const installed = readInstalled(destination, name);
|
|
336
|
+
oldManifest = installed.verified;
|
|
337
|
+
oldBins = pluginBins(oldManifest.manifest);
|
|
338
|
+
oldReceipt = readReceipt(name);
|
|
339
|
+
if (oldReceipt)
|
|
340
|
+
verifyReceipt(oldReceipt.receipt, destination, oldManifest);
|
|
341
|
+
oldIdentity = rootIdentity(destination);
|
|
342
|
+
}
|
|
343
|
+
const newBins = pluginBins(newManifest.manifest);
|
|
344
|
+
for (const [name, rel] of Object.entries(oldBins))
|
|
345
|
+
preflightBinLink(destination, name, rel);
|
|
346
|
+
for (const [name, rel] of Object.entries(newBins)) {
|
|
347
|
+
const link = join(pluginBinStorage(), name);
|
|
348
|
+
if (!entryExists(link))
|
|
349
|
+
continue;
|
|
350
|
+
if (!oldBins[name])
|
|
351
|
+
throw new Error(`refusing to overwrite foreign command entry '${link}'`);
|
|
352
|
+
preflightBinLink(destination, name, oldBins[name], false);
|
|
353
|
+
// The new target itself was verified in staging; changed relative targets are replaced after activation.
|
|
354
|
+
void rel;
|
|
355
|
+
}
|
|
356
|
+
if (existsSync(destination)) {
|
|
357
|
+
renameSync(destination, backup);
|
|
358
|
+
oldMoved = true;
|
|
359
|
+
const moved = rootIdentity(backup);
|
|
360
|
+
if (!oldIdentity || moved.rootDev !== oldIdentity.rootDev || moved.rootIno !== oldIdentity.rootIno) {
|
|
361
|
+
throw new Error(`plugin '${name}' root identity changed during update`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
renameSync(staging, destination);
|
|
365
|
+
newActivated = true;
|
|
366
|
+
newManifest = readVerifiedPluginManifest(destination);
|
|
367
|
+
for (const [name, rel] of Object.entries(oldBins)) {
|
|
368
|
+
if (newBins[name] !== rel)
|
|
369
|
+
unlinkBinLink(destination, name, rel);
|
|
370
|
+
}
|
|
371
|
+
for (const [name, rel] of Object.entries(newBins))
|
|
372
|
+
ensureBinLink(destination, name, rel);
|
|
373
|
+
writeReceipt(name, destination, newManifest);
|
|
374
|
+
const result = {
|
|
375
|
+
name,
|
|
376
|
+
version: newManifest.manifest.version || "0.0.0",
|
|
377
|
+
root: realpathSync.native(destination),
|
|
378
|
+
manifest: newManifest.manifest,
|
|
379
|
+
};
|
|
380
|
+
// Activation and its receipt are committed. Cleanup is deliberately outside rollback: recursive backup
|
|
381
|
+
// removal can partially succeed, so a cleanup error must leave an inert `_backup-*` entry rather than
|
|
382
|
+
// restore a partially removed old package over the verified new one.
|
|
383
|
+
if (oldMoved) {
|
|
384
|
+
try {
|
|
385
|
+
safeTemporaryRemove(backup);
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
// A later bounded maintenance pass may remove the skipped, non-loadable backup after inspection.
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return result;
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
const rollbackErrors = [];
|
|
395
|
+
const failedManifest = newManifest;
|
|
396
|
+
const name = failedManifest?.manifest.name;
|
|
397
|
+
if (failedManifest && name && destination) {
|
|
398
|
+
try {
|
|
399
|
+
if (newActivated) {
|
|
400
|
+
for (const [binName, rel] of Object.entries(pluginBins(failedManifest.manifest))) {
|
|
401
|
+
if (entryExists(join(pluginBinStorage(), binName)))
|
|
402
|
+
unlinkBinLink(destination, binName, rel);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
catch (rollbackError) {
|
|
407
|
+
rollbackErrors.push(`new command cleanup: ${rollbackError?.message ?? String(rollbackError)}`);
|
|
408
|
+
}
|
|
409
|
+
try {
|
|
410
|
+
if (newActivated && existsSync(destination))
|
|
411
|
+
safeTemporaryRemove(destination);
|
|
412
|
+
if (oldMoved && existsSync(backup))
|
|
413
|
+
renameSync(backup, destination);
|
|
414
|
+
}
|
|
415
|
+
catch (rollbackError) {
|
|
416
|
+
rollbackErrors.push(`directory restore: ${rollbackError?.message ?? String(rollbackError)}`);
|
|
417
|
+
}
|
|
418
|
+
try {
|
|
419
|
+
if (oldMoved && existsSync(destination))
|
|
420
|
+
restoreBinLinks(destination, oldBins);
|
|
421
|
+
}
|
|
422
|
+
catch (rollbackError) {
|
|
423
|
+
rollbackErrors.push(`old command restore: ${rollbackError?.message ?? String(rollbackError)}`);
|
|
424
|
+
}
|
|
425
|
+
try {
|
|
426
|
+
restoreReceipt(name, oldReceipt?.raw ?? null);
|
|
427
|
+
}
|
|
428
|
+
catch (rollbackError) {
|
|
429
|
+
rollbackErrors.push(`receipt restore: ${rollbackError?.message ?? String(rollbackError)}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
safeTemporaryRemove(staging);
|
|
434
|
+
safeTemporaryRemove(backup);
|
|
435
|
+
}
|
|
436
|
+
catch (rollbackError) {
|
|
437
|
+
rollbackErrors.push(`staging cleanup: ${rollbackError?.message ?? String(rollbackError)}`);
|
|
438
|
+
}
|
|
439
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
440
|
+
throw new Error(rollbackErrors.length ? `${detail}; rollback warning: ${rollbackErrors.join("; ")}` : detail);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
export function uninstallPlugin(rawName) {
|
|
444
|
+
const name = safePluginId(rawName);
|
|
445
|
+
const destination = pluginRoot(name);
|
|
446
|
+
if (!existsSync(destination))
|
|
172
447
|
return false;
|
|
173
|
-
|
|
174
|
-
|
|
448
|
+
const installed = readInstalled(destination, name);
|
|
449
|
+
const stored = readReceipt(name);
|
|
450
|
+
if (!stored) {
|
|
451
|
+
throw new Error(`refusing to uninstall legacy plugin '${name}' without an ownership receipt; ` +
|
|
452
|
+
"reinstall the same source with this Hara version, then remove it");
|
|
453
|
+
}
|
|
454
|
+
verifyReceipt(stored.receipt, destination, installed.verified);
|
|
455
|
+
for (const [binName, rel] of Object.entries(stored.receipt.bins))
|
|
456
|
+
preflightBinLink(destination, binName, rel);
|
|
457
|
+
const quarantine = join(pluginStorage(), `_remove-${process.pid}-${randomUUID()}`);
|
|
458
|
+
renameSync(destination, quarantine);
|
|
459
|
+
const moved = rootIdentity(quarantine);
|
|
460
|
+
if (moved.rootDev !== stored.receipt.rootDev || moved.rootIno !== stored.receipt.rootIno) {
|
|
461
|
+
renameSync(quarantine, destination);
|
|
462
|
+
throw new Error(`plugin '${name}' root identity changed during uninstall`);
|
|
463
|
+
}
|
|
464
|
+
try {
|
|
465
|
+
for (const [binName, rel] of Object.entries(stored.receipt.bins))
|
|
466
|
+
unlinkBinLink(destination, binName, rel);
|
|
467
|
+
removePrivateStateFile(stored.binding.path, stored.snapshot, stored.binding.directory);
|
|
468
|
+
}
|
|
469
|
+
catch (error) {
|
|
470
|
+
const rollbackErrors = [];
|
|
471
|
+
try {
|
|
472
|
+
if (existsSync(quarantine) && !existsSync(destination))
|
|
473
|
+
renameSync(quarantine, destination);
|
|
474
|
+
restoreBinLinks(destination, stored.receipt.bins);
|
|
475
|
+
}
|
|
476
|
+
catch (rollbackError) {
|
|
477
|
+
rollbackErrors.push(rollbackError?.message ?? String(rollbackError));
|
|
478
|
+
}
|
|
479
|
+
try {
|
|
480
|
+
restoreReceipt(name, stored.raw);
|
|
481
|
+
}
|
|
482
|
+
catch (rollbackError) {
|
|
483
|
+
rollbackErrors.push(rollbackError?.message ?? String(rollbackError));
|
|
484
|
+
}
|
|
485
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
486
|
+
throw new Error(rollbackErrors.length ? `${detail}; rollback warning: ${rollbackErrors.join("; ")}` : detail);
|
|
487
|
+
}
|
|
488
|
+
// The namespace, executable links, and receipt are already removed. A cleanup failure leaves only an
|
|
489
|
+
// inert unpredictable quarantine directory and must not reactivate unreceipted code.
|
|
490
|
+
try {
|
|
491
|
+
safeTemporaryRemove(quarantine);
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
// A later bounded maintenance pass may remove it after inspecting ownership.
|
|
495
|
+
}
|
|
175
496
|
return true;
|
|
176
497
|
}
|
|
177
|
-
|
|
178
|
-
|
|
498
|
+
function safeMarkerExists(cwd, marker) {
|
|
499
|
+
try {
|
|
500
|
+
const rel = safePluginRelativePath(marker, "plugin panel detect marker");
|
|
501
|
+
const canonicalRoot = realpathSync.native(resolve(cwd));
|
|
502
|
+
let current = canonicalRoot;
|
|
503
|
+
for (const part of rel.split("/")) {
|
|
504
|
+
current = join(current, part);
|
|
505
|
+
const info = lstatSync(current);
|
|
506
|
+
if (info.isSymbolicLink())
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
const relToRoot = relative(canonicalRoot, realpathSync.native(current));
|
|
510
|
+
return !relToRoot.startsWith("..") && !isAbsolute(relToRoot);
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
179
516
|
export function matchPanels(plugins, cwd) {
|
|
180
517
|
const out = [];
|
|
181
|
-
for (const
|
|
182
|
-
for (const panel of
|
|
518
|
+
for (const plugin of plugins) {
|
|
519
|
+
for (const panel of plugin.manifest.panels ?? []) {
|
|
183
520
|
if (!panel.detect?.length)
|
|
184
|
-
continue;
|
|
185
|
-
if (panel.detect.some((
|
|
186
|
-
out.push({ plugin:
|
|
521
|
+
continue;
|
|
522
|
+
if (panel.detect.some((marker) => safeMarkerExists(cwd, marker)))
|
|
523
|
+
out.push({ plugin: plugin.name, panel });
|
|
187
524
|
}
|
|
188
525
|
}
|
|
189
526
|
return out;
|
|
@@ -191,8 +528,8 @@ export function matchPanels(plugins, cwd) {
|
|
|
191
528
|
export function panelsForProject(cwd) {
|
|
192
529
|
return matchPanels(enabledPlugins(), cwd);
|
|
193
530
|
}
|
|
194
|
-
|
|
195
|
-
|
|
531
|
+
export function setPluginEnabled(rawName, on) {
|
|
532
|
+
const name = safePluginId(rawName);
|
|
196
533
|
updateRawConfig((config) => {
|
|
197
534
|
const plugins = (config.plugins && typeof config.plugins === "object" ? config.plugins : {});
|
|
198
535
|
plugins.enabled = { ...(plugins.enabled ?? {}), [name]: on };
|
|
@@ -8,7 +8,7 @@ import { basename, dirname, join, resolve } from "node:path";
|
|
|
8
8
|
import { FileReadLimitError, MAX_EDIT_READ_BYTES, decodeUtf8Strict, openVerifiedRegularFileNoFollow, verifyOpenedRegularFileSync, } from "../fs-read.js";
|
|
9
9
|
import { optionalPosixOpenFlag } from "../fs-open-flags.js";
|
|
10
10
|
import { sameOpenedFileIdentity } from "../fs-identity.js";
|
|
11
|
-
const PRIVATE_TREES = new Set(["sessions", "checkpoints", "index", "gateway", "cron", "weixin", "tool-results"]);
|
|
11
|
+
const PRIVATE_TREES = new Set(["sessions", "checkpoints", "index", "gateway", "cron", "weixin", "tool-results", "plugin-receipts"]);
|
|
12
12
|
const tightenedHomes = new Set();
|
|
13
13
|
const DEFAULT_MIGRATION_CAP = 50_000;
|
|
14
14
|
function isMissing(error) {
|