@gajae-code/coding-agent 0.16.1 → 0.16.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 (146) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/types/cli/args.d.ts +2 -2
  3. package/dist/types/cli/root-flags.d.ts +4 -2
  4. package/dist/types/cli.d.ts +1 -1
  5. package/dist/types/commands/launch.d.ts +1 -1
  6. package/dist/types/config/autorouting-tier-map.d.ts +3 -0
  7. package/dist/types/config/model-registry.d.ts +4 -0
  8. package/dist/types/config/model-resolver.d.ts +9 -0
  9. package/dist/types/config/settings-schema.d.ts +29 -1
  10. package/dist/types/discovery/claude-plugins.d.ts +3 -1
  11. package/dist/types/discovery/helpers.d.ts +2 -0
  12. package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +16 -0
  13. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -0
  14. package/dist/types/extensibility/runtime-skill-discovery.d.ts +1 -1
  15. package/dist/types/extensibility/shared-events.d.ts +1 -1
  16. package/dist/types/gjc-runtime/session-state-lock.d.ts +2 -0
  17. package/dist/types/modes/components/assistant-message.d.ts +1 -2
  18. package/dist/types/modes/components/settings-selector.d.ts +6 -1
  19. package/dist/types/modes/components/status-line/command.d.ts +35 -0
  20. package/dist/types/modes/components/status-line/index.d.ts +1 -0
  21. package/dist/types/modes/components/status-line/types.d.ts +5 -0
  22. package/dist/types/modes/components/tool-status-header.d.ts +14 -0
  23. package/dist/types/modes/controllers/event-controller.d.ts +6 -0
  24. package/dist/types/modes/interactive-mode.d.ts +1 -0
  25. package/dist/types/modes/types.d.ts +1 -0
  26. package/dist/types/runtime-mcp/client.d.ts +9 -1
  27. package/dist/types/runtime-mcp/manager.d.ts +7 -1
  28. package/dist/types/runtime-mcp/pool.d.ts +22 -3
  29. package/dist/types/runtime-mcp/transports/stdio.d.ts +0 -4
  30. package/dist/types/runtime-mcp/types.d.ts +22 -0
  31. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +12 -0
  32. package/dist/types/sdk/bus/slack-live-provider.d.ts +6 -0
  33. package/dist/types/sdk/bus/slack-provider.d.ts +12 -0
  34. package/dist/types/sdk/cli/rows.d.ts +7 -0
  35. package/dist/types/sdk/host/events.d.ts +1 -0
  36. package/dist/types/sdk/host/host.d.ts +2 -0
  37. package/dist/types/sdk/host/session-runtime.d.ts +23 -0
  38. package/dist/types/sdk/session-api-key.d.ts +26 -0
  39. package/dist/types/sdk/transport/serve-cli.d.ts +1 -1
  40. package/dist/types/session/agent-session.d.ts +9 -0
  41. package/dist/types/setup/paseo/announce.d.ts +111 -0
  42. package/dist/types/setup/paseo/setup-deps.d.ts +2 -1
  43. package/dist/types/system-prompt.d.ts +2 -0
  44. package/dist/types/task/executor.d.ts +2 -1
  45. package/dist/types/tools/fetch.d.ts +7 -0
  46. package/dist/types/tools/index.d.ts +8 -2
  47. package/dist/types/tools/path-utils.d.ts +2 -0
  48. package/dist/types/tools/search.d.ts +6 -0
  49. package/dist/types/utils/external-editor.d.ts +5 -0
  50. package/package.json +7 -7
  51. package/scripts/capture-status-line-custom-editor-showcase.ts +151 -0
  52. package/scripts/generate-sdk-operation-inventory.ts +9 -0
  53. package/scripts/verify-status-line-custom-editor-showcase.ts +309 -0
  54. package/src/cli/args.ts +20 -9
  55. package/src/cli/root-flags.ts +5 -3
  56. package/src/config/autorouting-tier-map.ts +1 -0
  57. package/src/config/file-lock.ts +181 -17
  58. package/src/config/model-preset-registry.ts +5 -3
  59. package/src/config/model-profiles.ts +40 -0
  60. package/src/config/model-registry.ts +57 -11
  61. package/src/config/model-resolver.ts +48 -0
  62. package/src/config/settings-schema.ts +33 -1
  63. package/src/config/settings.ts +1 -0
  64. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +11 -11
  65. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +13 -2
  66. package/src/discovery/builtin.ts +13 -4
  67. package/src/discovery/claude-plugins.ts +12 -4
  68. package/src/discovery/helpers.ts +10 -2
  69. package/src/extensibility/gjc-plugins/compiler.ts +208 -58
  70. package/src/extensibility/gjc-plugins/installer.ts +13 -12
  71. package/src/extensibility/gjc-plugins/mcp-policy.ts +81 -52
  72. package/src/extensibility/gjc-plugins/paths.ts +13 -0
  73. package/src/extensibility/gjc-plugins/runtime-adapters.ts +758 -16
  74. package/src/extensibility/runtime-skill-discovery.ts +77 -151
  75. package/src/extensibility/shared-events.ts +1 -1
  76. package/src/extensibility/skills.ts +44 -16
  77. package/src/gjc-runtime/repository-binding.ts +1 -1
  78. package/src/gjc-runtime/session-state-lock.ts +94 -6
  79. package/src/internal-urls/docs-index.generated.ts +10 -10
  80. package/src/main.ts +72 -34
  81. package/src/modes/DESIGN.md +54 -0
  82. package/src/modes/acp/acp-agent.ts +29 -14
  83. package/src/modes/components/assistant-message.ts +22 -9
  84. package/src/modes/components/model-selector.ts +166 -33
  85. package/src/modes/components/settings-selector.ts +1075 -235
  86. package/src/modes/components/status-line/command.ts +190 -0
  87. package/src/modes/components/status-line/index.ts +1 -0
  88. package/src/modes/components/status-line/segments.ts +32 -5
  89. package/src/modes/components/status-line/types.ts +5 -0
  90. package/src/modes/components/tool-status-header.ts +393 -21
  91. package/src/modes/controllers/command-controller.ts +2 -0
  92. package/src/modes/controllers/event-controller.ts +148 -5
  93. package/src/modes/controllers/selector-controller.ts +21 -0
  94. package/src/modes/interactive-mode.ts +47 -1
  95. package/src/modes/types.ts +1 -0
  96. package/src/modes/utils/ui-helpers.ts +1 -0
  97. package/src/prompts/system/system-prompt.md +8 -0
  98. package/src/prompts/tools/bash.md +1 -1
  99. package/src/prompts/tools/job.md +1 -1
  100. package/src/prompts/tools/search.md +2 -0
  101. package/src/runtime-mcp/client.ts +60 -17
  102. package/src/runtime-mcp/manager.ts +171 -14
  103. package/src/runtime-mcp/pool.test.ts +209 -0
  104. package/src/runtime-mcp/pool.ts +224 -23
  105. package/src/runtime-mcp/transports/stdio.ts +158 -24
  106. package/src/runtime-mcp/types.ts +24 -0
  107. package/src/sdk/acp/adapter.ts +1 -1
  108. package/src/sdk/broker/broker.ts +5 -4
  109. package/src/sdk/bus/chat-daemon-control.ts +1 -1
  110. package/src/sdk/bus/chat-daemon-runtime.ts +44 -1
  111. package/src/sdk/bus/slack-daemon.ts +92 -2
  112. package/src/sdk/bus/slack-live-provider.ts +12 -0
  113. package/src/sdk/bus/slack-provider.ts +5 -0
  114. package/src/sdk/cli/rows.ts +47 -2
  115. package/src/sdk/cli/session-cli.ts +47 -23
  116. package/src/sdk/host/events.ts +25 -2
  117. package/src/sdk/host/host.ts +4 -1
  118. package/src/sdk/host/session-runtime.test.ts +36 -25
  119. package/src/sdk/host/session-runtime.ts +279 -43
  120. package/src/sdk/protocol/operation-inventory.generated.json +66 -0
  121. package/src/sdk/router/session-router.ts +60 -37
  122. package/src/sdk/session-api-key.ts +94 -0
  123. package/src/sdk/session.ts +73 -30
  124. package/src/sdk/transport/serve-cli.ts +3 -3
  125. package/src/session/agent-session.ts +123 -38
  126. package/src/session/contribution-prep.ts +214 -21
  127. package/src/setup/paseo/announce.ts +450 -0
  128. package/src/setup/paseo/setup-deps.ts +15 -3
  129. package/src/slash-commands/builtin-registry.ts +1 -0
  130. package/src/system-prompt.ts +4 -0
  131. package/src/task/executor.ts +2 -1
  132. package/src/task/index.ts +17 -4
  133. package/src/tools/bash.ts +50 -21
  134. package/src/tools/cron.ts +1 -1
  135. package/src/tools/fetch.ts +29 -1
  136. package/src/tools/index.ts +8 -2
  137. package/src/tools/job.ts +8 -2
  138. package/src/tools/monitor.ts +8 -2
  139. package/src/tools/path-utils.ts +7 -0
  140. package/src/tools/search.ts +116 -58
  141. package/src/tools/skill.ts +22 -2
  142. package/src/tools/subagent.ts +3 -1
  143. package/src/tools/tool-catalog.generated.ts +9 -2
  144. package/src/utils/external-editor.ts +16 -2
  145. package/src/web/insane/bridge.ts +113 -23
  146. package/src/web/search/providers/perplexity.ts +102 -41
@@ -7,7 +7,12 @@ import { Effort } from '@gajae-code/ai/model-thinking';
7
7
  import type { OAuthCredentials, OAuthLoginCallbacks } from '@gajae-code/ai/utils/oauth/types';
8
8
  import { loginXai, refreshXaiToken, XAI_OAUTH_SCOPE } from '@gajae-code/ai/utils/oauth/xai';
9
9
  import type { ExtensionAPI, ProviderConfig } from '@gajae-code/coding-agent';
10
- import { type GrokCliModelConfig, resolveModels } from '../models/catalog.js';
10
+ import {
11
+ type GrokCliModelConfig,
12
+ getMaxReasoningEffort,
13
+ resolveModels,
14
+ supportsReasoningEffort,
15
+ } from '../models/catalog.js';
11
16
  import { sanitizePayload } from '../payload/sanitize.js';
12
17
  import { getBaseUrl, isGrokBuildBaseUrlOverrideIgnored } from '../shared/base-url.js';
13
18
  import { streamGrokCli } from './stream.js';
@@ -38,7 +43,7 @@ export default function registerGrokCli(api: ExtensionAPI) {
38
43
  thinking: m.reasoning
39
44
  ? {
40
45
  minLevel: Effort.Low,
41
- maxLevel: m.maxReasoningEffort ?? Effort.XHigh,
46
+ maxLevel: getMaxReasoningEffort(m.id) ?? Effort.XHigh,
42
47
  mode: 'effort',
43
48
  }
44
49
  : undefined,
@@ -46,6 +51,12 @@ export default function registerGrokCli(api: ExtensionAPI) {
46
51
  cost: m.cost,
47
52
  contextWindow: m.contextWindow,
48
53
  maxTokens: m.maxTokens,
54
+ // cli-chat-proxy is not a generic audited OpenAI origin. Carry only the
55
+ // catalog's model-scoped evidence into core reasoning request gating.
56
+ compat:
57
+ m.reasoning && supportsReasoningEffort(m.id)
58
+ ? { supportsReasoningEffort: true }
59
+ : undefined,
49
60
  })),
50
61
  oauth: {
51
62
  name: 'Grok Build',
@@ -321,7 +321,9 @@ registerProvider<SystemPrompt>(systemPromptCapability.id, {
321
321
  // Skills
322
322
  async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
323
323
  // Walk up from cwd finding .gjc/skills/ in ancestors (closest first)
324
- const ancestors = getAncestorDirs(ctx.cwd, ctx.repoRoot ?? ctx.home);
324
+ const ancestors = getAncestorDirs(ctx.cwd, ctx.repoRoot ?? ctx.home).filter(
325
+ ({ dir }) => path.resolve(dir) !== path.resolve(ctx.home),
326
+ );
325
327
  const projectScans = ancestors.flatMap(({ dir }) =>
326
328
  getProjectConfigDirs().map(projectConfigDir =>
327
329
  scanSkillsFromDir(ctx, {
@@ -334,14 +336,21 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
334
336
  );
335
337
 
336
338
  // User-level scan from the active agent-directory profile.
337
- const userScans = [
339
+ const userSkillDirs = [
340
+ ...new Set([
341
+ path.join(resolveUserAgentDir(ctx), "skills"),
342
+ path.join(ctx.home, PATHS.userBase, "skills"),
343
+ path.join(ctx.home, ".gjc", "skills"),
344
+ ]),
345
+ ];
346
+ const userScans = userSkillDirs.map(dir =>
338
347
  scanSkillsFromDir(ctx, {
339
- dir: path.join(resolveUserAgentDir(ctx), "skills"),
348
+ dir,
340
349
  providerId: PROVIDER_ID,
341
350
  level: "user",
342
351
  requireDescription: true,
343
352
  }),
344
- ];
353
+ );
345
354
 
346
355
  const results = await Promise.all([...projectScans, ...userScans]);
347
356
 
@@ -17,6 +17,7 @@ import { rootContainsGjcManifest } from "../extensibility/gjc-plugins";
17
17
  import {
18
18
  type ClaudePluginRoot,
19
19
  createSourceMeta,
20
+ invalidateClaudePluginRoots,
20
21
  listClaudePluginRoots,
21
22
  loadFilesFromDir,
22
23
  scanSkillsFromDir,
@@ -95,12 +96,15 @@ async function resolvePluginDir(
95
96
  async function listNonGjcPluginRoots(
96
97
  home: string,
97
98
  cwd: string,
99
+ allowedLevels: ReadonlySet<"user" | "project"> = new Set(["user", "project"]),
98
100
  ): Promise<{ roots: ClaudePluginRoot[]; warnings: string[] }> {
101
+ await invalidateClaudePluginRoots(home, cwd);
99
102
  const { roots, warnings } = await listClaudePluginRoots(home, cwd);
100
103
  const filteredRoots: ClaudePluginRoot[] = [];
101
- const filteredWarnings = [...warnings];
104
+ const filteredWarnings = allowedLevels.size === 2 ? [...warnings] : [];
102
105
 
103
106
  for (const root of roots) {
107
+ if (!allowedLevels.has(root.scope)) continue;
104
108
  if (await rootContainsGjcManifest(root.path)) {
105
109
  filteredWarnings.push(`[claude-plugins] Skipping gajae-code plugin root (binding-only): ${root.path}`);
106
110
  continue;
@@ -115,11 +119,14 @@ async function listNonGjcPluginRoots(
115
119
  // Skills
116
120
  // =============================================================================
117
121
 
118
- async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
122
+ export async function loadMarketplaceSkills(
123
+ ctx: LoadContext,
124
+ allowedLevels: ReadonlySet<"user" | "project"> = new Set(["user", "project"]),
125
+ ): Promise<LoadResult<Skill>> {
119
126
  const items: Skill[] = [];
120
127
  const warnings: string[] = [];
121
128
 
122
- const { roots, warnings: rootWarnings } = await listNonGjcPluginRoots(ctx.home, ctx.cwd);
129
+ const { roots, warnings: rootWarnings } = await listNonGjcPluginRoots(ctx.home, ctx.cwd, allowedLevels);
123
130
  warnings.push(...rootWarnings);
124
131
 
125
132
  const results = await Promise.all(
@@ -129,6 +136,7 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
129
136
  dir: skillsDir,
130
137
  providerId: PROVIDER_ID,
131
138
  level: root.scope,
139
+ requireDescription: true,
132
140
  });
133
141
  return { root, result, warning };
134
142
  }),
@@ -371,7 +379,7 @@ registerProvider<Skill>(skillCapability.id, {
371
379
  displayName: DISPLAY_NAME,
372
380
  description: "Load skills from GJC marketplace plugins",
373
381
  priority: PRIORITY,
374
- load: loadSkills,
382
+ load: loadMarketplaceSkills,
375
383
  });
376
384
 
377
385
  registerProvider<SlashCommand>(slashCommandCapability.id, {
@@ -852,6 +852,14 @@ export async function resolveOrDefaultProjectRegistryPath(cwd: string): Promise<
852
852
 
853
853
  const pluginRootsCache = new Map<string, { roots: ClaudePluginRoot[]; warnings: string[] }>();
854
854
 
855
+ /** Invalidate the exact user/project registry pair and their resolved-root cache entry. */
856
+ export async function invalidateClaudePluginRoots(home: string, cwd?: string): Promise<void> {
857
+ const resolvedProjectPath = cwd ? await resolveActiveProjectRegistryPath(cwd) : null;
858
+ invalidateFsCache(path.join(getPluginsDir(home), "installed_plugins.json"));
859
+ if (resolvedProjectPath) invalidateFsCache(resolvedProjectPath);
860
+ pluginRootsCache.delete(`${home}:${resolvedProjectPath ?? ""}`);
861
+ }
862
+
855
863
  /**
856
864
  * List installed GJC plugin roots from the GJC plugin registry and, when present,
857
865
  * the nearest project-scoped registry resolved from `cwd`.
@@ -885,7 +893,7 @@ export async function listClaudePluginRoots(
885
893
  if (!Array.isArray(entries) || entries.length === 0) continue;
886
894
 
887
895
  const atIndex = pluginId.lastIndexOf("@");
888
- if (atIndex === -1) {
896
+ if (atIndex <= 0 || atIndex === pluginId.length - 1) {
889
897
  warnings.push(`Invalid plugin ID format (missing @marketplace): ${pluginId}`);
890
898
  continue;
891
899
  }
@@ -927,7 +935,7 @@ export async function listClaudePluginRoots(
927
935
  for (const [pluginId, entries] of Object.entries(projectRegistry.plugins)) {
928
936
  if (!Array.isArray(entries) || entries.length === 0) continue;
929
937
  const atIndex = pluginId.lastIndexOf("@");
930
- if (atIndex === -1) {
938
+ if (atIndex <= 0 || atIndex === pluginId.length - 1) {
931
939
  warnings.push(`Invalid plugin ID format (missing @marketplace): ${pluginId}`);
932
940
  continue;
933
941
  }
@@ -1,8 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
+ import * as nodeFs from "node:fs";
2
3
  import * as fs from "node:fs/promises";
3
4
  import * as path from "node:path";
4
5
  import { parseFrontmatter, pathIsWithin } from "@gajae-code/utils";
5
- import { readSchemaDeclaration, schemaHash } from "./metadata";
6
+ import { classifyStdioInvocation } from "./mcp-policy";
7
+ import { canonicalizeJsonSchema, extractDeclaredToolSchema, schemaHash } from "./metadata";
6
8
  import { resolveWithinRoot } from "./paths";
7
9
  import { parseManifest, parseSubskillFrontmatter } from "./schema";
8
10
  import {
@@ -10,6 +12,7 @@ import {
10
12
  type GjcPluginAppendixManifestEntry,
11
13
  GjcPluginLoadError,
12
14
  type GjcPluginMcpManifestEntry,
15
+ type JsonSchema202012,
13
16
  type NormalizedAgentAppendixSurface,
14
17
  type NormalizedAppendixSurface,
15
18
  type NormalizedGjcPluginBundle,
@@ -22,6 +25,101 @@ import {
22
25
  } from "./types";
23
26
  import { validateBinding } from "./validation";
24
27
 
28
+ const PLUGIN_FILE_MAX_BYTES = 16 * 1024 * 1024;
29
+ const PLUGIN_MANIFEST_MAX_BYTES = PLUGIN_FILE_MAX_BYTES;
30
+ const PLUGIN_FILE_OPEN_FLAGS =
31
+ nodeFs.constants.O_RDONLY | (process.platform === "win32" ? 0 : (nodeFs.constants.O_NOFOLLOW ?? 0));
32
+
33
+ interface FileIdentity {
34
+ dev: bigint;
35
+ ino: bigint;
36
+ }
37
+
38
+ interface FileAuthority {
39
+ path: string;
40
+ realPath: string;
41
+ identity: FileIdentity;
42
+ }
43
+
44
+ interface ManifestSnapshot {
45
+ bytes: Buffer;
46
+ json: unknown;
47
+ }
48
+
49
+ function sameIdentity(left: FileIdentity, right: FileIdentity): boolean {
50
+ return left.dev === right.dev && left.ino === right.ino;
51
+ }
52
+
53
+ function errorCode(error: unknown): string | undefined {
54
+ return typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
55
+ ? error.code
56
+ : undefined;
57
+ }
58
+
59
+ function tooLarge(rel: string, maxBytes: number): GjcPluginLoadError {
60
+ return new GjcPluginLoadError("security_policy", `GJC plugin file exceeds ${maxBytes} bytes: ${rel}`);
61
+ }
62
+
63
+ async function readBoundedFile(file: FileAuthority, rel: string, maxBytes: number): Promise<Buffer> {
64
+ let handle: fs.FileHandle;
65
+ try {
66
+ handle = await fs.open(file.path, PLUGIN_FILE_OPEN_FLAGS);
67
+ } catch (error) {
68
+ if (errorCode(error) === "ELOOP") {
69
+ throw new GjcPluginLoadError("security_policy", `GJC plugin path became a symlink before opening: ${rel}`, {
70
+ cause: error instanceof Error ? error : undefined,
71
+ });
72
+ }
73
+ throw error;
74
+ }
75
+ try {
76
+ const before = await handle.stat({ bigint: true });
77
+ if (!before.isFile()) {
78
+ throw new GjcPluginLoadError("security_policy", `GJC plugin path is not a regular file: ${rel}`);
79
+ }
80
+ if (!sameIdentity(file.identity, before)) {
81
+ throw new GjcPluginLoadError("security_policy", `GJC plugin file changed before opening: ${rel}`);
82
+ }
83
+ const [settledRealPath, settledPath] = await Promise.all([
84
+ fs.realpath(file.path),
85
+ fs.lstat(file.path, { bigint: true }),
86
+ ]);
87
+ if (
88
+ settledRealPath !== file.realPath ||
89
+ settledPath.isSymbolicLink() ||
90
+ !settledPath.isFile() ||
91
+ !sameIdentity(settledPath, before)
92
+ ) {
93
+ throw new GjcPluginLoadError("security_policy", `GJC plugin file changed before reading: ${rel}`);
94
+ }
95
+ if (before.size > BigInt(maxBytes)) throw tooLarge(rel, maxBytes);
96
+ const chunks: Buffer[] = [];
97
+ let offset = 0;
98
+ for (;;) {
99
+ const remaining = maxBytes + 1 - offset;
100
+ if (remaining <= 0) throw tooLarge(rel, maxBytes);
101
+ const chunk = Buffer.allocUnsafe(Math.min(1024 * 1024, remaining));
102
+ const { bytesRead } = await handle.read(chunk, 0, chunk.byteLength, offset);
103
+ if (bytesRead === 0) break;
104
+ chunks.push(chunk.subarray(0, bytesRead));
105
+ offset += bytesRead;
106
+ }
107
+ const after = await handle.stat({ bigint: true });
108
+ if (
109
+ !sameIdentity(before, after) ||
110
+ before.size !== after.size ||
111
+ before.mtimeNs !== after.mtimeNs ||
112
+ before.ctimeNs !== after.ctimeNs ||
113
+ BigInt(offset) !== after.size
114
+ ) {
115
+ throw new GjcPluginLoadError("security_policy", `GJC plugin file changed while reading: ${rel}`);
116
+ }
117
+ return Buffer.concat(chunks, offset);
118
+ } finally {
119
+ await handle.close();
120
+ }
121
+ }
122
+
25
123
  function sha256(bytes: Buffer | string): string {
26
124
  return createHash("sha256").update(bytes).digest("hex");
27
125
  }
@@ -43,17 +141,19 @@ export const surfaceIds = {
43
141
  `subskill-tool:${parent}:${phase}:${activationArg}:${relativePath}`,
44
142
  } as const;
45
143
 
46
- async function readManifestJson(filePath: string): Promise<unknown> {
47
- let text: string;
144
+ async function readManifestSnapshot(pluginRoot: string, filePath: string): Promise<ManifestSnapshot> {
145
+ let bytes: Buffer;
48
146
  try {
49
- text = await fs.readFile(filePath, "utf8");
147
+ const file = await resolveDeclaredFile(pluginRoot, GJC_PLUGIN_MANIFEST_FILENAME);
148
+ bytes = await readBoundedFile(file, GJC_PLUGIN_MANIFEST_FILENAME, PLUGIN_MANIFEST_MAX_BYTES);
50
149
  } catch (error) {
150
+ if (error instanceof GjcPluginLoadError && error.code !== "missing_file") throw error;
51
151
  throw new GjcPluginLoadError("missing_file", `Missing GJC plugin manifest at ${filePath}`, {
52
152
  cause: error instanceof Error ? error : undefined,
53
153
  });
54
154
  }
55
155
  try {
56
- return JSON.parse(text) as unknown;
156
+ return { bytes, json: JSON.parse(bytes.toString("utf8")) as unknown };
57
157
  } catch (error) {
58
158
  throw new GjcPluginLoadError("invalid_manifest", `Invalid GJC plugin manifest JSON at ${filePath}`, {
59
159
  cause: error instanceof Error ? error : undefined,
@@ -61,37 +161,64 @@ async function readManifestJson(filePath: string): Promise<unknown> {
61
161
  }
62
162
  }
63
163
 
164
+ async function resolveDeclaredDirectory(pluginRoot: string, rel: string): Promise<string> {
165
+ const resolved = resolveWithinRoot(pluginRoot, rel || ".");
166
+ let stat: Awaited<ReturnType<typeof fs.lstat>>;
167
+ try {
168
+ stat = await fs.lstat(resolved);
169
+ } catch (error) {
170
+ throw new GjcPluginLoadError("missing_file", `Missing GJC plugin directory at ${resolved}`, {
171
+ cause: error instanceof Error ? error : undefined,
172
+ });
173
+ }
174
+ if (!stat.isDirectory()) {
175
+ throw new GjcPluginLoadError("security_policy", `GJC plugin cwd must be a real directory: ${rel || "."}`);
176
+ }
177
+ const [realRoot, real] = await Promise.all([fs.realpath(pluginRoot), fs.realpath(resolved)]);
178
+ if (real !== realRoot && !pathIsWithin(realRoot, real)) {
179
+ throw new GjcPluginLoadError("security_policy", `GJC plugin cwd escapes root via symlink: ${rel || "."}`);
180
+ }
181
+ return resolved;
182
+ }
183
+
64
184
  /**
65
185
  * Resolve a declared relative path, rejecting lexical escapes AND symlink
66
186
  * escapes out of the plugin root. Never imports the file.
67
187
  */
68
- async function resolveDeclaredFile(pluginRoot: string, rel: string): Promise<string> {
188
+ async function resolveDeclaredFile(pluginRoot: string, rel: string): Promise<FileAuthority> {
69
189
  const resolved = resolveWithinRoot(pluginRoot, rel);
70
- let real: string;
190
+ let realRoot: string;
191
+ let realPath: string;
192
+ let stat: nodeFs.BigIntStats;
71
193
  try {
72
- real = await fs.realpath(resolved);
194
+ realRoot = await fs.realpath(pluginRoot);
195
+ realPath = await fs.realpath(resolved);
196
+ stat = await fs.lstat(resolved, { bigint: true });
73
197
  } catch (error) {
74
198
  throw new GjcPluginLoadError("missing_file", `Missing GJC plugin file at ${resolved}`, {
75
199
  cause: error instanceof Error ? error : undefined,
76
200
  });
77
201
  }
78
- const realRoot = await fs.realpath(pluginRoot);
79
- if (!pathIsWithin(realRoot, real)) {
202
+ if (!pathIsWithin(realRoot, realPath)) {
80
203
  throw new GjcPluginLoadError("security_policy", `GJC plugin file escapes root via symlink: ${rel}`);
81
204
  }
82
- return resolved;
205
+ if (stat.isSymbolicLink() || !stat.isFile()) {
206
+ throw new GjcPluginLoadError("security_policy", `GJC plugin path must be a real file: ${rel}`);
207
+ }
208
+ return { path: resolved, realPath, identity: stat };
83
209
  }
84
210
 
85
211
  async function hashFile(
86
- absPath: string,
212
+ file: FileAuthority,
87
213
  rel: string,
88
214
  declaredSha?: string,
89
- ): Promise<{ sha256: string; bytes: number }> {
215
+ ): Promise<{ sha256: string; bytes: number; content: Buffer }> {
90
216
  let buf: Buffer;
91
217
  try {
92
- buf = await fs.readFile(absPath);
218
+ buf = await readBoundedFile(file, rel, PLUGIN_FILE_MAX_BYTES);
93
219
  } catch (error) {
94
- throw new GjcPluginLoadError("missing_file", `Missing GJC plugin file at ${absPath}`, {
220
+ if (error instanceof GjcPluginLoadError) throw error;
221
+ throw new GjcPluginLoadError("missing_file", `Missing GJC plugin file at ${file.path}`, {
95
222
  cause: error instanceof Error ? error : undefined,
96
223
  });
97
224
  }
@@ -99,7 +226,38 @@ async function hashFile(
99
226
  if (declaredSha !== undefined && declaredSha.toLowerCase() !== digest) {
100
227
  throw new GjcPluginLoadError("hash_mismatch", `GJC plugin file hash mismatch for ${rel}`);
101
228
  }
102
- return { sha256: digest, bytes: buf.byteLength };
229
+ return { sha256: digest, bytes: buf.byteLength, content: buf };
230
+ }
231
+
232
+ function schemaFromSnapshots(
233
+ declaration: unknown,
234
+ sourceBytes: Buffer,
235
+ schemaFile?: { relativePath: string; bytes: Buffer },
236
+ ): JsonSchema202012 {
237
+ if (schemaFile !== undefined) {
238
+ try {
239
+ return canonicalizeJsonSchema(JSON.parse(schemaFile.bytes.toString("utf8")) as unknown);
240
+ } catch (error) {
241
+ if (error instanceof GjcPluginLoadError) throw error;
242
+ throw new GjcPluginLoadError(
243
+ "invalid_schema",
244
+ `Invalid JSON Schema declaration at ${schemaFile.relativePath}`,
245
+ {
246
+ cause: error instanceof Error ? error : undefined,
247
+ },
248
+ );
249
+ }
250
+ }
251
+ if (declaration !== undefined) return canonicalizeJsonSchema(declaration);
252
+ try {
253
+ return extractDeclaredToolSchema(sourceBytes.toString("utf8"));
254
+ } catch (error) {
255
+ if (error instanceof GjcPluginLoadError) throw error;
256
+ throw new GjcPluginLoadError(
257
+ "invalid_schema",
258
+ `Tool parameters schema is not statically readable: ${error instanceof Error ? error.message : String(error)}`,
259
+ );
260
+ }
103
261
  }
104
262
 
105
263
  function mcpConfigHash(entry: GjcPluginMcpManifestEntry): string {
@@ -141,8 +299,8 @@ async function compileAppendix(
141
299
  return { contentHash: digest, bytes: Buffer.byteLength(content), content };
142
300
  }
143
301
  const rel = entry.path as string;
144
- const abs = await resolveDeclaredFile(pluginRoot, rel);
145
- const { sha256: digest, bytes } = await hashFile(abs, rel, entry.sha256);
302
+ const file = await resolveDeclaredFile(pluginRoot, rel);
303
+ const { sha256: digest, bytes } = await hashFile(file, rel, entry.sha256);
146
304
  if (bytes === 0) {
147
305
  throw new GjcPluginLoadError("invalid_appendix", `Invalid GJC plugin ${field}: file is empty`);
148
306
  }
@@ -158,40 +316,34 @@ async function compileAppendix(
158
316
  export async function compileGjcPluginBundle(root: string): Promise<NormalizedGjcPluginBundle> {
159
317
  const pluginRoot = path.resolve(root);
160
318
  const manifestPath = path.join(pluginRoot, GJC_PLUGIN_MANIFEST_FILENAME);
161
- const manifest = parseManifest(await readManifestJson(manifestPath), manifestPath);
319
+ const manifestSnapshot = await readManifestSnapshot(pluginRoot, manifestPath);
320
+ const manifest = parseManifest(manifestSnapshot.json, manifestPath);
162
321
 
163
322
  const files = new Map<string, { sha256: string; bytes: number }>();
164
323
  const manifestSubskillTools = manifest.tools.filter(tool => tool.surface === "subskill");
165
324
  const manifestSubskillFiles = new Map<string, { name: string; sha256: string }>();
166
325
  for (const tool of manifestSubskillTools) {
167
- const abs = await resolveDeclaredFile(pluginRoot, tool.path);
168
- const { sha256: digest, bytes } = await hashFile(abs, tool.path, tool.sha256);
326
+ const file = await resolveDeclaredFile(pluginRoot, tool.path);
327
+ const { sha256: digest, bytes } = await hashFile(file, tool.path, tool.sha256);
169
328
  files.set(tool.path, { sha256: digest, bytes });
170
329
  manifestSubskillFiles.set(tool.path, { name: tool.name, sha256: digest });
171
330
  }
172
331
 
173
332
  const subskills: NormalizedSubskillSurface[] = [];
174
333
  for (const rel of manifest.subskills) {
175
- const abs = await resolveDeclaredFile(pluginRoot, rel);
176
- const { sha256: digest, bytes } = await hashFile(abs, rel);
334
+ const file = await resolveDeclaredFile(pluginRoot, rel);
335
+ const { sha256: digest, bytes, content: contentBytes } = await hashFile(file, rel);
177
336
  files.set(rel, { sha256: digest, bytes });
178
- let content: string;
179
- try {
180
- content = await fs.readFile(abs, "utf8");
181
- } catch (error) {
182
- throw new GjcPluginLoadError("missing_file", `Missing GJC sub-skill file at ${abs}`, {
183
- cause: error instanceof Error ? error : undefined,
184
- });
185
- }
337
+ const content = contentBytes.toString("utf8");
186
338
  let parsed: { frontmatter: Record<string, unknown>; body: string };
187
339
  try {
188
- parsed = parseFrontmatter(content, { source: abs, level: "fatal" });
340
+ parsed = parseFrontmatter(content, { source: file.path, level: "fatal" });
189
341
  } catch (error) {
190
- throw new GjcPluginLoadError("invalid_frontmatter", `Invalid GJC sub-skill frontmatter at ${abs}`, {
342
+ throw new GjcPluginLoadError("invalid_frontmatter", `Invalid GJC sub-skill frontmatter at ${file.path}`, {
191
343
  cause: error instanceof Error ? error : undefined,
192
344
  });
193
345
  }
194
- const fm = parseSubskillFrontmatter(parsed.frontmatter, abs);
346
+ const fm = parseSubskillFrontmatter(parsed.frontmatter, file.path);
195
347
  validateBinding(fm);
196
348
  // Subskill-scoped frontmatter tools are hashed for copy-ownership and
197
349
  // escape checks (the loader resolves these at runtime).
@@ -212,8 +364,8 @@ export async function compileGjcPluginBundle(root: string): Promise<NormalizedGj
212
364
  }
213
365
  for (const toolRel of fmToolPaths) {
214
366
  if (toolRel.trim().length === 0) continue;
215
- const toolAbs = await resolveDeclaredFile(pluginRoot, toolRel);
216
- const { sha256: toolDigest, bytes: toolBytes } = await hashFile(toolAbs, toolRel);
367
+ const toolFile = await resolveDeclaredFile(pluginRoot, toolRel);
368
+ const { sha256: toolDigest, bytes: toolBytes } = await hashFile(toolFile, toolRel);
217
369
  files.set(toolRel, { sha256: toolDigest, bytes: toolBytes });
218
370
  const extensionId = surfaceIds.subskillTool(fm.binds_to, fm.phase, fm.activation_arg, toolRel);
219
371
  if (seenToolRefs.has(extensionId)) continue;
@@ -238,18 +390,18 @@ export async function compileGjcPluginBundle(root: string): Promise<NormalizedGj
238
390
  // surface; legacy string shorthand stays subskill-scoped (loader-handled).
239
391
  const tools: NormalizedToolSurface[] = [];
240
392
  for (const tool of manifest.tools) {
241
- const abs = await resolveDeclaredFile(pluginRoot, tool.path);
242
- const { sha256: digest, bytes } = await hashFile(abs, tool.path, tool.sha256);
393
+ const file = await resolveDeclaredFile(pluginRoot, tool.path);
394
+ const { sha256: digest, bytes, content: sourceBytes } = await hashFile(file, tool.path, tool.sha256);
243
395
  files.set(tool.path, { sha256: digest, bytes });
244
396
  if (tool.surface !== "always-on") continue;
245
- let schemaPath: string | undefined;
397
+ let schemaFileSnapshot: { relativePath: string; bytes: Buffer } | undefined;
246
398
  if (tool.schemaPath !== undefined) {
247
- const schemaAbs = await resolveDeclaredFile(pluginRoot, tool.schemaPath);
248
- const schemaFile = await hashFile(schemaAbs, tool.schemaPath);
249
- files.set(tool.schemaPath, schemaFile);
250
- schemaPath = tool.schemaPath;
399
+ const schemaFile = await resolveDeclaredFile(pluginRoot, tool.schemaPath);
400
+ const { sha256: schemaDigest, bytes: schemaBytes, content } = await hashFile(schemaFile, tool.schemaPath);
401
+ files.set(tool.schemaPath, { sha256: schemaDigest, bytes: schemaBytes });
402
+ schemaFileSnapshot = { relativePath: tool.schemaPath, bytes: content };
251
403
  }
252
- const schema = await readSchemaDeclaration(pluginRoot, abs, tool.schema, schemaPath);
404
+ const schema = schemaFromSnapshots(tool.schema, sourceBytes, schemaFileSnapshot);
253
405
  tools.push({
254
406
  extensionId: surfaceIds.tool(tool.name),
255
407
  name: tool.name,
@@ -267,8 +419,8 @@ export async function compileGjcPluginBundle(root: string): Promise<NormalizedGj
267
419
  for (const hook of manifest.hooks) {
268
420
  // Path safety first: resolve/hash before semantic checks so traversal and
269
421
  // missing-file failures take precedence over contract validation.
270
- const abs = await resolveDeclaredFile(pluginRoot, hook.path);
271
- const { sha256: digest, bytes } = await hashFile(abs, hook.path, hook.sha256);
422
+ const file = await resolveDeclaredFile(pluginRoot, hook.path);
423
+ const { sha256: digest, bytes } = await hashFile(file, hook.path, hook.sha256);
272
424
  files.set(hook.path, { sha256: digest, bytes });
273
425
  // Minimal compile-time hook contract: tool_call hooks must name a target
274
426
  // and a before/after phase so the constrained runner (M3/M4) can bind them.
@@ -320,17 +472,15 @@ export async function compileGjcPluginBundle(root: string): Promise<NormalizedGj
320
472
  `GJC plugin MCP "${entry.name}": ${entry.transport} requires a url`,
321
473
  );
322
474
  }
323
- // Hash bundled stdio script args (relative file paths) so the registry owns
324
- // their copied-file boundary. Path/security failures must propagate, not be
325
- // swallowed, so traversal/symlink-escape/missing bundled files fail compile.
326
- for (const arg of entry.args ?? []) {
327
- // Skip flags (e.g. "--port", "-v"); only treat clearly path-like args as
328
- // bundled files subject to root confinement.
329
- if (arg.startsWith("-")) continue;
330
- if (!arg.startsWith(".") && !arg.includes("/")) continue;
331
- const abs = await resolveDeclaredFile(pluginRoot, arg);
332
- const { sha256: digest, bytes } = await hashFile(abs, arg, undefined);
333
- files.set(arg, { sha256: digest, bytes });
475
+ // Derive ownership from the same cwd-aware bare Node/Bun grammar used at runtime.
476
+ if (entry.transport === "stdio") {
477
+ const invocation = classifyStdioInvocation(entry, { pluginRoot });
478
+ await resolveDeclaredDirectory(pluginRoot, path.relative(pluginRoot, invocation.cwd));
479
+ for (const relativePath of new Set(invocation.ownedRelativePaths)) {
480
+ const file = await resolveDeclaredFile(pluginRoot, relativePath);
481
+ const { sha256: digest, bytes } = await hashFile(file, relativePath, undefined);
482
+ files.set(relativePath, { sha256: digest, bytes });
483
+ }
334
484
  }
335
485
  mcps.push({
336
486
  extensionId: surfaceIds.mcp(entry.name),
@@ -377,7 +527,7 @@ export async function compileGjcPluginBundle(root: string): Promise<NormalizedGj
377
527
  agentAppendices,
378
528
  };
379
529
 
380
- const manifestBytes = await fs.readFile(manifestPath);
530
+ const manifestBytes = manifestSnapshot.bytes;
381
531
  const manifestHash = sha256(manifestBytes);
382
532
 
383
533
  const copiedFiles = [...files.entries()]
@@ -5,7 +5,8 @@ import * as os from "node:os";
5
5
  import * as path from "node:path";
6
6
  import { gunzipSync } from "node:zlib";
7
7
  import { compileGjcPluginBundle } from "./compiler";
8
- import { gjcPluginProjectRoot, gjcPluginUserRoot } from "./paths";
8
+ import { classifyStdioInvocation } from "./mcp-policy";
9
+ import { gjcPluginInstallRoot, gjcPluginProjectRoot, gjcPluginSafeDirectoryName, gjcPluginUserRoot } from "./paths";
9
10
  import { readRegistry, sortRegistryEntries, withRegistryLock, writeRegistryUnlocked } from "./registry";
10
11
  import {
11
12
  GJC_PLUGIN_MANIFEST_FILENAME,
@@ -64,14 +65,6 @@ function scopeRoot(scope: GjcPluginScope, cwd: string): string {
64
65
  return scope === "user" ? gjcPluginUserRoot() : gjcPluginProjectRoot(cwd);
65
66
  }
66
67
 
67
- function safeDirSegment(name: string): string {
68
- const seg = name.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/^-+|-+$/g, "");
69
- if (!seg || seg === "." || seg === "..") {
70
- throw new GjcPluginLoadError("invalid_manifest", `GJC plugin name is not a safe directory segment: ${name}`);
71
- }
72
- return seg;
73
- }
74
-
75
68
  async function isDirectory(p: string): Promise<boolean> {
76
69
  try {
77
70
  return (await fs.stat(p)).isDirectory();
@@ -392,6 +385,14 @@ function sha256(buf: Buffer): string {
392
385
  * copied, so the installed tree equals the validated set.
393
386
  */
394
387
  async function copyValidatedFiles(bundle: NormalizedGjcPluginBundle, stagingDir: string): Promise<void> {
388
+ const cwdPaths = new Set(
389
+ bundle.surfaces.mcps.flatMap(surface => {
390
+ if (surface.transport !== "stdio") return [];
391
+ const invocation = classifyStdioInvocation(surface.config, { pluginRoot: bundle.root });
392
+ return [path.relative(bundle.root, invocation.cwd)];
393
+ }),
394
+ );
395
+ for (const cwdPath of cwdPaths) await fs.mkdir(path.join(stagingDir, cwdPath), { recursive: true });
395
396
  for (const file of bundle.files) {
396
397
  const src = path.join(bundle.root, file.relativePath);
397
398
  const lst = await fs.lstat(src);
@@ -438,9 +439,9 @@ export async function runGjcBundleTransaction(
438
439
  // lock roots or sweep installation remnants.
439
440
  const bundle = await compileGjcPluginBundle(resolved.dir);
440
441
  validateInstallPlan(bundle, []);
441
- const dirName = safeDirSegment(bundle.name);
442
+ const dirName = gjcPluginSafeDirectoryName(bundle.name);
442
443
  const root = scopeRoot(options.scope, options.cwd);
443
- const finalDir = path.join(root, dirName);
444
+ const finalDir = gjcPluginInstallRoot(options.scope, options.cwd, bundle.name);
444
445
 
445
446
  // Lock-free refusal preflight. Acquiring a scope lock creates the scope
446
447
  // root and mutates directory metadata, so a create-only refusal must be
@@ -578,7 +579,7 @@ export function candidateRegistryEntry(
578
579
  source: GjcPluginRegistrySource,
579
580
  now: string,
580
581
  ): GjcPluginRegistryEntry {
581
- const finalDir = path.join(scopeRoot(scope, cwd), safeDirSegment(bundle.name));
582
+ const finalDir = gjcPluginInstallRoot(scope, cwd, bundle.name);
582
583
  return bundleToRegistryEntry(bundle, finalDir, scope, source, now);
583
584
  }
584
585