agentlas 1.0.65 → 1.0.66

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 +78 -0
  2. package/README.md +6 -4
  3. package/bin/agentlas.cjs +341 -16
  4. package/engine/acp/server.cjs +27 -1
  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 +29 -5
  30. package/engine/agentlas-workload-routing.cjs +83 -10
  31. package/engine/agentlas.cjs +10 -3
  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 +80 -28
  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 +308 -113
  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 +42 -12
  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 +8 -2
  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 +29 -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 +230 -61
  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 +15 -5
  118. package/engine/mcp/plan.cjs +5 -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 +212 -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
@@ -9,9 +9,11 @@
9
9
  */
10
10
  const path = require("node:path");
11
11
  const fs = require("node:fs");
12
+ const crypto = require("node:crypto");
12
13
  const { spawnSync } = require("node:child_process");
13
14
 
14
15
  const PERM_RANK = { read: 0, write: 1, full: 2 };
16
+ const MAX_TEXT_FILE_BYTES = 8 * 1024 * 1024;
15
17
 
16
18
  function pathDenied(reason) {
17
19
  throw new Error(`workspace path denied: ${reason}`);
@@ -126,9 +128,12 @@ function openRegularFile(file, flags, mode) {
126
128
  }
127
129
 
128
130
  function readUtf8File(file) {
129
- if (!fs.statSync(file).isFile()) pathDenied("only regular files may be read");
130
131
  const fd = openRegularFile(file, fs.constants.O_RDONLY);
131
132
  try {
133
+ const stat = fs.fstatSync(fd);
134
+ if (stat.size > MAX_TEXT_FILE_BYTES) {
135
+ throw new Error(`text file exceeds ${MAX_TEXT_FILE_BYTES} byte limit`);
136
+ }
132
137
  return fs.readFileSync(fd, "utf8");
133
138
  } finally {
134
139
  fs.closeSync(fd);
@@ -136,6 +141,11 @@ function readUtf8File(file) {
136
141
  }
137
142
 
138
143
  function writeUtf8File(file, content) {
144
+ if (typeof content !== "string") throw new Error("content must be a string");
145
+ const contentBytes = Buffer.byteLength(content, "utf8");
146
+ if (contentBytes > MAX_TEXT_FILE_BYTES) {
147
+ throw new Error(`text content exceeds ${MAX_TEXT_FILE_BYTES} byte limit`);
148
+ }
139
149
  // Replace through a fresh inode instead of truncating an existing one. If the
140
150
  // workspace entry is a hard link to a file elsewhere, this updates only the
141
151
  // workspace path and cannot mutate the other link's inode.
@@ -218,6 +228,12 @@ const TOOLS = [
218
228
  },
219
229
  run(args, ctx) {
220
230
  const file = resolveExistingIn(ctx.cwd, args.path);
231
+ if (args.offset != null && (!Number.isInteger(args.offset) || args.offset < 1)) {
232
+ throw new Error("offset must be a positive integer");
233
+ }
234
+ if (args.limit != null && (!Number.isInteger(args.limit) || args.limit < 1)) {
235
+ throw new Error("limit must be a positive integer");
236
+ }
221
237
  let content = readUtf8File(file);
222
238
  if (args.offset || args.limit) {
223
239
  const lines = content.split("\n");
@@ -344,16 +360,17 @@ function toolAskFor(tool, args) {
344
360
  function runTool(name, args, ctx) {
345
361
  const tool = BY_NAME[name];
346
362
  if (!tool) return { ok: false, content: `unknown tool: ${name}` };
363
+ const context = ctx && typeof ctx === "object" ? ctx : {};
347
364
  const ask = toolAskFor(tool, args);
348
365
  let ruled = null;
349
- if (ctx && ctx.db) {
366
+ if (context.db) {
350
367
  try {
351
368
  const permissions = require("./agentlas-permissions.cjs");
352
- const verdict = permissions.decideCapability(ctx.db, {
369
+ const verdict = permissions.decideCapability(context.db, {
353
370
  ...ask,
354
- permission: ctx.permission,
355
- agentId: ctx.agentId,
356
- chatId: ctx.chatId,
371
+ permission: context.permission,
372
+ agentId: context.agentId,
373
+ chatId: context.chatId,
357
374
  });
358
375
  ruled = verdict.ruled;
359
376
  if (ruled === "deny") {
@@ -369,15 +386,15 @@ function runTool(name, args, ctx) {
369
386
  ruled = null;
370
387
  }
371
388
  }
372
- const rank = PERM_RANK[ctx.permission] ?? 0;
389
+ const rank = PERM_RANK[context.permission] ?? 0;
373
390
  if (ruled !== "allow" && (PERM_RANK[tool.minPerm] ?? 0) > rank) {
374
391
  return {
375
392
  ok: false,
376
- content: `permission denied: '${name}' requires '${tool.minPerm}' but current is '${ctx.permission}'. Ask the user to run /permission ${tool.minPerm}.`,
393
+ content: `permission denied: '${name}' requires '${tool.minPerm}' but current is '${context.permission || "read"}'. Ask the user to run /permission ${tool.minPerm}.`,
377
394
  };
378
395
  }
379
396
  try {
380
- return { ok: true, content: String(tool.run(args || {}, ctx)) };
397
+ return { ok: true, content: String(tool.run(args || {}, context)) };
381
398
  } catch (e) {
382
399
  return { ok: false, content: `${name} error: ${e && e.message ? e.message : String(e)}` };
383
400
  }
@@ -398,4 +415,14 @@ function openaiTools(permission) {
398
415
  }));
399
416
  }
400
417
 
401
- module.exports = { TOOLS, BY_NAME, allowedTools, runTool, anthropicTools, openaiTools, PERM_RANK, toolAskFor };
418
+ module.exports = {
419
+ TOOLS,
420
+ BY_NAME,
421
+ allowedTools,
422
+ runTool,
423
+ anthropicTools,
424
+ openaiTools,
425
+ PERM_RANK,
426
+ MAX_TEXT_FILE_BYTES,
427
+ toolAskFor,
428
+ };
@@ -116,10 +116,32 @@ function compactHomePath(value) {
116
116
  return home && value.startsWith(home + "/") ? "~/" + value.slice(home.length + 1) : value;
117
117
  }
118
118
 
119
+ function maskSecretValue(value) {
120
+ const text = String(value || "");
121
+ const quote = text[0];
122
+ return (quote === "\"" || quote === "'") && text[text.length - 1] === quote
123
+ ? quote + "••••" + quote
124
+ : "••••";
125
+ }
126
+
119
127
  function redactCommandSecrets(value) {
120
128
  return value
121
- .replace(/\b([A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD))=([^\s]+)/g, "$1=••••")
122
- .replace(/\b(authorization\s*:\s*bearer)\s+[^\s]+/gi, "$1 ••••")
129
+ // URL userinfo is a credential even when it is not an assignment.
130
+ .replace(/\b(https?:\/\/)[^\s/:@]+:[^\s/@]+@/gi, "$1[redacted]@")
131
+ // Authorization schemes and common credential-bearing headers. Keep the
132
+ // header name/scheme visible so the action remains understandable.
133
+ .replace(/\b((?:proxy-)?authorization\s*[:=]\s*)((?:basic|bearer)\s+)?("[^"]*"|'[^']*'|[^\s"']+)/gi, (_m, prefix, scheme, secret) => prefix + (scheme || "") + maskSecretValue(secret))
134
+ .replace(/\b((?:x[-_]?api[-_]?key|api[-_]?key|x[-_]?(?:auth(?:entication)?|access|refresh)[-_]?token|x[-_]?(?:client[-_]?secret)|cookie|set[-_]?cookie)\s*:\s*)("[^"]*"|'[^']*'|[^\s"']+)/gi, (_m, prefix, secret) => prefix + maskSecretValue(secret))
135
+ // Environment assignments are case-insensitive in shells. Keep the
136
+ // suffix allowlist narrow so ordinary arguments remain readable, while
137
+ // also covering bare names such as token= and password=.
138
+ .replace(/(^|[^A-Za-z0-9_])((?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|[A-Za-z0-9]+(?:[_-][A-Za-z0-9]+)*[_-](?:KEY|TOKEN|SECRET|PASSWORD|PASSWD)))(\s*=\s*)("[^"]*"|'[^']*'|[^\s"']+)/gi, (_m, lead, name, equals, secret) => lead + name + equals + maskSecretValue(secret))
139
+ // Header and CLI bearer forms are common in curl/npm-style commands.
140
+ .replace(/(\b((?:proxy-)?authorization\s*(?::|=|\s)\s*bearer)\s+)("[^"]*"|'[^']*'|[^\s"']+)/gi, (_m, prefix, _header, secret) => prefix + maskSecretValue(secret))
141
+ .replace(/(--(?:api[-_]?key|auth(?:entication)?[-_]?token|access[-_]?token|refresh[-_]?token|client[-_]?secret|token|secret|password|passwd)(?:\s*=\s*|\s+))("[^"]*"|'[^']*'|[^\s"']+)/gi, (_m, prefix, secret) => prefix + maskSecretValue(secret))
142
+ // A standalone bearer value is only masked when it has token-like length;
143
+ // this avoids turning ordinary prose such as "bearer token" into noise.
144
+ .replace(/(\bbearer)\s+([A-Za-z0-9._~+/=-]{16,})\b/gi, "$1 ••••")
123
145
  // Some remote MCPs put a bearer-like credential in the URL path instead of a header.
124
146
  // Keep the provider/path useful while ensuring activity summaries never print the secret.
125
147
  .replace(/(https?:\/\/[^\s]+\/)(ocm_[A-Za-z0-9_-]{12,})\b/gi, "$1[redacted]")
@@ -145,7 +167,7 @@ function compactToolArg(name, value, max = 120) {
145
167
  }
146
168
 
147
169
  function compactResult(text, ok, toolName, maxCells = 120) {
148
- const clean = stripAnsi(String(text || "")).replace(/\r/g, "").trim();
170
+ const clean = redactCommandSecrets(stripAnsi(String(text || ""))).replace(/\r/g, "").trim();
149
171
  const lines = clean.split("\n").map((line) => line.trim()).filter(Boolean);
150
172
  const count = lines.length;
151
173
  if (!ok) {
@@ -546,7 +568,7 @@ class Ui {
546
568
  toolResult(text, ok = true, options = {}) {
547
569
  if (!ok) this._turnFailures += 1;
548
570
  if (options.verbose) {
549
- const body = truncate(stripAnsi(String(text || "")).trim(), options.maxChars || 4_000);
571
+ const body = truncate(redactCommandSecrets(stripAnsi(String(text || "")).trim()), options.maxChars || 4_000);
550
572
  const lines = body ? body.split("\n") : [ok ? "done" : "error"];
551
573
  const marker = ok ? this.c.green(" └ ") : this.c.paw(" └ ");
552
574
  for (let index = 0; index < lines.length; index++) {
@@ -4649,18 +4649,42 @@ const TRANSIENT_MODEL_ERROR_RE = /Connection closed mid-response|"terminal_reaso
4649
4649
  function parseArgs(args) {
4650
4650
  const task = [];
4651
4651
  const options = {};
4652
+ const seen = new Set();
4653
+ let passthrough = false;
4652
4654
  for (let index = 0; index < args.length; index += 1) {
4653
4655
  const token = String(args[index]);
4654
- if (token === "--benchmark") options.benchmark = true;
4655
- else if (token === "--json") options.json = true;
4656
- else if (token === "--parallel" || token === "-n") options.concurrency = Number(args[++index]);
4657
- else task.push(token);
4656
+ if (passthrough) { task.push(token); continue; }
4657
+ if (token === "--") { passthrough = true; continue; }
4658
+ if (token === "--benchmark" || token === "--json") {
4659
+ const key = token.slice(2);
4660
+ if (seen.has(key)) throw new Error(`duplicate option: ${token}`);
4661
+ seen.add(key);
4662
+ options[key] = true;
4663
+ } else if (token === "--parallel" || token === "-n" || token.startsWith("--parallel=")) {
4664
+ if (seen.has("parallel")) throw new Error("duplicate option: --parallel");
4665
+ seen.add("parallel");
4666
+ const inline = token.startsWith("--parallel=");
4667
+ const raw = inline ? token.slice(11) : args[++index];
4668
+ const value = Number(raw);
4669
+ if (raw === undefined || raw === "" || (!inline && String(raw).startsWith("--")) || !Number.isInteger(value) || value < 1 || value > 8) {
4670
+ throw new Error("--parallel requires a positive integer (maximum 8)");
4671
+ }
4672
+ options.concurrency = value;
4673
+ } else if (token.startsWith("-")) {
4674
+ throw new Error(`unknown option: ${token} (use -- before a task token that starts with '-')`);
4675
+ } else task.push(token);
4658
4676
  }
4659
4677
  return { task: task.join(" ").trim(), options };
4660
4678
  }
4661
4679
 
4662
4680
  async function cmdWorkforce(db, args, runtimeOverride, executionContext = {}) {
4663
- const parsed = parseArgs(args);
4681
+ let parsed;
4682
+ try { parsed = parseArgs(args); }
4683
+ catch (error) {
4684
+ const ui = executionContext.ui || newUi();
4685
+ ui.error(String((error && error.message) || error), { reveal: true });
4686
+ return { ok: false, error: "invalid-arguments" };
4687
+ }
4664
4688
  if (!parsed.task) {
4665
4689
  const ui = executionContext.ui || newUi();
4666
4690
  ui.warn("usage: agentlas workforce <task> [--parallel N] [--benchmark] [--json]");
@@ -28,6 +28,52 @@ const EFFORTS = Object.freeze(["none", "minimal", "low", "medium", "high", "xhig
28
28
  const EFFORT_TOKEN_RE = /^[a-z][a-z0-9-]{0,23}$/;
29
29
  const CAPABILITIES = new Set(["code", "image", "tools", "long-context"]);
30
30
  const PHASES = new Set(["plan", "delegate", "synthesize"]);
31
+ const MAX_CODEX_MODEL_CACHE_BYTES = 2 * 1024 * 1024;
32
+ const MAX_DECISION_RECEIPT_BYTES = 16 * 1024 * 1024;
33
+ const MAX_DECISION_RECEIPT_RECORD_BYTES = 1024 * 1024;
34
+
35
+ function sameFileIdentity(left, right) {
36
+ return Boolean(left && right && left.dev === right.dev && left.ino === right.ino);
37
+ }
38
+
39
+ function safeRegularFile(stat, maxBytes, { nonEmpty = false } = {}) {
40
+ return Boolean(
41
+ stat && stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1 &&
42
+ Number.isSafeInteger(stat.size) && stat.size >= (nonEmpty ? 1 : 0) && stat.size <= maxBytes,
43
+ );
44
+ }
45
+
46
+ function readBoundedRegularFile(file, maxBytes) {
47
+ const before = fs.lstatSync(file);
48
+ if (!safeRegularFile(before, maxBytes, { nonEmpty: true })) throw new Error("model inventory must be a bounded single-link regular file");
49
+ const noFollow = fs.constants.O_NOFOLLOW || 0;
50
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | noFollow);
51
+ try {
52
+ const opened = fs.fstatSync(fd);
53
+ if (!safeRegularFile(opened, maxBytes, { nonEmpty: true }) || !sameFileIdentity(before, opened)) {
54
+ throw new Error("model inventory changed while opening");
55
+ }
56
+ const chunks = [];
57
+ let total = 0;
58
+ while (total <= maxBytes) {
59
+ const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes + 1 - total));
60
+ const count = fs.readSync(fd, buffer, 0, buffer.length, null);
61
+ if (!count) break;
62
+ chunks.push(buffer.subarray(0, count));
63
+ total += count;
64
+ }
65
+ if (total > maxBytes) throw new Error("model inventory exceeds its safety limit");
66
+ const after = fs.fstatSync(fd);
67
+ if (
68
+ !safeRegularFile(after, maxBytes, { nonEmpty: true }) || !sameFileIdentity(opened, after) ||
69
+ after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs ||
70
+ total !== after.size
71
+ ) throw new Error("model inventory changed while reading");
72
+ return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks, total));
73
+ } finally {
74
+ fs.closeSync(fd);
75
+ }
76
+ }
31
77
 
32
78
  function cleanText(value, max = 240) {
33
79
  return String(value || "").replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim().slice(0, max);
@@ -161,11 +207,12 @@ function defaultAvailableModels(runtime) {
161
207
  }
162
208
 
163
209
  function readCodexModelInventory(codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex")) {
164
- const file = path.join(codexHome, "models_cache.json");
165
210
  try {
166
- const stat = fs.statSync(file);
167
- if (!stat.isFile() || stat.size <= 0 || stat.size > 2 * 1024 * 1024) return [];
168
- const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
211
+ const realHome = fs.realpathSync(codexHome);
212
+ const homeStat = fs.lstatSync(realHome);
213
+ if (!homeStat.isDirectory() || homeStat.isSymbolicLink()) return [];
214
+ const file = path.join(realHome, "models_cache.json");
215
+ const parsed = JSON.parse(readBoundedRegularFile(file, MAX_CODEX_MODEL_CACHE_BYTES));
169
216
  if (!Array.isArray(parsed.models)) return [];
170
217
  const out = [];
171
218
  const seen = new Set();
@@ -517,22 +564,48 @@ function appendDecisionReceipt(receipt, file = defaultReceiptPath()) {
517
564
  const directory = path.dirname(file);
518
565
  fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
519
566
  try { fs.chmodSync(directory, 0o700); } catch { /* Windows/best effort */ }
567
+ const realDirectory = fs.realpathSync(directory);
568
+ const directoryStat = fs.lstatSync(realDirectory);
569
+ if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) {
570
+ throw new Error("model routing receipt directory must be a real directory");
571
+ }
572
+ const target = path.join(realDirectory, path.basename(file));
573
+ const line = Buffer.from(JSON.stringify(receipt) + "\n", "utf8");
574
+ if (line.length <= 1 || line.length > MAX_DECISION_RECEIPT_RECORD_BYTES) {
575
+ throw new Error("model routing receipt record exceeds its safety limit");
576
+ }
577
+ let before = null;
520
578
  try {
521
- const stat = fs.lstatSync(file);
522
- if (stat.isSymbolicLink()) throw new Error("model routing receipt path must not be a symlink");
579
+ before = fs.lstatSync(target);
580
+ if (before.isSymbolicLink()) {
581
+ throw new Error("model routing receipt path must not be a symlink");
582
+ }
583
+ if (!safeRegularFile(before, MAX_DECISION_RECEIPT_BYTES - line.length)) {
584
+ throw new Error("model routing receipt path must be a bounded single-link regular file");
585
+ }
523
586
  } catch (error) {
524
587
  if (error && error.code !== "ENOENT") throw error;
525
588
  }
526
589
  const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND | (fs.constants.O_NOFOLLOW || 0);
527
- const fd = fs.openSync(file, flags, 0o600);
590
+ const fd = fs.openSync(target, flags, 0o600);
528
591
  try {
529
- fs.writeSync(fd, JSON.stringify(receipt) + "\n", null, "utf8");
592
+ const opened = fs.fstatSync(fd);
593
+ if (
594
+ !safeRegularFile(opened, MAX_DECISION_RECEIPT_BYTES - line.length) ||
595
+ (before && !sameFileIdentity(before, opened))
596
+ ) throw new Error("model routing receipt changed while opening");
597
+ fs.fchmodSync(fd, 0o600);
598
+ const written = fs.writeSync(fd, line, 0, line.length, null);
599
+ if (written !== line.length) throw new Error("model routing receipt write was incomplete");
530
600
  fs.fsyncSync(fd);
601
+ const after = fs.fstatSync(fd);
602
+ if (!safeRegularFile(after, MAX_DECISION_RECEIPT_BYTES) || !sameFileIdentity(opened, after)) {
603
+ throw new Error("model routing receipt changed while writing");
604
+ }
531
605
  } finally {
532
606
  fs.closeSync(fd);
533
607
  }
534
- try { fs.chmodSync(file, 0o600); } catch { /* Windows/best effort */ }
535
- return file;
608
+ return target;
536
609
  }
537
610
 
538
611
  function plannerSystemPrompt({ language = "English", maxTasks = 12, mode = "swarm", liveRuntimeInventory = [] } = {}) {
@@ -171,7 +171,14 @@ function main() {
171
171
  const code = commands.SELF_HELP_COMMANDS.has(command)
172
172
  ? commands.dispatch(ctx, [command, "help"])
173
173
  : require("./commands/help.cjs").runForCommand(ctx, command);
174
- process.exit(typeof code === "number" ? code : 0);
174
+ // Self-owned help may be an async native passthrough (Research is the first
175
+ // such command). Exiting on the unresolved Promise kills its child before
176
+ // the help text reaches stdout.
177
+ Promise.resolve(code).then(
178
+ (n) => process.exit(typeof n === "number" ? n : 0),
179
+ (error) => { ctx.fail(error); process.exit(1); },
180
+ );
181
+ return;
175
182
  }
176
183
  // 옵션 정규화: -h/--help/-V/--version 은 하위 명령으로 변환
177
184
  const normalized = argv.map((a) => {
@@ -199,7 +206,7 @@ function main() {
199
206
  const { startRepl } = require("./ui/repl.cjs");
200
207
  return startRepl(ctx).then(
201
208
  (replCode) => process.exit(replCode || 0),
202
- (e) => { ctx.err(String((e && e.message) || e)); process.exit(1); },
209
+ (e) => { ctx.fail(e); process.exit(1); },
203
210
  );
204
211
  }
205
212
 
@@ -236,7 +243,7 @@ function main() {
236
243
 
237
244
  Promise.resolve(code).then(
238
245
  (n) => process.exit(typeof n === "number" ? n : 0),
239
- (e) => { ctx.err(String((e && e.message) || e)); process.exit(1); },
246
+ (e) => { ctx.fail(e); process.exit(1); },
240
247
  );
241
248
  }
242
249
 
@@ -15,11 +15,14 @@
15
15
  * `agentlas import <folder>`로 언제든 설치할 수 있다.
16
16
  */
17
17
  const crypto = require("node:crypto");
18
+ const path = require("node:path");
18
19
  const { runWriteTransaction } = require("../agentlas-sqlite-policy.cjs");
19
20
  const { columnExists } = require("../core/db.cjs");
20
21
 
21
22
  const BUILDER_SLUG = "agentlas-builder";
22
23
  const BUILDER_ID = "builtin-agentlas-builder";
24
+ const MAX_BUILDER_OUTPUT_BYTES = 256 * 1024;
25
+ const MAX_BUILT_FOLDER_BYTES = 4 * 1024;
23
26
 
24
27
  const BUILDER_SYSTEM_PROMPT = [
25
28
  "You are the Agentlas local agent builder, running inside the Agentlas terminal.",
@@ -45,12 +48,18 @@ const BUILDER_SYSTEM_PROMPT = [
45
48
  function ensureBuilderAgent(db) {
46
49
  const now = new Date().toISOString();
47
50
  const hasVisibility = columnExists(db, "installed_agents", "visibility");
48
- runWriteTransaction(db, () => {
49
- const existing = db.prepare("SELECT id FROM installed_agents WHERE id=? OR slug=?").get(BUILDER_ID, BUILDER_SLUG);
50
- if (existing) {
51
- db.prepare("UPDATE installed_agents SET system_prompt=?, name=?, name_en=?, tagline=?, tagline_en=? WHERE id=?")
52
- .run(BUILDER_SYSTEM_PROMPT, "Agent Builder", "Agent Builder", "Builds Agentlas agents locally", "Builds Agentlas agents locally", existing.id);
53
- return;
51
+ const identity = runWriteTransaction(db, () => {
52
+ const existing = db.prepare("SELECT id, slug FROM installed_agents WHERE id=? OR slug=?").all(BUILDER_ID, BUILDER_SLUG);
53
+ if (existing.length) {
54
+ if (existing.length !== 1 || existing[0].id !== BUILDER_ID || existing[0].slug !== BUILDER_SLUG) {
55
+ throw Object.assign(new Error("The reserved Agentlas builder identity is already used by another installed agent."), { code: "builder_identity_conflict", honestStop: true });
56
+ }
57
+ const visibilitySet = hasVisibility ? ", visibility='visible'" : "";
58
+ db.prepare(
59
+ `UPDATE installed_agents SET system_prompt=?, name=?, name_en=?, tagline=?, tagline_en=?, ` +
60
+ `builtin=1, role='orchestrator', tone='blue', trust_grade='A'${visibilitySet} WHERE id=?`,
61
+ ).run(BUILDER_SYSTEM_PROMPT, "Agent Builder", "Agent Builder", "Builds Agentlas agents locally", "Builds Agentlas agents locally", BUILDER_ID);
62
+ return { id: BUILDER_ID, slug: BUILDER_SLUG };
54
63
  }
55
64
  if (hasVisibility) {
56
65
  db.prepare(
@@ -63,10 +72,18 @@ function ensureBuilderAgent(db) {
63
72
  "VALUES (?,?,?,?,?,?,?,'[]','[]',NULL,'A',?,?,1,?)",
64
73
  ).run(BUILDER_ID, BUILDER_SLUG, "Agent Builder", "Agent Builder", "Builds Agentlas agents locally", "Builds Agentlas agents locally", BUILDER_SYSTEM_PROMPT, now, "blue", "orchestrator");
65
74
  }
75
+ return { id: BUILDER_ID, slug: BUILDER_SLUG };
66
76
  });
77
+ const stored = db.prepare("SELECT id, slug, builtin, role FROM installed_agents WHERE id=?").get(BUILDER_ID);
78
+ if (
79
+ !stored || stored.id !== identity.id || stored.slug !== identity.slug ||
80
+ Number(stored.builtin) !== 1 || stored.role !== "orchestrator"
81
+ ) {
82
+ throw Object.assign(new Error("The reserved Agentlas builder identity could not be verified after seeding."), { code: "builder_identity_conflict", honestStop: true });
83
+ }
67
84
  return {
68
- id: BUILDER_ID,
69
- slug: BUILDER_SLUG,
85
+ id: identity.id,
86
+ slug: identity.slug,
70
87
  name: "Agent Builder",
71
88
  nameEn: "Agent Builder",
72
89
  systemPrompt: BUILDER_SYSTEM_PROMPT,
@@ -77,10 +94,18 @@ function ensureBuilderAgent(db) {
77
94
 
78
95
  /** 빌더 산출물 마지막 줄에서 `BUILT: <folder>`를 뽑는다. */
79
96
  function parseBuiltFolder(finalText) {
80
- const lines = String(finalText || "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
97
+ const text = String(finalText || "");
98
+ if (Buffer.byteLength(text, "utf8") > MAX_BUILDER_OUTPUT_BYTES) return null;
99
+ const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
81
100
  for (let i = lines.length - 1; i >= 0; i -= 1) {
82
101
  const m = lines[i].match(/^BUILT:\s*(.+)$/);
83
- if (m) return m[1].trim();
102
+ if (!m) continue;
103
+ const folder = m[1].trim();
104
+ if (!folder || Buffer.byteLength(folder, "utf8") > MAX_BUILT_FOLDER_BYTES || /[\u0000-\u001f\u007f]/.test(folder)) return null;
105
+ if (path.isAbsolute(folder) || /^[A-Za-z]:[\\/]/.test(folder) || folder.includes("\\")) return null;
106
+ const segments = folder.split("/");
107
+ if (segments.some((segment) => !segment || segment === "." || segment === "..")) return null;
108
+ return folder;
84
109
  }
85
110
  return null;
86
111
  }
@@ -6,33 +6,68 @@
6
6
  * 관찰상 읽기 전용으로 유지돼야 소스 패키지를 재분류하지 않는다.
7
7
  */
8
8
  const fs = require("node:fs");
9
+ const os = require("node:os");
9
10
  const path = require("node:path");
10
11
  const { userDataDir } = require("../core/paths.cjs");
11
12
  const { routesMap } = require("./routes.cjs");
12
13
 
13
14
  const CLOUD_RESTORE_MARKER_PATH = ".agentlas-cloud-package.json";
15
+ const NATIVE_CONTEXT_MAX_CHARS = 256 * 1024;
14
16
 
15
17
  function cloudSlug(value) {
16
18
  return (String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "agentlas-cloud-agent");
17
19
  }
18
20
 
19
- function exists(p) { try { fs.accessSync(p); return true; } catch { return false; } }
20
-
21
21
  function agentSystemPrompt(agent) {
22
- return agent && agent.systemPrompt ? agent.systemPrompt : `You are ${(agent && agent.name) || "an Agentlas agent"}.`;
22
+ const value = agent && agent.systemPrompt ? agent.systemPrompt : `You are ${(agent && agent.name) || "an Agentlas agent"}.`;
23
+ const text = String(value);
24
+ if (text.length > NATIVE_CONTEXT_MAX_CHARS) throw new Error("agent system prompt exceeds the native context file limit");
25
+ return text;
26
+ }
27
+
28
+ function safeExternalAgentPath(value) {
29
+ if (typeof value !== "string" || !path.isAbsolute(value) || /[\u0000\r\n]/.test(value)) return null;
30
+ const resolved = path.resolve(value);
31
+ const unsafe = new Set([path.parse(resolved).root, path.resolve(os.homedir()), path.resolve(userDataDir())]);
32
+ if (unsafe.has(resolved)) return null;
33
+ try {
34
+ const stat = fs.lstatSync(resolved);
35
+ if (stat.isSymbolicLink() || !stat.isDirectory()) return null;
36
+ const canonical = fs.realpathSync(resolved);
37
+ if (unsafe.has(canonical)) return null;
38
+ return canonical;
39
+ } catch (error) {
40
+ // Preserve a safe missing local route for read-only `agentlas cd` output.
41
+ // The write boundary below refuses to recreate it.
42
+ return error && error.code === "ENOENT" ? resolved : null;
43
+ }
44
+ }
45
+
46
+ function isRegularNoFollow(file) {
47
+ try {
48
+ const stat = fs.lstatSync(file);
49
+ return stat.isFile() && !stat.isSymbolicLink();
50
+ } catch {
51
+ return false;
52
+ }
23
53
  }
24
54
 
25
55
  function agentFolder(agent) {
26
56
  const routes = routesMap();
27
57
  const r = routes[agent.id];
28
- if (r && r.path) return r.path; // 로컬 임포트는 원본 폴더
58
+ const localPath = safeExternalAgentPath(r && r.path);
59
+ if (localPath) return localPath; // 로컬 임포트는 원본 폴더
29
60
  const cloudRoot = path.join(userDataDir(), "cloud-agent-installs", cloudSlug(agent.slug));
30
- if (exists(path.join(cloudRoot, CLOUD_RESTORE_MARKER_PATH))) return cloudRoot;
31
- return path.join(userDataDir(), "agents", agent.slug);
61
+ if (safeExternalAgentPath(cloudRoot) && isRegularNoFollow(path.join(cloudRoot, CLOUD_RESTORE_MARKER_PATH))) return cloudRoot;
62
+ return path.join(userDataDir(), "agents", cloudSlug(agent.slug));
32
63
  }
33
64
 
34
65
  function writeIfMissing(file, content) {
35
- try { fs.lstatSync(file); return false; } catch (error) {
66
+ try {
67
+ const stat = fs.lstatSync(file);
68
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`native context target must be a regular non-symbolic-link file: ${file}`);
69
+ return false;
70
+ } catch (error) {
36
71
  if (!error || error.code !== "ENOENT") throw error;
37
72
  }
38
73
  const fd = fs.openSync(file, "wx", 0o600);
@@ -45,12 +80,40 @@ function writeIfMissing(file, content) {
45
80
  return true;
46
81
  }
47
82
 
83
+ function assertNativeFolder(folder) {
84
+ if (typeof folder !== "string" || !path.isAbsolute(folder) || /[\u0000\r\n]/.test(folder)) {
85
+ throw new Error("native context folder must be an absolute safe path");
86
+ }
87
+ const resolved = path.resolve(folder);
88
+ const data = path.resolve(userDataDir());
89
+ const internalRoots = [path.join(data, "agents"), path.join(data, "cloud-agent-installs")];
90
+ const internal = internalRoots.some((root) => resolved.startsWith(root + path.sep));
91
+ const unsafe = new Set([path.parse(resolved).root, path.resolve(os.homedir()), data]);
92
+ if (unsafe.has(resolved)) throw new Error("refusing to write native context files to a broad user/system directory");
93
+ try {
94
+ const stat = fs.lstatSync(resolved);
95
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("native context folder must be a real directory");
96
+ const canonical = fs.realpathSync(resolved);
97
+ if (unsafe.has(canonical)) throw new Error("refusing to write native context files to a broad user/system directory");
98
+ return canonical;
99
+ } catch (error) {
100
+ if (!error || error.code !== "ENOENT") throw error;
101
+ if (!internal) throw new Error("refusing to recreate a missing imported agent source folder");
102
+ fs.mkdirSync(resolved, { recursive: true });
103
+ const created = fs.lstatSync(resolved);
104
+ if (created.isSymbolicLink() || !created.isDirectory()) throw new Error("native context folder changed type during creation");
105
+ }
106
+ return resolved;
107
+ }
108
+
48
109
  function ensureNativeFiles(agent, folder) {
49
- fs.mkdirSync(folder, { recursive: true });
110
+ folder = assertNativeFolder(folder);
50
111
  const sys = agentSystemPrompt(agent);
51
112
  const created = [];
52
113
  if (writeIfMissing(path.join(folder, "system-prompt.md"), sys)) created.push("system-prompt.md");
53
- const header = `# ${agent.name}\n\n${agent.tagline || ""}\n\n${sys}\n`;
114
+ const name = String(agent.name || agent.slug || "Agentlas agent").replace(/[\u0000\r\n]/g, " ").slice(0, 500);
115
+ const tagline = String(agent.tagline || "").replace(/[\u0000\r]/g, " ").slice(0, 2_000);
116
+ const header = `# ${name}\n\n${tagline}\n\n${sys}\n`;
54
117
  // 네이티브 CLI가 프로젝트 지시로 자동 인식하는 파일들
55
118
  if (writeIfMissing(path.join(folder, "CLAUDE.md"), header)) created.push("CLAUDE.md");
56
119
  if (writeIfMissing(path.join(folder, "AGENTS.md"), header)) created.push("AGENTS.md");
@@ -58,4 +121,12 @@ function ensureNativeFiles(agent, folder) {
58
121
  return created;
59
122
  }
60
123
 
61
- module.exports = { agentFolder, ensureNativeFiles, agentSystemPrompt, cloudSlug, CLOUD_RESTORE_MARKER_PATH };
124
+ module.exports = {
125
+ agentFolder,
126
+ ensureNativeFiles,
127
+ agentSystemPrompt,
128
+ cloudSlug,
129
+ safeExternalAgentPath,
130
+ assertNativeFolder,
131
+ CLOUD_RESTORE_MARKER_PATH,
132
+ };
@@ -5,10 +5,12 @@
5
5
  * v1 모놀리스 890–1112 구간의 충실 이식 — 규칙 변경 금지(데스크탑과 판정 동형이어야 함).
6
6
  */
7
7
  const fs = require("node:fs");
8
+ const os = require("node:os");
8
9
  const path = require("node:path");
9
10
  const crypto = require("node:crypto");
10
11
  const { tableExists, columnExists } = require("../core/db.cjs");
11
12
  const { routesMap, saveRoutes } = require("./routes.cjs");
13
+ const { userDataDir } = require("../core/paths.cjs");
12
14
 
13
15
  function exists(p) { try { fs.accessSync(p); return true; } catch { return false; } }
14
16
  function isDir(p) { try { return fs.statSync(p).isDirectory(); } catch { return false; } }
@@ -159,8 +161,14 @@ function upsertLocalTeamFirm(db, dir, ceoAgentId, agentSlug, name, tagline) {
159
161
  }
160
162
 
161
163
  function importLocalFolder(db, absPath) {
162
- const dir = path.resolve(absPath);
163
- if (!isDir(dir)) throw new Error(`Not a directory: ${absPath}`);
164
+ const requested = path.resolve(absPath);
165
+ const unsafe = new Set([path.parse(requested).root, path.resolve(os.homedir()), path.resolve(userDataDir())]);
166
+ if (unsafe.has(requested)) throw new Error(`Refusing to import a broad user/system directory: ${absPath}`);
167
+ let stat;
168
+ try { stat = fs.lstatSync(requested); } catch { throw new Error(`Not a directory: ${absPath}`); }
169
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`Imported agent path must be a real non-symbolic-link directory: ${absPath}`);
170
+ const dir = fs.realpathSync(requested);
171
+ if (unsafe.has(dir)) throw new Error(`Refusing to import a broad user/system directory: ${absPath}`);
164
172
  const labels = detectRuntimeLabels(dir);
165
173
  const runtime = labels[0];
166
174
  const kind = detectKind(dir);
@@ -122,9 +122,16 @@ function findAgent(db, token) {
122
122
  const q = String(token || "").trim().toLowerCase();
123
123
  if (!q) return null;
124
124
  const agents = listRoutableAgents(db);
125
- const exact = agents.find((a) => a.slug.toLowerCase() === q || a.id === token)
126
- || agents.find((a) => (a.name || "").toLowerCase() === q || (a.nameEn || "").toLowerCase() === q);
127
- if (exact) return exact;
125
+ const exactId = agents.find((agent) => agent.id === token);
126
+ if (exactId) return exactId; // primary key is authoritative
127
+ const exactSlug = agents.filter((agent) => agent.slug.toLowerCase() === q);
128
+ if (exactSlug.length === 1) return exactSlug[0];
129
+ if (exactSlug.length > 1) return null;
130
+ const exactName = agents.filter((agent) =>
131
+ (agent.name || "").toLowerCase() === q || (agent.nameEn || "").toLowerCase() === q,
132
+ );
133
+ if (exactName.length === 1) return exactName[0];
134
+ if (exactName.length > 1) return null;
128
135
  const partial = agents.filter((a) =>
129
136
  a.slug.toLowerCase().includes(q) || (a.name || "").toLowerCase().includes(q) || (a.nameEn || "").toLowerCase().includes(q));
130
137
  if (partial.length === 1) return partial[0];