@yawlabs/caddy-mcp 2.1.0 → 2.2.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 (2) hide show
  1. package/bin/caddy-mcp.mjs +97 -3
  2. package/package.json +1 -1
package/bin/caddy-mcp.mjs CHANGED
@@ -28,18 +28,46 @@
28
28
  * For an MCP host config, point straight at oam and skip this file:
29
29
  * { "command": "oam", "args": ["run", "<abs>/dist/index.js"] }
30
30
  *
31
+ * THE `--permission` SANDBOX (oam 0.9.0+, opt-in)
32
+ * `CADDY_MCP_SANDBOX=1` runs the server under oam's permission model.
33
+ *
34
+ * The admin API endpoint is DERIVED from CADDY_ADMIN_URL (default
35
+ * http://127.0.0.1:2019), host and port both pinned -- grants are prefix-matched,
36
+ * so a bare host would also admit every other port on it. Filesystem AND
37
+ * child-process both stay denied: this server drives Caddy entirely over its
38
+ * admin HTTP API and never shells out to the `caddy` binary (the only
39
+ * execFileSync calls in the repo are in src/tests/).
40
+ *
41
+ * Opt-in, not default: a denied environment variable is ABSENT from process.env
42
+ * rather than throwing, so an under-granted CADDY_API_TOKEN reads as
43
+ * "unauthenticated". The env list is derived from the shipped bundle.
44
+ *
45
+ * MINIMUM OAM VERSION
46
+ * 0.9.0. Below it `child_process.execFile` ran its arguments through a SHELL,
47
+ * `exec` accepted `timeout` and ignored it, `spawnSync` truncated at
48
+ * `maxBuffer` while reporting success, and `stdio: 'inherit'`/`'ignore'` both
49
+ * behaved as `'pipe'`. This server spawns nothing, so the floor is
50
+ * enforced for consistency across @yawlabs/*-mcp rather than because this
51
+ * launcher was exposed.
52
+ * An older oam is not an error: the launcher falls back to Node and says so on
53
+ * stderr. Pinning the floor here is what makes that fallback automatic.
54
+ *
31
55
  * SELECTION
32
56
  * CADDY_MCP_RUNTIME=oam require oam; fail loudly if it is missing
33
57
  * CADDY_MCP_RUNTIME=node never use oam
34
58
  * CADDY_MCP_RUNTIME=auto prefer oam, silently fall back (default)
59
+ * CADDY_MCP_SANDBOX=1 run oam under --permission (oam 0.9.0+)
35
60
  * OAM_BIN=/path/to/oam explicit binary, checked before any discovery
36
61
  */
37
62
 
38
- import { spawn } from "node:child_process";
63
+ import { execFileSync, spawn } from "node:child_process";
39
64
  import { existsSync } from "node:fs";
40
65
  import { constants, homedir } from "node:os";
41
66
  import { delimiter, join } from "node:path";
42
- import { fileURLToPath } from "node:url";
67
+ import { fileURLToPath } from "node:url";
68
+
69
+ /** Oldest oam whose `child_process` matches Node. See MINIMUM OAM VERSION above. */
70
+ const OAM_MIN = [0, 9, 0];
43
71
 
44
72
  // Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
45
73
  // with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
@@ -89,6 +117,72 @@ function findOam() {
89
117
  return null;
90
118
  }
91
119
 
120
+ /**
121
+ * `oam --version` -> [major, minor, patch], or null when it cannot be read.
122
+ * A pre-release suffix (0.9.0-rc.1) truncates to its base version.
123
+ */
124
+ function oamVersion(cmd) {
125
+ try {
126
+ const out = execFileSync(cmd, ["--version"], {
127
+ encoding: "utf-8",
128
+ stdio: ["ignore", "pipe", "ignore"],
129
+ });
130
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(out);
131
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
132
+ } catch {
133
+ // Not executable, wrong arch, or deleted since the stat. Caller degrades.
134
+ return null;
135
+ }
136
+ }
137
+
138
+ /** True when `v` is at least `min`, comparing major/minor/patch in order. */
139
+ function atLeast(v, min) {
140
+ if (!v) return false;
141
+ for (let i = 0; i < min.length; i++) {
142
+ if (v[i] > min[i]) return true;
143
+ if (v[i] < min[i]) return false;
144
+ }
145
+ return true;
146
+ }
147
+
148
+ /**
149
+ * The `--permission` grant list, or [] when the sandbox is not requested.
150
+ *
151
+ * These are oam's PROCESS-level flags: they belong before the `run` subcommand,
152
+ * not after it. `oam run --permission file.js` is rejected outright, which is a
153
+ * good failure but only because it is loud -- ordering here is load-bearing.
154
+ *
155
+ * Net grants prefix-match `host` for fetch and `host:port` for sockets.
156
+ * A denied environment variable is ABSENT from process.env rather than throwing,
157
+ * so the env list below is derived from what the bundle actually reads; trimming
158
+ * it produces silent misbehaviour, not a clear denial.
159
+ */
160
+ function sandboxFlags() {
161
+ if (process.env.CADDY_MCP_SANDBOX !== "1") return [];
162
+
163
+ // Derived, not hardcoded: the only endpoint this server may reach is the one
164
+ // it was configured to reach. Grants are prefix-matched against "host:port"
165
+ // for sockets, so host alone would also admit any other port on that host --
166
+ // pin both. A DSN we cannot parse falls back to a bare grant rather than a
167
+ // broken one, because a wrong narrow grant fails at connect time.
168
+ const dsn = process.env.CADDY_ADMIN_URL ?? "http://127.0.0.1:2019";
169
+ let netFlag = "--allow-net";
170
+ if (dsn) {
171
+ try {
172
+ const u = new URL(dsn);
173
+ if (u.hostname) netFlag = `--allow-net=${u.hostname}:${u.port || 2019}`;
174
+ } catch {
175
+ // Unparseable CADDY_ADMIN_URL: leave the grant open. The server will fail on
176
+ // its own connection error, which names the real problem.
177
+ }
178
+ }
179
+
180
+ const env = ["CADDY_ADMIN_URL","CADDY_API_TOKEN","CADDY_LOAD_TIMEOUT","CADDY_MAX_RETRIES","CADDY_TIMEOUT"];
181
+
182
+ const flags = ["--permission", netFlag, `--allow-env=${env.join(",")}`];
183
+ return flags;
184
+ }
185
+
92
186
  /** Run the server in THIS process. The zero-overhead fallback. */
93
187
  async function runInProcess() {
94
188
  // A server may gate its bootstrap on being the process ENTRY POINT --
@@ -129,7 +223,7 @@ if (mode === "node") {
129
223
  } else {
130
224
  // `--` separates oam's own flags from the script's argv, so `caddy-mcp
131
225
  // --version` and any host-supplied flags survive the hop unchanged.
132
- const child = spawn(oam, ["run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
226
+ const child = spawn(oam, [...sandboxFlags(), "run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
133
227
  // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
134
228
  // stdin/stdout is untouched and the host's stdin-close still reaches the
135
229
  // server's shutdown path.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/caddy-mcp",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "mcpName": "io.github.YawLabs/caddy-mcp",
5
5
  "description": "MCP server for managing Caddy web servers via the admin API",
6
6
  "license": "MIT",