@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 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). `.claude/agents/*.md` subagents load as roles too.
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. `hara plugin
78
- add` **prints the exact commands** a plugin can run so you can review them; disable with
79
- `hara plugin disable <name>`.
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: a plugin's MCP servers + hooks run shell commands on every
2854
- // hara launch with no prompt. Installing a plugin = trusting its author to run code; show what.
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}: ${[s.command, ...(s.args ?? [])].join(" ")}`);
2858
- for (const h of [...(m.hooks?.PreToolUse ?? []), ...(m.hooks?.PostToolUse ?? [])])
2859
- execs.push(`hook: ${h.command}`);
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) => out(uninstallPlugin(name) ? c.green(`Removed ${name}\n`) : c.dim(`(no plugin '${name}')\n`)));
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")
@@ -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
+ }
@@ -1,189 +1,526 @@
1
- // Plugins a distribution unit that drops skills / roles / MCP servers onto disk; it owns nothing at
2
- // runtime. The existing loaders pick the contents up (skillsDirs/loadRoles append the resolvers below;
3
- // index.ts merges pluginMcpServers into the MCP set). Manifest is Claude-Code-compatible: we read
4
- // .claude-plugin/plugin.json, .hara-plugin/plugin.json, or a bare plugin.json at the plugin root.
5
- import { readFileSync, existsSync, mkdirSync, readdirSync, rmSync, cpSync, symlinkSync, chmodSync } from "node:fs";
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
- /** Symlink a plugin's `bin` entries into ~/.hara/bin (chmod +x the targets). Returns the command names linked. */
18
- function linkPluginBins(root, manifest) {
19
- if (!manifest.bin)
20
- return [];
21
- const dir = haraBinDir();
22
- mkdirSync(dir, { recursive: true });
23
- const linked = [];
24
- for (const [name, rel] of Object.entries(manifest.bin)) {
25
- const target = join(root, rel);
26
- if (!existsSync(target))
27
- continue;
28
- try {
29
- chmodSync(target, 0o755);
30
- }
31
- catch {
32
- /* best-effort */
33
- }
34
- const link = join(dir, name);
35
- try {
36
- rmSync(link, { force: true });
37
- symlinkSync(target, link);
38
- linked.push(name);
39
- }
40
- catch {
41
- /* skip a bin we can't link */
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
- return linked;
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
- /** Remove a plugin's linked bins (on uninstall). */
47
- function unlinkPluginBins(manifest) {
48
- if (!manifest?.bin)
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
- const MANIFEST_PATHS = [".claude-plugin/plugin.json", ".hara-plugin/plugin.json", "plugin.json"];
60
- function readManifest(root) {
61
- for (const rel of MANIFEST_PATHS) {
62
- const p = join(root, rel);
63
- if (!existsSync(p))
64
- continue;
65
- try {
66
- return JSON.parse(readFileSync(p, "utf8"));
67
- }
68
- catch {
69
- return null;
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
- /** Every installed plugin under ~/.hara/plugins (regardless of enabled state). */
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 = pluginsDir();
77
- if (!existsSync(dir))
78
- return [];
220
+ const dir = pluginStorage();
79
221
  const out = [];
80
222
  for (const entry of readdirSync(dir)) {
81
- const root = join(dir, entry);
82
- const manifest = readManifest(root);
83
- if (!manifest)
223
+ if (entry.startsWith(".") || entry.startsWith("_"))
84
224
  continue;
85
- out.push({ name: manifest.name || entry, version: manifest.version || "0.0.0", root, manifest });
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((p) => enabled[p.name] !== false);
241
+ return listInstalled().filter((plugin) => enabled[plugin.name] !== false);
93
242
  }
94
- function resolveDirs(p, entries) {
95
- return (entries ?? [])
96
- .map((e) => (isAbsolute(e) ? e : resolve(p.root, e)))
97
- .filter((d) => existsSync(d));
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((p) => resolveDirs(p, p.manifest.skills));
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((p) => resolveDirs(p, p.manifest.agents));
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 p of enabledPlugins())
112
- Object.assign(out, p.manifest.mcpServers ?? {});
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 p of enabledPlugins()) {
119
- const h = p.manifest.hooks;
120
- if (!h || typeof h !== "object")
268
+ for (const plugin of enabledPlugins()) {
269
+ const hooks = plugin.manifest.hooks;
270
+ if (!hooks)
121
271
  continue;
122
- if (Array.isArray(h.PreToolUse))
123
- out.PreToolUse.push(...h.PreToolUse);
124
- if (Array.isArray(h.PostToolUse))
125
- out.PostToolUse.push(...h.PostToolUse);
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
- /** Install a plugin from `file:<path>`, `github:<owner/repo>`, or `git:<url>` into ~/.hara/plugins/<name>. */
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
- mkdirSync(pluginsDir(), { recursive: true });
132
- const tmpName = `_install-${process.pid}-${Date.now()}`;
133
- const tmp = join(pluginsDir(), tmpName);
134
- rmSync(tmp, { recursive: true, force: true });
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
- if (source.startsWith("file:")) {
137
- const src = resolve(source.slice("file:".length));
138
- if (!existsSync(src))
139
- throw new Error(`no such path: ${src}`);
140
- cpSync(src, tmp, { recursive: true });
141
- }
142
- else if (source.startsWith("github:")) {
143
- execFileSync("git", ["clone", "--depth", "1", `https://github.com/${source.slice("github:".length)}.git`, tmp], { stdio: "ignore" });
144
- }
145
- else if (source.startsWith("git:")) {
146
- execFileSync("git", ["clone", "--depth", "1", source.slice("git:".length), tmp], { stdio: "ignore" });
147
- }
148
- else {
149
- throw new Error("source must be file:<path>, github:<owner/repo>, or git:<url>");
150
- }
151
- const manifest = readManifest(tmp);
152
- if (!manifest || !manifest.name) {
153
- rmSync(tmp, { recursive: true, force: true });
154
- throw new Error("no valid plugin.json (need at least a name) at the source root");
155
- }
156
- const dest = join(pluginsDir(), manifest.name);
157
- rmSync(dest, { recursive: true, force: true });
158
- // move tmp → dest (rename within the same dir)
159
- cpSync(tmp, dest, { recursive: true });
160
- rmSync(tmp, { recursive: true, force: true });
161
- linkPluginBins(dest, manifest); // expose any plugin CLI commands in ~/.hara/bin
162
- return { name: manifest.name, version: manifest.version || "0.0.0", root: dest, manifest };
163
- }
164
- catch (e) {
165
- rmSync(tmp, { recursive: true, force: true });
166
- throw e;
167
- }
168
- }
169
- export function uninstallPlugin(name) {
170
- const dest = join(pluginsDir(), name);
171
- if (!existsSync(dest))
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
- unlinkPluginBins(readManifest(dest)); // remove any linked CLI commands first
174
- rmSync(dest, { recursive: true, force: true });
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
- /** Panels applicable to a project cwd a panel matches when any of its `detect` markers exists under
178
- * the cwd. Pure over the given plugin list (testable); `panelsForProject` binds it to enabled plugins. */
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 pl of plugins) {
182
- for (const panel of pl.manifest.panels ?? []) {
518
+ for (const plugin of plugins) {
519
+ for (const panel of plugin.manifest.panels ?? []) {
183
520
  if (!panel.detect?.length)
184
- continue; // global-only panels stay off project surfaces
185
- if (panel.detect.some((m) => existsSync(join(cwd, m))))
186
- out.push({ plugin: pl.name, panel });
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
- /** Persist a plugin's enabled flag in ~/.hara/config.json (`plugins.enabled[name]`). */
195
- export function setPluginEnabled(name, on) {
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.126.0",
3
+ "version": "0.126.1",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "runtime-bootstrap.cjs"