@gajae-code/coding-agent 0.7.2 → 0.7.4

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.
Files changed (154) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/bin/gjc.js +4 -0
  3. package/dist/types/cli/mcp-cli.d.ts +25 -0
  4. package/dist/types/cli/plugin-cli.d.ts +2 -0
  5. package/dist/types/cli.d.ts +6 -0
  6. package/dist/types/commands/mcp.d.ts +70 -0
  7. package/dist/types/commands/plugin.d.ts +6 -0
  8. package/dist/types/commands/session.d.ts +6 -0
  9. package/dist/types/config/keybindings.d.ts +2 -2
  10. package/dist/types/config/model-profile-activation.d.ts +8 -1
  11. package/dist/types/deep-interview/plaintext-gate-guard.d.ts +11 -0
  12. package/dist/types/extensibility/gjc-plugins/compiler.d.ts +19 -0
  13. package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +29 -0
  14. package/dist/types/extensibility/gjc-plugins/index.d.ts +9 -0
  15. package/dist/types/extensibility/gjc-plugins/injection.d.ts +9 -0
  16. package/dist/types/extensibility/gjc-plugins/installer.d.ts +13 -0
  17. package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +26 -0
  18. package/dist/types/extensibility/gjc-plugins/observability.d.ts +27 -0
  19. package/dist/types/extensibility/gjc-plugins/prompt-appendix.d.ts +16 -0
  20. package/dist/types/extensibility/gjc-plugins/registry.d.ts +32 -0
  21. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +64 -0
  22. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +42 -0
  23. package/dist/types/extensibility/gjc-plugins/types.d.ts +158 -2
  24. package/dist/types/extensibility/gjc-plugins/validation.d.ts +8 -1
  25. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  26. package/dist/types/gjc-runtime/psmux-detect.d.ts +78 -0
  27. package/dist/types/gjc-runtime/team-runtime.d.ts +2 -0
  28. package/dist/types/gjc-runtime/tmux-common.d.ts +20 -1
  29. package/dist/types/gjc-runtime/tmux-sessions.d.ts +18 -0
  30. package/dist/types/main.d.ts +2 -0
  31. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  32. package/dist/types/modes/components/model-selector.d.ts +8 -0
  33. package/dist/types/modes/components/status-line/git-utils.d.ts +6 -0
  34. package/dist/types/modes/theme/defaults/index.d.ts +99 -0
  35. package/dist/types/notifications/html-format.d.ts +11 -0
  36. package/dist/types/notifications/index.d.ts +149 -1
  37. package/dist/types/notifications/lifecycle-commands.d.ts +72 -0
  38. package/dist/types/notifications/lifecycle-control-runtime.d.ts +98 -0
  39. package/dist/types/notifications/lifecycle-orchestrator.d.ts +144 -0
  40. package/dist/types/notifications/operator-runtime.d.ts +52 -0
  41. package/dist/types/notifications/rate-limit-pool.d.ts +2 -0
  42. package/dist/types/notifications/recent-activity.d.ts +35 -0
  43. package/dist/types/notifications/telegram-daemon.d.ts +114 -16
  44. package/dist/types/notifications/telegram-reference.d.ts +3 -1
  45. package/dist/types/notifications/topic-registry.d.ts +12 -9
  46. package/dist/types/runtime-mcp/types.d.ts +7 -0
  47. package/dist/types/sdk.d.ts +2 -0
  48. package/dist/types/session/agent-session.d.ts +14 -4
  49. package/dist/types/session/blob-store.d.ts +25 -0
  50. package/dist/types/session/session-manager.d.ts +57 -0
  51. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +6 -0
  52. package/dist/types/system-prompt.d.ts +2 -0
  53. package/dist/types/task/executor.d.ts +9 -1
  54. package/dist/types/tools/composer-bash-policy.d.ts +14 -0
  55. package/dist/types/tools/index.d.ts +3 -1
  56. package/dist/types/utils/changelog.d.ts +1 -0
  57. package/dist/types/web/insane/url-guard.d.ts +6 -3
  58. package/dist/types/web/scrapers/types.d.ts +5 -0
  59. package/dist/types/web/scrapers/utils.d.ts +7 -1
  60. package/package.json +11 -9
  61. package/scripts/g004-tmux-smoke.ts +100 -0
  62. package/scripts/g005-daemon-smoke.ts +181 -0
  63. package/scripts/g011-daemon-path-smoke.ts +153 -0
  64. package/src/cli/mcp-cli.ts +272 -0
  65. package/src/cli/plugin-cli.ts +66 -3
  66. package/src/cli.ts +27 -6
  67. package/src/commands/mcp.ts +117 -0
  68. package/src/commands/plugin.ts +4 -0
  69. package/src/commands/session.ts +18 -0
  70. package/src/config/keybindings.ts +2 -2
  71. package/src/config/model-profile-activation.ts +55 -7
  72. package/src/deep-interview/plaintext-gate-guard.ts +94 -0
  73. package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +1 -1
  74. package/src/defaults/gjc/skills/deep-interview/SKILL.md +7 -6
  75. package/src/defaults/gjc/skills/team/SKILL.md +5 -3
  76. package/src/defaults/gjc/skills/ultragoal/SKILL.md +41 -13
  77. package/src/export/html/index.ts +2 -2
  78. package/src/extensibility/extensions/runner.ts +1 -0
  79. package/src/extensibility/gjc-plugins/compiler.ts +351 -0
  80. package/src/extensibility/gjc-plugins/constrained-hooks.ts +170 -0
  81. package/src/extensibility/gjc-plugins/index.ts +9 -0
  82. package/src/extensibility/gjc-plugins/injection.ts +109 -0
  83. package/src/extensibility/gjc-plugins/installer.ts +434 -0
  84. package/src/extensibility/gjc-plugins/loader.ts +3 -1
  85. package/src/extensibility/gjc-plugins/mcp-policy.ts +239 -0
  86. package/src/extensibility/gjc-plugins/observability.ts +84 -0
  87. package/src/extensibility/gjc-plugins/paths.ts +1 -1
  88. package/src/extensibility/gjc-plugins/prompt-appendix.ts +109 -0
  89. package/src/extensibility/gjc-plugins/registry.ts +180 -0
  90. package/src/extensibility/gjc-plugins/runtime-adapters.ts +234 -0
  91. package/src/extensibility/gjc-plugins/schema.ts +250 -20
  92. package/src/extensibility/gjc-plugins/session-validation.ts +147 -0
  93. package/src/extensibility/gjc-plugins/types.ts +199 -3
  94. package/src/extensibility/gjc-plugins/validation.ts +80 -0
  95. package/src/extensibility/skills.ts +15 -0
  96. package/src/gjc-runtime/launch-tmux.ts +61 -7
  97. package/src/gjc-runtime/psmux-detect.ts +239 -0
  98. package/src/gjc-runtime/team-runtime.ts +56 -23
  99. package/src/gjc-runtime/tmux-common.ts +30 -3
  100. package/src/gjc-runtime/tmux-sessions.ts +51 -1
  101. package/src/gjc-runtime/ultragoal-guard.ts +25 -8
  102. package/src/gjc-runtime/ultragoal-runtime.ts +75 -15
  103. package/src/hooks/skill-state.ts +57 -0
  104. package/src/internal-urls/docs-index.generated.ts +12 -8
  105. package/src/main.ts +14 -3
  106. package/src/modes/bridge/bridge-mode.ts +11 -0
  107. package/src/modes/components/custom-editor.ts +2 -0
  108. package/src/modes/components/footer.ts +2 -3
  109. package/src/modes/components/hook-editor.ts +1 -1
  110. package/src/modes/components/hook-selector.ts +67 -43
  111. package/src/modes/components/model-selector.ts +56 -11
  112. package/src/modes/components/status-line/git-utils.ts +25 -0
  113. package/src/modes/components/status-line.ts +10 -11
  114. package/src/modes/components/welcome.ts +2 -3
  115. package/src/modes/controllers/extension-ui-controller.ts +0 -27
  116. package/src/modes/controllers/selector-controller.ts +53 -11
  117. package/src/modes/interactive-mode.ts +4 -1
  118. package/src/modes/shared/agent-wire/scopes.ts +1 -1
  119. package/src/modes/theme/defaults/gruvbox-dark.json +99 -0
  120. package/src/modes/theme/defaults/index.ts +2 -0
  121. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  122. package/src/notifications/html-format.ts +38 -0
  123. package/src/notifications/index.ts +242 -12
  124. package/src/notifications/lifecycle-commands.ts +228 -0
  125. package/src/notifications/lifecycle-control-runtime.ts +400 -0
  126. package/src/notifications/lifecycle-orchestrator.ts +358 -0
  127. package/src/notifications/operator-runtime.ts +171 -0
  128. package/src/notifications/rate-limit-pool.ts +19 -0
  129. package/src/notifications/recent-activity.ts +132 -0
  130. package/src/notifications/telegram-daemon.ts +778 -257
  131. package/src/notifications/telegram-reference.ts +25 -7
  132. package/src/notifications/topic-registry.ts +23 -9
  133. package/src/prompts/agents/executor.md +2 -2
  134. package/src/runtime-mcp/transports/stdio.ts +38 -4
  135. package/src/runtime-mcp/types.ts +7 -0
  136. package/src/sdk.ts +157 -10
  137. package/src/session/agent-session.ts +166 -74
  138. package/src/session/blob-store.ts +196 -8
  139. package/src/session/session-manager.ts +678 -7
  140. package/src/slash-commands/builtin-registry.ts +23 -3
  141. package/src/slash-commands/helpers/fast-status-report.ts +13 -3
  142. package/src/slash-commands/helpers/parse.ts +2 -1
  143. package/src/system-prompt.ts +9 -0
  144. package/src/task/executor.ts +31 -7
  145. package/src/task/index.ts +2 -0
  146. package/src/tools/ask.ts +5 -1
  147. package/src/tools/bash.ts +9 -0
  148. package/src/tools/composer-bash-policy.ts +96 -0
  149. package/src/tools/fetch.ts +18 -2
  150. package/src/tools/index.ts +3 -1
  151. package/src/utils/changelog.ts +8 -0
  152. package/src/web/insane/url-guard.ts +18 -14
  153. package/src/web/scrapers/types.ts +143 -45
  154. package/src/web/scrapers/utils.ts +70 -19
@@ -0,0 +1,434 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import * as fs from "node:fs/promises";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+ import { gunzipSync } from "node:zlib";
7
+ import { compileGjcPluginBundle } from "./compiler";
8
+ import { gjcPluginProjectRoot, gjcPluginUserRoot } from "./paths";
9
+ import {
10
+ readRegistry,
11
+ registryEntryFingerprint,
12
+ sortRegistryEntries,
13
+ withRegistryLock,
14
+ writeRegistryUnlocked,
15
+ } from "./registry";
16
+ import {
17
+ GJC_PLUGIN_MANIFEST_FILENAME,
18
+ GjcPluginLoadError,
19
+ type GjcPluginRegistryEntry,
20
+ type GjcPluginRegistrySource,
21
+ type GjcPluginScope,
22
+ type NormalizedGjcPluginBundle,
23
+ } from "./types";
24
+ import { validateInstallPlan } from "./validation";
25
+
26
+ export interface InstallGjcPluginOptions {
27
+ scope: GjcPluginScope;
28
+ cwd: string;
29
+ force?: boolean;
30
+ }
31
+
32
+ export interface InstallGjcPluginResult {
33
+ status: "installed" | "updated" | "unchanged";
34
+ entry: GjcPluginRegistryEntry;
35
+ }
36
+
37
+ // Resource limits for the in-house tar extractor (third-party security boundary).
38
+ const TAR_MAX_FILES = 8192;
39
+ const TAR_MAX_FILE_BYTES = 16 * 1024 * 1024;
40
+ const TAR_MAX_TOTAL_BYTES = 128 * 1024 * 1024;
41
+
42
+ function scopeRoot(scope: GjcPluginScope, cwd: string): string {
43
+ return scope === "user" ? gjcPluginUserRoot() : gjcPluginProjectRoot(cwd);
44
+ }
45
+
46
+ function safeDirSegment(name: string): string {
47
+ const seg = name.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/^-+|-+$/g, "");
48
+ if (!seg || seg === "." || seg === "..") {
49
+ throw new GjcPluginLoadError("invalid_manifest", `GJC plugin name is not a safe directory segment: ${name}`);
50
+ }
51
+ return seg;
52
+ }
53
+
54
+ async function isDirectory(p: string): Promise<boolean> {
55
+ try {
56
+ return (await fs.stat(p)).isDirectory();
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ async function fileExists(p: string): Promise<boolean> {
63
+ try {
64
+ await fs.access(p);
65
+ return true;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Source resolution
73
+ // ---------------------------------------------------------------------------
74
+
75
+ interface ResolvedSource {
76
+ dir: string;
77
+ source: GjcPluginRegistrySource;
78
+ cleanup: () => Promise<void>;
79
+ }
80
+
81
+ function isTarball(source: string): boolean {
82
+ return /\.(tgz|tar\.gz|tar)$/i.test(source);
83
+ }
84
+
85
+ function looksLikeGit(source: string): boolean {
86
+ return /^(https?|ssh|git):\/\//i.test(source) || /^git@/.test(source) || source.startsWith("git:");
87
+ }
88
+
89
+ async function resolveLocalPath(source: string): Promise<ResolvedSource> {
90
+ const abs = path.resolve(source);
91
+ if (!(await isDirectory(abs))) {
92
+ throw new GjcPluginLoadError("missing_file", `GJC plugin source directory not found: ${source}`);
93
+ }
94
+ return {
95
+ dir: abs,
96
+ source: { kind: "path", uri: abs, resolvedAt: new Date().toISOString() },
97
+ cleanup: async () => {},
98
+ };
99
+ }
100
+
101
+ function tarHeaderChecksumOk(header: Uint8Array): boolean {
102
+ const stored = Number.parseInt(new TextDecoder().decode(header.subarray(148, 156)).replace(/\0.*$/, "").trim(), 8);
103
+ if (!Number.isFinite(stored)) return false;
104
+ let unsigned = 0;
105
+ let signed = 0;
106
+ for (let i = 0; i < 512; i++) {
107
+ const byte = i >= 148 && i < 156 ? 0x20 : (header[i] ?? 0);
108
+ unsigned += byte;
109
+ signed += byte < 128 ? byte : byte - 256;
110
+ }
111
+ return stored === unsigned || stored === signed;
112
+ }
113
+
114
+ /** Minimal, traversal/symlink-safe, resource-bounded extraction of a tar(.gz). */
115
+ async function extractTarball(tarPath: string, destRoot: string): Promise<void> {
116
+ const raw = await fs.readFile(tarPath);
117
+ const buf = /\.(tgz|tar\.gz)$/i.test(tarPath) ? gunzipSync(raw) : raw;
118
+ const resolvedRoot = path.resolve(destRoot);
119
+ const decoder = new TextDecoder();
120
+ let offset = 0;
121
+ let fileCount = 0;
122
+ let totalBytes = 0;
123
+ while (offset + 512 <= buf.byteLength) {
124
+ const header = buf.subarray(offset, offset + 512);
125
+ offset += 512;
126
+ if (header.every(b => b === 0)) break; // end-of-archive marker
127
+ if (!tarHeaderChecksumOk(header)) {
128
+ throw new GjcPluginLoadError("security_policy", "Corrupt tar header checksum");
129
+ }
130
+ const name = decoder.decode(header.subarray(0, 100)).replace(/\0.*$/, "");
131
+ const sizeField = decoder.decode(header.subarray(124, 136)).replace(/\0.*$/, "").trim();
132
+ if (!/^[0-7]*$/.test(sizeField)) {
133
+ throw new GjcPluginLoadError("security_policy", `Unsupported tar size encoding for ${name}`);
134
+ }
135
+ const size = sizeField ? Number.parseInt(sizeField, 8) : 0;
136
+ if (!Number.isSafeInteger(size) || size < 0 || size > TAR_MAX_FILE_BYTES) {
137
+ throw new GjcPluginLoadError("security_policy", `Tar entry size out of bounds for ${name}`);
138
+ }
139
+ const typeFlag = String.fromCharCode(header[156] ?? 0);
140
+ const dataStart = offset;
141
+ if (dataStart + size > buf.byteLength) {
142
+ throw new GjcPluginLoadError("security_policy", `Truncated tar entry for ${name}`);
143
+ }
144
+ offset += Math.ceil(size / 512) * 512;
145
+ // Skip metadata-only entries.
146
+ if (typeFlag === "x" || typeFlag === "g") continue;
147
+ const normalized = name.replace(/^\.\//, "");
148
+ if (!normalized || normalized === "." || normalized === "pax_global_header") continue;
149
+ if (normalized.startsWith("PaxHeader/") || normalized.includes("/PaxHeader/")) continue;
150
+ if (path.basename(normalized).startsWith("._")) continue; // AppleDouble sidecar
151
+ // Fail closed: only regular files and directories are allowed.
152
+ const isDir = typeFlag === "5" || normalized.endsWith("/");
153
+ const isFile = typeFlag === "0" || typeFlag === "\0" || typeFlag === "";
154
+ if (!isDir && !isFile) {
155
+ throw new GjcPluginLoadError("security_policy", `Unsafe tar entry type "${typeFlag}" for ${name}`);
156
+ }
157
+ if (path.isAbsolute(normalized)) {
158
+ throw new GjcPluginLoadError("security_policy", `Absolute path in tar entry: ${name}`);
159
+ }
160
+ const dest = path.resolve(resolvedRoot, normalized);
161
+ const rel = path.relative(resolvedRoot, dest);
162
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
163
+ throw new GjcPluginLoadError("security_policy", `Tar entry escapes destination: ${name}`);
164
+ }
165
+ if (isDir) {
166
+ await fs.mkdir(dest, { recursive: true });
167
+ continue;
168
+ }
169
+ fileCount += 1;
170
+ totalBytes += size;
171
+ if (fileCount > TAR_MAX_FILES || totalBytes > TAR_MAX_TOTAL_BYTES) {
172
+ throw new GjcPluginLoadError("security_policy", "Tar archive exceeds extraction limits");
173
+ }
174
+ await fs.mkdir(path.dirname(dest), { recursive: true });
175
+ await fs.writeFile(dest, buf.subarray(dataStart, dataStart + size));
176
+ }
177
+ }
178
+
179
+ async function findManifestRoot(base: string): Promise<string | null> {
180
+ if (await fileExists(path.join(base, GJC_PLUGIN_MANIFEST_FILENAME))) return base;
181
+ let entries: import("node:fs").Dirent[];
182
+ try {
183
+ entries = await fs.readdir(base, { withFileTypes: true });
184
+ } catch {
185
+ return null;
186
+ }
187
+ for (const dir of entries.filter(e => e.isDirectory())) {
188
+ const candidate = path.join(base, dir.name);
189
+ if (await fileExists(path.join(candidate, GJC_PLUGIN_MANIFEST_FILENAME))) return candidate;
190
+ }
191
+ return null;
192
+ }
193
+
194
+ async function resolveTarball(source: string): Promise<ResolvedSource> {
195
+ const temp = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-plugin-tar-"));
196
+ try {
197
+ await extractTarball(source, temp);
198
+ const dir = await findManifestRoot(temp);
199
+ if (!dir) throw new GjcPluginLoadError("missing_file", `No ${GJC_PLUGIN_MANIFEST_FILENAME} found in tarball`);
200
+ return {
201
+ dir,
202
+ source: { kind: "tarball", uri: path.resolve(source), resolvedAt: new Date().toISOString() },
203
+ cleanup: async () => {
204
+ await fs.rm(temp, { recursive: true, force: true });
205
+ },
206
+ };
207
+ } catch (error) {
208
+ await fs.rm(temp, { recursive: true, force: true });
209
+ throw error;
210
+ }
211
+ }
212
+
213
+ function runGit(args: string[], cwd?: string): Promise<string> {
214
+ return new Promise((resolve, reject) => {
215
+ // argv array (no shell) — repo/ref are passed as discrete args, not interpolated.
216
+ const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
217
+ let stdout = "";
218
+ let stderr = "";
219
+ child.stdout.on("data", d => {
220
+ stdout += d;
221
+ });
222
+ child.stderr.on("data", d => {
223
+ stderr += d;
224
+ });
225
+ child.on("error", reject);
226
+ child.on("close", code => {
227
+ if (code === 0) resolve(stdout.trim());
228
+ else reject(new GjcPluginLoadError("install_conflict", `git ${args[0]} failed: ${stderr.trim()}`));
229
+ });
230
+ });
231
+ }
232
+
233
+ async function resolveGit(source: string): Promise<ResolvedSource> {
234
+ const hashIndex = source.indexOf("#");
235
+ const repo = hashIndex >= 0 ? source.slice(0, hashIndex) : source;
236
+ const ref = hashIndex >= 0 ? source.slice(hashIndex + 1) : undefined;
237
+ const temp = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-plugin-git-"));
238
+ try {
239
+ const cloneArgs = ["clone", "--depth", "1"];
240
+ if (ref) cloneArgs.push("--branch", ref);
241
+ cloneArgs.push("--", repo, temp);
242
+ await runGit(cloneArgs);
243
+ let sha: string | undefined;
244
+ try {
245
+ sha = await runGit(["rev-parse", "HEAD"], temp);
246
+ } catch {
247
+ sha = undefined;
248
+ }
249
+ const dir = await findManifestRoot(temp);
250
+ if (!dir) throw new GjcPluginLoadError("missing_file", `No ${GJC_PLUGIN_MANIFEST_FILENAME} found in git source`);
251
+ return {
252
+ dir,
253
+ source: { kind: "git", uri: repo, ref, sha, resolvedAt: new Date().toISOString() },
254
+ cleanup: async () => {
255
+ await fs.rm(temp, { recursive: true, force: true });
256
+ },
257
+ };
258
+ } catch (error) {
259
+ await fs.rm(temp, { recursive: true, force: true });
260
+ throw error;
261
+ }
262
+ }
263
+
264
+ async function resolveSource(source: string): Promise<ResolvedSource> {
265
+ if (isTarball(source)) return resolveTarball(source);
266
+ if (looksLikeGit(source)) return resolveGit(source);
267
+ return resolveLocalPath(source);
268
+ }
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // Copy + publish
272
+ // ---------------------------------------------------------------------------
273
+
274
+ function bundleToRegistryEntry(
275
+ bundle: NormalizedGjcPluginBundle,
276
+ pluginRoot: string,
277
+ scope: GjcPluginScope,
278
+ source: GjcPluginRegistrySource,
279
+ now: string,
280
+ ): GjcPluginRegistryEntry {
281
+ return {
282
+ name: bundle.name,
283
+ version: bundle.version,
284
+ scope,
285
+ enabled: true,
286
+ pluginRoot,
287
+ manifestPath: path.join(pluginRoot, GJC_PLUGIN_MANIFEST_FILENAME),
288
+ manifestHash: bundle.manifestHash,
289
+ source,
290
+ installedAt: now,
291
+ updatedAt: now,
292
+ copiedFiles: bundle.files,
293
+ surfaces: bundle.surfaces,
294
+ disabledSurfaceIds: [],
295
+ };
296
+ }
297
+
298
+ function sha256(buf: Buffer): string {
299
+ return createHash("sha256").update(buf).digest("hex");
300
+ }
301
+
302
+ /**
303
+ * Copy ONLY the validated, hashed files (bundle.files) from the source into the
304
+ * staging dir, re-verifying each hash. Undeclared files and symlinks are never
305
+ * copied, so the installed tree equals the validated set.
306
+ */
307
+ async function copyValidatedFiles(bundle: NormalizedGjcPluginBundle, stagingDir: string): Promise<void> {
308
+ for (const file of bundle.files) {
309
+ const src = path.join(bundle.root, file.relativePath);
310
+ const lst = await fs.lstat(src);
311
+ if (lst.isSymbolicLink()) {
312
+ throw new GjcPluginLoadError("security_policy", `Refusing to copy symlink: ${file.relativePath}`);
313
+ }
314
+ const buf = await fs.readFile(src);
315
+ if (sha256(buf) !== file.sha256) {
316
+ throw new GjcPluginLoadError("hash_mismatch", `Source changed during install: ${file.relativePath}`);
317
+ }
318
+ const dest = path.join(stagingDir, file.relativePath);
319
+ await fs.mkdir(path.dirname(dest), { recursive: true });
320
+ await fs.writeFile(dest, buf);
321
+ }
322
+ }
323
+
324
+ async function cleanupOrphans(root: string, dirName: string): Promise<void> {
325
+ try {
326
+ const entries = await fs.readdir(root);
327
+ await Promise.all(
328
+ entries
329
+ .filter(e => e.startsWith(`${dirName}.installing-`) || e.startsWith(`${dirName}.backup-`))
330
+ .map(e => fs.rm(path.join(root, e), { recursive: true, force: true })),
331
+ );
332
+ } catch {
333
+ // best-effort
334
+ }
335
+ }
336
+
337
+ export async function installGjcPluginBundle(
338
+ source: string,
339
+ options: InstallGjcPluginOptions,
340
+ ): Promise<InstallGjcPluginResult> {
341
+ const resolved = await resolveSource(source);
342
+ try {
343
+ // 1. Compile + validate (never imports plugin code).
344
+ const bundle = await compileGjcPluginBundle(resolved.dir);
345
+ const dirName = safeDirSegment(bundle.name);
346
+ const root = scopeRoot(options.scope, options.cwd);
347
+ const finalDir = path.join(root, dirName);
348
+
349
+ // 2-4. Conflict check, atomic swap, and registry write are one serialized
350
+ // transaction per scope so concurrent installs cannot race or lose updates.
351
+ return await withRegistryLock(options.scope, options.cwd, async () => {
352
+ await fs.mkdir(root, { recursive: true });
353
+ await cleanupOrphans(root, dirName);
354
+
355
+ const registry = await readRegistry(options.scope, options.cwd);
356
+ const existing = registry.plugins.find(p => p.name === bundle.name);
357
+ // Hard install-time collision + MCP security validation against the
358
+ // effective installed registry (registry is the collision authority).
359
+ validateInstallPlan(bundle, registry.plugins);
360
+ const candidate = bundleToRegistryEntry(
361
+ bundle,
362
+ finalDir,
363
+ options.scope,
364
+ resolved.source,
365
+ new Date().toISOString(),
366
+ );
367
+ if (existing) {
368
+ const sameContent = registryEntryFingerprint(existing) === registryEntryFingerprint(candidate);
369
+ if (sameContent && (await isDirectory(finalDir))) {
370
+ return { status: "unchanged" as const, entry: existing };
371
+ }
372
+ if (!options.force) {
373
+ throw new GjcPluginLoadError(
374
+ "install_conflict",
375
+ `GJC plugin "${bundle.name}" is already installed with different content; pass --force to replace it`,
376
+ );
377
+ }
378
+ }
379
+
380
+ const unique = `${process.pid}-${randomBytes(6).toString("hex")}`;
381
+ const stagingDir = `${finalDir}.installing-${unique}`;
382
+ const backupDir = `${finalDir}.backup-${unique}`;
383
+ await fs.rm(stagingDir, { recursive: true, force: true });
384
+ try {
385
+ await copyValidatedFiles(bundle, stagingDir);
386
+ const hadFinal = await isDirectory(finalDir);
387
+ if (hadFinal) await fs.rename(finalDir, backupDir);
388
+ try {
389
+ await fs.rename(stagingDir, finalDir);
390
+ } catch (error) {
391
+ if (hadFinal) await fs.rename(backupDir, finalDir);
392
+ throw error;
393
+ }
394
+ // Registry write last; on failure, roll the filesystem back.
395
+ try {
396
+ const next = sortRegistryEntries([
397
+ ...registry.plugins.filter(p => p.name !== bundle.name),
398
+ { ...candidate, installedAt: existing?.installedAt ?? candidate.installedAt },
399
+ ]);
400
+ await writeRegistryUnlocked({ version: 1, scope: options.scope, plugins: next }, options.cwd);
401
+ } catch (error) {
402
+ await fs.rm(finalDir, { recursive: true, force: true });
403
+ if (hadFinal) await fs.rename(backupDir, finalDir);
404
+ throw error;
405
+ }
406
+ if (hadFinal) await fs.rm(backupDir, { recursive: true, force: true });
407
+ return { status: existing ? ("updated" as const) : ("installed" as const), entry: candidate };
408
+ } finally {
409
+ await fs.rm(stagingDir, { recursive: true, force: true });
410
+ }
411
+ });
412
+ } finally {
413
+ await resolved.cleanup();
414
+ }
415
+ }
416
+
417
+ /** True only when the source actually resolves to a GJC plugin bundle (root gajae-plugin.json). */
418
+ export async function isGjcPluginBundleSource(source: string): Promise<boolean> {
419
+ if (!isTarball(source) && !looksLikeGit(source)) {
420
+ const abs = path.resolve(source);
421
+ return await fileExists(path.join(abs, GJC_PLUGIN_MANIFEST_FILENAME));
422
+ }
423
+ // Probe git/tarball content safely, then clean up; never throw for non-bundles.
424
+ try {
425
+ const resolved = await resolveSource(source);
426
+ try {
427
+ return await fileExists(path.join(resolved.dir, GJC_PLUGIN_MANIFEST_FILENAME));
428
+ } finally {
429
+ await resolved.cleanup();
430
+ }
431
+ } catch {
432
+ return false;
433
+ }
434
+ }
@@ -59,7 +59,9 @@ export async function loadGjcPlugin(root: string): Promise<LoadedGjcPlugin> {
59
59
  const pluginRoot = path.resolve(root);
60
60
  const manifestPath = path.join(pluginRoot, GJC_PLUGIN_MANIFEST_FILENAME);
61
61
  const manifest = parseManifest(await readJsonFile(manifestPath), manifestPath);
62
- const manifestToolPaths = manifest.tools.map(rel => resolveWithinRoot(pluginRoot, rel));
62
+ const manifestToolPaths = manifest.tools
63
+ .filter(tool => tool.surface === "subskill")
64
+ .map(tool => resolveWithinRoot(pluginRoot, tool.path));
63
65
 
64
66
  for (const toolPath of manifestToolPaths) {
65
67
  await readRequiredText(toolPath, "tool");
@@ -0,0 +1,239 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import * as path from "node:path";
3
+ import { pathIsWithin } from "@gajae-code/utils";
4
+ import { GjcPluginLoadError, type GjcPluginMcpManifestEntry } from "./types";
5
+
6
+ /**
7
+ * Shared MCP security policy applied at BOTH install validation and runtime
8
+ * connect for third-party plugin-bundle MCP servers. Defaults are deny-first:
9
+ * HTTPS only, no private/loopback/link-local/metadata endpoints, stdio confined
10
+ * to the plugin root.
11
+ */
12
+
13
+ const ALLOWED_HTTP_SCHEMES = new Set(["https:"]);
14
+ const ALLOWED_STDIO_LAUNCHERS = new Set(["node", "bun"]);
15
+
16
+ function fail(message: string): never {
17
+ throw new GjcPluginLoadError("security_policy", message);
18
+ }
19
+
20
+ function ipv4ToOctets(host: string): number[] | null {
21
+ const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
22
+ if (!m) return null;
23
+ const octets = m.slice(1, 5).map(Number);
24
+ if (octets.some(o => o < 0 || o > 255)) return null;
25
+ return octets;
26
+ }
27
+
28
+ export function isDeniedIpv4(host: string): boolean {
29
+ const o = ipv4ToOctets(host);
30
+ if (!o) return false;
31
+ const [a, b] = o;
32
+ if (a === 127) return true; // loopback 127.0.0.0/8
33
+ if (a === 10) return true; // private 10.0.0.0/8
34
+ if (a === 172 && b >= 16 && b <= 31) return true; // private 172.16.0.0/12
35
+ if (a === 192 && b === 168) return true; // private 192.168.0.0/16
36
+ if (a === 169 && b === 254) return true; // link-local 169.254.0.0/16 (incl 169.254.169.254 metadata)
37
+ if (a === 0) return true; // 0.0.0.0/8 unspecified/this-network
38
+ if (a >= 224) return true; // multicast/reserved 224.0.0.0/4 and 240.0.0.0/4
39
+ return false;
40
+ }
41
+
42
+ /** Expand an IPv6 literal (with optional zone id) to 8 hextets, or null. */
43
+ function expandIpv6(host: string): number[] | null {
44
+ let h = host.toLowerCase().replace(/^\[|\]$/g, "");
45
+ const zone = h.indexOf("%");
46
+ if (zone >= 0) h = h.slice(0, zone); // strip zone id (e.g. %eth0 / %25eth0)
47
+ if (!h.includes(":")) return null;
48
+ // Handle embedded dotted IPv4 tail by converting it to two hextets.
49
+ const dotted = h.match(/(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
50
+ if (dotted) {
51
+ const octs = ipv4ToOctets(dotted[2] ?? "");
52
+ if (!octs) return null;
53
+ const hi = ((octs[0] << 8) | octs[1]).toString(16);
54
+ const lo = ((octs[2] << 8) | octs[3]).toString(16);
55
+ h = `${dotted[1]}${hi}:${lo}`;
56
+ }
57
+ const parts = h.split("::");
58
+ if (parts.length > 2) return null;
59
+ const head = parts[0] ? parts[0].split(":") : [];
60
+ const tail = parts.length === 2 ? (parts[1] ? parts[1].split(":") : []) : null;
61
+ let groups: string[];
62
+ if (tail === null) {
63
+ groups = head;
64
+ } else {
65
+ const fill = 8 - head.length - tail.length;
66
+ if (fill < 0) return null;
67
+ groups = [...head, ...Array(fill).fill("0"), ...tail];
68
+ }
69
+ if (groups.length !== 8) return null;
70
+ const out: number[] = [];
71
+ for (const g of groups) {
72
+ if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
73
+ out.push(Number.parseInt(g, 16));
74
+ }
75
+ return out;
76
+ }
77
+
78
+ export function isDeniedIpv6(host: string): boolean {
79
+ const g = expandIpv6(host);
80
+ if (!g) return false;
81
+ const isZero = (n: number, count: number): boolean => g.slice(0, count).every(x => x === n);
82
+ if (g.every(x => x === 0)) return true; // :: unspecified
83
+ if (isZero(0, 7) && g[7] === 1) return true; // ::1 loopback
84
+ if ((g[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
85
+ if ((g[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 unique-local
86
+ if ((g[0] & 0xff00) === 0xff00) return true; // ff00::/8 multicast
87
+ // IPv4-mapped ::ffff:a.b.c.d and IPv4-compatible ::a.b.c.d -> check embedded v4.
88
+ const mappedFfff = isZero(0, 5) && g[5] === 0xffff;
89
+ const compat = isZero(0, 6) && !(g[6] === 0 && g[7] === 0);
90
+ if (mappedFfff || compat) {
91
+ const v4 = `${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`;
92
+ if (isDeniedIpv4(v4)) return true;
93
+ }
94
+ return false;
95
+ }
96
+
97
+ function isDeniedHostLiteral(host: string): boolean {
98
+ // Strip trailing dots (FQDN root) so localhost. / foo.localhost. are caught.
99
+ const lowered = host.toLowerCase().replace(/\.+$/, "");
100
+ if (lowered === "localhost" || lowered.endsWith(".localhost")) return true;
101
+ return isDeniedIpv4(lowered) || isDeniedIpv6(host);
102
+ }
103
+
104
+ /**
105
+ * Synchronous URL policy (scheme, credentials, host literal ranges). Used for
106
+ * the primary endpoint and any redirect/token/discovery URL.
107
+ */
108
+ export function assertUrlAllowed(rawUrl: string, label = "MCP url"): URL {
109
+ let url: URL;
110
+ try {
111
+ url = new URL(rawUrl);
112
+ } catch {
113
+ return fail(`${label} is not a valid URL: ${rawUrl}`);
114
+ }
115
+ if (!ALLOWED_HTTP_SCHEMES.has(url.protocol)) {
116
+ fail(`${label} scheme not allowed (https only for third-party bundles): ${url.protocol}`);
117
+ }
118
+ if (url.username || url.password) {
119
+ fail(`${label} must not embed credentials`);
120
+ }
121
+ if (!url.hostname) {
122
+ fail(`${label} has no host`);
123
+ }
124
+ if (isDeniedHostLiteral(url.hostname)) {
125
+ fail(`${label} resolves to a denied private/loopback/link-local/metadata host: ${url.hostname}`);
126
+ }
127
+ return url;
128
+ }
129
+
130
+ /** Reject headers with control characters / CRLF injection. */
131
+ export function assertHeadersAllowed(headers: Record<string, string> | undefined): void {
132
+ if (!headers) return;
133
+ for (const [key, value] of Object.entries(headers)) {
134
+ if (/[\r\n\0]/.test(key) || /[\r\n\0]/.test(value)) {
135
+ fail(`MCP header contains control characters: ${key}`);
136
+ }
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Runtime DNS check: resolve the host and ensure no resolved address falls in a
142
+ * denied range (covers DNS rebinding when re-run before each connect).
143
+ */
144
+ export async function assertDnsResolvesPublic(hostname: string, label = "MCP host"): Promise<void> {
145
+ let addrs: { address: string; family: number }[];
146
+ try {
147
+ addrs = await lookup(hostname, { all: true });
148
+ } catch {
149
+ fail(`${label} DNS resolution failed for ${hostname}`);
150
+ }
151
+ for (const { address, family } of addrs) {
152
+ const denied = family === 6 ? isDeniedIpv6(address) : isDeniedIpv4(address);
153
+ if (denied) fail(`${label} resolves to a denied address: ${hostname} -> ${address}`);
154
+ }
155
+ }
156
+
157
+ export interface StdioPolicyContext {
158
+ pluginRoot: string;
159
+ }
160
+
161
+ // Node/Bun flags that can execute or load code outside the bundled script.
162
+ const DANGEROUS_LAUNCHER_FLAGS = [
163
+ "-e",
164
+ "--eval",
165
+ "-p",
166
+ "--print",
167
+ "-r",
168
+ "--require",
169
+ "--import",
170
+ "--loader",
171
+ "--experimental-loader",
172
+ "--input-type",
173
+ ];
174
+
175
+ /** stdio launcher/path confinement policy. */
176
+ export function assertStdioAllowed(entry: GjcPluginMcpManifestEntry, ctx: StdioPolicyContext): void {
177
+ const command = entry.command ?? "";
178
+ if (!command) fail(`MCP "${entry.name}": stdio requires a command`);
179
+ const root = path.resolve(ctx.pluginRoot);
180
+ const base = path.basename(command);
181
+ const isBareLauncher = !command.includes("/") && ALLOWED_STDIO_LAUNCHERS.has(base);
182
+ const isRootConfinedExecutable = command.includes("/") && pathIsWithin(root, path.resolve(root, command));
183
+ // Absolute or relative paths must stay inside the plugin root; bare launchers
184
+ // must be in the allowlist. An absolute /bin/node is rejected (outside root).
185
+ if (!isBareLauncher && !isRootConfinedExecutable) {
186
+ fail(`MCP "${entry.name}": stdio command not allowed: ${command}`);
187
+ }
188
+ const usesNodeLauncher = isBareLauncher || ALLOWED_STDIO_LAUNCHERS.has(base);
189
+ const args = entry.args ?? [];
190
+ // Reject code-eval/loader flags for node/bun launchers.
191
+ if (usesNodeLauncher) {
192
+ for (const arg of args) {
193
+ const flag = arg.split("=")[0];
194
+ if (DANGEROUS_LAUNCHER_FLAGS.includes(flag)) {
195
+ fail(`MCP "${entry.name}": stdio launcher flag not allowed: ${arg}`);
196
+ }
197
+ }
198
+ // Require a root-confined script as the first non-flag argument.
199
+ const firstScript = args.find(a => !a.startsWith("-"));
200
+ if (!firstScript) {
201
+ fail(`MCP "${entry.name}": node/bun stdio launcher requires a bundled script argument`);
202
+ }
203
+ if (!pathIsWithin(root, path.resolve(root, firstScript))) {
204
+ fail(`MCP "${entry.name}": stdio script escapes plugin root: ${firstScript}`);
205
+ }
206
+ }
207
+ // cwd must resolve within the plugin root.
208
+ const cwd = entry.cwd ? path.resolve(root, entry.cwd) : root;
209
+ if (!pathIsWithin(root, cwd) && cwd !== root) {
210
+ fail(`MCP "${entry.name}": stdio cwd escapes plugin root: ${entry.cwd}`);
211
+ }
212
+ // File-like args must resolve within the plugin root; reject env-expansion.
213
+ for (const arg of args) {
214
+ if (/\$\{?[A-Za-z_]/.test(arg) || arg.includes("`") || arg.includes("$(")) {
215
+ fail(`MCP "${entry.name}": stdio arg expansion not allowed: ${arg}`);
216
+ }
217
+ if (arg.startsWith("-")) continue;
218
+ if (!arg.startsWith(".") && !arg.includes("/")) continue;
219
+ const resolvedArg = path.resolve(root, arg);
220
+ if (!pathIsWithin(root, resolvedArg)) {
221
+ fail(`MCP "${entry.name}": stdio arg escapes plugin root: ${arg}`);
222
+ }
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Install-time MCP policy (no network required). Validates scheme/host literals
228
+ * and stdio confinement. Runtime connect additionally calls
229
+ * assertDnsResolvesPublic and re-validates redirect/token URLs.
230
+ */
231
+ export function assertMcpInstallPolicy(entry: GjcPluginMcpManifestEntry, ctx: StdioPolicyContext): void {
232
+ if (entry.transport === "stdio") {
233
+ assertStdioAllowed(entry, ctx);
234
+ return;
235
+ }
236
+ if (!entry.url) fail(`MCP "${entry.name}": ${entry.transport} requires a url`);
237
+ assertUrlAllowed(entry.url, `MCP "${entry.name}" url`);
238
+ assertHeadersAllowed(entry.headers);
239
+ }