@nanhara/hara 0.126.0 → 0.127.0

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,43 @@ 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.127.0 — 2026-07-20 — structured context and typed task lifecycle
9
+
10
+ - **Prompt assembly is now an explicit, ordered runtime layer.** Cache-stable policy, tool, project, and
11
+ session context are separated from turn-specific execution state and memory digests, deduplicated, and
12
+ assembled in a deterministic order. Anthropic requests preserve a stable cache prefix while other
13
+ providers continue to receive the same complete system contract.
14
+ - **Conversation and execution state now travel on separate protocol planes.** Authenticated Serve clients
15
+ can negotiate the versioned `event.task_state` stream for running, waiting, paused, completed, and blocked
16
+ work, with stable task/turn identities, phases, accepted task briefs, checkpoints, approvals, outcomes,
17
+ restore snapshots, and explicit stop/finish transitions.
18
+ - **Mid-turn input uses expected-turn steering.** `session.steer` durably accepts an update only for the
19
+ intended live turn, so Desktop and other clients can distinguish a real refinement from the next queued
20
+ task without parsing model prose or losing an end-of-turn race.
21
+ - Ambient lifecycle events never include command or path previews, and resumed client history removes the
22
+ internal steering-triage wrapper before display. Detailed tool output remains confined to the explicit
23
+ conversation surface.
24
+ - Upgrade with `npm i -g @nanhara/hara@0.127.0`.
25
+
26
+ ## 0.126.1 — 2026-07-19 — verified plugin packages and ownership-safe lifecycle
27
+
28
+ - **Plugin packages now fail closed at staging.** Hara validates a bounded manifest schema, portable IDs and
29
+ relative paths, declared Skills/roles/binaries/panels, and MCP entry points before activation. Complete
30
+ package scans reject symbolic links, hard-linked or special files, protected credential-shaped files,
31
+ filesystem-boundary crossings, and excessive file/byte counts.
32
+ - **Install, update, and removal have an ownership transaction.** A same-filesystem private staging directory
33
+ is atomically activated; command collisions never overwrite foreign entries; and a private receipt binds the
34
+ installed root device/inode, manifest digest, and exact command links. Update rollback preserves the previous
35
+ package and commands, while uninstall refuses a changed root, a foreign command replacement, or an older
36
+ unreceipted install. A legacy plugin remains usable; reinstalling it from the same reviewed source performs
37
+ the one-time receipt migration, after which later updates and removal use the verified ownership path.
38
+ - **Plugin MCP processes are rooted in their installed package.** Relative executables and conventional
39
+ Node/Bun/Deno/Python entry scripts are verified and converted to absolute installed paths, and each server
40
+ receives the immutable plugin root as its working directory instead of the user's project.
41
+ - Install warnings no longer echo manifest-provided MCP arguments or hook command bodies, which could contain
42
+ credentials. They still identify each executable surface so the package source can be reviewed before use.
43
+ - Upgrade with `npm i -g @nanhara/hara@0.126.1`.
44
+
8
45
  ## 0.126.0 — 2026-07-19 — provider control plane, bounded tools, and honest execution time
9
46
 
10
47
  - **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
@@ -24,6 +24,7 @@ import { prepareHistoryForModel } from "./context-budget.js";
24
24
  import { rolesDigest } from "../org/roles.js";
25
25
  import { applyTaskBrief } from "../session/task.js";
26
26
  import { askUserTool } from "../tools/ask_user.js";
27
+ import { PromptAssembler } from "./prompt.js";
27
28
  /** File tools whose `path` input marks the file as "recently worked with" (post-compaction restore). */
28
29
  const FILE_TOUCH_TOOLS = new Set(["read_file", "edit_file", "write_file"]);
29
30
  /** Engine-owned, non-authority helpers. Role filters still govern every deferred target activated by
@@ -61,8 +62,7 @@ export function needsConfirm(kind, mode) {
61
62
  return kind === "exec";
62
63
  return true; // suggest: confirm edits and exec
63
64
  }
64
- const HARA_SYSTEM = (cwd) => `You are hara, a coding agent running in the user's terminal.
65
- Working directory: ${cwd}
65
+ const HARA_SYSTEM = () => `You are hara, a coding agent running in the user's terminal.
66
66
  Be concise and direct. Use the provided tools to read files, edit/write files, and run shell
67
67
  commands. Prefer small, verifiable steps; edit existing files with edit_file rather than rewriting
68
68
  them whole. Batch INDEPENDENT tool calls in a single response — especially reads (read_file / grep /
@@ -153,8 +153,9 @@ const CONTINUATION_SYSTEM = "# Existing-session continuity\n" +
153
153
  "re-inventory the workspace, or summarize files merely to understand what happened before. Follow the latest user request " +
154
154
  "and reuse prior conclusions and tool results. Inspect files only when the latest request requires it. If the working " +
155
155
  "directory is Home, ask the user to start Hara from a concrete project instead of enumerating Home or its children.";
156
- function composeSystem(cwd, projectContext, override, memory, continuationSession = false, executionContext, intake) {
157
- const head = override ? `${override}\n\nWorking directory: ${cwd}` : HARA_SYSTEM(cwd);
156
+ export function composeSystem(cwd, projectContext, override, memory, continuationSession = false, executionContext, intake) {
157
+ const assembler = new PromptAssembler();
158
+ assembler.add("core", "static", "core", override || HARA_SYSTEM());
158
159
  const skills = skillsDigest(cwd);
159
160
  const roles = override ? "" : rolesDigest(cwd);
160
161
  const intakeContext = !intake?.enabled
@@ -176,15 +177,17 @@ function composeSystem(cwd, projectContext, override, memory, continuationSessio
176
177
  "the interpreted goal, intent, constraints, acceptance checks, and short steps. Use intent `answer` " +
177
178
  "for a direct answer, `investigate` for evidence gathering/diagnosis, and `change` when the user asked " +
178
179
  "you to modify or deliver something. Do not claim completion until the acceptance checks are verified.");
179
- return (head +
180
- gatewayNote() +
181
- (continuationSession ? `\n\n${CONTINUATION_SYSTEM}` : "") +
182
- (executionContext ? `\n\n${executionContext}` : "") +
183
- intakeContext +
184
- (projectContext ? `\n\n# Project context (AGENTS.md)\n${projectContext}` : "") +
185
- (memory ? `\n\n# Memory (durable — facts/decisions/prefs you've saved; use memory_search/get for more)\n${memory}` : "") +
186
- (roles ? `\n\n# Specialist roles (metadata only — use \`agent\` with a role id for bounded read-only expertise)\n${roles}` : "") +
187
- (skills ? `\n\n# Skills (capabilities you can load — call the \`skill\` tool with the id for full instructions before using one)\n${skills}` : ""));
180
+ assembler
181
+ .add("working-directory", "session", "runtime", `Working directory: ${cwd}`)
182
+ .add("gateway-channel", "session", "channel", gatewayNote())
183
+ .add("continuation", "session", "task", continuationSession ? CONTINUATION_SYSTEM : "")
184
+ .add("execution", "session", "task", executionContext)
185
+ .add("project", "session", "project", projectContext ? `# Project context (AGENTS.md)\n${projectContext}` : "")
186
+ .add("memory", "session", "memory", memory ? `# Memory (durable — facts/decisions/prefs you've saved; use memory_search/get for more)\n${memory}` : "")
187
+ .add("roles", "session", "role", roles ? `# Specialist roles (metadata only — use \`agent\` with a role id for bounded read-only expertise)\n${roles}` : "")
188
+ .add("skills", "session", "skill", skills ? `# Skills (capabilities you can load — call the \`skill\` tool with the id for full instructions before using one)\n${skills}` : "")
189
+ .add("task-intake", "turn", "task", intakeContext);
190
+ return assembler.build();
188
191
  }
189
192
  const RUN_STOPPED = Symbol("agent-run-stopped");
190
193
  const REPEATED_FAILURE_LIMIT = 3;
@@ -618,7 +621,8 @@ async function runAgentInner(history, opts, life) {
618
621
  ? [...baseSpecs, ...runExtraTools.map((t) => ({ name: t.name, description: t.description, input_schema: t.input_schema }))]
619
622
  : baseSpecs;
620
623
  const sink = ctx.ui; // TUI mode: route output to ink instead of stdout
621
- const system = composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory, opts.continuationSession, opts.executionContext, { enabled: !!opts.taskIntake, brief: intakeTask?.brief });
624
+ const assembledSystem = composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory, opts.continuationSession, opts.executionContext, { enabled: !!opts.taskIntake, brief: intakeTask?.brief });
625
+ const system = assembledSystem.text;
622
626
  const prepared = prepareHistoryForModel(history, {
623
627
  model: activeProvider.model,
624
628
  system,
@@ -714,6 +718,7 @@ async function runAgentInner(history, opts, life) {
714
718
  }
715
719
  return activeProvider.turn({
716
720
  system,
721
+ systemParts: assembledSystem.parts,
717
722
  history: prepared.history,
718
723
  tools: specs,
719
724
  // Any stream chunk keeps the connection considered alive — even suppressed reasoning_content, so a
@@ -0,0 +1,48 @@
1
+ import { createHash } from "node:crypto";
2
+ const STABILITY_ORDER = {
3
+ static: 0,
4
+ session: 1,
5
+ turn: 2,
6
+ };
7
+ function promptDigest(content) {
8
+ return createHash("sha256").update(content).digest("hex").slice(0, 16);
9
+ }
10
+ /** Assemble the model's runtime context as a governed object instead of an ever-growing string.
11
+ *
12
+ * Ordering is intentionally monotonic. If a caller tries to place stable material after a dynamic task
13
+ * suffix, fail during assembly instead of silently destroying provider prefix-cache locality. */
14
+ export class PromptAssembler {
15
+ parts = [];
16
+ ids = new Set();
17
+ lastStability = "static";
18
+ add(id, stability, source, content) {
19
+ const normalizedId = id.trim();
20
+ const normalizedContent = content?.trim();
21
+ if (!normalizedContent)
22
+ return this;
23
+ if (!normalizedId)
24
+ throw new Error("system prompt part id must not be empty");
25
+ if (this.ids.has(normalizedId))
26
+ throw new Error(`duplicate system prompt part id: ${normalizedId}`);
27
+ if (this.parts.length && STABILITY_ORDER[stability] < STABILITY_ORDER[this.lastStability]) {
28
+ throw new Error(`system prompt part '${normalizedId}' (${stability}) cannot follow ${this.lastStability} context`);
29
+ }
30
+ this.ids.add(normalizedId);
31
+ this.lastStability = stability;
32
+ this.parts.push({
33
+ id: normalizedId,
34
+ stability,
35
+ source,
36
+ content: normalizedContent,
37
+ digest: promptDigest(normalizedContent),
38
+ });
39
+ return this;
40
+ }
41
+ build() {
42
+ const parts = this.parts.map((part) => ({ ...part }));
43
+ return {
44
+ text: parts.map((part) => part.content).join("\n\n"),
45
+ parts,
46
+ };
47
+ }
48
+ }
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
+ }