@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.
- package/assets/skill.md +159 -15
- package/dist/cli/src/commands/auth-login.js +4 -3
- package/dist/cli/src/commands/auth-logout.js +2 -1
- package/dist/cli/src/commands/doc-bind.js +3 -3
- package/dist/cli/src/commands/doc-create.js +5 -5
- package/dist/cli/src/commands/doc-delete.js +4 -4
- package/dist/cli/src/commands/doc-import-gdoc.js +82 -0
- package/dist/cli/src/commands/doc-list.js +7 -7
- package/dist/cli/src/commands/doc-move.js +8 -8
- package/dist/cli/src/commands/doc-share-list.js +11 -8
- package/dist/cli/src/commands/doc-share.js +7 -5
- package/dist/cli/src/commands/doc-show.js +6 -6
- package/dist/cli/src/commands/doc-sync.js +44 -0
- package/dist/cli/src/commands/doc-unbind.js +4 -4
- package/dist/cli/src/commands/doc-unshare.js +9 -7
- package/dist/cli/src/commands/doc-update.js +5 -5
- package/dist/cli/src/commands/hook.js +2 -2
- package/dist/cli/src/commands/memory-project-add.js +19 -4
- package/dist/cli/src/commands/memory-project-list.js +1 -2
- package/dist/cli/src/commands/memory-promote.js +3 -3
- package/dist/cli/src/commands/memory-rm.js +1 -2
- package/dist/cli/src/commands/memory-task-add.js +19 -4
- package/dist/cli/src/commands/memory-task-list.js +1 -2
- package/dist/cli/src/commands/milestone-create.js +4 -4
- package/dist/cli/src/commands/milestone-delete.js +5 -5
- package/dist/cli/src/commands/milestone-list.js +3 -3
- package/dist/cli/src/commands/milestone-show.js +5 -5
- package/dist/cli/src/commands/milestone-update.js +6 -5
- package/dist/cli/src/commands/project-list.js +3 -3
- package/dist/cli/src/commands/session-attach.js +5 -5
- package/dist/cli/src/commands/session-detach.js +3 -3
- package/dist/cli/src/commands/session-status.js +3 -3
- package/dist/cli/src/commands/setup.js +33 -7
- package/dist/cli/src/commands/sprint-add.js +3 -3
- package/dist/cli/src/commands/sprint-close.js +5 -5
- package/dist/cli/src/commands/sprint-create.js +4 -4
- package/dist/cli/src/commands/sprint-delete.js +5 -5
- package/dist/cli/src/commands/sprint-list.js +3 -3
- package/dist/cli/src/commands/sprint-remove.js +3 -3
- package/dist/cli/src/commands/sprint-show.js +4 -4
- package/dist/cli/src/commands/sprint-start.js +4 -4
- package/dist/cli/src/commands/sprint-summary.js +7 -7
- package/dist/cli/src/commands/sprint-update.js +6 -5
- package/dist/cli/src/commands/task-artifact-add.js +17 -5
- package/dist/cli/src/commands/task-artifact-list.js +4 -4
- package/dist/cli/src/commands/task-artifact-rm.js +4 -4
- package/dist/cli/src/commands/task-artifact-show.js +8 -8
- package/dist/cli/src/commands/task-artifact-update.js +5 -5
- package/dist/cli/src/commands/task-comment-list.js +111 -0
- package/dist/cli/src/commands/task-comment.js +3 -3
- package/dist/cli/src/commands/task-context.js +24 -12
- package/dist/cli/src/commands/task-create.js +7 -7
- package/dist/cli/src/commands/task-figma-add.js +3 -2
- package/dist/cli/src/commands/task-figma-context.js +61 -0
- package/dist/cli/src/commands/task-figma-list.js +3 -2
- package/dist/cli/src/commands/task-figma-refresh.js +4 -3
- package/dist/cli/src/commands/task-figma-rm.js +3 -2
- package/dist/cli/src/commands/task-list.js +1 -2
- package/dist/cli/src/commands/task-pr-show.js +66 -0
- package/dist/cli/src/commands/task-show.js +8 -7
- package/dist/cli/src/commands/task-slack-show.js +59 -0
- package/dist/cli/src/commands/task-update.js +7 -7
- package/dist/cli/src/commands/task-web-show.js +64 -0
- package/dist/cli/src/commands/whoami.js +4 -3
- package/dist/cli/src/index.js +167 -102
- package/dist/cli/src/lib/agent.js +10 -1
- package/dist/cli/src/lib/api.js +81 -1
- package/dist/cli/src/lib/config.js +2 -1
- package/dist/cli/src/lib/doc-input.js +12 -1
- package/dist/cli/src/lib/figma-api.js +1 -1
- package/dist/cli/src/lib/format.js +3 -2
- package/dist/cli/src/lib/hook-runner.js +26 -10
- package/dist/cli/src/lib/hooks-template.js +52 -7
- package/dist/cli/src/lib/memory-content.js +4 -3
- package/dist/cli/src/lib/path-guard.js +125 -0
- package/dist/cli/src/lib/resolve-doc-id.js +2 -1
- package/dist/cli/src/lib/resolve-member.js +2 -1
- package/dist/cli/src/lib/sanitize.js +17 -0
- package/dist/cli/src/lib/tag-resolver.js +2 -1
- package/dist/cli/src/lib/update-check.js +2 -2
- package/package.json +1 -1
package/dist/cli/src/lib/api.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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 = {
|
|
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
|
-
|
|
62
|
-
|
|
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
|
|
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 {
|