agentlas 1.0.66 → 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.
@@ -185,11 +185,23 @@ function readCredentialNames(userDataDir, env = process.env) {
185
185
  return names;
186
186
  }
187
187
 
188
+ function consentFingerprintForServer({ registryServerId, catalogId, transport, command, args, credentialKeyNames }) {
189
+ return crypto.createHash("sha256").update(JSON.stringify({
190
+ schemaVersion: "agentlas.terminal-mcp-consent-fingerprint.v1",
191
+ registryServerId,
192
+ catalogId,
193
+ transport,
194
+ command,
195
+ args,
196
+ credentialKeyNames,
197
+ }), "utf8").digest("hex");
198
+ }
199
+
188
200
  function collectSystemMcpInventory(db, options = {}) {
189
201
  let rows = [];
190
202
  let registryStatus = "complete";
191
203
  try {
192
- rows = db.prepare("SELECT id, catalog_id, name, name_en, transport, env_keys_json, enabled FROM mcp_servers ORDER BY installed_at ASC LIMIT 1025").all();
204
+ rows = db.prepare("SELECT id, catalog_id, name, name_en, transport, command, args_json, env_keys_json, enabled FROM mcp_servers ORDER BY installed_at ASC LIMIT 1025").all();
193
205
  } catch {
194
206
  // 읽을 수 없는 레지스트리는 "읽었더니 비어 있음"과 다른 사실이다.
195
207
  // 둘 다 empty-MCP로 fail-closed하지만, 사용자 플랜에는 원인을 보존한다.
@@ -235,6 +247,20 @@ function collectSystemMcpInventory(db, options = {}) {
235
247
  value: crypto.createHash("sha256").update(JSON.stringify(keyNames), "utf8").digest("hex"),
236
248
  enumerable: false,
237
249
  });
250
+ const args = parseRuntimeServerArgs(row.args_json || "[]");
251
+ Object.defineProperty(item, "consentFingerprint", {
252
+ value: typeof row.command === "string" && args
253
+ ? consentFingerprintForServer({
254
+ registryServerId: String(row.id),
255
+ catalogId,
256
+ transport: String(row.transport || ""),
257
+ command: row.command,
258
+ args,
259
+ credentialKeyNames: keyNames,
260
+ })
261
+ : null,
262
+ enumerable: false,
263
+ });
238
264
  inventory.push(item);
239
265
  }
240
266
  Object.defineProperty(inventory, "registryStatus", { value: registryStatus, enumerable: false });
@@ -276,15 +302,14 @@ function materializeTrustedSystemMcpServer(row, options = {}) {
276
302
  enumerable: false,
277
303
  });
278
304
  Object.defineProperty(server, "consentFingerprint", {
279
- value: crypto.createHash("sha256").update(JSON.stringify({
280
- schemaVersion: "agentlas.terminal-mcp-consent-fingerprint.v1",
305
+ value: consentFingerprintForServer({
281
306
  registryServerId,
282
307
  catalogId,
283
308
  transport: "stdio",
284
309
  command: row.command,
285
310
  args,
286
311
  credentialKeyNames,
287
- }), "utf8").digest("hex"),
312
+ }),
288
313
  enumerable: false,
289
314
  });
290
315
  if (options.createRuntimeHome !== false) {
@@ -314,7 +339,8 @@ function readApprovedSystemMcpServer(db, entry, options = {}) {
314
339
  const server = materializeTrustedSystemMcpServer(row, options);
315
340
  if (
316
341
  !server || String(row.id) !== entry.registryServerId || server.catalog_id !== entry.resolvedCatalogId ||
317
- !entry.credentialKeyFingerprint || server.credentialKeyFingerprint !== entry.credentialKeyFingerprint
342
+ !entry.credentialKeyFingerprint || server.credentialKeyFingerprint !== entry.credentialKeyFingerprint ||
343
+ !entry.consentFingerprint || server.consentFingerprint !== entry.consentFingerprint
318
344
  ) return null;
319
345
  return server;
320
346
  }
@@ -209,11 +209,16 @@ function buildMcpPlan(options) {
209
209
  value: resolution.selected?.credentialKeyFingerprint || null,
210
210
  enumerable: false,
211
211
  });
212
+ Object.defineProperty(entry, "consentFingerprint", {
213
+ value: resolution.selected?.consentFingerprint || null,
214
+ enumerable: false,
215
+ });
212
216
  Object.defineProperty(entry, "runtimeCandidates", {
213
217
  value: resolution.candidates.map((candidate) => ({
214
218
  resolvedCatalogId: candidate.item.catalogId,
215
219
  registryServerId: candidate.item.registryServerId || null,
216
220
  credentialKeyFingerprint: candidate.item.credentialKeyFingerprint || null,
221
+ consentFingerprint: candidate.item.consentFingerprint || null,
217
222
  })),
218
223
  enumerable: false,
219
224
  });
@@ -16,6 +16,144 @@ const { runCwd } = require("./paths.cjs");
16
16
 
17
17
  const MAX_MANAGED_CREDENTIAL_METADATA_BYTES = 1024 * 1024;
18
18
  const MAX_CREDENTIAL_FILE_BYTES = 16 * 1024 * 1024;
19
+ const CREDENTIAL_NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
20
+ const credentialDestinationBindings = new Map();
21
+
22
+ function credentialSameDirectoryIdentity(left, right) {
23
+ return Boolean(
24
+ left && right && left.isDirectory() && right.isDirectory() &&
25
+ !left.isSymbolicLink() && !right.isSymbolicLink() &&
26
+ left.dev === right.dev && left.ino === right.ino,
27
+ );
28
+ }
29
+
30
+ function credentialDirectoryAnchor(target, label, containedBy = null) {
31
+ const stat = fs.lstatSync(target);
32
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`${label} must be a real managed directory`);
33
+ const realpath = fs.realpathSync.native(target);
34
+ if (containedBy && !(
35
+ realpath === containedBy.realpath || realpath.startsWith(`${containedBy.realpath}${path.sep}`)
36
+ )) throw new Error(`${label} escaped the managed project`);
37
+ return { path: target, realpath, dev: stat.dev, ino: stat.ino, stat };
38
+ }
39
+
40
+ function credentialAssertDirectoryAnchor(anchor, label, containedBy = null) {
41
+ const current = credentialDirectoryAnchor(anchor.path, label, containedBy);
42
+ if (!credentialSameDirectoryIdentity(anchor.stat || anchor, current.stat || current) || current.realpath !== anchor.realpath) {
43
+ throw new Error(`${label} changed while it was being used`);
44
+ }
45
+ return current;
46
+ }
47
+
48
+ function credentialSameFileIdentity(left, right) {
49
+ return Boolean(
50
+ left && right && left.isFile() && right.isFile() &&
51
+ !left.isSymbolicLink() && !right.isSymbolicLink() &&
52
+ left.dev === right.dev && left.ino === right.ino,
53
+ );
54
+ }
55
+
56
+ function credentialSameFileSnapshot(left, right) {
57
+ return credentialSameFileIdentity(left, right) && left.nlink === right.nlink &&
58
+ left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
59
+ }
60
+
61
+ function credentialFileSnapshot(file, label, { allowMissing = false, allowHardLinks = false } = {}) {
62
+ let stat;
63
+ try { stat = fs.lstatSync(file); }
64
+ catch (error) {
65
+ if (allowMissing && error && error.code === "ENOENT") return null;
66
+ throw error;
67
+ }
68
+ if (!stat.isFile() || stat.isSymbolicLink() || (!allowHardLinks && stat.nlink !== 1) || stat.size > MAX_CREDENTIAL_FILE_BYTES) {
69
+ throw new Error(`${label} must be a bounded regular non-symbolic-link file`);
70
+ }
71
+ return stat;
72
+ }
73
+
74
+ function credentialRemoveOwnedFile(file, expected, { allowLinked = false } = {}) {
75
+ try {
76
+ const current = fs.lstatSync(file);
77
+ if (credentialSameFileIdentity(current, expected) && (current.nlink === 1 || (allowLinked && current.nlink >= 2))) {
78
+ fs.unlinkSync(file);
79
+ }
80
+ } catch { /* leave unknown successors and recovery artifacts untouched */ }
81
+ }
82
+
83
+ function credentialRestoreBackup(backup, target, expected, parent) {
84
+ try {
85
+ credentialAssertDirectoryAnchor(parent, "credential destination directory");
86
+ if (credentialFileSnapshot(target, "credential destination successor", { allowMissing: true })) return false;
87
+ const current = credentialFileSnapshot(backup, "credential destination backup");
88
+ if (!current || !credentialSameFileIdentity(current, expected) || current.nlink !== 1) return false;
89
+ fs.linkSync(backup, target);
90
+ const restored = credentialFileSnapshot(target, "credential destination restored", { allowHardLinks: true });
91
+ if (!restored || !credentialSameFileIdentity(restored, expected) || restored.nlink < 2) return false;
92
+ fs.unlinkSync(backup);
93
+ return true;
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ function credentialPublishFile(parent, targetName, temporary, expected) {
100
+ credentialAssertDirectoryAnchor(parent, "credential destination directory");
101
+ const target = path.join(parent.realpath, targetName);
102
+ const current = credentialFileSnapshot(target, "credential destination", { allowMissing: true });
103
+ if ((expected && (!current || !credentialSameFileSnapshot(current, expected))) || (!expected && current)) {
104
+ throw new Error("credential destination changed before replacement");
105
+ }
106
+ let backup = null;
107
+ let linked = false;
108
+ try {
109
+ if (expected) {
110
+ backup = `${target}.previous-${process.pid}-${crypto.randomUUID()}`;
111
+ fs.renameSync(target, backup);
112
+ const moved = credentialFileSnapshot(backup, "credential destination backup");
113
+ if (!moved || !credentialSameFileIdentity(moved, expected) || moved.nlink !== 1) {
114
+ credentialRestoreBackup(backup, target, expected, parent);
115
+ throw new Error("credential destination changed before replacement");
116
+ }
117
+ credentialAssertDirectoryAnchor(parent, "credential destination directory");
118
+ if (credentialFileSnapshot(target, "credential destination successor", { allowMissing: true })) {
119
+ throw new Error("credential destination successor appeared during replacement");
120
+ }
121
+ }
122
+ credentialAssertDirectoryAnchor(parent, "credential destination directory");
123
+ if (credentialFileSnapshot(target, "credential destination successor", { allowMissing: true })) {
124
+ throw new Error("credential destination successor appeared during replacement");
125
+ }
126
+ fs.linkSync(temporary.path, target);
127
+ linked = true;
128
+ const linkedTarget = credentialFileSnapshot(target, "credential destination", { allowHardLinks: true });
129
+ if (!linkedTarget || !credentialSameFileIdentity(linkedTarget, temporary.stat) || linkedTarget.nlink < 2) {
130
+ throw new Error("credential publication produced an unsafe target");
131
+ }
132
+ credentialAssertDirectoryAnchor(parent, "credential destination directory");
133
+ credentialRemoveOwnedFile(temporary.path, temporary.stat, { allowLinked: true });
134
+ const installed = credentialFileSnapshot(target, "credential destination");
135
+ if (!installed || !credentialSameFileIdentity(installed, temporary.stat) || installed.nlink !== 1) {
136
+ throw new Error("credential publication identity changed");
137
+ }
138
+ try { fs.chmodSync(target, 0o600); } catch { /* Windows/best-effort */ }
139
+ const final = credentialFileSnapshot(target, "credential destination");
140
+ if (!final || !credentialSameFileIdentity(final, temporary.stat) || final.nlink !== 1 ||
141
+ (process.platform !== "win32" && (final.mode & 0o777) !== 0o600)) {
142
+ throw new Error("credential publication mode or identity changed");
143
+ }
144
+ credentialAssertDirectoryAnchor(parent, "credential destination directory");
145
+ if (backup) {
146
+ const backupStat = credentialFileSnapshot(backup, "credential destination backup", { allowMissing: true });
147
+ if (backupStat && credentialSameFileIdentity(backupStat, expected) && backupStat.nlink === 1) fs.unlinkSync(backup);
148
+ }
149
+ return target;
150
+ } catch (error) {
151
+ if (linked) credentialRemoveOwnedFile(target, temporary.stat);
152
+ credentialRemoveOwnedFile(temporary.path, temporary.stat);
153
+ if (backup) credentialRestoreBackup(backup, target, expected, parent);
154
+ throw error;
155
+ }
156
+ }
19
157
 
20
158
  function credentialProjectRootCli(projectPath) {
21
159
  const requested = path.resolve(projectPath || "");
@@ -373,6 +511,7 @@ function safeCredentialDestRelCli(destRel) {
373
511
  function resolveCredentialDestinationCli(projectPath, destRel) {
374
512
  const root = credentialProjectRootCli(projectPath);
375
513
  const rel = safeCredentialDestRelCli(destRel);
514
+ const rootAnchor = credentialDirectoryAnchor(root, "credential project root");
376
515
  const parentRel = path.dirname(rel);
377
516
  const parent = parentRel === "." ? root : ensureManagedDirectoryCli(root, parentRel);
378
517
  const realParent = fs.realpathSync.native(parent);
@@ -380,40 +519,71 @@ function resolveCredentialDestinationCli(projectPath, destRel) {
380
519
  if (path.isAbsolute(relativeParent) || relativeParent === ".." || relativeParent.startsWith(`..${path.sep}`)) {
381
520
  throw new Error("credential destination escaped the project");
382
521
  }
383
- return path.join(realParent, path.basename(rel));
522
+ const parentAnchor = credentialDirectoryAnchor(realParent, "credential destination directory", rootAnchor);
523
+ const destination = path.join(parentAnchor.realpath, path.basename(rel));
524
+ credentialDestinationBindings.set(path.resolve(destination), {
525
+ root: rootAnchor,
526
+ parent: parentAnchor,
527
+ });
528
+ return destination;
384
529
  }
385
530
 
386
531
  function copyCredentialFileAtomicCli(sourcePath, destinationPath, options = {}) {
387
- const source = fs.realpathSync.native(path.resolve(sourcePath));
388
- const sourceStat = fs.statSync(source);
389
- if (!sourceStat.isFile() || sourceStat.size <= 0 || sourceStat.size > MAX_CREDENTIAL_FILE_BYTES) {
390
- throw new Error("credential source must be a non-empty regular file no larger than 16 MiB");
391
- }
392
- const snapshot = (() => {
393
- try {
394
- const stat = fs.lstatSync(destinationPath);
395
- if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("credential destination must be a regular non-symbolic-link file");
396
- if (!options.force) throw new Error("credential destination already exists (use --force to replace)");
397
- return { exists: true, stat };
398
- } catch (error) {
399
- if (error && error.code === "ENOENT") return { exists: false };
400
- throw error;
401
- }
402
- })();
403
- const temp = path.join(path.dirname(destinationPath), `.${path.basename(destinationPath)}.${process.pid}.${crypto.randomUUID()}.tmp`);
532
+ const resolvedDestination = path.resolve(destinationPath);
533
+ const binding = credentialDestinationBindings.get(resolvedDestination) || null;
404
534
  try {
405
- fs.copyFileSync(source, temp, fs.constants.COPYFILE_EXCL);
406
- const copied = fs.lstatSync(temp);
407
- if (!copied.isFile() || copied.isSymbolicLink() || copied.size !== sourceStat.size) {
408
- throw new Error("credential copy did not produce the exact regular file");
535
+ const source = fs.realpathSync.native(path.resolve(sourcePath));
536
+ const sourceStat = fs.statSync(source);
537
+ if (!sourceStat.isFile() || sourceStat.size <= 0 || sourceStat.size > MAX_CREDENTIAL_FILE_BYTES) {
538
+ throw new Error("credential source must be a non-empty regular file no larger than 16 MiB");
539
+ }
540
+ const root = binding ? credentialAssertDirectoryAnchor(binding.root, "credential project root") : null;
541
+ const parent = binding
542
+ ? credentialAssertDirectoryAnchor(binding.parent, "credential destination directory", root)
543
+ : credentialDirectoryAnchor(path.dirname(resolvedDestination), "credential destination directory");
544
+ if (path.dirname(resolvedDestination) !== parent.path || path.basename(resolvedDestination).includes(path.sep)) {
545
+ throw new Error("credential destination path is not anchored to its managed directory");
409
546
  }
410
- try { fs.chmodSync(temp, 0o600); } catch { /* Windows/best-effort */ }
411
- replaceManagedFileCli(temp, destinationPath, snapshot);
412
- try { fs.chmodSync(destinationPath, 0o600); } catch { /* Windows/best-effort */ }
547
+ const destination = path.join(parent.realpath, path.basename(resolvedDestination));
548
+ const snapshot = (() => {
549
+ try {
550
+ const stat = credentialFileSnapshot(destination, "credential destination");
551
+ if (!options.force) throw new Error("credential destination already exists (use --force to replace)");
552
+ return { exists: true, stat };
553
+ } catch (error) {
554
+ if (error && error.code === "ENOENT") return { exists: false };
555
+ throw error;
556
+ }
557
+ })();
558
+ const temp = path.join(parent.realpath, `.${path.basename(destination)}.${process.pid}.${crypto.randomUUID()}.tmp`);
559
+ let tempStat = null;
560
+ try {
561
+ credentialAssertDirectoryAnchor(parent, "credential destination directory", root);
562
+ fs.copyFileSync(source, temp, fs.constants.COPYFILE_EXCL);
563
+ tempStat = credentialFileSnapshot(temp, "credential temporary file");
564
+ if (!tempStat || tempStat.size !== sourceStat.size) {
565
+ throw new Error("credential copy did not produce the exact regular file");
566
+ }
567
+ let readFd;
568
+ try {
569
+ readFd = fs.openSync(temp, fs.constants.O_RDONLY | CREDENTIAL_NOFOLLOW);
570
+ const opened = fs.fstatSync(readFd);
571
+ if (!credentialSameFileSnapshot(opened, tempStat) || opened.size !== sourceStat.size) {
572
+ throw new Error("credential temporary file changed while opening");
573
+ }
574
+ } finally {
575
+ if (readFd !== undefined) try { fs.closeSync(readFd); } catch { /* preserve original failure */ }
576
+ }
577
+ try { fs.chmodSync(temp, 0o600); } catch { /* Windows/best-effort */ }
578
+ credentialAssertDirectoryAnchor(parent, "credential destination directory", root);
579
+ credentialPublishFile(parent, path.basename(destination), { path: temp, stat: tempStat }, snapshot.exists ? snapshot.stat : null);
580
+ } finally {
581
+ if (tempStat) credentialRemoveOwnedFile(temp, tempStat);
582
+ }
583
+ return destinationPath;
413
584
  } finally {
414
- try { fs.rmSync(temp, { force: true }); } catch { /* best-effort cleanup */ }
585
+ if (binding) credentialDestinationBindings.delete(resolvedDestination);
415
586
  }
416
- return destinationPath;
417
587
  }
418
588
 
419
589
  module.exports = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlas",
3
- "version": "1.0.66",
3
+ "version": "1.0.67",
4
4
  "description": "Agentlas project terminal — run project controllers and task-scoped agent teams from the terminal. Standalone: no desktop app process required.",
5
5
  "bin": {
6
6
  "agentlas": "bin/agentlas.cjs"