clementine-agent 1.1.3 → 1.1.5

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.
@@ -3789,7 +3789,10 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3789
3789
  const cronProfile = agentSlug && agentSlug !== 'clementine'
3790
3790
  ? this.profileManager.get(agentSlug)
3791
3791
  : null;
3792
- const cronGuard = new StallGuard();
3792
+ // Cron jobs deliver via side effects (sent emails, updated records, etc),
3793
+ // not chat text — pass mode='cron' so high_effort_low_output guard is
3794
+ // disabled. Loop detection and circular-reasoning checks stay active.
3795
+ const cronGuard = new StallGuard('cron');
3793
3796
  const sdkOptions = this.buildOptions({
3794
3797
  isHeartbeat: true,
3795
3798
  cronTier: tier,
@@ -4271,7 +4274,8 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
4271
4274
  logger.info(`Unleashed task ${jobName}: starting phase ${phase}`);
4272
4275
  // Re-assert autonomous source — a chat message may have changed it between phases
4273
4276
  setInteractionSource('autonomous');
4274
- const phaseGuard = new StallGuard();
4277
+ // Unleashed phases run side-effect-heavy work; same logic as cron mode.
4278
+ const phaseGuard = new StallGuard('unleashed');
4275
4279
  const sdkOptions = this.buildOptions({
4276
4280
  isHeartbeat: true,
4277
4281
  cronTier: tier,
@@ -28,7 +28,21 @@ export interface MetacognitiveSummary {
28
28
  confidenceFinal: 'high' | 'medium' | 'low';
29
29
  signals: string[];
30
30
  }
31
+ /**
32
+ * Execution mode the monitor is observing. Chat sessions deliver via output
33
+ * text, so "many tool calls + zero output" is genuinely suspicious. Cron
34
+ * jobs (especially unleashed) deliver via side effects (sent emails, updated
35
+ * records, written files) — chat-text length is NOT the success signal, so
36
+ * the high_effort_low_output heuristic must be disabled or it produces
37
+ * 100+ false-positive interventions per run (observed 2026-04-26 on
38
+ * market-leader-followup which sent 17 real emails while this guard fired
39
+ * 169 times). Other heuristics (circular_reasoning via repeated identical
40
+ * tool calls, research_without_action via consecutive reads) stay active —
41
+ * those are real bug shapes regardless of mode.
42
+ */
43
+ export type MetacognitiveMode = 'chat' | 'cron' | 'unleashed';
31
44
  export declare class MetacognitiveMonitor {
45
+ private readonly mode;
32
46
  private toolCalls;
33
47
  private uniqueTools;
34
48
  private consecutiveReads;
@@ -37,6 +51,7 @@ export declare class MetacognitiveMonitor {
37
51
  private interventionCount;
38
52
  private signals;
39
53
  private confidence;
54
+ constructor(mode?: MetacognitiveMode);
40
55
  /**
41
56
  * Record a tool call. Returns a signal if the pattern is concerning.
42
57
  */
@@ -25,8 +25,8 @@ const ACTION_TOOLS = new Set([
25
25
  'team_message', 'discord_channel_send', 'outlook_draft', 'outlook_send',
26
26
  'set_timer', 'self_restart', 'feedback_log', 'teach_skill', 'create_tool',
27
27
  ]);
28
- // ── MetacognitiveMonitor ────────────────────────────────────────────
29
28
  export class MetacognitiveMonitor {
29
+ mode;
30
30
  toolCalls = [];
31
31
  uniqueTools = new Set();
32
32
  consecutiveReads = 0;
@@ -35,6 +35,9 @@ export class MetacognitiveMonitor {
35
35
  interventionCount = 0;
36
36
  signals = [];
37
37
  confidence = 'high';
38
+ constructor(mode = 'chat') {
39
+ this.mode = mode;
40
+ }
38
41
  /**
39
42
  * Record a tool call. Returns a signal if the pattern is concerning.
40
43
  */
@@ -95,31 +98,34 @@ export class MetacognitiveMonitor {
95
98
  return signal;
96
99
  }
97
100
  // Signal: excessive tool calls with near-zero output.
98
- // Warn at 20, intervene (hard stop) at 60 beyond 60 the agent is
99
- // almost certainly in a runaway loop that will burn through the
100
- // budget cap with nothing to show for it.
101
- if (this.toolCalls.length >= 60 && this.outputCharCount < 200) {
102
- this.confidence = 'low';
103
- if (!this.signals.includes('high_effort_low_output')) {
104
- this.signals.push('high_effort_low_output');
105
- }
106
- this.interventionCount++;
107
- return {
108
- type: 'intervene',
109
- reason: 'high_effort_low_output',
110
- guidance: `You've made ${this.toolCalls.length} tool calls across ${this.uniqueTools.size} tools with only ${this.outputCharCount} chars of output. This is a runaway loop. Stopping now to prevent budget waste.`,
111
- };
112
- }
113
- if (this.toolCalls.length > 20 && this.outputCharCount < 200) {
114
- this.confidence = 'low';
115
- if (!this.signals.includes('high_effort_low_output')) {
116
- this.signals.push('high_effort_low_output');
101
+ // Chat scenarios deliver via output text, so this is meaningful there.
102
+ // Cron and unleashed scenarios deliver via side effects (emails sent,
103
+ // records updated, files written) chat-text length is irrelevant.
104
+ // Skip entirely outside chat mode.
105
+ if (this.mode === 'chat') {
106
+ if (this.toolCalls.length >= 60 && this.outputCharCount < 200) {
107
+ this.confidence = 'low';
108
+ if (!this.signals.includes('high_effort_low_output')) {
109
+ this.signals.push('high_effort_low_output');
110
+ }
111
+ this.interventionCount++;
117
112
  return {
118
- type: 'warn',
113
+ type: 'intervene',
119
114
  reason: 'high_effort_low_output',
120
- guidance: 'You\'ve made 20+ tool calls with minimal output. Step back and simplify your approach.',
115
+ guidance: `You've made ${this.toolCalls.length} tool calls across ${this.uniqueTools.size} tools with only ${this.outputCharCount} chars of output. This is a runaway loop. Stopping now to prevent budget waste.`,
121
116
  };
122
117
  }
118
+ if (this.toolCalls.length > 20 && this.outputCharCount < 200) {
119
+ this.confidence = 'low';
120
+ if (!this.signals.includes('high_effort_low_output')) {
121
+ this.signals.push('high_effort_low_output');
122
+ return {
123
+ type: 'warn',
124
+ reason: 'high_effort_low_output',
125
+ guidance: 'You\'ve made 20+ tool calls with minimal output. Step back and simplify your approach.',
126
+ };
127
+ }
128
+ }
123
129
  }
124
130
  return { type: 'ok' };
125
131
  }
@@ -11,7 +11,8 @@
11
11
  * 3. recordToolCall() called for each tool_use block in the stream
12
12
  * 4. After query: detectPromiseWithoutAction() + getSummary() for cross-query nudges
13
13
  */
14
- import { type MetacognitiveSignal, type MetacognitiveSummary } from './metacognition.js';
14
+ import { type MetacognitiveMode, type MetacognitiveSignal, type MetacognitiveSummary } from './metacognition.js';
15
+ export type StallGuardMode = MetacognitiveMode;
15
16
  export interface StallSummary {
16
17
  metacognition: MetacognitiveSummary;
17
18
  breakerActivated: boolean;
@@ -20,10 +21,17 @@ export interface StallSummary {
20
21
  }
21
22
  export declare class StallGuard {
22
23
  private loopDetector;
23
- private metacog;
24
+ private readonly metacog;
24
25
  private breakerActive;
25
26
  private breakerReason;
26
27
  private toolCallLog;
28
+ /**
29
+ * @param mode 'chat' (default) keeps full output-text-driven heuristics.
30
+ * 'cron' / 'unleashed' disable the high_effort_low_output check
31
+ * since side effects, not chat text, are the deliverable for
32
+ * those execution contexts.
33
+ */
34
+ constructor(mode?: StallGuardMode);
27
35
  /**
28
36
  * Check if a tool should be blocked. Called from canUseTool.
29
37
  * When the breaker is active, denies read-only tools to force the agent
@@ -12,7 +12,7 @@
12
12
  * 4. After query: detectPromiseWithoutAction() + getSummary() for cross-query nudges
13
13
  */
14
14
  import { ToolLoopDetector } from './tool-loop-detector.js';
15
- import { MetacognitiveMonitor } from './metacognition.js';
15
+ import { MetacognitiveMonitor, } from './metacognition.js';
16
16
  import pino from 'pino';
17
17
  const logger = pino({ name: 'clementine.stall-guard' });
18
18
  // Only block SDK read tools — MCP tools (memory_read, etc.) are intentionally
@@ -21,10 +21,19 @@ const READ_ONLY_TOOLS = new Set(['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch'
21
21
  // ── StallGuard ──────────────────────────────────────────────────────
22
22
  export class StallGuard {
23
23
  loopDetector = new ToolLoopDetector();
24
- metacog = new MetacognitiveMonitor();
24
+ metacog;
25
25
  breakerActive = false;
26
26
  breakerReason = '';
27
27
  toolCallLog = [];
28
+ /**
29
+ * @param mode 'chat' (default) keeps full output-text-driven heuristics.
30
+ * 'cron' / 'unleashed' disable the high_effort_low_output check
31
+ * since side effects, not chat text, are the deliverable for
32
+ * those execution contexts.
33
+ */
34
+ constructor(mode = 'chat') {
35
+ this.metacog = new MetacognitiveMonitor(mode);
36
+ }
28
37
  /**
29
38
  * Check if a tool should be blocked. Called from canUseTool.
30
39
  * When the breaker is active, denies read-only tools to force the agent
@@ -20,37 +20,43 @@
20
20
  * (the macOS system prompt — the one that DOES reliably appear). After
21
21
  * approving, all entries become readable without further prompts.
22
22
  */
23
+ /**
24
+ * Both keychain service names the codebase has used over time:
25
+ * - "clementine-agent" — used by src/secrets/keychain.ts (env_set / migrate-to-keychain)
26
+ * - "clementine" — getSecret's default fallback when no explicit service
27
+ * passed (src/config.ts: ASSISTANT_NAME.toLowerCase()).
28
+ * Holds older per-agent and handoff entries.
29
+ */
30
+ declare const SERVICES: readonly ["clementine-agent", "clementine"];
31
+ type Service = typeof SERVICES[number];
23
32
  export interface KeychainEntry {
33
+ service: Service;
24
34
  account: string;
35
+ /** True when isClementineAccount returned true; only these get fixed. */
36
+ isClementine: boolean;
25
37
  }
26
38
  export interface AclFixResult {
39
+ service: Service;
27
40
  account: string;
28
- status: 'fixed' | 'failed';
41
+ status: 'fixed' | 'failed' | 'skipped-foreign';
29
42
  error?: string;
30
43
  }
31
44
  /**
32
- * Enumerate every clementine-agent keychain entry. Uses the dump-keychain
33
- * grep approach since `security` doesn't expose a clean list-by-service.
34
- * Read-only, no prompts.
35
- */
36
- export declare function listClementineKeychainEntries(): KeychainEntry[];
37
- /**
38
- * Add `apple-tool:,apple:` to the partition list of a given account.
39
- *
40
- * `security set-generic-password-partition-list` prompts on the controlling
41
- * terminal — `password to unlock default:` — for the user's login keychain
42
- * password. We must inherit stdio so the child can read from the parent's
43
- * TTY; piped stdio causes security to consume an empty line and fail with
44
- * "exit code null" / "wrong password."
45
+ * Enumerate every keychain entry under any service in SERVICES. Uses the
46
+ * dump-keychain grep approach since `security` doesn't expose a clean
47
+ * list-by-service. Read-only, no prompts.
45
48
  *
46
- * That means this function only works when called from an interactive shell.
47
- * Callers in non-TTY contexts should fall back to instructing the user to
48
- * run `clementine config keychain-fix-acl` from their own terminal.
49
+ * For the legacy "clementine" service we set `isClementine: false` on any
50
+ * entry that doesn't match our naming patterns those get reported but
51
+ * never touched (could be other apps that coincidentally chose that name).
49
52
  */
50
- export declare function fixAcl(account: string): AclFixResult;
53
+ export declare function listClementineKeychainEntries(): KeychainEntry[];
54
+ export declare function fixAcl(service: Service, account: string): AclFixResult;
51
55
  /**
52
- * Plan + apply: enumerate entries, fix each in turn. Returns per-entry
53
- * results so the CLI can render a checklist.
56
+ * Plan + apply: enumerate entries, fix each Clementine-shaped one in turn.
57
+ * Foreign entries (other apps under the legacy "clementine" service) get
58
+ * reported with status='skipped-foreign' and never touched.
54
59
  */
55
60
  export declare function fixAllClementineEntries(): AclFixResult[];
61
+ export {};
56
62
  //# sourceMappingURL=keychain-fix-acl.d.ts.map
@@ -21,30 +21,97 @@
21
21
  * approving, all entries become readable without further prompts.
22
22
  */
23
23
  import { execSync, spawnSync } from 'node:child_process';
24
- const SERVICE = 'clementine-agent';
25
24
  /**
26
- * Enumerate every clementine-agent keychain entry. Uses the dump-keychain
27
- * grep approach since `security` doesn't expose a clean list-by-service.
28
- * Read-only, no prompts.
25
+ * Both keychain service names the codebase has used over time:
26
+ * - "clementine-agent" used by src/secrets/keychain.ts (env_set / migrate-to-keychain)
27
+ * - "clementine" — getSecret's default fallback when no explicit service
28
+ * passed (src/config.ts: ASSISTANT_NAME.toLowerCase()).
29
+ * Holds older per-agent and handoff entries.
30
+ */
31
+ const SERVICES = ['clementine-agent', 'clementine'];
32
+ /**
33
+ * Under the legacy "clementine" service, some non-Clementine apps
34
+ * coincidentally store entries (e.g., macOS "Local Crypto Key Data"
35
+ * with a UUID prefix). We refuse to touch those — only entries that
36
+ * match our naming conventions get the ACL update.
37
+ */
38
+ function isClementineAccount(service, account) {
39
+ if (service === 'clementine-agent')
40
+ return true; // we own this whole service
41
+ // For the legacy "clementine" service, conservatively only touch entries
42
+ // that look like things we set: per-agent secrets (AGENT_*),
43
+ // handoff-decryption-key-*, oauth-tokens, env-var names (UPPER_SNAKE),
44
+ // anything starting with "clementine-".
45
+ if (account.startsWith('AGENT_'))
46
+ return true;
47
+ if (account.startsWith('handoff-'))
48
+ return true;
49
+ if (account === 'oauth-tokens')
50
+ return true;
51
+ if (account.startsWith('clementine-'))
52
+ return true;
53
+ if (/^[A-Z][A-Z0-9_]*$/.test(account))
54
+ return true;
55
+ return false;
56
+ }
57
+ /**
58
+ * Enumerate every keychain entry under any service in SERVICES. Uses the
59
+ * dump-keychain grep approach since `security` doesn't expose a clean
60
+ * list-by-service. Read-only, no prompts.
61
+ *
62
+ * For the legacy "clementine" service we set `isClementine: false` on any
63
+ * entry that doesn't match our naming patterns — those get reported but
64
+ * never touched (could be other apps that coincidentally chose that name).
29
65
  */
30
66
  export function listClementineKeychainEntries() {
67
+ let raw;
31
68
  try {
32
- const out = execSync('/usr/bin/security dump-keychain 2>/dev/null', {
69
+ raw = execSync('/usr/bin/security dump-keychain 2>/dev/null', {
33
70
  encoding: 'utf-8',
34
- timeout: 5000,
71
+ timeout: 10_000,
35
72
  stdio: ['pipe', 'pipe', 'pipe'],
73
+ maxBuffer: 32 * 1024 * 1024,
36
74
  });
37
- const accounts = new Set();
38
- // Lines look like: "acct"<blob>="clementine-agent-DISCORD_TOKEN"
39
- const re = /"acct"<blob>="(clementine-agent-[^"]+)"/g;
40
- for (const m of out.matchAll(re)) {
41
- accounts.add(m[1]);
42
- }
43
- return Array.from(accounts).sort().map(account => ({ account }));
44
75
  }
45
76
  catch {
46
77
  return [];
47
78
  }
79
+ // dump-keychain emits one record per item. Within a record, fields appear
80
+ // in arbitrary order — `acct` often comes BEFORE `svce`. So we can't track
81
+ // "last-seen svce" line-by-line; we have to split into per-record blocks
82
+ // and extract both fields from each block.
83
+ //
84
+ // Each record starts with `keychain: "/path/to/keychain"` followed by the
85
+ // `version`, `class`, `attributes:` lines and the field blobs. The next
86
+ // record begins at the next `^keychain: ` line.
87
+ const entries = [];
88
+ const seen = new Set();
89
+ // Split by record boundary. Use a positive lookahead so the delimiter stays
90
+ // at the start of each chunk.
91
+ const blocks = raw.split(/\n(?=keychain: ")/);
92
+ for (const block of blocks) {
93
+ const svceMatch = block.match(/"svce"<blob>="([^"]+)"/);
94
+ const acctMatch = block.match(/"acct"<blob>="([^"]+)"/);
95
+ if (!svceMatch || !acctMatch)
96
+ continue;
97
+ const svc = svceMatch[1];
98
+ const account = acctMatch[1];
99
+ if (!SERVICES.includes(svc))
100
+ continue;
101
+ const service = svc;
102
+ const dedupeKey = `${service}\x00${account}`;
103
+ if (seen.has(dedupeKey))
104
+ continue;
105
+ seen.add(dedupeKey);
106
+ entries.push({
107
+ service,
108
+ account,
109
+ isClementine: isClementineAccount(service, account),
110
+ });
111
+ }
112
+ // Stable sort: service first, then account
113
+ entries.sort((a, b) => a.service === b.service ? a.account.localeCompare(b.account) : a.service.localeCompare(b.service));
114
+ return entries;
48
115
  }
49
116
  /**
50
117
  * Add `apple-tool:,apple:` to the partition list of a given account.
@@ -59,34 +126,75 @@ export function listClementineKeychainEntries() {
59
126
  * Callers in non-TTY contexts should fall back to instructing the user to
60
127
  * run `clementine config keychain-fix-acl` from their own terminal.
61
128
  */
62
- export function fixAcl(account) {
63
- const result = spawnSync('/usr/bin/security', [
129
+ /**
130
+ * Discover which keychain a (service, account) pair lives in. Returns the
131
+ * path or null if find-generic-password can't locate it (in which case we
132
+ * skip — the entry isn't reachable via standard search anyway).
133
+ */
134
+ function locateKeychain(service, account) {
135
+ const probe = spawnSync('/usr/bin/security', [
136
+ 'find-generic-password',
137
+ '-s', service,
138
+ '-a', account,
139
+ ], {
140
+ stdio: ['pipe', 'pipe', 'pipe'],
141
+ timeout: 5000,
142
+ encoding: 'utf-8',
143
+ });
144
+ if (probe.status !== 0)
145
+ return null;
146
+ // First line is `keychain: "/path/to/keychain"` — extract.
147
+ const first = (probe.stdout || '').split('\n')[0] ?? '';
148
+ const m = first.match(/^keychain:\s+"([^"]+)"/);
149
+ return m ? m[1] : null;
150
+ }
151
+ export function fixAcl(service, account) {
152
+ const keychainPath = locateKeychain(service, account);
153
+ if (!keychainPath) {
154
+ return {
155
+ service,
156
+ account,
157
+ status: 'failed',
158
+ error: 'item not findable via standard search (may be in iCloud or a non-default keychain) — leaving alone',
159
+ };
160
+ }
161
+ // Pass the keychain path as the trailing positional arg so partition-list
162
+ // doesn't search the wrong store.
163
+ const args = [
64
164
  'set-generic-password-partition-list',
65
- '-s', SERVICE,
165
+ '-s', service,
66
166
  '-a', account,
67
167
  '-S', 'apple-tool:,apple:',
68
- ], {
168
+ keychainPath,
169
+ ];
170
+ const result = spawnSync('/usr/bin/security', args, {
69
171
  stdio: 'inherit',
70
- timeout: 120_000, // 2min — generous since the user is typing per call
172
+ timeout: 120_000,
71
173
  });
72
174
  if (result.status === 0) {
73
- return { account, status: 'fixed' };
175
+ return { service, account, status: 'fixed' };
74
176
  }
75
177
  return {
178
+ service,
76
179
  account,
77
180
  status: 'failed',
78
181
  error: result.error?.message ?? `exit code ${result.status}`,
79
182
  };
80
183
  }
81
184
  /**
82
- * Plan + apply: enumerate entries, fix each in turn. Returns per-entry
83
- * results so the CLI can render a checklist.
185
+ * Plan + apply: enumerate entries, fix each Clementine-shaped one in turn.
186
+ * Foreign entries (other apps under the legacy "clementine" service) get
187
+ * reported with status='skipped-foreign' and never touched.
84
188
  */
85
189
  export function fixAllClementineEntries() {
86
190
  const entries = listClementineKeychainEntries();
87
191
  const results = [];
88
192
  for (const entry of entries) {
89
- results.push(fixAcl(entry.account));
193
+ if (!entry.isClementine) {
194
+ results.push({ service: entry.service, account: entry.account, status: 'skipped-foreign' });
195
+ continue;
196
+ }
197
+ results.push(fixAcl(entry.service, entry.account));
90
198
  }
91
199
  return results;
92
200
  }
package/dist/config.d.ts CHANGED
@@ -14,6 +14,13 @@ export declare const BASE_DIR: string;
14
14
  export declare function envSnapshot(): Record<string, string | undefined>;
15
15
  /** Test-only: clear the keychain ref cache so re-resolution can be tested. */
16
16
  export declare function _resetKeychainRefCache(): void;
17
+ /**
18
+ * Return the keychain stubs that couldn't be resolved this process. Used by
19
+ * the daemon entrypoint to log a clear remediation hint at boot if any
20
+ * keychain reads are failing (typically: ACL not yet partition-listed →
21
+ * `clementine config keychain-fix-acl` fixes it).
22
+ */
23
+ export declare function getFailedKeychainResolutions(): string[];
17
24
  export declare const VAULT_DIR: string;
18
25
  export declare const SYSTEM_DIR: string;
19
26
  export declare const DAILY_NOTES_DIR: string;
package/dist/config.js CHANGED
@@ -117,6 +117,20 @@ export function envSnapshot() {
117
117
  export function _resetKeychainRefCache() {
118
118
  resolvedKeychainRefs.clear();
119
119
  }
120
+ /**
121
+ * Return the keychain stubs that couldn't be resolved this process. Used by
122
+ * the daemon entrypoint to log a clear remediation hint at boot if any
123
+ * keychain reads are failing (typically: ACL not yet partition-listed →
124
+ * `clementine config keychain-fix-acl` fixes it).
125
+ */
126
+ export function getFailedKeychainResolutions() {
127
+ const out = [];
128
+ for (const [stub, value] of resolvedKeychainRefs) {
129
+ if (value === null)
130
+ out.push(stub);
131
+ }
132
+ return out;
133
+ }
120
134
  // ── Paths ────────────────────────────────────────────────────────────
121
135
  export const VAULT_DIR = path.join(BASE_DIR, 'vault');
122
136
  export const SYSTEM_DIR = path.join(VAULT_DIR, '00-System');
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import pino from 'pino';
9
9
  import { DeliveryQueue } from './delivery-queue.js';
10
+ import { redactSecrets } from '../security/redact.js';
10
11
  const logger = pino({ name: 'clementine.notifications' });
11
12
  /** Safety cap — prevent runaway messages, but each channel handles its own chunking/limits. */
12
13
  const MAX_MESSAGE_LENGTH = 8000;
@@ -62,10 +63,18 @@ export class NotificationDispatcher {
62
63
  logger.warn('No notification senders registered — message dropped');
63
64
  return { delivered: false, channelErrors: { _: 'no channels registered' } };
64
65
  }
66
+ // Outbound credential redaction — last-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
+ }
65
74
  // Sanity cap only — each channel sender handles its own chunking/truncation
66
- const capped = text.length > MAX_MESSAGE_LENGTH
67
- ? text.slice(0, MAX_MESSAGE_LENGTH - 20) + '\n\n_(truncated)_'
68
- : text;
75
+ const capped = redacted.length > MAX_MESSAGE_LENGTH
76
+ ? redacted.slice(0, MAX_MESSAGE_LENGTH - 20) + '\n\n_(truncated)_'
77
+ : redacted;
69
78
  // If sessionKey is set, route only to the channel that owns it.
70
79
  // Fan out to all channels only when no originating channel is known.
71
80
  const targetChannel = context?.sessionKey ? channelForSessionKey(context.sessionKey) : null;
package/dist/index.js CHANGED
@@ -548,6 +548,17 @@ async function asyncMain() {
548
548
  hydrateSecretsFromEnv();
549
549
  }
550
550
  catch { /* non-fatal — non-macOS systems, or keychain unavailable */ }
551
+ // ── Surface keychain resolution failures with a clear remediation hint ──
552
+ // If any keychain ref couldn't be read at module-init time, the user is
553
+ // probably hitting the per-process approval-dialog issue (entry written
554
+ // with the wrong ACL). The fix is one command — print it loud so they
555
+ // don't have to grep for the answer.
556
+ const failedKcRefs = config.getFailedKeychainResolutions();
557
+ if (failedKcRefs.length > 0) {
558
+ logger.warn({ count: failedKcRefs.length, refs: failedKcRefs }, `${failedKcRefs.length} keychain reference(s) could not be resolved at startup.`);
559
+ logger.warn('Affected channels/integrations may be degraded. Fix in one command: clementine config keychain-fix-acl');
560
+ logger.warn('See: https://github.com/Natebreynolds/Clementine-AI-Assistant#keychain-prompts');
561
+ }
551
562
  // ── Check MCP extension permissions ────────────────────────────
552
563
  try {
553
564
  const { checkPermissionsOnStartup, bootstrapClaudeIntegrationsFromAuditLog, probeAvailableTools } = await import('./agent/mcp-bridge.js');
@@ -1023,8 +1023,10 @@ export class MemoryStore {
1023
1023
  const tagFilters = (category || topic) ? { category, topic } : undefined;
1024
1024
  // 1. FTS5 relevance (fetch extra to allow re-ranking after boost)
1025
1025
  const ftsResults = this.searchFts(query, agentSlug ? limit * 2 : limit, tagFilters, agentSlug && strict ? agentSlug : undefined);
1026
- // Apply salience boost to FTS results
1026
+ // Apply boosts. Order doesn't matter (all multiplicative) but readability does.
1027
+ const nowMs = Date.now();
1027
1028
  for (const r of ftsResults) {
1029
+ // Salience: editor-curated importance (admin tag, sticky note, etc.)
1028
1030
  if (r.salience > 0) {
1029
1031
  r.score *= 1.0 + r.salience;
1030
1032
  }
@@ -1036,6 +1038,17 @@ export class MemoryStore {
1036
1038
  if (outcome !== 0) {
1037
1039
  r.score *= 1.0 + 0.3 * outcome;
1038
1040
  }
1041
+ // Temporal decay — without this, a 2-year-old chunk with the same BM25
1042
+ // score ranks identically to one from yesterday. Half-life of 30 days
1043
+ // (matches TEMPORAL_DECAY_HALF_LIFE_DAYS in config). Applied to a
1044
+ // bounded fraction (max 60% reduction) so genuinely high-relevance
1045
+ // historical context still surfaces — this is a tiebreaker, not a cliff.
1046
+ if (r.lastUpdated) {
1047
+ const daysOld = Math.max(0, (nowMs - new Date(r.lastUpdated).getTime()) / 86_400_000);
1048
+ const decay = temporalDecay(daysOld, 30);
1049
+ // Clamp to [0.4, 1.0] so very old chunks lose at most 60% of their score.
1050
+ r.score *= Math.max(0.4, decay);
1051
+ }
1039
1052
  }
1040
1053
  // Soft-isolation: apply agent affinity boost when not strict
1041
1054
  if (agentSlug && !strict) {
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Outbound credential redaction.
3
+ *
4
+ * Last-line defense against prompt-injection exfil: any outbound text
5
+ * (Discord, Slack, email, dashboard chat) gets scanned for credential
6
+ * shapes BEFORE delivery. Matches are replaced with [REDACTED:reason]
7
+ * so the recipient sees that something was stripped without seeing the
8
+ * value itself.
9
+ *
10
+ * Two layers:
11
+ * 1. Pattern-based — well-known token formats from common providers
12
+ * (Stripe, Anthropic, OpenAI, GitHub, Slack, AWS, Discord). These
13
+ * catch credentials whose values we don't know in advance — including
14
+ * ones the agent might have just learned about from external sources.
15
+ * 2. Known-value — exact-match against the live values of credential-
16
+ * shaped keys in process.env / .env. Caught even if the format
17
+ * doesn't match a known pattern (e.g. internal API keys, custom
18
+ * webhook secrets).
19
+ *
20
+ * Designed to be cheap (single pass over each pattern + known-value set)
21
+ * so we can run on every outbound message without measurable latency.
22
+ *
23
+ * Designed to err on the side of REDACTING. False positives (a chunk of
24
+ * text that happens to look like a Stripe key) just produce a [REDACTED]
25
+ * marker; the recipient knows to ask. False negatives (a real credential
26
+ * leaked) are the bug we're trying to prevent.
27
+ */
28
+ export interface RedactionStats {
29
+ redactionCount: number;
30
+ /** Labels that fired, deduped. Useful for audit logging. */
31
+ labelsHit: string[];
32
+ }
33
+ export interface RedactionResult {
34
+ text: string;
35
+ stats: RedactionStats;
36
+ }
37
+ /**
38
+ * Pull credential values from process.env for any key that looks sensitive
39
+ * (matches isSensitiveEnvKey). Used to build the known-value redaction set
40
+ * lazily — re-read on each call so a freshly-set credential is covered
41
+ * within one tick.
42
+ */
43
+ export declare function buildKnownValueSet(env?: NodeJS.ProcessEnv): Set<string>;
44
+ /**
45
+ * Run all redaction layers against a string. Returns the redacted text
46
+ * plus stats about what fired.
47
+ *
48
+ * `knownValues` defaults to a fresh process.env scan but tests pass an
49
+ * explicit set for hermetic coverage.
50
+ */
51
+ export declare function redactSecrets(text: string, knownValues?: Set<string>): RedactionResult;
52
+ //# sourceMappingURL=redact.d.ts.map
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Outbound credential redaction.
3
+ *
4
+ * Last-line defense against prompt-injection exfil: any outbound text
5
+ * (Discord, Slack, email, dashboard chat) gets scanned for credential
6
+ * shapes BEFORE delivery. Matches are replaced with [REDACTED:reason]
7
+ * so the recipient sees that something was stripped without seeing the
8
+ * value itself.
9
+ *
10
+ * Two layers:
11
+ * 1. Pattern-based — well-known token formats from common providers
12
+ * (Stripe, Anthropic, OpenAI, GitHub, Slack, AWS, Discord). These
13
+ * catch credentials whose values we don't know in advance — including
14
+ * ones the agent might have just learned about from external sources.
15
+ * 2. Known-value — exact-match against the live values of credential-
16
+ * shaped keys in process.env / .env. Caught even if the format
17
+ * doesn't match a known pattern (e.g. internal API keys, custom
18
+ * webhook secrets).
19
+ *
20
+ * Designed to be cheap (single pass over each pattern + known-value set)
21
+ * so we can run on every outbound message without measurable latency.
22
+ *
23
+ * Designed to err on the side of REDACTING. False positives (a chunk of
24
+ * text that happens to look like a Stripe key) just produce a [REDACTED]
25
+ * marker; the recipient knows to ask. False negatives (a real credential
26
+ * leaked) are the bug we're trying to prevent.
27
+ */
28
+ import { isSensitiveEnvKey } from '../secrets/sensitivity.js';
29
+ // pragma: allowlist secret (this module exists to recognize secret patterns)
30
+ const PATTERNS = [
31
+ { label: 'stripe', re: /\bsk_(?:live|test)_[A-Za-z0-9]{16,}\b/g },
32
+ { label: 'anthropic', re: /\bsk-ant-(?:api|admin)\w*-[A-Za-z0-9_-]{16,}\b/g },
33
+ { label: 'openai-project', re: /\bsk-proj-[A-Za-z0-9_-]{20,}\b/g },
34
+ { label: 'openai', re: /\bsk-[A-Za-z0-9]{40,}\b/g },
35
+ { label: 'github', re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{30,}\b/g },
36
+ { label: 'slack', re: /\bxox[abpors]-[A-Za-z0-9-]{10,}\b/g },
37
+ { label: 'aws-access', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
38
+ { label: 'discord', re: /\b[A-Za-z0-9_-]{24,28}\.[A-Za-z0-9_-]{6,7}\.[A-Za-z0-9_-]{27,38}\b/g },
39
+ { label: 'jwt', re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g },
40
+ { label: 'private-key', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----[\s\S]+?-----END (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g },
41
+ ];
42
+ /**
43
+ * Pull credential values from process.env for any key that looks sensitive
44
+ * (matches isSensitiveEnvKey). Used to build the known-value redaction set
45
+ * lazily — re-read on each call so a freshly-set credential is covered
46
+ * within one tick.
47
+ */
48
+ export function buildKnownValueSet(env = process.env) {
49
+ const out = new Set();
50
+ for (const [key, value] of Object.entries(env)) {
51
+ if (!value)
52
+ continue;
53
+ if (value.length < 12)
54
+ continue; // short values likely false positives
55
+ if (value.startsWith('keychain:'))
56
+ continue; // reference, not the secret itself
57
+ if (!isSensitiveEnvKey(key))
58
+ continue;
59
+ out.add(value);
60
+ }
61
+ return out;
62
+ }
63
+ /**
64
+ * Run all redaction layers against a string. Returns the redacted text
65
+ * plus stats about what fired.
66
+ *
67
+ * `knownValues` defaults to a fresh process.env scan but tests pass an
68
+ * explicit set for hermetic coverage.
69
+ */
70
+ export function redactSecrets(text, knownValues = buildKnownValueSet()) {
71
+ if (!text)
72
+ return { text, stats: { redactionCount: 0, labelsHit: [] } };
73
+ let working = text;
74
+ const labelsHit = new Set();
75
+ let count = 0;
76
+ // Pattern pass first — catches well-known formats whose values we may
77
+ // not know in advance.
78
+ for (const { label, re } of PATTERNS) {
79
+ working = working.replace(re, () => {
80
+ labelsHit.add(label);
81
+ count++;
82
+ return `[REDACTED:${label}]`;
83
+ });
84
+ }
85
+ // Known-value pass — exact-match every credential currently loaded into
86
+ // process.env. Sort by length descending so longer values get replaced
87
+ // first (a longer secret might contain a shorter one as substring).
88
+ const sortedValues = [...knownValues].sort((a, b) => b.length - a.length);
89
+ for (const v of sortedValues) {
90
+ if (!v || v.length < 12)
91
+ continue;
92
+ let idx = working.indexOf(v);
93
+ while (idx !== -1) {
94
+ working = working.slice(0, idx) + '[REDACTED:env]' + working.slice(idx + v.length);
95
+ labelsHit.add('env');
96
+ count++;
97
+ idx = working.indexOf(v, idx + '[REDACTED:env]'.length);
98
+ }
99
+ }
100
+ return {
101
+ text: working,
102
+ stats: { redactionCount: count, labelsHit: [...labelsHit] },
103
+ };
104
+ }
105
+ //# sourceMappingURL=redact.js.map
@@ -122,10 +122,10 @@ export function registerAdminTools(server) {
122
122
  return textResult(`Timer set. Reminder in ${minutes} minute${minutes !== 1 ? 's' : ''} (~${fireTime}): "${message}"`);
123
123
  });
124
124
  // ── Env self-configuration (owner-DM only) ────────────────────────────
125
- server.tool('env_set', 'Save or update an env var. Owner-DM only. In "auto" mode (default), credential-shaped keys (API keys, tokens, secrets, passwords) go to the macOS Keychain; everything else goes to plain ~/.clementine/.env. Force keychain or env via the storage arg if you need to override. Changes take effect immediately; process.env gets the real value and the next tool call can use it. Use this when the owner gives a value in chat never tell them to hand-edit files.', {
125
+ server.tool('env_set', 'Save or update an env var. Owner-DM only. Default behavior writes to plain ~/.clementine/.env (mode 0600). Pass storage="keychain" to opt into macOS Keychain storage but be aware keychain entries can require per-app approval prompts on first read which create UX friction (see commit history for the rabbit hole). Use plain .env unless you specifically need at-rest encryption beyond filesystem permissions.', {
126
126
  key: z.string().describe('Env var name (uppercase with underscores, e.g. STRIPE_API_KEY)'),
127
127
  value: z.string().describe('The value to store. Never echo back to the user; it will be masked in logs.'),
128
- storage: z.enum(['keychain', 'env', 'auto']).optional().describe('Where to store it. "auto" (default) routes credential-shaped keys to Keychain and config-shaped keys to plain .env. "keychain" forces Keychain. "env" forces plaintext .env.'),
128
+ storage: z.enum(['keychain', 'env', 'auto']).optional().describe('Where to store it. Default (and "auto"/"env") writes plaintext to ~/.clementine/.env. "keychain" opts into macOS Keychain only use when at-rest encryption matters more than read ergonomics.'),
129
129
  }, async ({ key, value, storage }) => {
130
130
  const gate = requireOwnerDm();
131
131
  if (!gate.ok)
@@ -137,14 +137,19 @@ export function registerAdminTools(server) {
137
137
  if (!value)
138
138
  return textResult('Refused: empty value. Use env_unset to remove a key.');
139
139
  const mode = storage ?? 'auto';
140
- const looksSensitive = isSensitiveEnvKey(normalizedKey);
141
- // auto mode: keychain-route only credential-shaped keys, so config knobs
142
- // (BUDGET_*, OWNER_NAME, etc.) stay readable as plain .env values.
143
- const useKeychain = mode === 'keychain' ||
144
- (mode === 'auto' && looksSensitive && keychain.isAvailable());
140
+ // Keychain is now strictly opt-in. The legacy 'auto' mode used to route
141
+ // credential-shaped keys to keychain, but that produced a class of read-
142
+ // approval dialog UX bugs (see commits 88cfd99 .. c34da0b). Plaintext .env
143
+ // with mode 0600 is the safer default — credentials still encrypted at
144
+ // rest if FileVault is on, and no per-process keychain prompts.
145
+ const useKeychain = mode === 'keychain';
145
146
  if (mode === 'keychain' && !keychain.isAvailable()) {
146
147
  return textResult('Refused: Keychain storage requested but macOS Keychain is unavailable on this system.');
147
148
  }
149
+ // Reference unused-but-imported helper so the import line stays meaningful
150
+ // for grep — it's used by other modules and we may re-enable smart routing
151
+ // later behind a feature flag.
152
+ void isSensitiveEnvKey;
148
153
  const map = parseEnvFile();
149
154
  const existed = map.has(normalizedKey);
150
155
  let envFileValue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",