@aipermission/mcp 0.2.36 → 0.2.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,7 +33,7 @@ npx -y @aipermission/mcp init \
33
33
 
34
34
  The init command prompts for your AIPermission API token and writes the MCP client configuration for the selected provider. Generated runtime configs pin the exact package version that wrote them; re-run init when you intentionally upgrade a client.
35
35
 
36
- The generated MCP config contains a bearer token. Keep it private. For project-local configs such as `.mcp.json`, `.cursor/mcp.json`, and `.vscode/mcp.json`, the init command refuses to write into files already tracked by Git unless `--force` is passed. For untracked project-local configs, it adds the file to `.git/info/exclude` when it detects a Git repository. Use `--print` if you prefer to copy the config manually. If a token config is committed or shared, revoke that token in the AIPermission UI.
36
+ The generated MCP config contains a bearer token. Keep it private. For project-local configs such as `.mcp.json`, `.cursor/mcp.json`, and `.vscode/mcp.json`, the init command refuses to write into files already tracked by Git unless `--force` is passed. For untracked project-local configs, it adds both the final file and its crash-safe temporary-file pattern to `.git/info/exclude` before writing. Symbolic-link config destinations are rejected instead of being silently replaced; use `--print` and update a symlink-managed config through its owning tool. If a token config is committed or shared, revoke that token in the AIPermission UI.
37
37
 
38
38
  ## Manual Config
39
39
 
@@ -42,7 +42,7 @@ The generated MCP config contains a bearer token. Keep it private. For project-l
42
42
  "mcpServers": {
43
43
  "aipermission": {
44
44
  "command": "npx",
45
- "args": ["-y", "@aipermission/mcp@0.2.36"],
45
+ "args": ["-y", "@aipermission/mcp@0.2.38"],
46
46
  "env": {
47
47
  "NODE_ENV": "production",
48
48
  "AIPERMISSION_API_URL": "http://localhost:3210",
package/dist/init.js CHANGED
@@ -8,6 +8,15 @@ import { promisify } from "node:util";
8
8
  import { pathToFileURL } from "node:url";
9
9
  import { stdin as input, stdout as output } from "node:process";
10
10
  import { DEFAULT_API_URL, normalizeLocalAPIURL } from "./local-url.js";
11
+ import {
12
+ atomicWritePrivateFile,
13
+ privateLockPath,
14
+ privateStagingIgnorePath,
15
+ privateStagingPath,
16
+ privateTemporaryIgnorePath,
17
+ privateTemporaryPath,
18
+ withPrivateFileLock,
19
+ } from "./private-file.js";
11
20
 
12
21
  const require = createRequire(import.meta.url);
13
22
  const execFileAsync = promisify(execFile);
@@ -78,9 +87,7 @@ export async function runInit(argv = []) {
78
87
  const provider = flags.provider
79
88
  ? findProvider(flags.provider)
80
89
  : await selectProvider("Which AI client should use this token?", providers);
81
- const name = sanitizeName(
82
- flags.name || (await ask(rl, "MCP server name", "aipermission"))
83
- );
90
+ const name = sanitizeName(flags.name || (await ask(rl, "MCP server name", "aipermission")));
84
91
  const apiUrl = normalizeURL(flags.apiUrl || DEFAULT_API_URL);
85
92
  const token = await resolveToken({ ...flags, stdinToken }, rl);
86
93
 
@@ -104,7 +111,9 @@ export async function runInit(argv = []) {
104
111
  console.log(`${color.dim}Git:${color.reset} added ${result.gitExcludeEntry} to .git/info/exclude`);
105
112
  }
106
113
  console.log("");
107
- console.log(`${color.yellow}Keep this config private:${color.reset} it contains an AIPermission bearer token. If it is committed, revoke the token.`);
114
+ console.log(
115
+ `${color.yellow}Keep this config private:${color.reset} it contains an AIPermission bearer token. If it is committed, revoke the token.`,
116
+ );
108
117
  console.log(`${color.yellow}Restart the AI client so it reloads MCP servers.${color.reset}`);
109
118
  } finally {
110
119
  rl.close();
@@ -151,9 +160,7 @@ export function parseFlags(argv) {
151
160
 
152
161
  function findProvider(idOrLabel) {
153
162
  const normalized = String(idOrLabel).trim().toLowerCase();
154
- const provider = providers.find(
155
- (item) => item.id === normalized || item.label.toLowerCase() === normalized
156
- );
163
+ const provider = providers.find((item) => item.id === normalized || item.label.toLowerCase() === normalized);
157
164
  if (!provider) {
158
165
  throw new Error(`Unknown provider: ${idOrLabel}`);
159
166
  }
@@ -175,18 +182,12 @@ async function selectProvider(title, items) {
175
182
  output.write(`\x1b[${renderedLines}A`);
176
183
  output.write("\x1b[J");
177
184
  }
178
- const lines = [
179
- `${color.bold}${color.cyan}${title}${color.reset}`,
180
- `${color.dim}Use ↑/↓ and Enter.${color.reset}`,
181
- "",
182
- ];
185
+ const lines = [`${color.bold}${color.cyan}${title}${color.reset}`, `${color.dim}Use ↑/↓ and Enter.${color.reset}`, ""];
183
186
  for (let i = 0; i < items.length; i += 1) {
184
187
  const selected = i === index;
185
188
  const marker = selected ? `${color.green}›${color.reset}` : " ";
186
189
  const label = selected ? `${color.bold}${items[i].label}${color.reset}` : items[i].label;
187
- lines.push(
188
- `${marker} ${label} ${color.dim}- ${items[i].description}${color.reset}`
189
- );
190
+ lines.push(`${marker} ${label} ${color.dim}- ${items[i].description}${color.reset}`);
190
191
  }
191
192
  output.write("\x1b[?25l");
192
193
  output.write(`${lines.join("\n")}\n`);
@@ -210,7 +211,7 @@ async function selectProvider(title, items) {
210
211
  };
211
212
  const onData = (buffer) => {
212
213
  const value = buffer.toString("utf8");
213
- const keys = value.match(/\u001b\[[AB]|\r|\n|\u0003|./g) || [];
214
+ const keys = splitInputKeys(value);
214
215
  for (const key of keys) {
215
216
  if (key === "\u0003") {
216
217
  cleanup();
@@ -313,87 +314,112 @@ export function buildMCPServerConfig({ apiUrl, token }) {
313
314
  }
314
315
 
315
316
  export async function writeProviderConfig(providerID, name, config, options = {}) {
317
+ const projectRoot = process.cwd();
318
+ const homeRoot = os.homedir();
316
319
  if (providerID === "codex") {
317
- const filePath = path.join(os.homedir(), ".codex", "config.toml");
318
- await writeCodexConfig(filePath, name, config);
320
+ const filePath = path.join(homeRoot, ".codex", "config.toml");
321
+ await writeCodexConfig(filePath, name, config, { trustedRoot: homeRoot });
319
322
  return { path: filePath };
320
323
  }
321
324
  if (providerID === "claude-code") {
322
- const filePath = path.join(process.cwd(), ".mcp.json");
325
+ const filePath = path.join(projectRoot, ".mcp.json");
323
326
  await assertProjectConfigWritable(filePath, options);
324
- await writeJSONMCPConfig(filePath, name, config, "mcpServers");
325
- return { path: filePath, ...(await protectGitIgnoredConfig(filePath)) };
327
+ const protection = await protectGitIgnoredConfig(filePath);
328
+ await writeJSONMCPConfig(filePath, name, config, "mcpServers", { trustedRoot: projectRoot });
329
+ return { path: filePath, ...protection };
326
330
  }
327
331
  if (providerID === "cursor") {
328
- const filePath = path.join(process.cwd(), ".cursor", "mcp.json");
332
+ const filePath = path.join(projectRoot, ".cursor", "mcp.json");
329
333
  await assertProjectConfigWritable(filePath, options);
330
- await writeJSONMCPConfig(filePath, name, config, "mcpServers");
331
- return { path: filePath, ...(await protectGitIgnoredConfig(filePath)) };
334
+ const protection = await protectGitIgnoredConfig(filePath);
335
+ await writeJSONMCPConfig(filePath, name, config, "mcpServers", { trustedRoot: projectRoot });
336
+ return { path: filePath, ...protection };
332
337
  }
333
338
  if (providerID === "vscode") {
334
- const filePath = path.join(process.cwd(), ".vscode", "mcp.json");
339
+ const filePath = path.join(projectRoot, ".vscode", "mcp.json");
335
340
  await assertProjectConfigWritable(filePath, options);
336
- await writeJSONMCPConfig(filePath, name, config, "servers");
337
- return { path: filePath, ...(await protectGitIgnoredConfig(filePath)) };
341
+ const protection = await protectGitIgnoredConfig(filePath);
342
+ await writeJSONMCPConfig(filePath, name, config, "servers", { trustedRoot: projectRoot });
343
+ return { path: filePath, ...protection };
338
344
  }
339
345
  if (providerID === "windsurf") {
340
- const filePath = path.join(os.homedir(), ".codeium", "windsurf", "mcp_config.json");
341
- await writeJSONMCPConfig(filePath, name, config, "mcpServers");
346
+ const filePath = path.join(homeRoot, ".codeium", "windsurf", "mcp_config.json");
347
+ await writeJSONMCPConfig(filePath, name, config, "mcpServers", { trustedRoot: homeRoot });
342
348
  return { path: filePath };
343
349
  }
344
350
  if (providerID === "antigravity") {
345
- const filePath = path.join(os.homedir(), ".gemini", "antigravity", "mcp_config.json");
346
- await writeJSONMCPConfig(filePath, name, config, "mcpServers");
351
+ const filePath = path.join(homeRoot, ".gemini", "antigravity", "mcp_config.json");
352
+ await writeJSONMCPConfig(filePath, name, config, "mcpServers", { trustedRoot: homeRoot });
347
353
  return { path: filePath };
348
354
  }
349
355
  if (providerID === "gemini") {
350
- const filePath = path.join(os.homedir(), ".gemini", "settings.json");
351
- await writeJSONMCPConfig(filePath, name, config, "mcpServers");
356
+ const filePath = path.join(homeRoot, ".gemini", "settings.json");
357
+ await writeJSONMCPConfig(filePath, name, config, "mcpServers", { trustedRoot: homeRoot });
352
358
  return { path: filePath };
353
359
  }
354
360
  throw new Error(`Unsupported provider: ${providerID}`);
355
361
  }
356
362
 
357
- export async function writeJSONMCPConfig(filePath, name, config, rootKey) {
358
- await fs.mkdir(path.dirname(filePath), { recursive: true });
359
- let root = {};
360
- try {
361
- root = JSON.parse(await fs.readFile(filePath, "utf8"));
362
- } catch (error) {
363
- if (error.code !== "ENOENT") {
364
- throw new Error(`Could not read JSON config at ${filePath}: ${error.message}`);
365
- }
366
- }
367
- if (!root || typeof root !== "object" || Array.isArray(root)) {
368
- root = {};
369
- }
370
- root[rootKey] =
371
- root[rootKey] && typeof root[rootKey] === "object" && !Array.isArray(root[rootKey])
372
- ? root[rootKey]
373
- : {};
374
- root[rootKey][name] = config;
375
- await writePrivateFile(filePath, `${JSON.stringify(root, null, 2)}\n`);
363
+ export async function writeJSONMCPConfig(filePath, name, config, rootKey, options = {}) {
364
+ await withPrivateFileLock(
365
+ filePath,
366
+ async () => {
367
+ let root = {};
368
+ try {
369
+ root = JSON.parse(await fs.readFile(filePath, "utf8"));
370
+ } catch (error) {
371
+ if (error.code !== "ENOENT") {
372
+ throw new Error(`Could not read JSON config at ${filePath}: ${error.message}`, { cause: error });
373
+ }
374
+ }
375
+ if (!root || typeof root !== "object" || Array.isArray(root)) root = {};
376
+ const currentServers = root[rootKey];
377
+ const servers =
378
+ currentServers && typeof currentServers === "object" && !Array.isArray(currentServers)
379
+ ? { ...currentServers }
380
+ : Object.create(null);
381
+ Object.defineProperty(servers, name, { value: config, enumerable: true, configurable: true, writable: true });
382
+ root[rootKey] = servers;
383
+ await writePrivateFile(filePath, `${JSON.stringify(root, null, 2)}\n`, options);
384
+ },
385
+ options,
386
+ );
376
387
  }
377
388
 
378
- async function writeCodexConfig(filePath, name, config) {
379
- await fs.mkdir(path.dirname(filePath), { recursive: true });
380
- let current = "";
381
- try {
382
- current = await fs.readFile(filePath, "utf8");
383
- } catch (error) {
384
- if (error.code !== "ENOENT") {
385
- throw error;
389
+ function splitInputKeys(value) {
390
+ const keys = [];
391
+ for (let index = 0; index < value.length; index += 1) {
392
+ const sequence = value.slice(index, index + 3);
393
+ if (sequence === "\u001b[A" || sequence === "\u001b[B") {
394
+ keys.push(sequence);
395
+ index += 2;
396
+ continue;
386
397
  }
398
+ keys.push(value[index]);
387
399
  }
400
+ return keys;
401
+ }
388
402
 
389
- const next = removeCodexServer(current, name).trimEnd();
390
- const block = codexServerBlock(name, config);
391
- await writePrivateFile(filePath, `${next ? `${next}\n\n` : ""}${block}\n`);
403
+ async function writeCodexConfig(filePath, name, config, options = {}) {
404
+ await withPrivateFileLock(
405
+ filePath,
406
+ async () => {
407
+ let current = "";
408
+ try {
409
+ current = await fs.readFile(filePath, "utf8");
410
+ } catch (error) {
411
+ if (error.code !== "ENOENT") throw error;
412
+ }
413
+ const next = removeCodexServer(current, name).trimEnd();
414
+ const block = codexServerBlock(name, config);
415
+ await writePrivateFile(filePath, `${next ? `${next}\n\n` : ""}${block}\n`, options);
416
+ },
417
+ options,
418
+ );
392
419
  }
393
420
 
394
- async function writePrivateFile(filePath, contents) {
395
- await fs.writeFile(filePath, contents, { mode: 0o600 });
396
- await fs.chmod(filePath, 0o600);
421
+ async function writePrivateFile(filePath, contents, options = {}) {
422
+ await atomicWritePrivateFile(filePath, contents, options);
397
423
  }
398
424
 
399
425
  async function assertProjectConfigWritable(filePath, options = {}) {
@@ -408,7 +434,7 @@ async function assertProjectConfigWritable(filePath, options = {}) {
408
434
  [
409
435
  `Refusing to write AIPERMISSION_API_TOKEN into tracked git file: ${tracked}`,
410
436
  "Use --print to copy the config manually, untrack/ignore that file, or rerun with --force if you intentionally accept commit risk.",
411
- ].join("\n")
437
+ ].join("\n"),
412
438
  );
413
439
  }
414
440
 
@@ -421,26 +447,52 @@ async function protectGitIgnoredConfig(filePath) {
421
447
  if (relativePath.startsWith("../") || path.isAbsolute(relativePath)) {
422
448
  return {};
423
449
  }
424
- const excludePath = path.join(repository.gitDir, "info", "exclude");
425
- let current = "";
450
+ const excludePath = repository.excludePath;
451
+ const temporaryRelativePath = path.relative(repository.workTree, privateTemporaryIgnorePath(filePath)).split(path.sep).join("/");
452
+ const stagingRelativePath = path.relative(repository.workTree, privateStagingIgnorePath(filePath)).split(path.sep).join("/");
453
+ const lockRelativePath = path.relative(repository.workTree, privateLockPath(filePath)).split(path.sep).join("/");
454
+ const ignoreEntries = [
455
+ gitIgnoreLiteral(relativePath),
456
+ gitIgnoreWildcardPath(temporaryRelativePath),
457
+ gitIgnoreWildcardPath(stagingRelativePath),
458
+ gitIgnoreLiteral(lockRelativePath),
459
+ ];
426
460
  try {
427
- current = await fs.readFile(excludePath, "utf8");
461
+ await withPrivateFileLock(
462
+ excludePath,
463
+ async () => {
464
+ let current = "";
465
+ try {
466
+ current = await fs.readFile(excludePath, "utf8");
467
+ } catch (error) {
468
+ if (error.code !== "ENOENT") throw error;
469
+ }
470
+ const entries = new Set(current.split(/\r?\n/));
471
+ const missingEntries = ignoreEntries.filter((entry) => !entries.has(entry));
472
+ if (missingEntries.length === 0) return;
473
+ const prefix = current && !current.endsWith("\n") ? "\n" : "";
474
+ await atomicWritePrivateFile(excludePath, `${current}${prefix}${missingEntries.join("\n")}\n`, {
475
+ trustedRoot: path.dirname(excludePath),
476
+ });
477
+ },
478
+ { trustedRoot: path.dirname(excludePath) },
479
+ );
480
+ await assertGitIgnored(repository, [
481
+ relativePath,
482
+ privateTemporaryCheckPath(filePath, repository.workTree),
483
+ privateStagingCheckPath(filePath, repository.workTree),
484
+ lockRelativePath,
485
+ ]);
428
486
  } catch (error) {
429
- if (error.code !== "ENOENT") {
430
- return {};
431
- }
432
- }
433
- const entries = current.split(/\r?\n/).map((line) => line.trim());
434
- if (!entries.includes(relativePath)) {
435
- const prefix = current && !current.endsWith("\n") ? "\n" : "";
436
- try {
437
- await fs.mkdir(path.dirname(excludePath), { recursive: true });
438
- await fs.appendFile(excludePath, `${prefix}${relativePath}\n`, { mode: 0o600 });
439
- } catch {
440
- return {};
441
- }
487
+ throw new Error(`Could not protect MCP config with local Git excludes: ${error.message}`, { cause: error });
442
488
  }
443
- return { gitExcluded: true, gitExcludeEntry: relativePath };
489
+ return {
490
+ gitExcluded: true,
491
+ gitExcludeEntry: relativePath,
492
+ gitExcludeTemporaryEntry: temporaryRelativePath,
493
+ gitExcludeStagingEntry: stagingRelativePath,
494
+ gitExcludeLockEntry: lockRelativePath,
495
+ };
444
496
  }
445
497
 
446
498
  async function gitTrackedPath(filePath) {
@@ -453,30 +505,87 @@ async function gitTrackedPath(filePath) {
453
505
  return "";
454
506
  }
455
507
  try {
456
- await execFileAsync("git", ["-C", repository.workTree, "ls-files", "--error-unmatch", "--", relativePath], { windowsHide: true });
508
+ await execFileAsync("git", ["-C", repository.workTree, "ls-files", "--error-unmatch", "--", relativePath], {
509
+ windowsHide: true,
510
+ });
457
511
  return relativePath;
458
- } catch {
459
- return "";
512
+ } catch (error) {
513
+ if (error.code === 1) return "";
514
+ throw new Error(`Could not verify whether MCP config is tracked by Git: ${gitErrorMessage(error)}`, { cause: error });
460
515
  }
461
516
  }
462
517
 
463
518
  async function discoverGitRepository(startDir) {
464
519
  try {
465
- const { stdout } = await execFileAsync(
466
- "git",
467
- ["-C", path.resolve(startDir), "rev-parse", "--show-toplevel", "--absolute-git-dir"],
468
- { encoding: "utf8", windowsHide: true }
469
- );
520
+ const { stdout } = await execFileAsync("git", ["-C", path.resolve(startDir), "rev-parse", "--show-toplevel", "--absolute-git-dir"], {
521
+ encoding: "utf8",
522
+ windowsHide: true,
523
+ });
470
524
  const [workTree, gitDir] = stdout.trim().split(/\r?\n/);
471
525
  if (!workTree || !gitDir) {
472
526
  return null;
473
527
  }
474
- return { workTree: path.resolve(workTree), gitDir: path.resolve(gitDir) };
475
- } catch {
476
- return null;
528
+ const { stdout: excludeOutput } = await execFileAsync(
529
+ "git",
530
+ ["-C", path.resolve(startDir), "rev-parse", "--path-format=absolute", "--git-path", "info/exclude"],
531
+ { encoding: "utf8", windowsHide: true },
532
+ );
533
+ const excludePath = excludeOutput.trim();
534
+ if (!excludePath) throw new Error("Git did not return an exclude path");
535
+ return { workTree: path.resolve(workTree), gitDir: path.resolve(gitDir), excludePath: path.resolve(excludePath) };
536
+ } catch (error) {
537
+ if (/not a git repository/i.test(`${error.stderr || ""}\n${error.message || ""}`)) return null;
538
+ throw new Error(`Could not inspect Git repository: ${gitErrorMessage(error)}`, { cause: error });
539
+ }
540
+ }
541
+
542
+ async function assertGitIgnored(repository, relativePaths) {
543
+ for (const relativePath of relativePaths) {
544
+ try {
545
+ await execFileAsync("git", ["-C", repository.workTree, "check-ignore", "--no-index", "-q", "--", relativePath], {
546
+ windowsHide: true,
547
+ });
548
+ } catch (error) {
549
+ if (error.code === 1) {
550
+ throw new Error(`Git still permits sensitive MCP path: ${relativePath}`, { cause: error });
551
+ }
552
+ throw new Error(`Could not verify local Git exclusion: ${gitErrorMessage(error)}`, { cause: error });
553
+ }
477
554
  }
478
555
  }
479
556
 
557
+ function privateTemporaryCheckPath(filePath, workTree) {
558
+ return path.relative(workTree, privateTemporaryPath(filePath, "git-check")).split(path.sep).join("/");
559
+ }
560
+
561
+ function privateStagingCheckPath(filePath, workTree) {
562
+ return path.relative(workTree, privateStagingPath(filePath, "git-check")).split(path.sep).join("/");
563
+ }
564
+
565
+ function escapeGitIgnoreFragment(value) {
566
+ let result = "";
567
+ for (const character of value) {
568
+ result += ["\\", "*", "?", "[", "]", "#", "!", " "].includes(character) ? `\\${character}` : character;
569
+ }
570
+ return result;
571
+ }
572
+
573
+ function gitIgnoreLiteral(relativePath) {
574
+ return `/${escapeGitIgnoreFragment(relativePath)}`;
575
+ }
576
+
577
+ function gitIgnoreWildcardPath(relativePath) {
578
+ const wildcardIndex = relativePath.lastIndexOf("*");
579
+ if (wildcardIndex < 0) throw new Error(`Git ignore wildcard path is missing its generated wildcard: ${relativePath}`);
580
+ return `/${escapeGitIgnoreFragment(relativePath.slice(0, wildcardIndex))}*${escapeGitIgnoreFragment(
581
+ relativePath.slice(wildcardIndex + 1),
582
+ )}`;
583
+ }
584
+
585
+ function gitErrorMessage(error) {
586
+ return String(error.stderr || error.message || error).trim();
587
+ }
588
+
480
589
  function removeCodexServer(source, name) {
481
590
  const main = `[mcp_servers.${tomlKey(name)}]`;
482
591
  const env = `[mcp_servers.${tomlKey(name)}.env]`;
@@ -533,6 +642,9 @@ export function sanitizeName(value) {
533
642
  if (!name) {
534
643
  throw new Error("MCP server name is required.");
535
644
  }
645
+ if (["__proto__", "prototype", "constructor"].includes(name.toLowerCase())) {
646
+ throw new Error("MCP server name is reserved.");
647
+ }
536
648
  return name;
537
649
  }
538
650
 
@@ -79,10 +79,7 @@ export async function loadSkill(source) {
79
79
  }
80
80
 
81
81
  function bundledSkillCandidates() {
82
- return [
83
- path.join(moduleDir, "resources", SKILL_NAME, "SKILL.md"),
84
- path.join(moduleDir, "..", "resources", SKILL_NAME, "SKILL.md"),
85
- ];
82
+ return [path.join(moduleDir, "resources", SKILL_NAME, "SKILL.md"), path.join(moduleDir, "..", "resources", SKILL_NAME, "SKILL.md")];
86
83
  }
87
84
 
88
85
  async function readSkillSource(source) {
@@ -131,10 +128,12 @@ export function renderInstruction(client, skill) {
131
128
  }
132
129
 
133
130
  export function normalizeClient(value) {
134
- const client = String(value || "").trim().toLowerCase();
131
+ const client = String(value || "")
132
+ .trim()
133
+ .toLowerCase();
135
134
  const aliases = {
136
135
  claude: "claude-code",
137
- "claude_code": "claude-code",
136
+ claude_code: "claude-code",
138
137
  "claude-code": "claude-code",
139
138
  copilot: "vscode",
140
139
  "vs-code": "vscode",
@@ -151,16 +150,18 @@ export function normalizeClient(value) {
151
150
  }
152
151
 
153
152
  function clientLabel(client) {
154
- return {
155
- codex: "Codex",
156
- "claude-code": "Claude Code",
157
- cursor: "Cursor",
158
- vscode: "VS Code / GitHub Copilot",
159
- windsurf: "Windsurf",
160
- antigravity: "Google Antigravity",
161
- gemini: "Gemini CLI",
162
- custom: "Custom",
163
- }[client] || client;
153
+ return (
154
+ {
155
+ codex: "Codex",
156
+ "claude-code": "Claude Code",
157
+ cursor: "Cursor",
158
+ vscode: "VS Code / GitHub Copilot",
159
+ windsurf: "Windsurf",
160
+ antigravity: "Google Antigravity",
161
+ gemini: "Gemini CLI",
162
+ custom: "Custom",
163
+ }[client] || client
164
+ );
164
165
  }
165
166
 
166
167
  function stripSkillFrontmatter(value) {
@@ -0,0 +1,299 @@
1
+ import { execFile } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+
7
+ const execFileAsync = promisify(execFile);
8
+ const staleTemporaryAgeMs = 24 * 60 * 60 * 1000;
9
+ const lockRetryDelayMs = 50;
10
+ const lockRetryLimit = 100;
11
+
12
+ export async function atomicWritePrivateFile(filePath, contents, options = {}) {
13
+ const destination = path.resolve(filePath);
14
+ const rename = options.rename || fs.rename;
15
+ const suffix = options.suffix || `${process.pid}-${randomBytes(8).toString("hex")}`;
16
+ const directory = path.dirname(destination);
17
+ let temporaryPath = privateTemporaryPath(destination, suffix);
18
+ let stagingDirectory;
19
+ let handle;
20
+
21
+ await ensurePrivateDirectory(directory, options);
22
+ await validatePrivateDestination(destination, options);
23
+ await cleanupStalePrivateFiles(destination, options);
24
+ try {
25
+ if (operatingSystem(options) === "win32") {
26
+ stagingDirectory = await createPrivateStagingDirectory(destination, options);
27
+ temporaryPath = path.join(stagingDirectory, path.basename(temporaryPath));
28
+ }
29
+ handle = await fs.open(temporaryPath, "wx", 0o600);
30
+ await enforcePrivateFilePermissions(temporaryPath, options);
31
+ await handle.writeFile(contents, { encoding: "utf8" });
32
+ await handle.sync();
33
+ await handle.close();
34
+ handle = undefined;
35
+ await validatePrivateDestination(destination, options);
36
+ await rename(temporaryPath, destination);
37
+ await (options.syncDirectory || syncParentDirectory)(directory);
38
+ } catch (error) {
39
+ await handle?.close().catch(() => {});
40
+ await fs.unlink(temporaryPath).catch(() => {});
41
+ throw error;
42
+ } finally {
43
+ if (stagingDirectory) await fs.rm(stagingDirectory, { recursive: true, force: true }).catch(() => {});
44
+ }
45
+ }
46
+
47
+ export async function withPrivateFileLock(filePath, task, options = {}) {
48
+ const destination = path.resolve(filePath);
49
+ const directory = path.dirname(destination);
50
+ const lockPath = `${destination}.aipermission.lock`;
51
+ await ensurePrivateDirectory(directory, options);
52
+ await validatePrivateDestination(destination, options);
53
+
54
+ const ownerToken = randomBytes(16).toString("hex");
55
+ const ownerRecord = JSON.stringify({ pid: process.pid, token: ownerToken });
56
+ let handle;
57
+ for (let attempt = 0; attempt < (options.lockRetryLimit ?? lockRetryLimit); attempt += 1) {
58
+ let createdLock = false;
59
+ try {
60
+ handle = await fs.open(lockPath, "wx", 0o600);
61
+ createdLock = true;
62
+ await enforcePrivateFilePermissions(lockPath, options);
63
+ await handle.writeFile(`${ownerRecord}\n`, { encoding: "utf8" });
64
+ await handle.sync();
65
+ break;
66
+ } catch (error) {
67
+ await handle?.close().catch(() => {});
68
+ handle = undefined;
69
+ if (createdLock) await fs.unlink(lockPath).catch(() => {});
70
+ if (error.code !== "EEXIST") throw error;
71
+ await delay(options.lockRetryDelayMs ?? lockRetryDelayMs);
72
+ }
73
+ }
74
+ if (!handle) {
75
+ throw new Error(
76
+ `Timed out waiting for private config lock: ${destination}. If no other MCP setup process is running, remove ${lockPath} and retry.`,
77
+ );
78
+ }
79
+
80
+ try {
81
+ return await task();
82
+ } finally {
83
+ await handle.close().catch(() => {});
84
+ await releaseOwnedLock(lockPath, ownerToken);
85
+ await (options.syncDirectory || syncParentDirectory)(directory);
86
+ }
87
+ }
88
+
89
+ export function privateTemporaryPath(filePath, suffix) {
90
+ return path.join(path.dirname(filePath), `.${path.basename(filePath)}.aipermission-${suffix}.tmp`);
91
+ }
92
+
93
+ export function privateTemporaryIgnorePath(filePath) {
94
+ return privateTemporaryPath(filePath, "*");
95
+ }
96
+
97
+ export function privateStagingPath(filePath, suffix) {
98
+ return path.join(path.dirname(filePath), `.${path.basename(filePath)}.aipermission-stage-${suffix}`);
99
+ }
100
+
101
+ export function privateStagingIgnorePath(filePath) {
102
+ return privateStagingPath(filePath, "*");
103
+ }
104
+
105
+ export function privateLockPath(filePath) {
106
+ return `${filePath}.aipermission.lock`;
107
+ }
108
+
109
+ export async function cleanupStalePrivateFiles(filePath, options = {}) {
110
+ const directory = path.dirname(filePath);
111
+ const prefix = `.${path.basename(filePath)}.aipermission-`;
112
+ const now = options.now ?? Date.now();
113
+ const maximumAge = options.staleAgeMs ?? staleTemporaryAgeMs;
114
+ let entries;
115
+ try {
116
+ entries = await fs.readdir(directory, { withFileTypes: true });
117
+ } catch (error) {
118
+ if (error.code === "ENOENT") return;
119
+ throw error;
120
+ }
121
+ for (const entry of entries) {
122
+ const temporaryFile = entry.isFile() && entry.name.startsWith(prefix) && entry.name.endsWith(".tmp");
123
+ const stagingDirectory = entry.isDirectory() && entry.name.startsWith(`${prefix}stage-`);
124
+ if (!temporaryFile && !stagingDirectory) continue;
125
+ const temporaryPath = path.join(directory, entry.name);
126
+ try {
127
+ const stat = await fs.lstat(temporaryPath);
128
+ if (now - stat.mtimeMs < maximumAge) continue;
129
+ if (stat.isFile()) await fs.unlink(temporaryPath);
130
+ if (stat.isDirectory()) await fs.rm(temporaryPath, { recursive: true, force: true });
131
+ } catch (error) {
132
+ if (error.code !== "ENOENT") throw error;
133
+ }
134
+ }
135
+ }
136
+
137
+ async function ensurePrivateDirectory(directory, options) {
138
+ const trustedRoot = path.resolve(options.trustedRoot || directory);
139
+ const resolvedDirectory = path.resolve(directory);
140
+ assertPathWithinRoot(trustedRoot, resolvedDirectory);
141
+ const missing = [];
142
+ const relativeParts = path.relative(trustedRoot, resolvedDirectory).split(path.sep).filter(Boolean);
143
+ let current = trustedRoot;
144
+ for (const part of ["", ...relativeParts]) {
145
+ if (part) current = path.join(current, part);
146
+ try {
147
+ const stat = await fs.lstat(current);
148
+ if (stat.isSymbolicLink()) throw new Error(`Refusing private config path through symbolic link or junction: ${current}`);
149
+ if (!stat.isDirectory()) throw new Error(`Private config parent is not a directory: ${current}`);
150
+ } catch (error) {
151
+ if (error.code !== "ENOENT") throw error;
152
+ missing.push(current);
153
+ }
154
+ }
155
+ await fs.mkdir(resolvedDirectory, { recursive: true, mode: 0o700 });
156
+ await rejectSymbolicPathComponents(trustedRoot, resolvedDirectory);
157
+ for (const created of missing) {
158
+ await fs.chmod(created, 0o700).catch((error) => {
159
+ if (process.platform !== "win32") throw error;
160
+ });
161
+ await (options.syncDirectory || syncParentDirectory)(path.dirname(created));
162
+ }
163
+ }
164
+
165
+ async function createPrivateStagingDirectory(destination, options) {
166
+ const prefix = path.join(path.dirname(destination), `.${path.basename(destination)}.aipermission-stage-`);
167
+ const directory = await (options.makeTemporaryDirectory || fs.mkdtemp)(prefix);
168
+ try {
169
+ await enforcePrivateDirectoryPermissions(directory, options);
170
+ return directory;
171
+ } catch (error) {
172
+ await fs.rm(directory, { recursive: true, force: true }).catch(() => {});
173
+ throw error;
174
+ }
175
+ }
176
+
177
+ async function validatePrivateDestination(filePath, options) {
178
+ const trustedRoot = path.resolve(options.trustedRoot || path.dirname(filePath));
179
+ assertPathWithinRoot(trustedRoot, filePath);
180
+ await rejectSymbolicPathComponents(trustedRoot, path.dirname(filePath));
181
+ await rejectSymbolicLink(filePath);
182
+ }
183
+
184
+ async function rejectSymbolicPathComponents(root, directory) {
185
+ const relativeParts = path.relative(root, directory).split(path.sep).filter(Boolean);
186
+ let current = root;
187
+ for (const part of ["", ...relativeParts]) {
188
+ if (part) current = path.join(current, part);
189
+ const stat = await fs.lstat(current);
190
+ if (stat.isSymbolicLink()) {
191
+ throw new Error(`Refusing private config path through symbolic link or junction: ${current}`);
192
+ }
193
+ if (!stat.isDirectory()) {
194
+ throw new Error(`Private config parent is not a directory: ${current}`);
195
+ }
196
+ }
197
+ }
198
+
199
+ function assertPathWithinRoot(root, destination) {
200
+ const relative = path.relative(path.resolve(root), path.resolve(destination));
201
+ if (relative.startsWith(`..${path.sep}`) || relative === ".." || path.isAbsolute(relative)) {
202
+ throw new Error(`Refusing private config path outside trusted root: ${destination}`);
203
+ }
204
+ }
205
+
206
+ async function rejectSymbolicLink(filePath) {
207
+ try {
208
+ const stat = await fs.lstat(filePath);
209
+ if (stat.isSymbolicLink()) {
210
+ throw new Error(`Refusing to replace symbolic-link config: ${filePath}`);
211
+ }
212
+ } catch (error) {
213
+ if (error.code === "ENOENT") return;
214
+ throw error;
215
+ }
216
+ }
217
+
218
+ async function releaseOwnedLock(lockPath, ownerToken) {
219
+ try {
220
+ const owner = JSON.parse(await fs.readFile(lockPath, "utf8"));
221
+ if (owner?.token !== ownerToken) return;
222
+ await fs.unlink(lockPath);
223
+ } catch (error) {
224
+ if (error.code === "ENOENT" || error instanceof SyntaxError) return;
225
+ throw error;
226
+ }
227
+ }
228
+
229
+ async function enforcePrivateFilePermissions(filePath, options) {
230
+ if (options.enforcePermissions) {
231
+ await options.enforcePermissions(filePath);
232
+ return;
233
+ }
234
+ if (operatingSystem(options) !== "win32") {
235
+ await fs.chmod(filePath, 0o600);
236
+ return;
237
+ }
238
+ const sid = await currentWindowsSID(options);
239
+ await runWindowsSystemExecutable("icacls", [filePath, "/inheritance:r", "/grant:r", `*${sid}:(F)`], options);
240
+ }
241
+
242
+ async function enforcePrivateDirectoryPermissions(directory, options) {
243
+ if (options.enforceDirectoryPermissions) {
244
+ await options.enforceDirectoryPermissions(directory);
245
+ return;
246
+ }
247
+ if (operatingSystem(options) !== "win32") {
248
+ await fs.chmod(directory, 0o700);
249
+ return;
250
+ }
251
+ const sid = await currentWindowsSID(options);
252
+ await runWindowsSystemExecutable("icacls", [directory, "/inheritance:r", "/grant:r", `*${sid}:(OI)(CI)(F)`], options);
253
+ }
254
+
255
+ async function currentWindowsSID(options) {
256
+ const { stdout } = await runWindowsSystemExecutable("whoami", ["/user", "/fo", "csv", "/nh"], options, {
257
+ encoding: "utf8",
258
+ });
259
+ const sid = stdout.match(/S-\d-(?:\d+-)+\d+/)?.[0];
260
+ if (!sid) throw new Error("Could not determine current Windows SID for private config ACL");
261
+ return sid;
262
+ }
263
+
264
+ function runWindowsSystemExecutable(name, args, options, executionOptions = {}) {
265
+ const execute = options.execFile || execFileAsync;
266
+ return execute(windowsSystemExecutable(name, options), args, {
267
+ windowsHide: true,
268
+ ...executionOptions,
269
+ });
270
+ }
271
+
272
+ function windowsSystemExecutable(name, options) {
273
+ const systemRoot = options.windowsSystemRoot || process.env.SystemRoot || process.env.windir || "C:\\Windows";
274
+ if (!path.win32.isAbsolute(systemRoot)) {
275
+ throw new Error("Windows SystemRoot must be an absolute path for private config ACL setup");
276
+ }
277
+ return path.win32.join(systemRoot, "System32", `${name}.exe`);
278
+ }
279
+
280
+ function operatingSystem(options) {
281
+ return options.platform || process.platform;
282
+ }
283
+
284
+ async function syncParentDirectory(directory) {
285
+ let handle;
286
+ try {
287
+ handle = await fs.open(directory, "r");
288
+ await handle.sync();
289
+ } catch (error) {
290
+ if (process.platform === "win32" && ["EACCES", "EINVAL", "ENOTSUP", "EPERM"].includes(error.code)) return;
291
+ throw error;
292
+ } finally {
293
+ await handle?.close().catch(() => {});
294
+ }
295
+ }
296
+
297
+ function delay(milliseconds) {
298
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
299
+ }
@@ -204,8 +204,11 @@ prefer this sequence:
204
204
  type, headers, or existence.
205
205
  5. Use `download_object` only for bounded object reads explicitly requested by
206
206
  the operator.
207
- 6. Keep `overwrite=false` for `upload_object` and `rename_object` unless the
208
- operator explicitly approved replacement.
207
+ 6. Keep `overwrite=false` for `upload_object` unless the operator explicitly
208
+ approved replacement. `rename_object` is intentionally unavailable because
209
+ S3-compatible APIs do not provide an atomic cross-key move. Keep the source
210
+ intact after creating a destination; deletion is a separate destructive
211
+ operator decision and must not be inferred from copy verification.
209
212
  7. Treat `delete_object` as destructive and ask for explicit confirmation if
210
213
  approval mode does not already provide it.
211
214
  8. Use `presign_download` and `presign_upload` only for one exact object key
package/dist/server.js CHANGED
@@ -30,7 +30,7 @@ server.tool(
30
30
  {},
31
31
  async () => {
32
32
  return jsonToolResult(() => apiGet("/api/mcp/connector-targets"));
33
- }
33
+ },
34
34
  );
35
35
 
36
36
  server.tool(
@@ -44,7 +44,7 @@ server.tool(
44
44
  const params = new URLSearchParams({ target_ref });
45
45
  return apiGet(`/api/mcp/connector-help?${params.toString()}`);
46
46
  });
47
- }
47
+ },
48
48
  );
49
49
 
50
50
  server.tool(
@@ -58,7 +58,7 @@ server.tool(
58
58
  const params = new URLSearchParams({ target_ref });
59
59
  return apiGet(`/api/mcp/connector-actions?${params.toString()}`);
60
60
  });
61
- }
61
+ },
62
62
  );
63
63
 
64
64
  server.tool(
@@ -69,17 +69,24 @@ server.tool(
69
69
  action_name: z.string().min(1).describe("Action name from get_connector_actions."),
70
70
  input: z.record(z.unknown()).optional().describe("Connector-specific action input."),
71
71
  reason: z.string().optional().describe("Why this connector action is needed."),
72
- idempotency_key: z.string().min(1).max(128).optional().describe("Caller-stable key that makes retries return the original request without running twice."),
72
+ idempotency_key: z
73
+ .string()
74
+ .min(1)
75
+ .max(128)
76
+ .optional()
77
+ .describe("Caller-stable key that makes retries return the original request without running twice."),
73
78
  },
74
79
  async ({ target_ref, action_name, input, reason, idempotency_key }) => {
75
- return jsonToolResult(() => apiPost("/api/mcp/connector-actions/call", {
76
- target_ref,
77
- action_name,
78
- input: input || {},
79
- reason: reason || "",
80
- idempotency_key,
81
- }));
82
- }
80
+ return jsonToolResult(() =>
81
+ apiPost("/api/mcp/connector-actions/call", {
82
+ target_ref,
83
+ action_name,
84
+ input: input || {},
85
+ reason: reason || "",
86
+ idempotency_key,
87
+ }),
88
+ );
89
+ },
83
90
  );
84
91
 
85
92
  server.tool(
@@ -90,7 +97,7 @@ server.tool(
90
97
  },
91
98
  async ({ request_id }) => {
92
99
  return jsonToolResult(() => apiGet(`/api/mcp/connector-action-requests/${request_id}`));
93
- }
100
+ },
94
101
  );
95
102
 
96
103
  server.tool(
@@ -104,7 +111,7 @@ server.tool(
104
111
  const query = params.toString();
105
112
  return apiGet(`/api/mcp/vault-items${query ? `?${query}` : ""}`);
106
113
  });
107
- }
114
+ },
108
115
  );
109
116
 
110
117
  server.tool(
@@ -112,14 +119,16 @@ server.tool(
112
119
  "Run a Vault action under the configured project capability. Prompt waits for local approval; Always executes immediately through the same tracked request path. generate_item input accepts name, secret_type, generator_kind, provider, environment, description, expires_at, expiry_warning_days, tags (string array), usage_notes (array of {location, notes}), and shared_project_ids (integer array). restart_session_with_environment input requires target_ref and items with item_id, source_project_id, and optional replace_existing. Never include raw secret values.",
113
120
  callVaultActionSchema,
114
121
  async ({ project_ref, action_name, input, reason, idempotency_key }) => {
115
- return jsonToolResult(() => apiPost("/api/mcp/vault-actions/call", {
116
- project_ref,
117
- action_name,
118
- input,
119
- reason,
120
- idempotency_key,
121
- }));
122
- }
122
+ return jsonToolResult(() =>
123
+ apiPost("/api/mcp/vault-actions/call", {
124
+ project_ref,
125
+ action_name,
126
+ input,
127
+ reason,
128
+ idempotency_key,
129
+ }),
130
+ );
131
+ },
123
132
  );
124
133
 
125
134
  server.tool(
@@ -128,7 +137,7 @@ server.tool(
128
137
  vaultActionRequestSchema,
129
138
  async ({ request_id }) => {
130
139
  return jsonToolResult(() => apiGet(`/api/mcp/vault-action-requests/${request_id}`));
131
- }
140
+ },
132
141
  );
133
142
 
134
143
  server.tool(
@@ -137,7 +146,7 @@ server.tool(
137
146
  vaultActionRequestSchema,
138
147
  async ({ request_id }) => {
139
148
  return jsonToolResult(() => apiPost(`/api/mcp/vault-action-requests/${request_id}/cancel`, {}));
140
- }
149
+ },
141
150
  );
142
151
 
143
152
  const transport = new StdioServerTransport();
@@ -190,7 +199,7 @@ async function apiFetch(path, options) {
190
199
  });
191
200
  } catch (error) {
192
201
  if (error?.name === "AbortError") {
193
- throw new Error(`AIPermission API request timed out after ${timeout}ms`);
202
+ throw new Error(`AIPermission API request timed out after ${timeout}ms`, { cause: error });
194
203
  }
195
204
  throw error;
196
205
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aipermission/mcp",
3
- "version": "0.2.36",
3
+ "version": "0.2.38",
4
4
  "mcpName": "io.github.aipermission/aipermission-mcp",
5
5
  "description": "Local-first MCP bridge for the aipermission gateway.",
6
6
  "license": "AGPL-3.0-only",
@@ -28,6 +28,9 @@
28
28
  ],
29
29
  "scripts": {
30
30
  "build": "node scripts/build.js",
31
+ "format": "prettier --write \"src/**/*.js\" \"test/**/*.js\" \"scripts/**/*.js\"",
32
+ "format:check": "prettier --check \"src/**/*.js\" \"test/**/*.js\" \"scripts/**/*.js\"",
33
+ "lint": "eslint src test scripts",
31
34
  "prepack": "npm run build",
32
35
  "test": "node --test test/*.test.js",
33
36
  "start": "node dist/cli.js",
@@ -36,8 +39,21 @@
36
39
  "engines": {
37
40
  "node": ">=20"
38
41
  },
42
+ "devEngines": {
43
+ "runtime": {
44
+ "name": "node",
45
+ "version": ">=20.19.0",
46
+ "onFail": "error"
47
+ }
48
+ },
39
49
  "dependencies": {
40
50
  "@modelcontextprotocol/sdk": "1.30.0",
41
51
  "zod": "3.25.76"
52
+ },
53
+ "devDependencies": {
54
+ "@eslint/js": "^10.0.1",
55
+ "eslint": "^10.9.1",
56
+ "globals": "^17.11.0",
57
+ "prettier": "^3.9.6"
42
58
  }
43
59
  }
package/server.json CHANGED
@@ -3,12 +3,12 @@
3
3
  "name": "io.github.aipermission/aipermission-mcp",
4
4
  "title": "AIPermission",
5
5
  "description": "Local-first MCP bridge for the AIPermission gateway.",
6
- "version": "0.2.36",
6
+ "version": "0.2.38",
7
7
  "packages": [
8
8
  {
9
9
  "registryType": "npm",
10
10
  "identifier": "@aipermission/mcp",
11
- "version": "0.2.36",
11
+ "version": "0.2.38",
12
12
  "transport": {
13
13
  "type": "stdio"
14
14
  }