@cruxy/cli 0.20.0 → 0.22.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.
Files changed (63) hide show
  1. package/dist/approval/classify.js +24 -0
  2. package/dist/approval/policy.js +7 -0
  3. package/dist/approval/prompt.js +7 -0
  4. package/dist/approval/types.d.ts +6 -0
  5. package/dist/brand/voice.d.ts +1 -1
  6. package/dist/brand/voice.js +1 -1
  7. package/dist/cli/commands/mcp.d.ts +9 -0
  8. package/dist/cli/commands/mcp.js +87 -0
  9. package/dist/cli/commands/run.js +22 -5
  10. package/dist/cli/program.js +2 -0
  11. package/dist/cli/session-factory.d.ts +2 -2
  12. package/dist/cli/session-factory.js +20 -2
  13. package/dist/config/schema.d.ts +362 -38
  14. package/dist/config/schema.js +100 -5
  15. package/dist/constants.d.ts +8 -0
  16. package/dist/constants.js +8 -0
  17. package/dist/errors/constructors.d.ts +46 -0
  18. package/dist/errors/constructors.js +123 -0
  19. package/dist/errors/types.d.ts +26 -0
  20. package/dist/errors/types.js +41 -0
  21. package/dist/lsp/transport.d.ts +6 -15
  22. package/dist/lsp/transport.js +10 -66
  23. package/dist/mcp/adapter.d.ts +44 -0
  24. package/dist/mcp/adapter.js +70 -0
  25. package/dist/mcp/bounds.d.ts +35 -0
  26. package/dist/mcp/bounds.js +36 -0
  27. package/dist/mcp/client.d.ts +19 -0
  28. package/dist/mcp/client.js +93 -0
  29. package/dist/mcp/demarcate.d.ts +12 -0
  30. package/dist/mcp/demarcate.js +71 -0
  31. package/dist/mcp/index.d.ts +9 -0
  32. package/dist/mcp/index.js +8 -0
  33. package/dist/mcp/service.d.ts +54 -0
  34. package/dist/mcp/service.js +99 -0
  35. package/dist/mcp/transport.d.ts +30 -0
  36. package/dist/mcp/transport.js +188 -0
  37. package/dist/mcp/trust-gate.d.ts +35 -0
  38. package/dist/mcp/trust-gate.js +40 -0
  39. package/dist/mcp/trust.d.ts +52 -0
  40. package/dist/mcp/trust.js +111 -0
  41. package/dist/mcp/types.d.ts +52 -0
  42. package/dist/mcp/types.js +7 -0
  43. package/dist/tools/registry.js +3 -1
  44. package/dist/tools/types.d.ts +15 -1
  45. package/dist/utils/child-tree.d.ts +35 -0
  46. package/dist/utils/child-tree.js +76 -0
  47. package/dist/web/demarcate.d.ts +13 -0
  48. package/dist/web/demarcate.js +78 -0
  49. package/dist/web/fetch.d.ts +11 -0
  50. package/dist/web/fetch.js +174 -0
  51. package/dist/web/index.d.ts +7 -0
  52. package/dist/web/index.js +7 -0
  53. package/dist/web/provider.d.ts +29 -0
  54. package/dist/web/provider.js +77 -0
  55. package/dist/web/search.d.ts +17 -0
  56. package/dist/web/search.js +42 -0
  57. package/dist/web/ssrf.d.ts +55 -0
  58. package/dist/web/ssrf.js +223 -0
  59. package/dist/web/tools.d.ts +20 -0
  60. package/dist/web/tools.js +81 -0
  61. package/dist/web/types.d.ts +62 -0
  62. package/dist/web/types.js +1 -0
  63. package/package.json +2 -1
@@ -22,6 +22,8 @@ export function classify(action, cwd) {
22
22
  return vcsRequest(action, root);
23
23
  case "rollback":
24
24
  return rollbackRequest(action, root);
25
+ case "mcp":
26
+ return mcpRequest(action, root);
25
27
  default:
26
28
  return {
27
29
  action,
@@ -132,6 +134,28 @@ function rollbackRequest(action, root) {
132
134
  cwd: root,
133
135
  };
134
136
  }
137
+ // ── mcp (call an external MCP server's tool, C.27) ──────────────────────────────
138
+ /**
139
+ * An MCP tool call. Always `destructive` — the tool is arbitrary code in a
140
+ * trusted-but-external server, running UNSANDBOXED. Crucially, the classifier
141
+ * NEVER consults the server's `readOnlyHint` (or any server-supplied
142
+ * annotation): a server cannot mark its own tool low-risk, so this can only ever
143
+ * be destructive. Grantable at the tightest scope — the exact server+tool pair —
144
+ * so approving one MCP tool for the session never widens to any other tool.
145
+ */
146
+ function mcpRequest(action, root) {
147
+ const server = action.server ?? "";
148
+ const tool = action.tool ?? "";
149
+ const grantable = server !== "" && tool !== "";
150
+ return {
151
+ action,
152
+ tier: "destructive",
153
+ scope: grantable ? { kind: "mcp-tool", server, tool } : { kind: "none" },
154
+ summary: `call MCP tool ${tool || "(unknown)"} on server ${server || "(unknown)"}`,
155
+ targets: [],
156
+ cwd: root,
157
+ };
158
+ }
135
159
  // ── file (write / edit / patch) ────────────────────────────────────────────────
136
160
  function fileRequest(action, tier, root) {
137
161
  const targets = fileTargets(action, root);
@@ -44,6 +44,13 @@ export function scopeCovers(scope, request) {
44
44
  return (request.action.kind === "test" &&
45
45
  (request.action.command ?? "").trim() === scope.command);
46
46
  }
47
+ if (scope.kind === "mcp-tool") {
48
+ // MCP grants (C.27): the exact server+tool pair, mcp actions only — a grant
49
+ // for one server's tool can never cover another tool or another server.
50
+ return (request.action.kind === "mcp" &&
51
+ request.action.server === scope.server &&
52
+ request.action.tool === scope.tool);
53
+ }
47
54
  // file-subtree
48
55
  return (request.targets.length > 0 &&
49
56
  request.targets.every((t) => isInside(scope.root, t)));
@@ -69,6 +69,11 @@ function detail(request, t) {
69
69
  ` ${t.muted(`in ${request.cwd}`)}`,
70
70
  ].join("\n");
71
71
  }
72
+ if (request.action.kind === "mcp") {
73
+ // The server runs UNSANDBOXED with the user's privileges — say so at the
74
+ // point of the call, not just at trust time.
75
+ return ` ${t.muted(`external MCP server "${request.action.server ?? ""}" — runs unsandboxed with your privileges`)}`;
76
+ }
72
77
  return renderActionPreview(request.action.preview, t);
73
78
  }
74
79
  /** The choices line, including a short label of what an `a` grant would cover. */
@@ -87,6 +92,8 @@ function scopeLabel(scope) {
87
92
  return `re-runs of \`${scope.command}\``;
88
93
  if (scope.kind === "file-subtree")
89
94
  return `changes under ${path.basename(scope.root)}/`;
95
+ if (scope.kind === "mcp-tool")
96
+ return `re-calls of \`${scope.tool}\` on ${scope.server}`;
90
97
  return null;
91
98
  }
92
99
  // ── default stdin-backed PromptIO ──────────────────────────────────────────────
@@ -21,6 +21,8 @@ export type RiskTier = "read" | "mutate" | "destructive";
21
21
  * a grant covers re-runs of precisely that test command, nothing else.
22
22
  * - `file-subtree` — an absolute directory (or, under the root-cap, an exact
23
23
  * file path); matches targets that resolve inside it.
24
+ * - `mcp-tool` — one exact MCP server+tool pair (C.27): a grant covers re-calls
25
+ * of precisely that tool on that server, and never any other MCP tool.
24
26
  * - `none` — nothing safe to grant (e.g. a multi-file patch spanning the root).
25
27
  */
26
28
  export type Scope = {
@@ -32,6 +34,10 @@ export type Scope = {
32
34
  } | {
33
35
  readonly kind: "file-subtree";
34
36
  readonly root: string;
37
+ } | {
38
+ readonly kind: "mcp-tool";
39
+ readonly server: string;
40
+ readonly tool: string;
35
41
  } | {
36
42
  readonly kind: "none";
37
43
  };
@@ -49,7 +49,7 @@ export interface ForbiddenTerm {
49
49
  * Deprecated synonyms banned from user-facing copy. Scoped to the curated
50
50
  * surfaces the lexicon test scans (command descriptions, error output, brand +
51
51
  * onboarding constants) — NOT a raw source grep — so legitimate internal uses
52
- * (`mcpServers` config key, docker `.Server.Version`, code comments) never
52
+ * (`mcp.servers` config key, docker `.Server.Version`, code comments) never
53
53
  * false-trip, while every string a user reads is covered.
54
54
  */
55
55
  export declare const FORBIDDEN_TERMS: readonly ForbiddenTerm[];
@@ -40,7 +40,7 @@ export const CANONICAL_TERMS = {
40
40
  * Deprecated synonyms banned from user-facing copy. Scoped to the curated
41
41
  * surfaces the lexicon test scans (command descriptions, error output, brand +
42
42
  * onboarding constants) — NOT a raw source grep — so legitimate internal uses
43
- * (`mcpServers` config key, docker `.Server.Version`, code comments) never
43
+ * (`mcp.servers` config key, docker `.Server.Version`, code comments) never
44
44
  * false-trip, while every string a user reads is covered.
45
45
  */
46
46
  export const FORBIDDEN_TERMS = [
@@ -0,0 +1,9 @@
1
+ import { Command } from "commander";
2
+ /**
3
+ * `cruxy mcp` — inspect and control MCP server integration (C.27). `list` shows
4
+ * the configured servers and whether this repo's config is trusted; `trust`
5
+ * records the explicit decision to connect them, bound to the current config
6
+ * fingerprint. Trusting a server runs its code UNSANDBOXED with your privileges,
7
+ * so `trust` restates that plainly before recording.
8
+ */
9
+ export declare function mcpCommand(): Command;
@@ -0,0 +1,87 @@
1
+ import path from "node:path";
2
+ import { Command } from "commander";
3
+ import { loadConfig } from "../../config/index.js";
4
+ import { shouldUseColor } from "../../errors/index.js";
5
+ import { themeForColor } from "../../theme/index.js";
6
+ import { fileMcpTrustStore, fingerprintMcpServers, isMcpTrusted, } from "../../mcp/index.js";
7
+ import { logger } from "../../utils/logger.js";
8
+ /**
9
+ * `cruxy mcp` — inspect and control MCP server integration (C.27). `list` shows
10
+ * the configured servers and whether this repo's config is trusted; `trust`
11
+ * records the explicit decision to connect them, bound to the current config
12
+ * fingerprint. Trusting a server runs its code UNSANDBOXED with your privileges,
13
+ * so `trust` restates that plainly before recording.
14
+ */
15
+ export function mcpCommand() {
16
+ const cmd = new Command("mcp").description("inspect and control MCP integrations");
17
+ cmd
18
+ .command("list", { isDefault: true })
19
+ .description("list configured MCP integrations and their trust status")
20
+ .action(() => {
21
+ const t = themeForColor(shouldUseColor(process.stdout));
22
+ const { config } = loadConfig();
23
+ const servers = config.mcp.servers;
24
+ const names = Object.keys(servers);
25
+ logger.print(`${t.strong("mcp:")} ${config.mcp.enabled ? t.success("enabled") : t.warning("disabled (mcp.enabled = false)")}`);
26
+ if (names.length === 0) {
27
+ logger.print(t.muted("\nno MCP servers configured (mcp.servers)"));
28
+ return;
29
+ }
30
+ const root = path.resolve(process.cwd());
31
+ const trusted = isMcpTrusted(fileMcpTrustStore(), root, fingerprintMcpServers(servers));
32
+ const trustLabel = trusted
33
+ ? t.success("trusted — will connect")
34
+ : t.danger("NOT trusted — will not connect; run `cruxy mcp trust .`");
35
+ logger.print(`\n${t.heading("servers")} ${trustLabel}`);
36
+ for (const [name, cfg] of Object.entries(servers)) {
37
+ logger.print(` ${t.strong(name)} ${t.muted(describeServer(cfg))}`);
38
+ }
39
+ });
40
+ cmd
41
+ .command("trust [path]")
42
+ .description("trust this repo's MCP integrations (they run with your full privileges)")
43
+ .action((target) => {
44
+ const t = themeForColor(shouldUseColor(process.stdout));
45
+ const { config } = loadConfig();
46
+ const servers = config.mcp.servers;
47
+ const root = path.resolve(target ?? process.cwd());
48
+ const names = Object.keys(servers);
49
+ if (names.length === 0) {
50
+ logger.print(t.muted(`no MCP servers configured under ${root}`));
51
+ return;
52
+ }
53
+ logger.print(t.danger(t.strong(`trusting ${names.length} MCP server${names.length === 1 ? "" : "s"} — these run their own code UNSANDBOXED with your privileges:`)));
54
+ for (const [name, cfg] of Object.entries(servers)) {
55
+ logger.print(` ${t.strong(name)} ${t.muted(describeServer(cfg))}`);
56
+ }
57
+ fileMcpTrustStore().record({
58
+ root,
59
+ fingerprint: fingerprintMcpServers(servers),
60
+ at: new Date().toISOString(),
61
+ });
62
+ logger.print(`${t.success("trusted")} — cruxy will connect these servers for ${root}. ` +
63
+ t.muted("changing the config will require re-trusting."));
64
+ });
65
+ cmd
66
+ .command("untrust [path]")
67
+ .description("clear MCP trust for this repo so cruxy stops connecting")
68
+ .action((target) => {
69
+ const t = themeForColor(shouldUseColor(process.stdout));
70
+ const root = path.resolve(target ?? process.cwd());
71
+ // Recording an unmatchable fingerprint makes the current config stale
72
+ // (isMcpTrusted requires an exact match), so servers stop connecting.
73
+ fileMcpTrustStore().record({
74
+ root,
75
+ fingerprint: "revoked",
76
+ at: new Date().toISOString(),
77
+ });
78
+ logger.print(`${t.success("untrusted")} — MCP servers for ${root} will no longer connect until re-trusted`);
79
+ });
80
+ return cmd;
81
+ }
82
+ function describeServer(cfg) {
83
+ if (cfg.command) {
84
+ return `(stdio: ${[cfg.command, ...(cfg.args ?? [])].join(" ")})`;
85
+ }
86
+ return cfg.url ? `(url: ${cfg.url})` : "(no transport)";
87
+ }
@@ -12,6 +12,8 @@ import { runInteractive } from "../repl.js";
12
12
  import { buildAgentSession } from "../session-factory.js";
13
13
  import { apiKeyEnvVar, maybeRunOnboarding } from "../onboard.js";
14
14
  import { resetLspServices } from "../../lsp/index.js";
15
+ import { connectMcpTools, resetMcpServices } from "../../mcp/index.js";
16
+ import { defaultPromptIO } from "../../approval/index.js";
15
17
  export function runCommand() {
16
18
  return new Command("run")
17
19
  .description("run a task once, or start an interactive session")
@@ -91,15 +93,28 @@ export function runCommand() {
91
93
  interactive: Boolean(process.stdin.isTTY),
92
94
  logger,
93
95
  });
94
- const session = buildAgentSession(config, apiKey, process.cwd(), Boolean(process.stdin.isTTY), planMode, renderer, checkpoints, sandbox, hooksService.runner);
96
+ // MCP servers (C.27): connect + trust-gate BEFORE building the session so
97
+ // the tool catalogue is complete when the model first runs. Off by
98
+ // default (no servers connect). A non-interactive run with an untrusted
99
+ // config THROWS CRUXY_E_MCP_UNTRUSTED here — before any server spawns.
100
+ const mcp = await connectMcpTools({
101
+ cwd: process.cwd(),
102
+ config,
103
+ logger,
104
+ interactive: Boolean(process.stdin.isTTY),
105
+ io: defaultPromptIO(shouldUseColor()),
106
+ });
107
+ const session = buildAgentSession(config, apiKey, process.cwd(), Boolean(process.stdin.isTTY), planMode, renderer, checkpoints, sandbox, hooksService.runner, mcp.tools);
95
108
  if (interactive) {
96
109
  try {
97
110
  await runInteractive(session, undefined, renderer, checkpoints, hooksService.commands);
98
111
  }
99
112
  finally {
100
- // LSP (C.12): gracefully shut down any language servers spawned
101
- // during the session (the process-exit kill-tree is the fail-safe).
113
+ // LSP (C.12) + MCP (C.27): gracefully shut down any external server
114
+ // processes spawned during the session (the shared process-exit
115
+ // kill-tree is the fail-safe for a hard kill).
102
116
  await resetLspServices();
117
+ await resetMcpServices();
103
118
  }
104
119
  return;
105
120
  }
@@ -118,9 +133,11 @@ export function runCommand() {
118
133
  }
119
134
  finally {
120
135
  renderer.close();
121
- // LSP (C.12): gracefully shut down any language servers spawned during
122
- // the run (the process-exit kill-tree is the fail-safe for a hard kill).
136
+ // LSP (C.12) + MCP (C.27): gracefully shut down any external server
137
+ // processes spawned during the run (the shared process-exit kill-tree
138
+ // is the fail-safe for a hard kill).
123
139
  await resetLspServices();
140
+ await resetMcpServices();
124
141
  }
125
142
  // End-of-run usage summary (C.22): honest tokens + per-tier breakdown +
126
143
  // cost (only when priced). Printed after the live region is torn down.
@@ -17,6 +17,7 @@ import { testCommand } from "./commands/test.js";
17
17
  import { hooksCommand } from "./commands/hooks.js";
18
18
  import { memoryCommand } from "./commands/memory.js";
19
19
  import { usageCommand } from "./commands/usage.js";
20
+ import { mcpCommand } from "./commands/mcp.js";
20
21
  import { loadConfig } from "../config/index.js";
21
22
  import { maybeRunOnboarding } from "./onboard.js";
22
23
  export function buildProgram() {
@@ -49,6 +50,7 @@ export function buildProgram() {
49
50
  program.addCommand(hooksCommand());
50
51
  program.addCommand(memoryCommand());
51
52
  program.addCommand(usageCommand());
53
+ program.addCommand(mcpCommand());
52
54
  // Default action: bare `cruxy` -> entrypoint. An unrecognized first operand
53
55
  // means an unknown command (Commander runs the default action with it as an
54
56
  // operand rather than erroring), so reject it as a usage error.
@@ -3,7 +3,7 @@ import type { ApprovalDecision } from "../approval/index.js";
3
3
  import type { CheckpointService } from "../checkpoint/index.js";
4
4
  import type { SandboxService } from "../sandbox/index.js";
5
5
  import type { StreamRenderer } from "../render/index.js";
6
- import { type ApproveAction } from "../tools/index.js";
6
+ import { type ApproveAction, type Tool } from "../tools/index.js";
7
7
  import { Session, type LifecycleHookRunner } from "../agent/index.js";
8
8
  /**
9
9
  * Wrap the approval gate with the C.32 auto-checkpoint hook. Ordering is the
@@ -24,4 +24,4 @@ export declare function withCheckpointGate(requestApproval: (action: ApproveActi
24
24
  * over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
25
25
  * approves → executes. Plan mode is fully opt-in; the default path is unchanged.
26
26
  */
27
- export declare function buildAgentSession(config: CruxyConfig, apiKey: string, cwd: string, ttyInteractive: boolean, planMode?: boolean, renderer?: StreamRenderer, checkpoints?: CheckpointService, sandbox?: SandboxService, hooks?: LifecycleHookRunner): Session;
27
+ export declare function buildAgentSession(config: CruxyConfig, apiKey: string, cwd: string, ttyInteractive: boolean, planMode?: boolean, renderer?: StreamRenderer, checkpoints?: CheckpointService, sandbox?: SandboxService, hooks?: LifecycleHookRunner, mcpTools?: Tool[]): Session;
@@ -4,12 +4,13 @@ import { logger } from "../utils/logger.js";
4
4
  import { getGitInfo } from "../utils/git.js";
5
5
  import { ApprovalService, InteractivePolicy, SessionAllowlist, classify, defaultPromptIO, } from "../approval/index.js";
6
6
  import { shouldUseColor } from "../errors/index.js";
7
- import { buildDefaultRegistry } from "../tools/index.js";
7
+ import { buildDefaultRegistry, } from "../tools/index.js";
8
8
  import { Session, } from "../agent/index.js";
9
9
  import { PlanExecutionPolicy, runPlanSession } from "../plan/index.js";
10
10
  import { routerForConfig } from "../routing/index.js";
11
11
  import { MemoryService, rememberTool } from "../memory/index.js";
12
12
  import { findDefinitionTool, findReferencesTool, getDiagnosticsTool, hoverTool, } from "../lsp/index.js";
13
+ import { createWebSearchTool, createWebFetchTool } from "../web/index.js";
13
14
  import { appendRun } from "../usage/index.js";
14
15
  import { SubagentOrchestrator, makeSpawnSubagentTool, } from "../subagent/index.js";
15
16
  /**
@@ -91,7 +92,7 @@ export function withCheckpointGate(requestApproval, checkpoints, cwd) {
91
92
  * over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
92
93
  * approves → executes. Plan mode is fully opt-in; the default path is unchanged.
93
94
  */
94
- export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode = false, renderer, checkpoints, sandbox, hooks) {
95
+ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode = false, renderer, checkpoints, sandbox, hooks, mcpTools = []) {
95
96
  const provider = createProvider({
96
97
  provider: config.model.provider,
97
98
  apiKey,
@@ -148,6 +149,23 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
148
149
  execRegistry.register(getDiagnosticsTool);
149
150
  execRegistry.register(hoverTool);
150
151
  }
152
+ // Web search + fetch (C.20): register the two read-only web tools only when
153
+ // enabled. They reach the PUBLIC internet and inject attacker-controllable data
154
+ // (results/pages are demarcated as untrusted and never persisted), so — like
155
+ // LSP and MCP — the feature is opt-in; when off, neither tool is registered and
156
+ // no search provider is ever constructed. Read-only (no approval), so they
157
+ // bypass the U.3 gate like search_codebase.
158
+ if (config.web.enabled) {
159
+ execRegistry.register(createWebSearchTool());
160
+ execRegistry.register(createWebFetchTool());
161
+ }
162
+ // MCP servers (C.27): the caller connected + trusted the servers and produced
163
+ // these tools through the single adapter seam BEFORE building the session, so
164
+ // registration here is a plain hand-off — every one is destructive-gated,
165
+ // demarcated, and bounded by construction. Empty unless `mcp.enabled` and at
166
+ // least one trusted server produced tools, so the default path is unchanged.
167
+ for (const tool of mcpTools)
168
+ execRegistry.register(tool);
151
169
  // One io shared by every prompt in the session (plan approval, the U.3 gate,
152
170
  // and any gate inside a subagent), so they all coordinate with the same live
153
171
  // region. The full wrapper stack around an ApprovalService is factored here