clementine-agent 1.1.5 → 1.1.6
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/dist/channels/discord-utils.js +13 -1
- package/dist/channels/slack-utils.js +9 -1
- package/dist/channels/telegram.js +8 -1
- package/dist/cli/dashboard.js +6 -1
- package/dist/cli/index.js +1 -1
- package/dist/config/config-doctor.js +21 -31
- package/dist/gateway/notifications.js +19 -15
- package/package.json +1 -1
|
@@ -115,7 +115,19 @@ export async function sendChunked(channel, text) {
|
|
|
115
115
|
return;
|
|
116
116
|
}
|
|
117
117
|
text = sanitizeResponse(text);
|
|
118
|
-
|
|
118
|
+
// Last-line outbound credential redaction. Dispatcher-level redaction
|
|
119
|
+
// (gateway/notifications.ts) covers cron/heartbeat sends, but chat
|
|
120
|
+
// replies bypass the dispatcher and arrive here directly. Idempotent:
|
|
121
|
+
// re-redacting an already-redacted string is a no-op since the
|
|
122
|
+
// [REDACTED:label] markers don't match any pattern or known value.
|
|
123
|
+
const { redactSecrets } = await import('../security/redact.js');
|
|
124
|
+
const { text: redacted, stats } = redactSecrets(text);
|
|
125
|
+
if (stats.redactionCount > 0) {
|
|
126
|
+
// Log via console — pino isn't imported here and adding an import would
|
|
127
|
+
// bloat this lightweight utility module.
|
|
128
|
+
console.warn(`[clementine] sendChunked: redacted ${stats.redactionCount} credential-shaped value(s) [${stats.labelsHit.join(',')}]`);
|
|
129
|
+
}
|
|
130
|
+
for (const chunk of chunkText(redacted, 1900)) {
|
|
119
131
|
await channel.send(chunk);
|
|
120
132
|
}
|
|
121
133
|
}
|
|
@@ -13,7 +13,15 @@ export function mdToSlack(text) {
|
|
|
13
13
|
}
|
|
14
14
|
// ── Chunked sending ───────────────────────────────────────────────────
|
|
15
15
|
export async function sendChunkedSlack(client, channel, text, threadTs) {
|
|
16
|
-
|
|
16
|
+
// Last-line outbound credential redaction. Same rationale as
|
|
17
|
+
// discord-utils.sendChunked — chat replies bypass the dispatcher and
|
|
18
|
+
// arrive here directly, so apply redaction at the channel boundary.
|
|
19
|
+
const { redactSecrets } = await import('../security/redact.js');
|
|
20
|
+
const { text: redacted, stats } = redactSecrets(text);
|
|
21
|
+
if (stats.redactionCount > 0) {
|
|
22
|
+
console.warn(`[clementine] sendChunkedSlack: redacted ${stats.redactionCount} credential-shaped value(s) [${stats.labelsHit.join(',')}]`);
|
|
23
|
+
}
|
|
24
|
+
let remaining = redacted;
|
|
17
25
|
while (remaining) {
|
|
18
26
|
if (remaining.length <= SLACK_MSG_LIMIT) {
|
|
19
27
|
await client.chat.postMessage({ channel, text: remaining, thread_ts: threadTs });
|
|
@@ -17,7 +17,14 @@ function mdToTelegram(text) {
|
|
|
17
17
|
}
|
|
18
18
|
// ── Chunked sending ───────────────────────────────────────────────────
|
|
19
19
|
async function sendChunked(bot, chatId, text) {
|
|
20
|
-
|
|
20
|
+
// Last-line outbound credential redaction. Same rationale as
|
|
21
|
+
// discord-utils.sendChunked — chat replies bypass the dispatcher.
|
|
22
|
+
const { redactSecrets } = await import('../security/redact.js');
|
|
23
|
+
const { text: redacted, stats } = redactSecrets(text);
|
|
24
|
+
if (stats.redactionCount > 0) {
|
|
25
|
+
console.warn(`[clementine] telegram sendChunked: redacted ${stats.redactionCount} credential-shaped value(s) [${stats.labelsHit.join(',')}]`);
|
|
26
|
+
}
|
|
27
|
+
let remaining = redacted;
|
|
21
28
|
while (remaining) {
|
|
22
29
|
if (remaining.length <= TELEGRAM_MSG_LIMIT) {
|
|
23
30
|
await bot.api.sendMessage(chatId, remaining);
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -4322,7 +4322,12 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
|
|
|
4322
4322
|
try {
|
|
4323
4323
|
const gateway = await getGateway();
|
|
4324
4324
|
const response = await gateway.handleMessage('dashboard:web', message);
|
|
4325
|
-
|
|
4325
|
+
// Outbound credential redaction — same defense applied at the channel
|
|
4326
|
+
// edges. Dashboard is admin-only but a leaked credential in chat output
|
|
4327
|
+
// could still end up in browser history, screenshots, etc.
|
|
4328
|
+
const { redactSecrets } = await import('../security/redact.js');
|
|
4329
|
+
const { text: redacted } = redactSecrets(response ?? '');
|
|
4330
|
+
res.json({ ok: true, response: redacted });
|
|
4326
4331
|
}
|
|
4327
4332
|
catch (err) {
|
|
4328
4333
|
res.status(500).json({ error: String(err) });
|
package/dist/cli/index.js
CHANGED
|
@@ -1918,7 +1918,7 @@ configCmd
|
|
|
1918
1918
|
});
|
|
1919
1919
|
configCmd
|
|
1920
1920
|
.command('migrate-to-keychain')
|
|
1921
|
-
.description('Move plaintext credentials in .env into the macOS keychain (in
|
|
1921
|
+
.description('Move plaintext credentials in .env into the macOS keychain (NOT recommended in v1.1.4+ — keychain entries can produce per-process approval prompts; plain .env at mode 0600 is the supported default)')
|
|
1922
1922
|
.option('--dry-run', 'Show what would migrate without writing anything')
|
|
1923
1923
|
.option('-k, --key <name...>', 'Limit to specific key(s); repeat or comma-separate for multiple')
|
|
1924
1924
|
.action(async (opts) => {
|
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
import { existsSync, readFileSync } from 'node:fs';
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
import { computeEffectiveConfig } from './effective-config.js';
|
|
19
|
-
import { isSensitiveEnvKey } from '../secrets/sensitivity.js';
|
|
20
19
|
// ── Type expectations ───────────────────────────────────────────────
|
|
21
20
|
//
|
|
22
21
|
// Keys that must parse as a finite number when set. The inspector already
|
|
@@ -165,41 +164,32 @@ function checkChannelRequirements(cfg, findings) {
|
|
|
165
164
|
}
|
|
166
165
|
}
|
|
167
166
|
function checkPlaintextSecretsInEnv(_cfg, baseDir, findings) {
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
167
|
+
// Sanity check on .env file permissions.
|
|
168
|
+
//
|
|
169
|
+
// History: this function previously WARNED whenever credential-shaped keys
|
|
170
|
+
// (DISCORD_TOKEN, *_API_KEY, etc.) sat as plaintext in .env, recommending
|
|
171
|
+
// migration to the macOS Keychain. After the 2026-04-26 rabbit hole
|
|
172
|
+
// (commits 88cfd99 .. c5a2eb5) we reversed that recommendation: plaintext
|
|
173
|
+
// .env at mode 0600 is the supported default, and keychain is opt-in only.
|
|
174
|
+
// The old warning is now misleading guidance, so it's removed.
|
|
175
|
+
//
|
|
176
|
+
// What we DO check: file mode. If .env is world-readable or group-readable
|
|
177
|
+
// we flag that as a real risk regardless of what's inside.
|
|
171
178
|
const envPath = path.join(baseDir, '.env');
|
|
172
179
|
if (!existsSync(envPath))
|
|
173
180
|
return;
|
|
174
|
-
let raw;
|
|
175
181
|
try {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const eq = trimmed.indexOf('=');
|
|
186
|
-
if (eq === -1)
|
|
187
|
-
continue;
|
|
188
|
-
const key = trimmed.slice(0, eq);
|
|
189
|
-
const value = trimmed.slice(eq + 1);
|
|
190
|
-
if (!isSensitiveEnvKey(key))
|
|
191
|
-
continue;
|
|
192
|
-
if (value.startsWith('keychain:'))
|
|
193
|
-
continue; // already a ref — fine
|
|
194
|
-
if (value.length < 16)
|
|
195
|
-
continue; // probably a config-shaped value (port number, etc.)
|
|
196
|
-
findings.push({
|
|
197
|
-
severity: 'warning',
|
|
198
|
-
key,
|
|
199
|
-
message: `${key} is stored as plaintext in .env. Credential-shaped keys should live in the keychain on macOS.`,
|
|
200
|
-
fix: `# In a chat with Clementine: env_set ${key} <value> storage=auto (auto routes credentials to keychain)`,
|
|
201
|
-
});
|
|
182
|
+
const st = require('node:fs').statSync(envPath);
|
|
183
|
+
const worldOrGroupReadable = (st.mode & 0o077) !== 0;
|
|
184
|
+
if (worldOrGroupReadable) {
|
|
185
|
+
findings.push({
|
|
186
|
+
severity: 'error',
|
|
187
|
+
message: `.env file is readable by other users (mode ${(st.mode & 0o777).toString(8)}). Restrict to owner-only.`,
|
|
188
|
+
fix: `chmod 600 ${envPath}`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
202
191
|
}
|
|
192
|
+
catch { /* stat failed — non-fatal, doctor continues */ }
|
|
203
193
|
}
|
|
204
194
|
function checkRangeSanity(cfg, findings) {
|
|
205
195
|
const byKey = new Map(cfg.entries.map(e => [e.key, e]));
|
|
@@ -50,10 +50,20 @@ export class NotificationDispatcher {
|
|
|
50
50
|
}
|
|
51
51
|
/** Send a notification; automatically queues for retry on total failure. */
|
|
52
52
|
async send(text, context) {
|
|
53
|
-
|
|
54
|
-
//
|
|
53
|
+
// Outbound credential redaction happens HERE, at the public entrypoint,
|
|
54
|
+
// BEFORE any failure could enqueue the message for retry. Otherwise an
|
|
55
|
+
// un-redacted credential would persist to ~/.clementine/.delivery-queue.json
|
|
56
|
+
// for the retry window. Pattern-based + known-value scan; cheap enough to
|
|
57
|
+
// run on every send. See src/security/redact.ts for policy.
|
|
58
|
+
const { text: redacted, stats: redactionStats } = redactSecrets(text);
|
|
59
|
+
if (redactionStats.redactionCount > 0) {
|
|
60
|
+
logger.warn({ count: redactionStats.redactionCount, labels: redactionStats.labelsHit, sessionKey: context?.sessionKey }, `Redacted ${redactionStats.redactionCount} credential-shaped value(s) before delivery`);
|
|
61
|
+
}
|
|
62
|
+
const result = await this.sendDirect(redacted, context);
|
|
63
|
+
// If delivery failed and there were actual senders (not "no channels"), queue for retry.
|
|
64
|
+
// Stored text is already-redacted so disk persistence never holds a credential.
|
|
55
65
|
if (!result.delivered && this.senders.size > 0) {
|
|
56
|
-
this._retryQueue.enqueue(
|
|
66
|
+
this._retryQueue.enqueue(redacted, context);
|
|
57
67
|
}
|
|
58
68
|
return result;
|
|
59
69
|
}
|
|
@@ -63,18 +73,12 @@ export class NotificationDispatcher {
|
|
|
63
73
|
logger.warn('No notification senders registered — message dropped');
|
|
64
74
|
return { delivered: false, channelErrors: { _: 'no channels registered' } };
|
|
65
75
|
}
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
logger.warn({ count: redactionStats.redactionCount, labels: redactionStats.labelsHit, sessionKey: context?.sessionKey }, `Redacted ${redactionStats.redactionCount} credential-shaped value(s) before delivery`);
|
|
73
|
-
}
|
|
74
|
-
// Sanity cap only — each channel sender handles its own chunking/truncation
|
|
75
|
-
const capped = redacted.length > MAX_MESSAGE_LENGTH
|
|
76
|
-
? redacted.slice(0, MAX_MESSAGE_LENGTH - 20) + '\n\n_(truncated)_'
|
|
77
|
-
: redacted;
|
|
76
|
+
// Sanity cap only — each channel sender handles its own chunking/truncation.
|
|
77
|
+
// Redaction happens at send() (public entrypoint) before any retry-enqueue,
|
|
78
|
+
// so anything reaching here is already safe.
|
|
79
|
+
const capped = text.length > MAX_MESSAGE_LENGTH
|
|
80
|
+
? text.slice(0, MAX_MESSAGE_LENGTH - 20) + '\n\n_(truncated)_'
|
|
81
|
+
: text;
|
|
78
82
|
// If sessionKey is set, route only to the channel that owns it.
|
|
79
83
|
// Fan out to all channels only when no originating channel is known.
|
|
80
84
|
const targetChannel = context?.sessionKey ? channelForSessionKey(context.sessionKey) : null;
|