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.
@@ -115,7 +115,19 @@ export async function sendChunked(channel, text) {
115
115
  return;
116
116
  }
117
117
  text = sanitizeResponse(text);
118
- for (const chunk of chunkText(text, 1900)) {
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
- let remaining = text;
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
- let remaining = text;
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);
@@ -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
- res.json({ ok: true, response });
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 place)')
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
- // Scan .env directly for sensitive-looking keys that hold long plaintext
169
- // values. Stops bot tokens from quietly sitting in .env when they should
170
- // be in the keychain.
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
- raw = readFileSync(envPath, 'utf-8');
177
- }
178
- catch {
179
- return;
180
- }
181
- for (const line of raw.split('\n')) {
182
- const trimmed = line.trim();
183
- if (!trimmed || trimmed.startsWith('#'))
184
- continue;
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
- const result = await this.sendDirect(text, context);
54
- // If delivery failed and there were actual senders (not "no channels"), queue for retry
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(text, context);
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
- // Outbound credential redactionlast-line defense against the agent
67
- // accidentally (or via prompt injection) shipping a credential to a
68
- // public channel. Pattern-based + known-value scan; cheap enough to
69
- // run on every send. See src/security/redact.ts for the policy.
70
- const { text: redacted, stats: redactionStats } = redactSecrets(text);
71
- if (redactionStats.redactionCount > 0) {
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 onlyeach 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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.1.5",
3
+ "version": "1.1.6",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",