@lumoai/cli 1.5.0 → 1.5.1

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 (81) hide show
  1. package/assets/skill.md +159 -15
  2. package/dist/cli/src/commands/auth-login.js +4 -3
  3. package/dist/cli/src/commands/auth-logout.js +2 -1
  4. package/dist/cli/src/commands/doc-bind.js +3 -3
  5. package/dist/cli/src/commands/doc-create.js +5 -5
  6. package/dist/cli/src/commands/doc-delete.js +4 -4
  7. package/dist/cli/src/commands/doc-import-gdoc.js +82 -0
  8. package/dist/cli/src/commands/doc-list.js +7 -7
  9. package/dist/cli/src/commands/doc-move.js +8 -8
  10. package/dist/cli/src/commands/doc-share-list.js +11 -8
  11. package/dist/cli/src/commands/doc-share.js +7 -5
  12. package/dist/cli/src/commands/doc-show.js +6 -6
  13. package/dist/cli/src/commands/doc-sync.js +44 -0
  14. package/dist/cli/src/commands/doc-unbind.js +4 -4
  15. package/dist/cli/src/commands/doc-unshare.js +9 -7
  16. package/dist/cli/src/commands/doc-update.js +5 -5
  17. package/dist/cli/src/commands/hook.js +2 -2
  18. package/dist/cli/src/commands/memory-project-add.js +19 -4
  19. package/dist/cli/src/commands/memory-project-list.js +1 -2
  20. package/dist/cli/src/commands/memory-promote.js +3 -3
  21. package/dist/cli/src/commands/memory-rm.js +1 -2
  22. package/dist/cli/src/commands/memory-task-add.js +19 -4
  23. package/dist/cli/src/commands/memory-task-list.js +1 -2
  24. package/dist/cli/src/commands/milestone-create.js +4 -4
  25. package/dist/cli/src/commands/milestone-delete.js +5 -5
  26. package/dist/cli/src/commands/milestone-list.js +3 -3
  27. package/dist/cli/src/commands/milestone-show.js +5 -5
  28. package/dist/cli/src/commands/milestone-update.js +6 -5
  29. package/dist/cli/src/commands/project-list.js +3 -3
  30. package/dist/cli/src/commands/session-attach.js +5 -5
  31. package/dist/cli/src/commands/session-detach.js +3 -3
  32. package/dist/cli/src/commands/session-status.js +3 -3
  33. package/dist/cli/src/commands/setup.js +33 -7
  34. package/dist/cli/src/commands/sprint-add.js +3 -3
  35. package/dist/cli/src/commands/sprint-close.js +5 -5
  36. package/dist/cli/src/commands/sprint-create.js +4 -4
  37. package/dist/cli/src/commands/sprint-delete.js +5 -5
  38. package/dist/cli/src/commands/sprint-list.js +3 -3
  39. package/dist/cli/src/commands/sprint-remove.js +3 -3
  40. package/dist/cli/src/commands/sprint-show.js +4 -4
  41. package/dist/cli/src/commands/sprint-start.js +4 -4
  42. package/dist/cli/src/commands/sprint-summary.js +7 -7
  43. package/dist/cli/src/commands/sprint-update.js +6 -5
  44. package/dist/cli/src/commands/task-artifact-add.js +17 -5
  45. package/dist/cli/src/commands/task-artifact-list.js +4 -4
  46. package/dist/cli/src/commands/task-artifact-rm.js +4 -4
  47. package/dist/cli/src/commands/task-artifact-show.js +8 -8
  48. package/dist/cli/src/commands/task-artifact-update.js +5 -5
  49. package/dist/cli/src/commands/task-comment-list.js +111 -0
  50. package/dist/cli/src/commands/task-comment.js +3 -3
  51. package/dist/cli/src/commands/task-context.js +24 -12
  52. package/dist/cli/src/commands/task-create.js +7 -7
  53. package/dist/cli/src/commands/task-figma-add.js +3 -2
  54. package/dist/cli/src/commands/task-figma-context.js +61 -0
  55. package/dist/cli/src/commands/task-figma-list.js +3 -2
  56. package/dist/cli/src/commands/task-figma-refresh.js +4 -3
  57. package/dist/cli/src/commands/task-figma-rm.js +3 -2
  58. package/dist/cli/src/commands/task-list.js +1 -2
  59. package/dist/cli/src/commands/task-pr-show.js +66 -0
  60. package/dist/cli/src/commands/task-show.js +8 -7
  61. package/dist/cli/src/commands/task-slack-show.js +59 -0
  62. package/dist/cli/src/commands/task-update.js +7 -7
  63. package/dist/cli/src/commands/task-web-show.js +64 -0
  64. package/dist/cli/src/commands/whoami.js +4 -3
  65. package/dist/cli/src/index.js +167 -102
  66. package/dist/cli/src/lib/agent.js +10 -1
  67. package/dist/cli/src/lib/api.js +81 -1
  68. package/dist/cli/src/lib/config.js +2 -1
  69. package/dist/cli/src/lib/doc-input.js +12 -1
  70. package/dist/cli/src/lib/figma-api.js +1 -1
  71. package/dist/cli/src/lib/format.js +3 -2
  72. package/dist/cli/src/lib/hook-runner.js +26 -10
  73. package/dist/cli/src/lib/hooks-template.js +52 -7
  74. package/dist/cli/src/lib/memory-content.js +4 -3
  75. package/dist/cli/src/lib/path-guard.js +125 -0
  76. package/dist/cli/src/lib/resolve-doc-id.js +2 -1
  77. package/dist/cli/src/lib/resolve-member.js +2 -1
  78. package/dist/cli/src/lib/sanitize.js +17 -0
  79. package/dist/cli/src/lib/tag-resolver.js +2 -1
  80. package/dist/cli/src/lib/update-check.js +2 -2
  81. package/package.json +1 -1
@@ -1,12 +1,92 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.assertSafeApiUrl = assertSafeApiUrl;
4
+ exports.hostMismatchWarning = hostMismatchWarning;
5
+ exports.resolveAuthedApiUrl = resolveAuthedApiUrl;
3
6
  exports.resolveApiUrl = resolveApiUrl;
4
7
  exports.trimTrailingSlash = trimTrailingSlash;
5
8
  exports.verifyToken = verifyToken;
6
9
  const DEFAULT_API_URL = 'https://www.uselumo.ai';
10
+ // Hostnames allowed to use plaintext http:// — local dev only. Everything
11
+ // else MUST be https:// so the Bearer token is never sent in the clear.
12
+ // `URL.hostname` returns IPv6 literals wrapped in brackets, so `::1` appears
13
+ // as `[::1]`.
14
+ const LOCALHOST_HOSTNAMES = new Set([
15
+ 'localhost',
16
+ '127.0.0.1',
17
+ '::1',
18
+ '[::1]',
19
+ ]);
20
+ /**
21
+ * Throw if `url` is not a safe target for sending the API token.
22
+ *
23
+ * The CLI attaches a `Bearer` token (and the hook runner POSTs full session
24
+ * content) to whatever `LUMO_API_URL` resolves to. An attacker who can set
25
+ * that env var could otherwise exfiltrate the token / session stream by
26
+ * pointing it at their own host, or downgrade to http:// to sniff it in
27
+ * plaintext. We therefore require https://, with an http:// exception for
28
+ * localhost so local dev still works.
29
+ */
30
+ function assertSafeApiUrl(url) {
31
+ let parsed;
32
+ try {
33
+ parsed = new URL(url);
34
+ }
35
+ catch {
36
+ throw new Error(`Invalid LUMO_API_URL: "${url}" is not a valid URL`);
37
+ }
38
+ if (parsed.protocol === 'https:')
39
+ return;
40
+ if (parsed.protocol === 'http:' && LOCALHOST_HOSTNAMES.has(parsed.hostname)) {
41
+ return;
42
+ }
43
+ throw new Error(`Refusing to use insecure API URL "${url}": only https:// is allowed ` +
44
+ `(http:// permitted for localhost only). This protects your API token ` +
45
+ `and session data from being sent in plaintext or to an untrusted host.`);
46
+ }
47
+ /**
48
+ * Return a prominent warning when the resolved API host differs from the host
49
+ * the credentials were issued for, otherwise null. Compares hostname only
50
+ * (case-insensitive, courtesy of URL parsing). Used to warn-then-send: a host
51
+ * change is suspicious (possible token exfiltration) but intentional dev/env
52
+ * redirects are legitimate, so we surface it rather than block.
53
+ */
54
+ function hostMismatchWarning(resolvedUrl, credsApiUrl) {
55
+ let resolvedHost;
56
+ let credsHost;
57
+ try {
58
+ resolvedHost = new URL(resolvedUrl).hostname;
59
+ credsHost = new URL(credsApiUrl).hostname;
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ if (resolvedHost === credsHost)
65
+ return null;
66
+ return (`⚠ LUMO_API_URL points at "${resolvedHost}" but your credentials were ` +
67
+ `issued for "${credsHost}". Your API token will be sent there. If you did ` +
68
+ `not set this, your token may be exfiltrated — unset LUMO_API_URL.`);
69
+ }
70
+ /**
71
+ * Resolve the API URL for an authenticated command: prefer a non-empty
72
+ * `LUMO_API_URL` override, else the baked-in `credsApiUrl`. The resolved URL
73
+ * is validated (throws on an insecure target) and any host-mismatch warning is
74
+ * routed to `opts.warn` (defaults to stderr via console.error).
75
+ */
76
+ function resolveAuthedApiUrl(credsApiUrl, opts) {
77
+ const envUrl = process.env.LUMO_API_URL?.trim();
78
+ const apiUrl = envUrl && envUrl.length > 0 ? envUrl : credsApiUrl;
79
+ assertSafeApiUrl(apiUrl);
80
+ const warning = hostMismatchWarning(apiUrl, credsApiUrl);
81
+ if (warning)
82
+ (opts?.warn ?? ((m) => console.error(m)))(warning);
83
+ return apiUrl;
84
+ }
7
85
  function resolveApiUrl() {
8
86
  const url = process.env.LUMO_API_URL?.trim();
9
- return url && url.length > 0 ? url : DEFAULT_API_URL;
87
+ const resolved = url && url.length > 0 ? url : DEFAULT_API_URL;
88
+ assertSafeApiUrl(resolved);
89
+ return resolved;
10
90
  }
11
91
  function trimTrailingSlash(url) {
12
92
  return url.replace(/\/+$/, '');
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.configDir = configDir;
36
37
  exports.credentialsPath = credentialsPath;
37
38
  exports.readCredentials = readCredentials;
38
39
  exports.writeCredentials = writeCredentials;
@@ -41,7 +42,7 @@ const fs = __importStar(require("fs"));
41
42
  const path = __importStar(require("path"));
42
43
  const os = __importStar(require("os"));
43
44
  function configDir() {
44
- return path.join(os.homedir(), '.lumo');
45
+ return process.env.LUMO_CONFIG_DIR || path.join(os.homedir(), '.lumo');
45
46
  }
46
47
  function credentialsPath() {
47
48
  return path.join(configDir(), 'credentials.json');
@@ -37,6 +37,7 @@ exports.resolveDocContent = resolveDocContent;
37
37
  exports.readStdinToString = readStdinToString;
38
38
  exports.readFileUtf8 = readFileUtf8;
39
39
  const fs = __importStar(require("fs"));
40
+ const path_guard_1 = require("./path-guard");
40
41
  /**
41
42
  * Pick one of --content / --file / stdin as the markdown source.
42
43
  * Explicit flags win: if --content or --file is set, stdin is ignored
@@ -56,7 +57,17 @@ async function resolveDocContent(args) {
56
57
  if (hasContent)
57
58
  return { kind: 'ok', markdown: args.content };
58
59
  if (hasFile) {
59
- const text = await args.readFile(args.file);
60
+ const check = (args.checkFilePath ?? path_guard_1.checkArtifactFilePath)(args.file);
61
+ if (!check.ok) {
62
+ return {
63
+ kind: 'error',
64
+ message: check.reason === 'unreadable'
65
+ ? `could not read file ${args.file}`
66
+ : `refusing to read ${args.file} — ${check.detail}. ` +
67
+ `--file must be a non-sensitive path inside the project directory.`,
68
+ };
69
+ }
70
+ const text = await args.readFile(check.resolved);
60
71
  return { kind: 'ok', markdown: text };
61
72
  }
62
73
  if (!args.stdinIsTTY) {
@@ -18,7 +18,7 @@ async function call(path, init) {
18
18
  const creds = (0, config_1.readCredentials)();
19
19
  if (!creds)
20
20
  throw new Error('Not logged in. Run: lumo auth login');
21
- const apiUrl = (0, api_1.resolveApiUrl)();
21
+ const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
22
22
  const res = await fetch(`${(0, api_1.trimTrailingSlash)(apiUrl)}${path}`, {
23
23
  ...init,
24
24
  headers: {
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.formatTaskListTable = formatTaskListTable;
4
+ const sanitize_1 = require("./sanitize");
4
5
  /**
5
6
  * Render a task list as a fixed-width table. Each row:
6
7
  * LUM-42 IN_PROGRESS HIGH project-name Title here
@@ -14,8 +15,8 @@ function formatTaskListTable(tasks) {
14
15
  identifier: `${t.teamIdentifier}-${t.number}`,
15
16
  status: t.status,
16
17
  priority: t.priority,
17
- project: t.project.name,
18
- title: t.title,
18
+ project: (0, sanitize_1.sanitizeField)(t.project.name),
19
+ title: (0, sanitize_1.sanitizeField)(t.title),
19
20
  }));
20
21
  const widths = {
21
22
  identifier: Math.max(...rows.map(r => r.identifier.length)),
@@ -6,6 +6,8 @@ exports.runHookWithBody = runHookWithBody;
6
6
  const config_1 = require("./config");
7
7
  const api_1 = require("./api");
8
8
  const hook_log_1 = require("./hook-log");
9
+ const sanitize_1 = require("./sanitize");
10
+ const agent_1 = require("./agent");
9
11
  /**
10
12
  * Hard timeout for the hook POST. On timeout the request is aborted,
11
13
  * logged, and `runHook` exits 0 — Claude Code is never blocked beyond
@@ -70,7 +72,7 @@ function formatHookStdoutLines(path, responseBody) {
70
72
  const sessionId = body.sessionId;
71
73
  const tb = body.taskBinding;
72
74
  if (tb && tb.bound === true) {
73
- lines.push(`[Lumo] session_id=${sessionId} | 当前任务: ${tb.taskIdentifier} - ${tb.taskTitle}`);
75
+ lines.push(`[Lumo] session_id=${sessionId} | 当前任务: ${tb.taskIdentifier} - ${(0, sanitize_1.sanitizeField)(tb.taskTitle ?? '')}`);
74
76
  }
75
77
  else if (tb && tb.bound === false) {
76
78
  lines.push(`[Lumo] session_id=${sessionId} | 当前未绑定任务。请告诉我你要处理的任务编号(如 LUM-42),或说"跳过"。`);
@@ -92,16 +94,16 @@ function formatHookStdoutLines(path, responseBody) {
92
94
  *
93
95
  * This function NEVER throws and NEVER rejects.
94
96
  */
95
- async function runHook(path) {
97
+ async function runHook(path, agentToken) {
96
98
  const body = await readStdin();
97
- return runHookWithBody(path, body);
99
+ return runHookWithBody(path, body, agentToken);
98
100
  }
99
101
  /**
100
102
  * Test-friendly worker. Takes the hook body as an argument so tests can
101
103
  * exercise the side-effect logic without poking process.stdin. Production
102
104
  * code always goes through `runHook`, which feeds it real stdin.
103
105
  */
104
- async function runHookWithBody(path, body) {
106
+ async function runHookWithBody(path, body, agentToken) {
105
107
  try {
106
108
  const creds = (0, config_1.readCredentials)();
107
109
  if (!creds) {
@@ -111,18 +113,32 @@ async function runHookWithBody(path, body) {
111
113
  // Allow `LUMO_API_URL` to override the baked-in creds.apiUrl for
112
114
  // redirecting hooks at dev-env switch without re-running `lumo auth
113
115
  // login`. The bearer token from creds.json is still used as-is.
114
- const envUrl = process.env.LUMO_API_URL?.trim();
115
- const apiUrl = envUrl && envUrl.length > 0 ? envUrl : creds.apiUrl;
116
+ // An insecure override (non-https, non-localhost) throws here and is
117
+ // caught by the outer try/catch logged, POST skipped, hook still exits 0.
118
+ // The host-mismatch warning goes to hook.log (never stdout, which would
119
+ // pollute Claude Code).
120
+ const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl, {
121
+ warn: message => (0, hook_log_1.logHookError)(`[${path}]`, message),
122
+ });
116
123
  const url = `${(0, api_1.trimTrailingSlash)(apiUrl)}/api/hooks/${path}`;
117
124
  const controller = new AbortController();
118
125
  const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
126
+ // Tell the server which coding agent produced this hook. The token is
127
+ // baked into the hook command at `lumo setup --agent <token>`; we send
128
+ // the normalized enum so the server can record it on the session. An
129
+ // unrecognized/absent token is dropped — the server then falls back to
130
+ // its default (CLAUDE_CODE).
131
+ const headers = {
132
+ 'Content-Type': 'application/json',
133
+ Authorization: `Bearer ${creds.token}`,
134
+ };
135
+ const agentEnum = agentToken ? (0, agent_1.normalizeAgent)(agentToken) : null;
136
+ if (agentEnum)
137
+ headers['X-Lumo-Agent'] = agentEnum;
119
138
  try {
120
139
  const res = await fetch(url, {
121
140
  method: 'POST',
122
- headers: {
123
- 'Content-Type': 'application/json',
124
- Authorization: `Bearer ${creds.token}`,
125
- },
141
+ headers,
126
142
  body: body || '{}',
127
143
  signal: controller.signal,
128
144
  });
@@ -35,35 +35,80 @@ exports.LUMO_HOOK_EVENTS = [
35
35
  ['CwdChanged', 'cwd-changed'],
36
36
  ['InstructionsLoaded', 'instructions-loaded'],
37
37
  ];
38
+ /** Default agent token baked into the hook commands when none is given. */
39
+ const DEFAULT_AGENT_TOKEN = 'claude-code';
40
+ /**
41
+ * A hook command belongs to Lumo when it is exactly `lumo hook <slug>` or
42
+ * carries trailing flags (`lumo hook <slug> --agent codex`). We match on the
43
+ * prefix so a re-run can recognize — and rewrite — an entry baked with a
44
+ * different (or no) `--agent` token.
45
+ */
46
+ function isLumoHookCommand(command, slug) {
47
+ return (command === `lumo hook ${slug}` || command.startsWith(`lumo hook ${slug} `));
48
+ }
38
49
  // Build the settings.json fragment we want to install. We do not use a
39
50
  // matcher because the Lumo hook handlers ingest every event of a given type;
40
51
  // adding a matcher would silently drop events that have no `tool_name` (e.g.
41
52
  // SessionStart). Keep this in step with cli/src/commands/hook.ts dispatch.
42
- function buildLumoHookFragment() {
53
+ //
54
+ // `agentToken` is baked into every command (`lumo hook <slug> --agent
55
+ // <token>`) so the hook tells the server which coding agent owns the session.
56
+ function buildLumoHookFragment(agentToken = DEFAULT_AGENT_TOKEN) {
43
57
  const hooks = {};
44
58
  for (const [event, slug] of exports.LUMO_HOOK_EVENTS) {
45
59
  hooks[event] = [
46
- { hooks: [{ type: 'command', command: `lumo hook ${slug}` }] },
60
+ {
61
+ hooks: [
62
+ {
63
+ type: 'command',
64
+ command: `lumo hook ${slug} --agent ${agentToken}`,
65
+ },
66
+ ],
67
+ },
47
68
  ];
48
69
  }
49
70
  return { hooks };
50
71
  }
51
- function mergeLumoHooks(existing) {
72
+ function mergeLumoHooks(existing, agentToken = DEFAULT_AGENT_TOKEN) {
52
73
  const base = existing
53
74
  ? JSON.parse(JSON.stringify(existing))
54
75
  : {};
55
76
  if (!base.hooks)
56
77
  base.hooks = {};
57
- const stats = { addedEvents: [], alreadyPresent: [] };
78
+ const stats = {
79
+ addedEvents: [],
80
+ alreadyPresent: [],
81
+ updatedEvents: [],
82
+ };
58
83
  for (const [event, slug] of exports.LUMO_HOOK_EVENTS) {
59
- const ourCmd = `lumo hook ${slug}`;
84
+ const ourCmd = `lumo hook ${slug} --agent ${agentToken}`;
60
85
  const groups = base.hooks[event] ?? [];
61
- const present = groups.some(g => Array.isArray(g.hooks) && g.hooks.some(h => h.command === ourCmd));
62
- if (present) {
86
+ let exact = false;
87
+ let rewritten = false;
88
+ for (const g of groups) {
89
+ if (!Array.isArray(g.hooks))
90
+ continue;
91
+ for (const h of g.hooks) {
92
+ if (h.command === ourCmd) {
93
+ exact = true;
94
+ }
95
+ else if (isLumoHookCommand(h.command, slug)) {
96
+ // legacy flagless or a different agent token — bring it up to date
97
+ h.command = ourCmd;
98
+ rewritten = true;
99
+ }
100
+ }
101
+ }
102
+ if (exact) {
63
103
  stats.alreadyPresent.push(event);
64
104
  base.hooks[event] = groups;
65
105
  continue;
66
106
  }
107
+ if (rewritten) {
108
+ stats.updatedEvents.push(event);
109
+ base.hooks[event] = groups;
110
+ continue;
111
+ }
67
112
  base.hooks[event] = [
68
113
  ...groups,
69
114
  { hooks: [{ type: 'command', command: ourCmd }] },
@@ -1,9 +1,10 @@
1
1
  "use strict";
2
- // Category/field metadata + builders for the `lumo memory` commands.
3
- // Mirrors the four content shapes validated server-side by parseMemoryContent.
4
2
  Object.defineProperty(exports, "__esModule", { value: true });
5
3
  exports.buildMemoryContent = buildMemoryContent;
6
4
  exports.formatMemoryList = formatMemoryList;
5
+ // Category/field metadata + builders for the `lumo memory` commands.
6
+ // Mirrors the four content shapes validated server-side by parseMemoryContent.
7
+ const sanitize_1 = require("./sanitize");
7
8
  const trimmed = (v) => (v ?? '').trim();
8
9
  /**
9
10
  * Validate the per-category flags and assemble { category, content }. Required
@@ -69,7 +70,7 @@ function headline(category, content) {
69
70
  : category === 'CONVENTION' ? 'rule'
70
71
  : 'workflow';
71
72
  const v = c[key];
72
- return typeof v === 'string' && v.length > 0 ? v : '(unparseable)';
73
+ return typeof v === 'string' && v.length > 0 ? (0, sanitize_1.sanitizeField)(v) : '(unparseable)';
73
74
  }
74
75
  /** Fixed-width rows: id SCOPE CATEGORY headline source(auto|manual). */
75
76
  function formatMemoryList(rows) {
@@ -0,0 +1,125 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.matchSensitiveName = matchSensitiveName;
37
+ exports.checkArtifactFilePath = checkArtifactFilePath;
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
40
+ const SENSITIVE_SEGMENTS = ['.ssh', '.aws', '.gnupg'];
41
+ const SENSITIVE_BASENAMES = [
42
+ 'id_rsa',
43
+ 'id_dsa',
44
+ 'id_ecdsa',
45
+ 'id_ed25519',
46
+ 'credentials',
47
+ '.npmrc',
48
+ '.netrc',
49
+ '.pgpass',
50
+ '.htpasswd',
51
+ ];
52
+ const SENSITIVE_EXTENSIONS = ['.pem', '.key', '.pfx', '.p12'];
53
+ /**
54
+ * Pure denylist check. Returns a human-readable reason when the path looks
55
+ * like a secret, or null when it is acceptable. Matches the basename
56
+ * (case-insensitive) and path segments; no filesystem access.
57
+ */
58
+ function matchSensitiveName(p) {
59
+ const segments = p.split(/[\\/]+/).filter(Boolean);
60
+ for (const seg of segments) {
61
+ if (SENSITIVE_SEGMENTS.includes(seg.toLowerCase())) {
62
+ return `path contains ${seg}`;
63
+ }
64
+ }
65
+ const base = (segments[segments.length - 1] ?? '').toLowerCase();
66
+ if (base === '.env' || base.startsWith('.env.'))
67
+ return 'matches .env';
68
+ if (SENSITIVE_BASENAMES.includes(base))
69
+ return `matches ${base}`;
70
+ for (const ext of SENSITIVE_EXTENSIONS) {
71
+ if (base.endsWith(ext))
72
+ return `matches *${ext}`;
73
+ }
74
+ return null;
75
+ }
76
+ /**
77
+ * Decide whether `rawPath` is safe to read and upload as an artifact body.
78
+ * Two layers: (1) it must resolve to a real path inside the project directory
79
+ * (cwd), and (2) neither the raw nor the canonical path may match the
80
+ * sensitive-filename denylist. realpath defeats symlinks that escape the tree.
81
+ * NOTE: there is a TOCTOU window between this check and the caller's read;
82
+ * the caller reads the returned canonical `resolved` path to narrow it.
83
+ */
84
+ function checkArtifactFilePath(rawPath, deps) {
85
+ const cwd = deps?.cwd ?? process.cwd;
86
+ const realpath = deps?.realpath ?? fs.realpathSync;
87
+ // 1. Fail fast on the raw path (no fs access).
88
+ const rawHit = matchSensitiveName(rawPath);
89
+ if (rawHit)
90
+ return { ok: false, reason: 'sensitive', detail: rawHit };
91
+ const root = cwd();
92
+ const absolute = path.resolve(root, rawPath);
93
+ // 2. Canonicalize. realpath throws if the path does not exist / is unreadable.
94
+ let resolved;
95
+ let rootReal;
96
+ try {
97
+ resolved = realpath(absolute);
98
+ rootReal = realpath(root);
99
+ }
100
+ catch {
101
+ return { ok: false, reason: 'unreadable', detail: 'path does not resolve' };
102
+ }
103
+ // 3. Re-check the denylist on the canonical path (catches innocent-looking
104
+ // symlinks pointing at a secret).
105
+ const canonHit = matchSensitiveName(resolved);
106
+ if (canonHit)
107
+ return { ok: false, reason: 'sensitive', detail: canonHit };
108
+ // The path must be a file inside the project, not the project root itself.
109
+ if (resolved === rootReal) {
110
+ return {
111
+ ok: false,
112
+ reason: 'outside-project',
113
+ detail: 'path resolves to the project root, not a file',
114
+ };
115
+ }
116
+ // 4. Confinement: the canonical path must be inside the project root.
117
+ if (resolved !== rootReal && !resolved.startsWith(rootReal + path.sep)) {
118
+ return {
119
+ ok: false,
120
+ reason: 'outside-project',
121
+ detail: 'resolves outside project directory',
122
+ };
123
+ }
124
+ return { ok: true, resolved };
125
+ }
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.lookupDocId = lookupDocId;
4
4
  const api_1 = require("./api");
5
5
  const resolve_doc_1 = require("./resolve-doc");
6
+ const sanitize_1 = require("./sanitize");
6
7
  /**
7
8
  * Resolve a user-typed doc reference (cuid OR case-insensitive title) to a
8
9
  * document id. Fetches GET /api/documents to perform a title lookup when
@@ -25,7 +26,7 @@ async function lookupDocId(apiUrl, token, reference) {
25
26
  if (result.kind === 'ambiguous') {
26
27
  console.error(`Error: title "${reference}" matches ${result.candidates.length} docs:`);
27
28
  for (const c of result.candidates) {
28
- console.error(` ${c.id} ${c.title}`);
29
+ console.error(` ${c.id} ${(0, sanitize_1.sanitizeField)(c.title)}`);
29
30
  }
30
31
  console.error('Re-run with the cuid.');
31
32
  return null;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.fetchMembers = fetchMembers;
4
4
  exports.resolveMember = resolveMember;
5
5
  const api_1 = require("./api");
6
+ const sanitize_1 = require("./sanitize");
6
7
  /**
7
8
  * Fetch the workspace member directory once. Used by doc-share commands to
8
9
  * resolve a free-form member ref → memberId locally, because the document
@@ -16,7 +17,7 @@ async function fetchMembers(apiUrl, token) {
16
17
  });
17
18
  if (!res.ok) {
18
19
  const text = await res.text();
19
- throw new Error(`failed to load members (${res.status} ${res.statusText}): ${text}`);
20
+ throw new Error(`failed to load members (${res.status} ${res.statusText}): ${(0, sanitize_1.sanitizeField)(text)}`);
20
21
  }
21
22
  const body = (await res.json());
22
23
  return body.members.map(m => ({
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sanitizeField = sanitizeField;
4
+ // Matches C0 control chars EXCEPT tab (\x09) and newline (\x0a), DEL (\x7f),
5
+ // and the C1 control range (\x80-\x9f). Includes ESC (\x1b) and the 8-bit CSI
6
+ // introducer (\x9b) — both ANSI escape-sequence injection vectors. U+0080-U+009F
7
+ // are never legitimate visible text, so stripping them is safe.
8
+ const CONTROL_CHARS = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
9
+ /**
10
+ * Strip terminal control characters from untrusted, server-returned fields
11
+ * before printing them, to prevent ANSI escape-sequence injection. Tab and
12
+ * newline are preserved so fixed-width tables and multi-line bodies render
13
+ * correctly. Callers handle optional values, e.g. `sanitizeField(name ?? '')`.
14
+ */
15
+ function sanitizeField(value) {
16
+ return value.replace(CONTROL_CHARS, '');
17
+ }
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.resolveTagRefs = resolveTagRefs;
4
4
  const api_1 = require("./api");
5
+ const sanitize_1 = require("./sanitize");
5
6
  async function resolveTagRefs(refs, deps) {
6
7
  const fetchImpl = deps.fetchImpl ?? fetch;
7
8
  // Trim + reject empty
@@ -36,7 +37,7 @@ async function resolveTagRefs(refs, deps) {
36
37
  });
37
38
  if (!res.ok) {
38
39
  const body = await res.text();
39
- throw new Error(`tag resolve failed: ${res.status} ${res.statusText}: ${body}`);
40
+ throw new Error(`tag resolve failed: ${res.status} ${res.statusText}: ${(0, sanitize_1.sanitizeField)(body)}`);
40
41
  }
41
42
  const json = (await res.json());
42
43
  nameIds = json.tags.map(t => t.id);
@@ -40,10 +40,10 @@ exports.maybeRefreshInBackground = maybeRefreshInBackground;
40
40
  exports.runBackgroundRefresh = runBackgroundRefresh;
41
41
  const fs = __importStar(require("fs"));
42
42
  const path = __importStar(require("path"));
43
- const os = __importStar(require("os"));
44
43
  const https = __importStar(require("https"));
45
44
  const child_process_1 = require("child_process");
46
- const CACHE_FILE = path.join(process.env.LUMO_CONFIG_DIR || path.join(os.homedir(), '.lumo'), 'update-check.json');
45
+ const config_1 = require("./config");
46
+ const CACHE_FILE = path.join((0, config_1.configDir)(), 'update-check.json');
47
47
  const CHECK_INTERVAL_MS = 1000 * 60 * 60 * 24;
48
48
  function readCache() {
49
49
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumoai/cli",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",