agentlas 1.0.65 → 1.0.67

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 (163) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +12 -4
  3. package/bin/agentlas.cjs +354 -16
  4. package/engine/acp/server.cjs +34 -3
  5. package/engine/agentlas-api-agent.cjs +151 -120
  6. package/engine/agentlas-banner.cjs +6 -2
  7. package/engine/agentlas-capabilities.cjs +17 -10
  8. package/engine/agentlas-cloud-runtime.cjs +4 -5
  9. package/engine/agentlas-config.cjs +19 -54
  10. package/engine/agentlas-core-harness.cjs +59 -5
  11. package/engine/agentlas-desktop-loadout.cjs +17 -3
  12. package/engine/agentlas-evolution.cjs +75 -18
  13. package/engine/agentlas-experience-exchange.cjs +145 -14
  14. package/engine/agentlas-experience-intake.cjs +2 -2
  15. package/engine/agentlas-i18n.cjs +0 -2
  16. package/engine/agentlas-input.cjs +273 -20
  17. package/engine/agentlas-judgment.cjs +0 -0
  18. package/engine/agentlas-mcp-env.cjs +84 -23
  19. package/engine/agentlas-mcp-wrapper.cjs +36 -4
  20. package/engine/agentlas-memory-governance.cjs +2 -1
  21. package/engine/agentlas-memory-import.cjs +49 -12
  22. package/engine/agentlas-native-host.cjs +279 -44
  23. package/engine/agentlas-onboard.cjs +23 -1
  24. package/engine/agentlas-permissions.cjs +5 -1
  25. package/engine/agentlas-sqlite-policy.cjs +40 -2
  26. package/engine/agentlas-tasks.cjs +7 -0
  27. package/engine/agentlas-tools.cjs +37 -10
  28. package/engine/agentlas-ui.cjs +26 -4
  29. package/engine/agentlas-workforce.cjs +30 -5
  30. package/engine/agentlas-workload-routing.cjs +83 -10
  31. package/engine/agentlas.cjs +21 -4
  32. package/engine/agents/builder.cjs +35 -10
  33. package/engine/agents/files.cjs +81 -10
  34. package/engine/agents/import-local.cjs +10 -2
  35. package/engine/agents/registry.cjs +10 -3
  36. package/engine/agents/router.cjs +7 -7
  37. package/engine/agents/routes.cjs +31 -9
  38. package/engine/automation/daemon.cjs +154 -40
  39. package/engine/automation/launchd.cjs +395 -16
  40. package/engine/automation/schedule.cjs +90 -3
  41. package/engine/automation/store.cjs +163 -59
  42. package/engine/browser/cdp.cjs +190 -17
  43. package/engine/browser/vault.cjs +1 -0
  44. package/engine/cli-output.cjs +96 -30
  45. package/engine/cloud/auth.cjs +131 -13
  46. package/engine/cloud/hub-client.cjs +41 -16
  47. package/engine/cloud-assets/cas.cjs +18 -9
  48. package/engine/cloud-assets/commands.cjs +108 -30
  49. package/engine/cloud-assets/package.cjs +66 -3
  50. package/engine/cloud-assets/restore.cjs +12 -9
  51. package/engine/cloud-assets/state.cjs +609 -116
  52. package/engine/commands/acp.cjs +13 -2
  53. package/engine/commands/automation.cjs +75 -24
  54. package/engine/commands/billing.cjs +6 -1
  55. package/engine/commands/browser.cjs +61 -29
  56. package/engine/commands/build.cjs +72 -14
  57. package/engine/commands/call.cjs +1 -1
  58. package/engine/commands/cd.cjs +1 -1
  59. package/engine/commands/cloud.cjs +2 -1
  60. package/engine/commands/connect.cjs +38 -13
  61. package/engine/commands/creds.cjs +74 -29
  62. package/engine/commands/doctor.cjs +17 -1
  63. package/engine/commands/document.cjs +38 -10
  64. package/engine/commands/env.cjs +6 -1
  65. package/engine/commands/firm.cjs +42 -12
  66. package/engine/commands/graph.cjs +36 -32
  67. package/engine/commands/help.cjs +11 -1
  68. package/engine/commands/hep.cjs +1 -1
  69. package/engine/commands/import.cjs +5 -4
  70. package/engine/commands/index.cjs +1 -1
  71. package/engine/commands/install.cjs +6 -4
  72. package/engine/commands/list.cjs +56 -16
  73. package/engine/commands/login.cjs +5 -0
  74. package/engine/commands/logout.cjs +6 -1
  75. package/engine/commands/mcp.cjs +150 -37
  76. package/engine/commands/memory.cjs +3 -2
  77. package/engine/commands/multimodal.cjs +12 -0
  78. package/engine/commands/native.cjs +5 -1
  79. package/engine/commands/one.cjs +78 -33
  80. package/engine/commands/plugin.cjs +13 -2
  81. package/engine/commands/project.cjs +15 -12
  82. package/engine/commands/research.cjs +18 -7
  83. package/engine/commands/roles.cjs +104 -28
  84. package/engine/commands/route.cjs +2 -2
  85. package/engine/commands/run.cjs +42 -9
  86. package/engine/commands/search.cjs +23 -2
  87. package/engine/commands/setup.cjs +6 -1
  88. package/engine/commands/storm.cjs +44 -6
  89. package/engine/commands/swarm.cjs +47 -9
  90. package/engine/commands/telegram.cjs +6 -1
  91. package/engine/commands/uninstall.cjs +12 -6
  92. package/engine/commands/update.cjs +5 -0
  93. package/engine/commands/upload.cjs +2 -1
  94. package/engine/commands/usage.cjs +6 -1
  95. package/engine/commands/version.cjs +6 -1
  96. package/engine/commands/whoami.cjs +6 -1
  97. package/engine/commands/workforce.cjs +60 -16
  98. package/engine/core/capability-grants.cjs +7 -2
  99. package/engine/core/daemon-client.cjs +36 -7
  100. package/engine/core/db.cjs +10 -2
  101. package/engine/core/desktop-core-fetch.cjs +477 -77
  102. package/engine/core/schema-ensure.cjs +23 -11
  103. package/engine/experience/build.cjs +5 -1
  104. package/engine/experience/intents.cjs +35 -82
  105. package/engine/experience/runtime.cjs +7 -1
  106. package/engine/experience/variant.cjs +58 -4
  107. package/engine/firms/orchestrate.cjs +9 -6
  108. package/engine/graph/ask-model.cjs +22 -11
  109. package/engine/graph/interview.cjs +74 -23
  110. package/engine/graph/package.cjs +63 -8
  111. package/engine/hephaestus/local-core.cjs +18 -5
  112. package/engine/hephaestus/runtime.cjs +48 -43
  113. package/engine/hub/install.cjs +484 -63
  114. package/engine/hub/plugins.cjs +391 -29
  115. package/engine/mcp/consent.cjs +25 -17
  116. package/engine/mcp/contract.cjs +229 -24
  117. package/engine/mcp/inventory.cjs +46 -10
  118. package/engine/mcp/plan.cjs +10 -1
  119. package/engine/mcp/probe.cjs +24 -9
  120. package/engine/memory-cli/curate.cjs +74 -16
  121. package/engine/oberon/common.cjs +37 -12
  122. package/engine/oberon/manifest.cjs +25 -2
  123. package/engine/oberon/outputs.cjs +49 -19
  124. package/engine/oberon/render.cjs +665 -40
  125. package/engine/project/career-graph.cjs +15 -2
  126. package/engine/project/controller.cjs +133 -15
  127. package/engine/project/credentials.cjs +382 -34
  128. package/engine/project/env-file.cjs +93 -8
  129. package/engine/project/memory-context.cjs +11 -57
  130. package/engine/project/ontology.cjs +66 -25
  131. package/engine/project/paths.cjs +42 -8
  132. package/engine/project/seed.cjs +88 -51
  133. package/engine/project/state.cjs +41 -16
  134. package/engine/project/team.cjs +26 -1
  135. package/engine/runtime-refusal.cjs +4 -1
  136. package/engine/runtimes/acp-driver.cjs +56 -9
  137. package/engine/runtimes/auth-evidence.cjs +21 -2
  138. package/engine/runtimes/detect.cjs +28 -8
  139. package/engine/runtimes/kinds.cjs +12 -4
  140. package/engine/runtimes/overrides.cjs +4 -3
  141. package/engine/runtimes/resolve.cjs +8 -7
  142. package/engine/runtimes/roles.cjs +7 -5
  143. package/engine/sessions/apply-fences.cjs +396 -56
  144. package/engine/sessions/fences.cjs +111 -27
  145. package/engine/sessions/memory-turn.cjs +62 -3
  146. package/engine/sessions/orchestrator.cjs +11 -1
  147. package/engine/sessions/prompt.cjs +66 -9
  148. package/engine/sessions/session.cjs +70 -10
  149. package/engine/sessions/sink.cjs +78 -12
  150. package/engine/sessions/store.cjs +29 -7
  151. package/engine/storm/storm.cjs +26 -3
  152. package/engine/storm/swarm.cjs +31 -3
  153. package/engine/telegram/connect.cjs +185 -22
  154. package/engine/ui/commands-catalog.cjs +1 -1
  155. package/engine/ui/palette.cjs +12 -2
  156. package/engine/ui/repl.cjs +57 -9
  157. package/engine/ui/screens.cjs +2 -4
  158. package/engine/vendor/desktop-core.manifest.json +5 -5
  159. package/engine/workforce/capture.cjs +9 -27
  160. package/engine/workforce/deps.cjs +18 -6
  161. package/engine/workforce/local-core-transport.cjs +68 -5
  162. package/package.json +1 -1
  163. package/engine/commands/career-graph.cjs +0 -51
@@ -206,6 +206,11 @@ function installPluginMcpRows(db, rows) {
206
206
 
207
207
  const PLUGIN_SKILL_SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
208
208
  const PLUGIN_SKILL_FILE_MAX_BYTES = 512 * 1024;
209
+ const PLUGIN_SKILL_MAX_COUNT = 64;
210
+ const PLUGIN_SKILL_MAX_FILES_PER_SKILL = 256;
211
+ const PLUGIN_SKILL_TOTAL_MAX_BYTES = 8 * 1024 * 1024;
212
+ const PLUGIN_MARKER_MAX_BYTES = 128 * 1024;
213
+ const PLUGIN_LOCAL_MAX_COUNT = 256;
209
214
 
210
215
  /** 세 채널이 공유하는 로컬 플러그인 저장소 루트. homeDir 주입은 테스트 격리용. */
211
216
  function agentlasPluginsDir({ homeDir } = {}) {
@@ -230,7 +235,12 @@ function planPluginSkillInstall(slug, manifest) {
230
235
  const skills = [];
231
236
  const declaredOnly = [];
232
237
  const refused = [];
233
- for (const entry of entries) {
238
+ let plannedBytes = 0;
239
+ const seenNames = new Set();
240
+ if (entries.length > PLUGIN_SKILL_MAX_COUNT) {
241
+ refused.push({ name: "(manifest)", reason: `too many skills (maximum ${PLUGIN_SKILL_MAX_COUNT})` });
242
+ }
243
+ for (const entry of entries.slice(0, PLUGIN_SKILL_MAX_COUNT)) {
234
244
  const name = typeof entry?.name === "string" ? entry.name.trim() : "";
235
245
  if (!name) continue;
236
246
  const rawFiles = Array.isArray(entry?.files) ? entry.files : [];
@@ -242,25 +252,284 @@ function planPluginSkillInstall(slug, manifest) {
242
252
  refused.push({ name, reason: "invalid skill name" });
243
253
  continue;
244
254
  }
255
+ const nameKey = name.toLowerCase();
256
+ if (seenNames.has(nameKey)) {
257
+ refused.push({ name, reason: "duplicate skill name" });
258
+ continue;
259
+ }
260
+ seenNames.add(nameKey);
261
+ if (rawFiles.length > PLUGIN_SKILL_MAX_FILES_PER_SKILL) {
262
+ refused.push({ name, reason: `too many files (maximum ${PLUGIN_SKILL_MAX_FILES_PER_SKILL})` });
263
+ continue;
264
+ }
245
265
  const files = [];
266
+ const seenPaths = new Set();
267
+ let skillBytes = 0;
246
268
  let bad = null;
247
269
  for (const file of rawFiles) {
248
270
  const filePath = typeof file?.path === "string" ? file.path.trim() : "";
249
271
  const content = typeof file?.content === "string" ? file.content : "";
250
272
  if (!pluginSkillSafeRelativePath(filePath)) { bad = `unsafe file path "${filePath}"`; break; }
273
+ const pathKey = filePath.toLowerCase();
274
+ if (seenPaths.has(pathKey)) { bad = `duplicate file path "${filePath}"`; break; }
275
+ seenPaths.add(pathKey);
251
276
  if (!content.trim()) { bad = `empty content for ${filePath}`; break; }
252
- if (Buffer.byteLength(content, "utf8") > PLUGIN_SKILL_FILE_MAX_BYTES) { bad = `${filePath} exceeds the file size cap`; break; }
277
+ const bytes = Buffer.byteLength(content, "utf8");
278
+ if (bytes > PLUGIN_SKILL_FILE_MAX_BYTES) { bad = `${filePath} exceeds the file size cap`; break; }
279
+ skillBytes += bytes;
253
280
  const sha256 = typeof file?.sha256 === "string" && /^[0-9a-f]{64}$/i.test(file.sha256)
254
281
  ? file.sha256.toLowerCase()
255
282
  : null;
256
283
  files.push({ path: filePath, content, sha256 });
257
284
  }
258
285
  if (bad) { refused.push({ name, reason: bad }); continue; }
286
+ if (plannedBytes + skillBytes > PLUGIN_SKILL_TOTAL_MAX_BYTES) {
287
+ refused.push({ name, reason: `skill payloads exceed the total size cap (${PLUGIN_SKILL_TOTAL_MAX_BYTES} bytes)` });
288
+ continue;
289
+ }
290
+ plannedBytes += skillBytes;
259
291
  skills.push({ name, description: typeof entry?.description === "string" ? entry.description : null, files });
260
292
  }
261
293
  return { skills, declaredOnly, refused };
262
294
  }
263
295
 
296
+ function privateDirectory(dir) {
297
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
298
+ const stat = fs.lstatSync(dir);
299
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
300
+ throw new Error(`refusing unsafe plugin directory: ${dir}`);
301
+ }
302
+ fs.chmodSync(dir, 0o700);
303
+ const verified = fs.lstatSync(dir);
304
+ if (!verified.isDirectory() || verified.isSymbolicLink() || !sameDirectoryIdentity(stat, verified)) {
305
+ throw new Error(`plugin directory changed during setup: ${dir}`);
306
+ }
307
+ return verified;
308
+ }
309
+
310
+ function sameDirectoryIdentity(left, right) {
311
+ return left
312
+ && right
313
+ && left.isDirectory()
314
+ && right.isDirectory()
315
+ && !left.isSymbolicLink()
316
+ && !right.isSymbolicLink()
317
+ && left.dev === right.dev
318
+ && left.ino === right.ino;
319
+ }
320
+
321
+ function privateDirectoryIdentity(dir) {
322
+ const stat = fs.lstatSync(dir);
323
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
324
+ throw new Error(`refusing unsafe plugin directory: ${dir}`);
325
+ }
326
+ return stat;
327
+ }
328
+
329
+ function assertPrivateDirectoryIdentity(dir, expected) {
330
+ const actual = privateDirectoryIdentity(dir);
331
+ if (!sameDirectoryIdentity(expected, actual)) {
332
+ throw new Error(`plugin directory changed during write: ${dir}`);
333
+ }
334
+ return actual;
335
+ }
336
+
337
+ function sameFileInode(left, right) {
338
+ return left
339
+ && right
340
+ && left.isFile()
341
+ && right.isFile()
342
+ && !left.isSymbolicLink()
343
+ && !right.isSymbolicLink()
344
+ && left.dev === right.dev
345
+ && left.ino === right.ino;
346
+ }
347
+
348
+ function existingPluginFile(target) {
349
+ let stat;
350
+ try {
351
+ stat = fs.lstatSync(target);
352
+ } catch (error) {
353
+ if (error && error.code === "ENOENT") return null;
354
+ throw error;
355
+ }
356
+ if (stat.isSymbolicLink()) throw new Error(`refusing symlink destination: ${target}`);
357
+ if (!stat.isFile()) throw new Error(`refusing non-file destination: ${target}`);
358
+ if (stat.nlink > 1) throw new Error(`refusing hard-linked destination: ${target}`);
359
+ return stat;
360
+ }
361
+
362
+ function writePluginFileAtomic(target, content) {
363
+ const parent = path.dirname(target);
364
+ const parentStat = privateDirectory(parent);
365
+ const expectedTarget = existingPluginFile(target);
366
+ assertPrivateDirectoryIdentity(parent, parentStat);
367
+ const temp = path.join(parent, `.${path.basename(target)}.agentlas-${crypto.randomUUID()}.tmp`);
368
+ const flags = fs.constants.O_WRONLY
369
+ | fs.constants.O_CREAT
370
+ | fs.constants.O_EXCL
371
+ | (fs.constants.O_NOFOLLOW || 0);
372
+ let fd = null;
373
+ let tempStat = null;
374
+ let tempPublished = false;
375
+ try {
376
+ assertPrivateDirectoryIdentity(parent, parentStat);
377
+ if (expectedTarget) {
378
+ // Node has no portable rename-no-replace primitive. For an existing file,
379
+ // update only the exact inode observed above, never a pathname successor.
380
+ fd = fs.openSync(target, fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW || 0));
381
+ const opened = fs.fstatSync(fd);
382
+ if (!sameFileInode(expectedTarget, opened) || opened.nlink !== 1) {
383
+ throw new Error(`plugin destination changed before update: ${target}`);
384
+ }
385
+ fs.ftruncateSync(fd, 0);
386
+ fs.writeSync(fd, content, 0, "utf8");
387
+ fs.fsyncSync(fd);
388
+ const updated = fs.fstatSync(fd);
389
+ if (!sameFileInode(opened, updated) || updated.nlink !== 1) {
390
+ throw new Error(`plugin destination identity changed during update: ${target}`);
391
+ }
392
+ fs.closeSync(fd);
393
+ fd = null;
394
+ assertPrivateDirectoryIdentity(parent, parentStat);
395
+ const finalStat = fs.lstatSync(target);
396
+ if (!sameFileInode(updated, finalStat) || finalStat.nlink !== 1) {
397
+ throw new Error(`plugin destination changed after update: ${target}`);
398
+ }
399
+ return;
400
+ }
401
+
402
+ fd = fs.openSync(temp, flags, 0o600);
403
+ fs.writeFileSync(fd, content, { encoding: "utf8" });
404
+ fs.fsyncSync(fd);
405
+ tempStat = fs.fstatSync(fd);
406
+ if (!tempStat.isFile() || tempStat.nlink !== 1) throw new Error(`unsafe temporary plugin file: ${target}`);
407
+ fs.closeSync(fd);
408
+ fd = null;
409
+ assertPrivateDirectoryIdentity(parent, parentStat);
410
+ // link(2) is atomic and no-replace: unlike rename(2), a successor that
411
+ // appears after the preflight can never be silently clobbered.
412
+ if (existingPluginFile(target)) {
413
+ throw new Error(`plugin destination appeared during write: ${target}`);
414
+ }
415
+ fs.linkSync(temp, target);
416
+ tempPublished = true;
417
+ assertPrivateDirectoryIdentity(parent, parentStat);
418
+ fs.unlinkSync(temp);
419
+ tempPublished = false;
420
+ const finalStat = fs.lstatSync(target);
421
+ if (
422
+ !sameFileInode(tempStat, finalStat)
423
+ || finalStat.nlink !== 1
424
+ ) {
425
+ throw new Error(`plugin file identity changed after write: ${target}`);
426
+ }
427
+ assertPrivateDirectoryIdentity(parent, parentStat);
428
+ } catch (error) {
429
+ if (fd !== null) {
430
+ try { fs.closeSync(fd); } catch { /* best effort */ }
431
+ }
432
+ try {
433
+ if (!tempPublished && sameDirectoryIdentity(parentStat, privateDirectoryIdentity(parent))) {
434
+ fs.unlinkSync(temp);
435
+ }
436
+ } catch (cleanupError) {
437
+ if (cleanupError && cleanupError.code !== "ENOENT") { /* best effort */ }
438
+ }
439
+ throw error;
440
+ }
441
+ }
442
+
443
+ function readPluginDirectory(dir) {
444
+ const stat = fs.lstatSync(dir);
445
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
446
+ throw new Error(`refusing unsafe plugin directory: ${dir}`);
447
+ }
448
+ return stat;
449
+ }
450
+
451
+ function sameFileIdentity(left, right) {
452
+ return left
453
+ && right
454
+ && left.dev === right.dev
455
+ && left.ino === right.ino
456
+ && left.size === right.size
457
+ && left.nlink === right.nlink;
458
+ }
459
+
460
+ function readPluginMarker(target) {
461
+ const before = fs.lstatSync(target);
462
+ if (before.isSymbolicLink() || !before.isFile() || before.nlink !== 1 || before.size > PLUGIN_MARKER_MAX_BYTES) {
463
+ throw new Error(`unsafe plugin marker: ${target}`);
464
+ }
465
+ const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
466
+ let fd = null;
467
+ try {
468
+ fd = fs.openSync(target, flags);
469
+ const opened = fs.fstatSync(fd);
470
+ if (!sameFileIdentity(before, opened)) throw new Error(`plugin marker changed before read: ${target}`);
471
+ const buffer = Buffer.alloc(PLUGIN_MARKER_MAX_BYTES + 1);
472
+ let bytesRead = 0;
473
+ while (bytesRead < buffer.length) {
474
+ const count = fs.readSync(fd, buffer, bytesRead, buffer.length - bytesRead, null);
475
+ if (!count) break;
476
+ bytesRead += count;
477
+ }
478
+ if (bytesRead > PLUGIN_MARKER_MAX_BYTES) throw new Error(`plugin marker exceeds the ${PLUGIN_MARKER_MAX_BYTES}-byte cap`);
479
+ const after = fs.fstatSync(fd);
480
+ if (!sameFileIdentity(opened, after) || bytesRead !== after.size) {
481
+ throw new Error(`plugin marker changed during read: ${target}`);
482
+ }
483
+ const value = buffer.subarray(0, bytesRead).toString("utf8");
484
+ const pathnameAfter = fs.lstatSync(target);
485
+ if (!sameFileIdentity(before, pathnameAfter)) {
486
+ throw new Error(`plugin marker changed after read: ${target}`);
487
+ }
488
+ return value;
489
+ } finally {
490
+ if (fd !== null) fs.closeSync(fd);
491
+ }
492
+ }
493
+
494
+ function validMarkerText(value, max) {
495
+ return typeof value === "string"
496
+ && value.length > 0
497
+ && value.length <= max
498
+ && !/[\u0000-\u001f\u007f-\u009f]/.test(value);
499
+ }
500
+
501
+ function validatePluginMarker(marker, directoryName) {
502
+ if (!marker || typeof marker !== "object" || Array.isArray(marker)) return null;
503
+ if (marker.schema !== "agentlas.local-plugin/v1") return null;
504
+ if (!validMarkerText(marker.slug, 64) || !PLUGIN_SKILL_SLUG_RE.test(marker.slug)) return null;
505
+ if (String(marker.slug).toLowerCase() !== String(directoryName).toLowerCase()) return null;
506
+ if (!validMarkerText(marker.name, 256)) return null;
507
+ if (!Array.isArray(marker.skills) || marker.skills.length > PLUGIN_SKILL_MAX_COUNT) return null;
508
+ const seenSkills = new Set();
509
+ for (const skill of marker.skills) {
510
+ if (!skill || typeof skill !== "object" || !validMarkerText(skill.name, 64) || !PLUGIN_SKILL_SLUG_RE.test(skill.name)) return null;
511
+ const skillKey = skill.name.toLowerCase();
512
+ if (seenSkills.has(skillKey)) return null;
513
+ seenSkills.add(skillKey);
514
+ if (!Array.isArray(skill.files) || skill.files.length > PLUGIN_SKILL_MAX_FILES_PER_SKILL) return null;
515
+ const seenFiles = new Set();
516
+ for (const file of skill.files) {
517
+ if (!file || typeof file !== "object" || !pluginSkillSafeRelativePath(file.path)) return null;
518
+ const fileKey = file.path.toLowerCase();
519
+ if (seenFiles.has(fileKey)) return null;
520
+ seenFiles.add(fileKey);
521
+ if (!/^[0-9a-f]{64}$/i.test(String(file.sha256 || "")) || typeof file.verified !== "boolean") return null;
522
+ }
523
+ }
524
+ return {
525
+ slug: marker.slug,
526
+ name: marker.name,
527
+ installedAt: validMarkerText(marker.installedAt, 128) ? marker.installedAt : null,
528
+ installedBy: validMarkerText(marker.installedBy, 128) ? marker.installedBy : null,
529
+ skills: marker.skills.map((skill) => String(skill.name)),
530
+ };
531
+ }
532
+
264
533
  /**
265
534
  * 계획된 스킬들을 ~/.agentlas/plugins/<slug>/skills/<name>/ 에 쓴다.
266
535
  *
@@ -272,35 +541,122 @@ function installPluginSkills(slug, plan, { homeDir, manifestUrl, meta } = {}) {
272
541
  if (!PLUGIN_SKILL_SLUG_RE.test(String(slug || ""))) {
273
542
  return { dir: "", installed: [], failed: [{ name: String(slug || ""), reason: "invalid plugin slug" }], verified: false };
274
543
  }
275
- const pluginDir = path.join(agentlasPluginsDir({ homeDir }), slug);
544
+ const pluginsRoot = agentlasPluginsDir({ homeDir });
545
+ const pluginDir = path.join(pluginsRoot, slug);
276
546
  const installed = [];
277
547
  const failed = [];
278
548
  const markerSkills = [];
279
549
  let allDeclared = true;
280
- for (const skill of plan.skills || []) {
550
+ let hadFailure = false;
551
+ try {
552
+ // Every directory owned by the installer is private and must be a real
553
+ // directory. In particular, do not follow a pre-created .agentlas/plugins
554
+ // or <slug>/skills symlink into an arbitrary location.
555
+ privateDirectory(path.dirname(pluginsRoot));
556
+ privateDirectory(pluginsRoot);
557
+ privateDirectory(pluginDir);
558
+ } catch (error) {
559
+ return {
560
+ dir: pluginDir,
561
+ installed,
562
+ failed: [{ name: "plugin", reason: String((error && error.message) || error).slice(0, 160) }],
563
+ verified: false,
564
+ };
565
+ }
566
+ const skills = Array.isArray(plan?.skills) ? plan.skills.slice(0, PLUGIN_SKILL_MAX_COUNT) : [];
567
+ if (Array.isArray(plan?.skills) && plan.skills.length > PLUGIN_SKILL_MAX_COUNT) {
568
+ failed.push({ name: "(manifest)", reason: `too many skills (maximum ${PLUGIN_SKILL_MAX_COUNT})` });
569
+ allDeclared = false;
570
+ hadFailure = true;
571
+ }
572
+ let installedBytes = 0;
573
+ const seenSkillNames = new Set();
574
+ for (const skill of skills) {
281
575
  const written = [];
282
576
  let mismatch = null;
577
+ let skillBytes = 0;
578
+ const seenPaths = new Set();
579
+ const skillName = typeof skill?.name === "string" ? skill.name : "";
580
+ if (!skill || typeof skill !== "object" || !PLUGIN_SKILL_SLUG_RE.test(skillName)) {
581
+ failed.push({ name: String(skill?.name || "skill"), reason: "invalid skill name" });
582
+ allDeclared = false;
583
+ hadFailure = true;
584
+ continue;
585
+ }
586
+ const skillNameKey = skillName.toLowerCase();
587
+ if (seenSkillNames.has(skillNameKey)) {
588
+ failed.push({ name: skillName, reason: "duplicate skill name" });
589
+ allDeclared = false;
590
+ hadFailure = true;
591
+ continue;
592
+ }
593
+ seenSkillNames.add(skillNameKey);
594
+ if (!Array.isArray(skill.files) || skill.files.length > PLUGIN_SKILL_MAX_FILES_PER_SKILL) {
595
+ failed.push({ name: skillName, reason: `too many files (maximum ${PLUGIN_SKILL_MAX_FILES_PER_SKILL})` });
596
+ allDeclared = false;
597
+ hadFailure = true;
598
+ continue;
599
+ }
283
600
  for (const file of skill.files) {
601
+ if (!file || typeof file !== "object" || !pluginSkillSafeRelativePath(file.path)) {
602
+ mismatch = `unsafe file path "${String(file?.path || "")}"`;
603
+ break;
604
+ }
605
+ const pathKey = file.path.toLowerCase();
606
+ if (seenPaths.has(pathKey)) {
607
+ mismatch = `duplicate file path "${file.path}"`;
608
+ break;
609
+ }
610
+ seenPaths.add(pathKey);
611
+ if (typeof file.content !== "string" || !file.content.trim()) {
612
+ mismatch = `empty content for ${file.path}`;
613
+ break;
614
+ }
615
+ const bytes = Buffer.byteLength(file.content, "utf8");
616
+ if (bytes > PLUGIN_SKILL_FILE_MAX_BYTES) {
617
+ mismatch = `${file.path} exceeds the file size cap`;
618
+ break;
619
+ }
620
+ skillBytes += bytes;
284
621
  const actual = crypto.createHash("sha256").update(file.content, "utf8").digest("hex");
285
622
  if (file.sha256 && file.sha256 !== actual) { mismatch = `sha256 mismatch for ${file.path}`; break; }
286
623
  if (!file.sha256) allDeclared = false;
287
624
  written.push({ path: file.path, sha256: actual, verified: Boolean(file.sha256) });
288
625
  }
289
- if (mismatch) { failed.push({ name: skill.name, reason: mismatch }); continue; }
626
+ if (mismatch) {
627
+ failed.push({ name: skillName, reason: mismatch });
628
+ hadFailure = true;
629
+ continue;
630
+ }
631
+ if (installedBytes + skillBytes > PLUGIN_SKILL_TOTAL_MAX_BYTES) {
632
+ failed.push({ name: skillName, reason: `skill payloads exceed the total size cap (${PLUGIN_SKILL_TOTAL_MAX_BYTES} bytes)` });
633
+ allDeclared = false;
634
+ hadFailure = true;
635
+ continue;
636
+ }
290
637
  try {
291
- const skillDir = path.join(pluginDir, "skills", skill.name);
638
+ const skillDir = path.join(pluginDir, "skills", skillName);
639
+ privateDirectory(path.join(pluginDir, "skills"));
640
+ privateDirectory(skillDir);
292
641
  for (const file of skill.files) {
293
- const target = path.join(skillDir, file.path);
294
- fs.mkdirSync(path.dirname(target), { recursive: true });
295
- fs.writeFileSync(target, file.content, "utf8");
642
+ const parts = file.path.split("/");
643
+ let parent = skillDir;
644
+ for (const part of parts.slice(0, -1)) {
645
+ parent = path.join(parent, part);
646
+ privateDirectory(parent);
647
+ }
648
+ writePluginFileAtomic(path.join(parent, parts[parts.length - 1]), file.content);
296
649
  }
297
- installed.push(skill.name);
298
- markerSkills.push({ name: skill.name, files: written });
650
+ installedBytes += skillBytes;
651
+ installed.push(skillName);
652
+ markerSkills.push({ name: skillName, files: written });
299
653
  } catch (e) {
300
- failed.push({ name: skill.name, reason: String((e && e.message) || e).slice(0, 160) });
654
+ failed.push({ name: skillName, reason: String((e && e.message) || e).slice(0, 160) });
655
+ hadFailure = true;
301
656
  }
302
657
  }
303
- const verified = installed.length > 0 && allDeclared;
658
+ const contentVerified = installed.length > 0 && allDeclared && !hadFailure;
659
+ let markerWritten = false;
304
660
  if (installed.length > 0) {
305
661
  // 마커는 마지막에 쓴다 — 마커가 있으면 스킬 파일도 있다는 뜻이어야 한다.
306
662
  const marker = {
@@ -311,17 +667,18 @@ function installPluginSkills(slug, plan, { homeDir, manifestUrl, meta } = {}) {
311
667
  version: (meta && meta.version) || null,
312
668
  installedAt: new Date().toISOString(),
313
669
  installedBy: "agentlas-terminal",
314
- source: { manifestUrl: manifestUrl || null, contentVerification: verified ? "manifest-sha256" : "none" },
670
+ source: { manifestUrl: manifestUrl || null, contentVerification: contentVerified ? "manifest-sha256" : "none" },
315
671
  skills: markerSkills,
316
672
  };
317
673
  try {
318
- fs.mkdirSync(pluginDir, { recursive: true });
319
- fs.writeFileSync(path.join(pluginDir, "plugin.json"), `${JSON.stringify(marker, null, 2)}\n`, "utf8");
674
+ writePluginFileAtomic(path.join(pluginDir, "plugin.json"), `${JSON.stringify(marker, null, 2)}\n`);
675
+ markerWritten = true;
320
676
  } catch (e) {
321
677
  failed.push({ name: "plugin.json", reason: String((e && e.message) || e).slice(0, 160) });
678
+ hadFailure = true;
322
679
  }
323
680
  }
324
- return { dir: pluginDir, installed, failed, verified };
681
+ return { dir: pluginDir, installed, failed, verified: contentVerified && markerWritten && !hadFailure };
325
682
  }
326
683
 
327
684
  /** ~/.agentlas/plugins/<slug>/plugin.json 마커들을 읽는다 — list의 설치 여부 표시용. */
@@ -329,25 +686,30 @@ function listInstalledLocalPlugins({ homeDir } = {}) {
329
686
  const root = agentlasPluginsDir({ homeDir });
330
687
  let names;
331
688
  try {
332
- names = fs.readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
689
+ const rootStat = readPluginDirectory(root);
690
+ names = fs.readdirSync(root, { withFileTypes: true })
691
+ .filter((d) => d.isDirectory() && !d.isSymbolicLink())
692
+ .map((d) => d.name)
693
+ .filter((name) => !name.startsWith("."))
694
+ .slice(0, PLUGIN_LOCAL_MAX_COUNT);
695
+ const rootAfter = readPluginDirectory(root);
696
+ if (rootAfter.dev !== rootStat.dev || rootAfter.ino !== rootStat.ino) return [];
333
697
  } catch {
334
698
  return [];
335
699
  }
336
700
  const out = [];
337
701
  for (const name of names) {
338
- if (name.startsWith(".")) continue;
339
702
  try {
340
- const marker = JSON.parse(fs.readFileSync(path.join(root, name, "plugin.json"), "utf8"));
341
- out.push({
342
- slug: String(marker.slug || name),
343
- name: String(marker.name || name),
344
- installedAt: marker.installedAt || null,
345
- installedBy: marker.installedBy || null,
346
- skills: Array.isArray(marker.skills) ? marker.skills.map((s) => String(s?.name || "")).filter(Boolean) : [],
347
- dir: path.join(root, name),
348
- });
703
+ const pluginDir = path.join(root, name);
704
+ const pluginBefore = readPluginDirectory(pluginDir);
705
+ const marker = JSON.parse(readPluginMarker(path.join(pluginDir, "plugin.json")));
706
+ const pluginAfter = readPluginDirectory(pluginDir);
707
+ if (pluginAfter.dev !== pluginBefore.dev || pluginAfter.ino !== pluginBefore.ino) continue;
708
+ const valid = validatePluginMarker(marker, name);
709
+ if (!valid) continue;
710
+ out.push({ ...valid, dir: pluginDir });
349
711
  } catch {
350
- // 마커 없는 디렉터리는 다른 도구의 산출물일 있다 — 조용히 건너뛴다.
712
+ // 마커 없는 디렉터리와 unsafe/corrupt markers는 설치 상태로 광고하지 않는다.
351
713
  }
352
714
  }
353
715
  return out;
@@ -11,16 +11,16 @@
11
11
  * 기존 동의는 자동 무효가 된다. 손상된 동의 상태는 fail-closed(빈 목록).
12
12
  */
13
13
  const readline = require("node:readline");
14
- const fs = require("node:fs");
15
14
  const path = require("node:path");
15
+ const { userDataDir: defaultUserDataDir } = require("../core/paths.cjs");
16
16
  const {
17
17
  MAX_APPROVED_MCP_PER_BUILD,
18
- MAX_JSON_BYTES,
19
18
  ID_RE,
20
19
  assertExactKeys,
21
20
  assertId,
22
21
  assertIsoDateOrNull,
23
22
  safeCatalogId,
23
+ readJsonFile,
24
24
  writePrivateJsonAtomic,
25
25
  withPrivateStateLock,
26
26
  parseIdList,
@@ -36,6 +36,10 @@ const {
36
36
  const MCP_CONSENT_STATE_SCHEMA = "agentlas.terminal-mcp-consents.v1";
37
37
  const MCP_CONSENT_RECEIPT_SCHEMA = "agentlas.terminal-mcp-consent.v1";
38
38
 
39
+ function resolvedMcpUserDataDir(value) {
40
+ return typeof value === "string" && value.trim() ? value : defaultUserDataDir();
41
+ }
42
+
39
43
  /*
40
44
  * ── 통합 능력 승인(데스크탑 capability_grants)과의 합류 ───────────────────────
41
45
  *
@@ -113,7 +117,7 @@ function rememberMcpCapabilityGrant(db, catalogId, decision = "allow") {
113
117
  }
114
118
 
115
119
  function mcpConsentStatePath(userDataDir) {
116
- return path.join(userDataDir, "terminal", "mcp-consents-v1.json");
120
+ return path.join(resolvedMcpUserDataDir(userDataDir), "terminal", "mcp-consents-v1.json");
117
121
  }
118
122
 
119
123
  function emptyMcpConsentState() {
@@ -136,12 +140,13 @@ function validateMcpConsentReceipt(receipt, index) {
136
140
 
137
141
  function loadMcpConsentState(userDataDir) {
138
142
  const file = mcpConsentStatePath(userDataDir);
139
- if (!fs.existsSync(file)) return emptyMcpConsentState();
140
- const stat = fs.lstatSync(file);
141
- if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > MAX_JSON_BYTES) {
142
- throw new Error("Terminal MCP consent state is unsafe or too large");
143
+ let state;
144
+ try {
145
+ ({ value: state } = readJsonFile(file, "Terminal MCP consent state"));
146
+ } catch (error) {
147
+ if (error && error.code === "ENOENT") return emptyMcpConsentState();
148
+ throw error;
143
149
  }
144
- const state = JSON.parse(fs.readFileSync(file, "utf8"));
145
150
  assertExactKeys(state, new Set(["schemaVersion", "updatedAt", "receipts"]), ["schemaVersion", "updatedAt", "receipts"], "Terminal MCP consent state");
146
151
  if (state.schemaVersion !== MCP_CONSENT_STATE_SCHEMA || !Array.isArray(state.receipts) || state.receipts.length > 256) {
147
152
  throw new Error("Terminal MCP consent state schema is invalid");
@@ -159,9 +164,10 @@ function withMcpConsentStateLock(userDataDir, action) {
159
164
  }
160
165
 
161
166
  function persistMcpConsentReceipts(userDataDir, servers) {
162
- if (!userDataDir || !(servers || []).length) return false;
163
- withMcpConsentStateLock(userDataDir, () => {
164
- const state = loadMcpConsentState(userDataDir);
167
+ if (!(servers || []).length) return false;
168
+ const resolvedUserDataDir = resolvedMcpUserDataDir(userDataDir);
169
+ withMcpConsentStateLock(resolvedUserDataDir, () => {
170
+ const state = loadMcpConsentState(resolvedUserDataDir);
165
171
  const now = new Date().toISOString();
166
172
  for (const server of servers) {
167
173
  if (!server || !ID_RE.test(String(server.id || "")) || !safeCatalogId(server.catalog_id) || !/^[0-9a-f]{64}$/.test(String(server.consentFingerprint || ""))) continue;
@@ -180,14 +186,15 @@ function persistMcpConsentReceipts(userDataDir, servers) {
180
186
  state.receipts.sort((a, b) => String(b.consentedAt).localeCompare(String(a.consentedAt)) || a.catalogId.localeCompare(b.catalogId));
181
187
  state.receipts = state.receipts.slice(0, 256);
182
188
  state.updatedAt = now;
183
- writePrivateJsonAtomic(mcpConsentStatePath(userDataDir), state);
189
+ writePrivateJsonAtomic(mcpConsentStatePath(resolvedUserDataDir), state);
184
190
  });
185
191
  return true;
186
192
  }
187
193
 
188
194
  function readConsentedSystemMcpServers(db, options = {}) {
195
+ const resolvedUserDataDir = resolvedMcpUserDataDir(options.userDataDir);
189
196
  let state;
190
- try { state = loadMcpConsentState(options.userDataDir); }
197
+ try { state = loadMcpConsentState(resolvedUserDataDir); }
191
198
  catch { return []; }
192
199
  // 영구 거부(deny)는 옛 동의 영수증을 이긴다 — 규칙이 영수증보다 위다.
193
200
  const partition = partitionMcpConsentByCapabilityGrants(
@@ -205,7 +212,7 @@ function readConsentedSystemMcpServers(db, options = {}) {
205
212
  "SELECT id, catalog_id, name, name_en, transport, command, args_json, env_keys_json, enabled FROM mcp_servers WHERE id=? LIMIT 1",
206
213
  ).get(receipt.registryServerId);
207
214
  } catch { continue; }
208
- const server = materializeTrustedSystemMcpServer(row, options);
215
+ const server = materializeTrustedSystemMcpServer(row, { ...options, userDataDir: resolvedUserDataDir });
209
216
  if (
210
217
  !server || server.id !== receipt.registryServerId || server.catalog_id !== receipt.catalogId ||
211
218
  server.consentFingerprint !== receipt.consentFingerprint
@@ -280,6 +287,7 @@ function askMcpConsentOnce(plan, options = {}) {
280
287
  }
281
288
 
282
289
  async function resolveApprovedMcpRuntimeAllowlist(options) {
290
+ const resolvedUserDataDir = resolvedMcpUserDataDir(options.userDataDir);
283
291
  const approved = new Set(options.approvedIds || []);
284
292
  const selectedGroups = (options.plan?.entries || []).map((entry) => {
285
293
  if (entry.status !== "available") return null;
@@ -298,7 +306,7 @@ async function resolveApprovedMcpRuntimeAllowlist(options) {
298
306
  const probe = options.probeServer || ((server, probeOptions = {}) => probeSystemMcpServerConnection(server, {
299
307
  cwd: options.cwd,
300
308
  env: options.env,
301
- userDataDir: options.userDataDir,
309
+ userDataDir: resolvedUserDataDir,
302
310
  timeoutMs: probeOptions.timeoutMs,
303
311
  signal: probeOptions.signal,
304
312
  }));
@@ -315,7 +323,7 @@ async function resolveApprovedMcpRuntimeAllowlist(options) {
315
323
 
316
324
  const probeCandidate = async (candidate) => {
317
325
  let server = null;
318
- try { server = readApprovedSystemMcpServer(options.db, candidate, { userDataDir: options.userDataDir }); }
326
+ try { server = readApprovedSystemMcpServer(options.db, candidate, { userDataDir: resolvedUserDataDir }); }
319
327
  catch { /* one unsafe/unwritable runtime boundary excludes only this server */ }
320
328
  if (!server) return { candidate, server: null, status: { connected: false, reason: "registry_row_unavailable" } };
321
329
  const remainingMs = deadline - Date.now();
@@ -382,7 +390,7 @@ async function resolveApprovedMcpRuntimeAllowlist(options) {
382
390
  }
383
391
  let consentPersisted = servers.length === 0;
384
392
  if (servers.length) {
385
- try { consentPersisted = persistMcpConsentReceipts(options.userDataDir, servers); }
393
+ try { consentPersisted = persistMcpConsentReceipts(resolvedUserDataDir, servers); }
386
394
  catch { consentPersisted = false; }
387
395
  }
388
396
  const receipt = {