@blockrun/franklin 3.15.9 → 3.15.11

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/README.md CHANGED
@@ -71,6 +71,8 @@ That's it. Zero signup, zero credit card, zero phone verification. Send **$5 of
71
71
 
72
72
  ### Prefer a GUI? Try Franklin for VS Code
73
73
 
74
+ [![Franklin for VS Code — Beta is here](assets/franklin-vscode-banner.png)](https://marketplace.visualstudio.com/items?itemName=blockrun.franklin-vscode)
75
+
74
76
  The same agent ships as a [VS Code extension](https://marketplace.visualstudio.com/items?itemName=blockrun.franklin-vscode) — chat panel, model picker, wallet balance, image / video generation, inline diff cards — all driven by the wallet you already funded for the CLI.
75
77
 
76
78
  ```
@@ -8,6 +8,7 @@ import { estimateHistoryTokens, updateActualTokens, resetTokenAnchor, getAnchore
8
8
  import { handleSlashCommand } from './commands.js';
9
9
  import { loadBundledSkills, getSkillVars } from '../skills/bootstrap.js';
10
10
  import { reduceTokens } from './reduce.js';
11
+ import { redactSecrets, stashSecretsToEnv, formatRedactionWarning } from './secret-redact.js';
11
12
  import { PermissionManager } from './permissions.js';
12
13
  import { StreamingExecutor } from './streaming-executor.js';
13
14
  import { optimizeHistory, CAPPED_MAX_TOKENS, ESCALATED_MAX_TOKENS, getMaxOutputTokens } from './optimize.js';
@@ -19,6 +20,7 @@ import { createActivateToolCapability } from '../tools/activate.js';
19
20
  import { recordUsage } from '../stats/tracker.js';
20
21
  import { recordSessionUsage } from '../stats/session-tracker.js';
21
22
  import { appendAudit, extractLastUserPrompt } from '../stats/audit.js';
23
+ import { logger, setDebugMode } from '../logger.js';
22
24
  import { estimateCost, OPUS_PRICING } from '../pricing.js';
23
25
  import { maybeMidSessionExtract } from '../learnings/extractor.js';
24
26
  import { extractMentions, buildEntityContext, loadEntities } from '../brain/store.js';
@@ -324,6 +326,9 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
324
326
  // fool Edit/Write into skipping the read-before-edit check or serve cached
325
327
  // webfetch content fetched under the previous session's intent.
326
328
  resetToolSessionState();
329
+ // Wire stderr-mirroring of log lines to the same flag the agent already
330
+ // uses to gate verbose console output. File writes happen regardless.
331
+ setDebugMode(!!config.debug);
327
332
  const client = new ModelClient({
328
333
  apiUrl: config.apiUrl,
329
334
  chain: config.chain,
@@ -476,6 +481,22 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
476
481
  input = cmdResult.rewritten;
477
482
  }
478
483
  }
484
+ // ── Secret redaction at the input boundary ──
485
+ // Catch GitHub PATs / API keys / private keys before they enter
486
+ // history, get persisted, or hit the model. Detected values are
487
+ // stashed on process.env (predictable name like GITHUB_TOKEN) so
488
+ // subsequent Bash tool calls can still use them via `$GITHUB_TOKEN`
489
+ // — the user keeps the convenience of "remember this credential"
490
+ // without the chat-history exposure that just happened.
491
+ const { redactedText, matches: secretMatches } = redactSecrets(input);
492
+ if (secretMatches.length > 0) {
493
+ const envVarsSet = stashSecretsToEnv(secretMatches);
494
+ onEvent({
495
+ kind: 'text_delta',
496
+ text: formatRedactionWarning(secretMatches, envVarsSet),
497
+ });
498
+ input = redactedText;
499
+ }
479
500
  lastUserInput = input;
480
501
  // Push the user's clean message; any harness-injected annotations
481
502
  // (pushback SYSTEM NOTE, prefetch context block) are applied AFTER
@@ -708,16 +729,12 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
708
729
  kind: 'text_delta',
709
730
  text: `\n*🗜 Auto-compacted: ~${(beforeTokens / 1000).toFixed(0)}K → ~${(afterTokens / 1000).toFixed(0)}K tokens (saved ${pct}%)*\n\n`,
710
731
  });
711
- if (config.debug) {
712
- console.error(`[franklin] History compacted: ~${afterTokens} tokens`);
713
- }
732
+ logger.info(`[franklin] History compacted: ~${afterTokens} tokens`);
714
733
  }
715
734
  }
716
735
  catch (compactErr) {
717
736
  compactFailures++;
718
- if (config.debug) {
719
- console.error(`[franklin] Compaction failed (${compactFailures}/3): ${compactErr.message}`);
720
- }
737
+ logger.warn(`[franklin] Compaction failed (${compactFailures}/3): ${compactErr.message}`);
721
738
  }
722
739
  }
723
740
  // Inject ultrathink instruction when mode is active
@@ -922,9 +939,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
922
939
  const oldModel = config.model;
923
940
  config.model = nextModel;
924
941
  config.onModelChange?.(nextModel, 'system');
925
- if (config.debug) {
926
- console.error(`[franklin] ${oldModel} returned empty — switching to ${nextModel}`);
927
- }
942
+ logger.warn(`[franklin] ${oldModel} returned empty — switching to ${nextModel}`);
928
943
  onEvent({ kind: 'text_delta', text: `\n*${oldModel} returned empty — switching to ${nextModel}*\n` });
929
944
  continue;
930
945
  }
@@ -956,9 +971,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
956
971
  // ── Media size error recovery (strip images/PDFs + retry) ──
957
972
  if (isMediaSizeError(errMsg) && recoveryAttempts < MAX_RECOVERY_ATTEMPTS) {
958
973
  recoveryAttempts++;
959
- if (config.debug) {
960
- console.error(`[franklin] Media too large — stripping and retrying (attempt ${recoveryAttempts})`);
961
- }
974
+ logger.warn(`[franklin] Media too large — stripping and retrying (attempt ${recoveryAttempts})`);
962
975
  const { history: stripped, stripped: didStrip } = stripMediaFromHistory(history);
963
976
  if (didStrip) {
964
977
  replaceHistory(history, stripped);
@@ -972,9 +985,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
972
985
  // the prompt is too long, so we must compact regardless of our threshold estimate.
973
986
  if (classified.category === 'context_limit' && recoveryAttempts < MAX_RECOVERY_ATTEMPTS) {
974
987
  recoveryAttempts++;
975
- if (config.debug) {
976
- console.error(`[franklin] Prompt too long — force compacting (attempt ${recoveryAttempts})`);
977
- }
988
+ logger.warn(`[franklin] Prompt too long — force compacting (attempt ${recoveryAttempts})`);
978
989
  onEvent({ kind: 'text_delta', text: '\n*Context limit hit — compacting conversation...*\n' });
979
990
  const { history: compactedAgain } = await forceCompact(history, config.model, client, config.debug);
980
991
  replaceHistory(history, compactedAgain);
@@ -1000,9 +1011,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1000
1011
  const continuationPrompt = buildContinuationPrompt();
1001
1012
  history.push(continuationPrompt);
1002
1013
  persistSessionMessage(continuationPrompt);
1003
- if (config.debug) {
1004
- console.error(`[franklin] Stream timeout on ${resolvedModel} — auto-continuing with chunked-task prompt`);
1005
- }
1014
+ logger.warn(`[franklin] Stream timeout on ${resolvedModel} — auto-continuing with chunked-task prompt`);
1006
1015
  onEvent({
1007
1016
  kind: 'text_delta',
1008
1017
  text: '\n*Task too big for one streaming turn — auto-continuing with a smaller chunk...*\n',
@@ -1014,10 +1023,8 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1014
1023
  const costText = retryDecision.estimatedReplayCostUsd > 0
1015
1024
  ? ` and at least $${retryDecision.estimatedReplayCostUsd.toFixed(4)} in input charges`
1016
1025
  : '';
1017
- if (config.debug) {
1018
- console.error(`[franklin] Timeout retry skipped for ${resolvedModel}: ` +
1019
- `~${tokenText} input tokens, replayCost=$${retryDecision.estimatedReplayCostUsd.toFixed(4)}`);
1020
- }
1026
+ logger.warn(`[franklin] Timeout retry skipped for ${resolvedModel}: ` +
1027
+ `~${tokenText} input tokens, replayCost=$${retryDecision.estimatedReplayCostUsd.toFixed(4)}`);
1021
1028
  onEvent({
1022
1029
  kind: 'turn_done',
1023
1030
  reason: 'error',
@@ -1062,9 +1069,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1062
1069
  }
1063
1070
  recoveryAttempts++;
1064
1071
  const backoffMs = getBackoffDelay(recoveryAttempts);
1065
- if (config.debug) {
1066
- console.error(`[franklin] ${classified.label} error — retrying in ${(backoffMs / 1000).toFixed(1)}s (attempt ${recoveryAttempts}/${effectiveMaxRetries}): ${errMsg.slice(0, 100)}`);
1067
- }
1072
+ logger.warn(`[franklin] ${classified.label} error — retrying in ${(backoffMs / 1000).toFixed(1)}s (attempt ${recoveryAttempts}/${effectiveMaxRetries}): ${errMsg.slice(0, 100)}`);
1068
1073
  // Surface the actual error + model so the user can see which model
1069
1074
  // is failing and what the upstream said. Old "Retrying after Server
1070
1075
  // error" was uninformative — users couldn't tell whether to wait,
@@ -1232,9 +1237,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1232
1237
  if (maxTokensOverride === undefined) {
1233
1238
  // First hit: escalate to 64K
1234
1239
  maxTokensOverride = ESCALATED_MAX_TOKENS;
1235
- if (config.debug) {
1236
- console.error(`[franklin] Max tokens hit — escalating to ${maxTokensOverride}`);
1237
- }
1240
+ logger.warn(`[franklin] Max tokens hit — escalating to ${maxTokensOverride}`);
1238
1241
  }
1239
1242
  // Append what we got + a continuation prompt with last-line anchor
1240
1243
  const partialAssistant = { role: 'assistant', content: responseParts };
@@ -1276,9 +1279,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1276
1279
  // the existing recovery flow handle it.
1277
1280
  const gatewayErr = looksLikeGatewayErrorAsText(responseParts);
1278
1281
  if (gatewayErr.match) {
1279
- if (config.debug) {
1280
- console.error(`[franklin] Gateway returned an error text in lieu of an answer (${resolvedModel}): ${gatewayErr.message}`);
1281
- }
1282
+ logger.error(`[franklin] Gateway returned an error text in lieu of an answer (${resolvedModel}): ${gatewayErr.message}`);
1282
1283
  throw new Error(gatewayErr.message);
1283
1284
  }
1284
1285
  // Reset recovery counter on successful completion
@@ -1555,9 +1556,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1555
1556
  }
1556
1557
  // Hard stop: if cap exceeded, force end this agent loop iteration
1557
1558
  if (turnToolCalls >= MAX_TOOL_CALLS_PER_TURN) {
1558
- if (config.debug) {
1559
- console.error(`[franklin] Tool call cap hit: ${turnToolCalls} calls this turn`);
1560
- }
1559
+ logger.warn(`[franklin] Tool call cap hit: ${turnToolCalls} calls this turn`);
1561
1560
  // Don't break — let the model respond one more time to summarize,
1562
1561
  // but inject the stop signal above so it knows to finish up.
1563
1562
  }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Secret detection + redaction for user-submitted text.
3
+ *
4
+ * Why this exists: a user pasted a GitHub PAT (`ghp_...`) directly into
5
+ * chat as a way to give Franklin authenticated access to the GitHub API.
6
+ * The model correctly refused to use the raw value and warned the user,
7
+ * but by then the token had already entered:
8
+ * - the LLM API request body (sent to the gateway + upstream provider)
9
+ * - the persisted session file on disk
10
+ * - any later compaction summary (which would re-send it to the model)
11
+ *
12
+ * What the user actually wants is for Franklin to **remember the credential
13
+ * and keep using it**, not refuse it. So this module's job is two-fold:
14
+ *
15
+ * 1. Strip the raw value out of the conversation so it never reaches
16
+ * the model, history, or disk.
17
+ * 2. Stash it on `process.env` under a predictable name so subsequent
18
+ * Bash / WebFetch tool calls can reference it via `$GITHUB_TOKEN`,
19
+ * `$ANTHROPIC_API_KEY`, etc. — no chat round-trip needed.
20
+ *
21
+ * Conservative pattern set: each entry matches a token format with an
22
+ * unambiguous prefix + length, so false positives are rare. Anything that
23
+ * could plausibly be a normal long string (random hex, base64 blobs) is
24
+ * deliberately not in here. False positives are worse than missed
25
+ * detections — silently mangling a hex hash a user pasted would be
26
+ * confusing and there's no recovery path.
27
+ */
28
+ export interface RedactionMatch {
29
+ label: string;
30
+ description: string;
31
+ /** First 4 chars of the secret + ellipsis — for user-facing display. */
32
+ preview: string;
33
+ /** Suggested env var name (e.g. GITHUB_TOKEN). */
34
+ envVar: string;
35
+ /** The actual secret value. INTERNAL USE ONLY — never log this. */
36
+ value: string;
37
+ }
38
+ export interface RedactionResult {
39
+ /** Input with each secret replaced by [REDACTED:label]. */
40
+ redactedText: string;
41
+ /** What got redacted. Includes raw `value` for the caller to stash. */
42
+ matches: RedactionMatch[];
43
+ }
44
+ /**
45
+ * Scan `input` for secret patterns and return a redacted copy plus a
46
+ * description of what was caught. Secrets are replaced with the literal
47
+ * string `[REDACTED:<label>]` so the model can still see *something* was
48
+ * there (helpful when the user's message refers to the token in context),
49
+ * just not the value.
50
+ *
51
+ * No I/O, no logging — pure transformation. The caller decides how to
52
+ * surface the warning to the user and whether to stash values on
53
+ * process.env (recommended for CLI usage so Bash tool calls can reference
54
+ * $GITHUB_TOKEN etc).
55
+ */
56
+ export declare function redactSecrets(input: string): RedactionResult;
57
+ /**
58
+ * Stash matched secrets onto `process.env` so subsequent tool calls
59
+ * (Bash, WebFetch with `$GITHUB_TOKEN`-style references) can use them
60
+ * without the value ever round-tripping through chat history.
61
+ *
62
+ * Returns the names of env vars that were set, deduped, in stash order.
63
+ * Safe to call on an empty match list (no-op).
64
+ */
65
+ export declare function stashSecretsToEnv(matches: RedactionMatch[]): string[];
66
+ /**
67
+ * Build a one-paragraph warning + usage hint to surface to the user when
68
+ * their input had secrets redacted and stashed. Names what was caught
69
+ * (with previews, never values) and tells them how to actually use the
70
+ * stashed credential going forward.
71
+ */
72
+ export declare function formatRedactionWarning(matches: RedactionMatch[], envVarsSet: string[]): string;
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Secret detection + redaction for user-submitted text.
3
+ *
4
+ * Why this exists: a user pasted a GitHub PAT (`ghp_...`) directly into
5
+ * chat as a way to give Franklin authenticated access to the GitHub API.
6
+ * The model correctly refused to use the raw value and warned the user,
7
+ * but by then the token had already entered:
8
+ * - the LLM API request body (sent to the gateway + upstream provider)
9
+ * - the persisted session file on disk
10
+ * - any later compaction summary (which would re-send it to the model)
11
+ *
12
+ * What the user actually wants is for Franklin to **remember the credential
13
+ * and keep using it**, not refuse it. So this module's job is two-fold:
14
+ *
15
+ * 1. Strip the raw value out of the conversation so it never reaches
16
+ * the model, history, or disk.
17
+ * 2. Stash it on `process.env` under a predictable name so subsequent
18
+ * Bash / WebFetch tool calls can reference it via `$GITHUB_TOKEN`,
19
+ * `$ANTHROPIC_API_KEY`, etc. — no chat round-trip needed.
20
+ *
21
+ * Conservative pattern set: each entry matches a token format with an
22
+ * unambiguous prefix + length, so false positives are rare. Anything that
23
+ * could plausibly be a normal long string (random hex, base64 blobs) is
24
+ * deliberately not in here. False positives are worse than missed
25
+ * detections — silently mangling a hex hash a user pasted would be
26
+ * confusing and there's no recovery path.
27
+ */
28
+ const SECRET_PATTERNS = [
29
+ // ── GitHub ──
30
+ // Personal access tokens, OAuth tokens, app installation/user-to-server
31
+ // tokens, fine-grained PATs. All have unique prefixes the user can verify.
32
+ {
33
+ label: 'github_pat',
34
+ pattern: /\bghp_[A-Za-z0-9]{36,}\b/g,
35
+ description: 'GitHub personal access token',
36
+ envVar: 'GITHUB_TOKEN',
37
+ },
38
+ {
39
+ label: 'github_oauth',
40
+ pattern: /\bgho_[A-Za-z0-9]{36,}\b/g,
41
+ description: 'GitHub OAuth token',
42
+ envVar: 'GITHUB_TOKEN',
43
+ },
44
+ {
45
+ label: 'github_app',
46
+ pattern: /\bghs_[A-Za-z0-9]{36,}\b/g,
47
+ description: 'GitHub App installation token',
48
+ envVar: 'GITHUB_TOKEN',
49
+ },
50
+ {
51
+ label: 'github_user',
52
+ pattern: /\bghu_[A-Za-z0-9]{36,}\b/g,
53
+ description: 'GitHub user-to-server token',
54
+ envVar: 'GITHUB_TOKEN',
55
+ },
56
+ {
57
+ label: 'github_pat_fine',
58
+ pattern: /\bgithub_pat_[A-Za-z0-9_]{22,}\b/g,
59
+ description: 'GitHub fine-grained PAT',
60
+ envVar: 'GITHUB_TOKEN',
61
+ },
62
+ // ── Anthropic ──
63
+ {
64
+ label: 'anthropic_api',
65
+ pattern: /\bsk-ant-(?:api|admin)\d+-[A-Za-z0-9_-]{20,}\b/g,
66
+ description: 'Anthropic API key',
67
+ envVar: 'ANTHROPIC_API_KEY',
68
+ },
69
+ // ── OpenAI ──
70
+ // sk-proj- (project keys, current format) is unambiguous. The legacy
71
+ // `sk-` + 48 chars format is more ambiguous so we require ≥48 chars to
72
+ // avoid clashing with normal short hex strings.
73
+ {
74
+ label: 'openai_project',
75
+ pattern: /\bsk-proj-[A-Za-z0-9_-]{40,}\b/g,
76
+ description: 'OpenAI project API key',
77
+ envVar: 'OPENAI_API_KEY',
78
+ },
79
+ {
80
+ label: 'openai_api',
81
+ pattern: /\bsk-[A-Za-z0-9]{48,}\b/g,
82
+ description: 'OpenAI API key',
83
+ envVar: 'OPENAI_API_KEY',
84
+ },
85
+ // ── AWS ──
86
+ {
87
+ label: 'aws_access_key',
88
+ pattern: /\bAKIA[0-9A-Z]{16}\b/g,
89
+ description: 'AWS access key ID',
90
+ envVar: 'AWS_ACCESS_KEY_ID',
91
+ },
92
+ // AWS secret keys are 40 base64 chars but have no prefix — too ambiguous
93
+ // to match safely. Skipped on purpose.
94
+ // ── Google ──
95
+ {
96
+ label: 'google_api',
97
+ pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g,
98
+ description: 'Google Cloud / Firebase API key',
99
+ envVar: 'GOOGLE_API_KEY',
100
+ },
101
+ // ── Slack ──
102
+ {
103
+ label: 'slack_token',
104
+ pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
105
+ description: 'Slack token',
106
+ envVar: 'SLACK_TOKEN',
107
+ },
108
+ // ── Stripe ──
109
+ {
110
+ label: 'stripe_live',
111
+ pattern: /\bsk_live_[A-Za-z0-9]{20,}\b/g,
112
+ description: 'Stripe live secret key',
113
+ envVar: 'STRIPE_SECRET_KEY',
114
+ },
115
+ {
116
+ label: 'stripe_test',
117
+ pattern: /\bsk_test_[A-Za-z0-9]{20,}\b/g,
118
+ description: 'Stripe test secret key',
119
+ envVar: 'STRIPE_SECRET_KEY',
120
+ },
121
+ // ── Twilio ──
122
+ {
123
+ label: 'twilio_account',
124
+ pattern: /\bAC[a-f0-9]{32}\b/g,
125
+ description: 'Twilio account SID',
126
+ envVar: 'TWILIO_ACCOUNT_SID',
127
+ },
128
+ // ── Private keys ──
129
+ // Multi-line PEM blocks. Match begin/end markers + content. envVar:
130
+ // PRIVATE_KEY is generic — if a user has multiple they'll need to rename.
131
+ {
132
+ label: 'private_key',
133
+ pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP )?PRIVATE KEY-----/g,
134
+ description: 'PEM private key',
135
+ envVar: 'PRIVATE_KEY',
136
+ },
137
+ // ── Cryptocurrency private keys ──
138
+ // 64-char hex strings preceded by an obvious key context. Standalone hex
139
+ // strings are too ambiguous (could be hashes) to redact silently.
140
+ {
141
+ label: 'eth_private_key',
142
+ pattern: /\b(?:private[_\s-]?key|priv[_\s-]?key|secret[_\s-]?key)\s*[:=]\s*0x[a-fA-F0-9]{64}\b/gi,
143
+ description: 'Ethereum-style private key',
144
+ envVar: 'WALLET_PRIVATE_KEY',
145
+ },
146
+ ];
147
+ /**
148
+ * Scan `input` for secret patterns and return a redacted copy plus a
149
+ * description of what was caught. Secrets are replaced with the literal
150
+ * string `[REDACTED:<label>]` so the model can still see *something* was
151
+ * there (helpful when the user's message refers to the token in context),
152
+ * just not the value.
153
+ *
154
+ * No I/O, no logging — pure transformation. The caller decides how to
155
+ * surface the warning to the user and whether to stash values on
156
+ * process.env (recommended for CLI usage so Bash tool calls can reference
157
+ * $GITHUB_TOKEN etc).
158
+ */
159
+ export function redactSecrets(input) {
160
+ let redactedText = input;
161
+ const matches = [];
162
+ // Dedupe by exact value, not by label — a user might paste two distinct
163
+ // GitHub tokens and we should preserve both for the env-var stash even
164
+ // though the description would read identically.
165
+ const seenValues = new Set();
166
+ for (const { label, pattern, description, envVar } of SECRET_PATTERNS) {
167
+ // Reset lastIndex on each pattern — RegExp objects with /g preserve it
168
+ // across calls, which would skip matches if the same regex were reused.
169
+ pattern.lastIndex = 0;
170
+ let match;
171
+ while ((match = pattern.exec(input)) !== null) {
172
+ const secret = match[0];
173
+ if (seenValues.has(secret))
174
+ continue;
175
+ seenValues.add(secret);
176
+ matches.push({
177
+ label,
178
+ description,
179
+ preview: secret.slice(0, 4) + '…',
180
+ envVar,
181
+ value: secret,
182
+ });
183
+ // Replace every occurrence of this exact secret in redactedText.
184
+ // Using the literal secret as a search string handles the common
185
+ // case where the same token appears multiple times in one message.
186
+ redactedText = redactedText.split(secret).join(`[REDACTED:${label}]`);
187
+ }
188
+ }
189
+ return { redactedText, matches };
190
+ }
191
+ /**
192
+ * Stash matched secrets onto `process.env` so subsequent tool calls
193
+ * (Bash, WebFetch with `$GITHUB_TOKEN`-style references) can use them
194
+ * without the value ever round-tripping through chat history.
195
+ *
196
+ * Returns the names of env vars that were set, deduped, in stash order.
197
+ * Safe to call on an empty match list (no-op).
198
+ */
199
+ export function stashSecretsToEnv(matches) {
200
+ const set = [];
201
+ for (const m of matches) {
202
+ // Don't clobber an env var the user has already exported in their
203
+ // shell — their existing value is presumably the right one and
204
+ // accidentally overwriting it could cause silent breakage.
205
+ if (process.env[m.envVar] && process.env[m.envVar] !== m.value) {
206
+ continue;
207
+ }
208
+ process.env[m.envVar] = m.value;
209
+ if (!set.includes(m.envVar))
210
+ set.push(m.envVar);
211
+ }
212
+ return set;
213
+ }
214
+ /**
215
+ * Build a one-paragraph warning + usage hint to surface to the user when
216
+ * their input had secrets redacted and stashed. Names what was caught
217
+ * (with previews, never values) and tells them how to actually use the
218
+ * stashed credential going forward.
219
+ */
220
+ export function formatRedactionWarning(matches, envVarsSet) {
221
+ if (matches.length === 0)
222
+ return '';
223
+ const list = matches
224
+ .map((m) => `• ${m.description} (${m.preview}) → \`$${m.envVar}\``)
225
+ .join('\n');
226
+ const skipped = matches.length - envVarsSet.length;
227
+ const skippedNote = skipped > 0
228
+ ? `\n_(${skipped} value${skipped === 1 ? '' : 's'} not stashed because the env var was already set in your shell — your existing export is preserved.)_`
229
+ : '';
230
+ return (`\n⚠️ **Secret detected, redacted from chat, and stashed for this session.**\n` +
231
+ `${list}${skippedNote}\n\n` +
232
+ `The raw value never reached the model, the conversation history, or the session file. ` +
233
+ `Tool calls (Bash / WebFetch) can use the env var name — for example: ` +
234
+ `\`gh api user --header "Authorization: Bearer $GITHUB_TOKEN"\`.\n\n` +
235
+ `**This still counts as exposed.** Anything you typed is in your terminal scrollback, ` +
236
+ `and any prior session file may have captured it before this redaction was added. ` +
237
+ `Treat the secret as compromised and rotate it now.\n\n` +
238
+ `Next time, set the credential via shell export before launching Franklin ` +
239
+ `(\`export GITHUB_TOKEN=...\`) instead of pasting it into chat.\n`);
240
+ }
@@ -188,9 +188,23 @@ export class SessionToolGuard {
188
188
  }
189
189
  }
190
190
  async beforeExecute(invocation, scope) {
191
- // Hard-block tools that have failed too many times this session
191
+ // Hard-block tools that have failed too many times this session.
192
+ // Modal lifecycle tools are exempt: orphan sandboxes keep billing
193
+ // GPU time, and ModalTerminate is the only way to recover from
194
+ // agent-side. Auto-disabling it after 3 transient errors would
195
+ // strand a $0.40/hr H100 until the session ends. Same logic for
196
+ // media-gen tools: failures are usually transient (gateway hiccup,
197
+ // prompt rejection) and the user often wants to retry.
198
+ const FAILURE_EXEMPT = new Set([
199
+ 'ImageGen',
200
+ 'VideoGen',
201
+ 'ModalCreate',
202
+ 'ModalExec',
203
+ 'ModalStatus',
204
+ 'ModalTerminate',
205
+ ]);
192
206
  const errorCount = this.toolErrorCounts.get(invocation.name) ?? 0;
193
- if (errorCount >= 3) {
207
+ if (errorCount >= 3 && !FAILURE_EXEMPT.has(invocation.name)) {
194
208
  return {
195
209
  output: `${invocation.name} has failed ${errorCount} times this session and is now disabled. ` +
196
210
  'Tell the user what went wrong and suggest alternatives.',
@@ -0,0 +1,10 @@
1
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
2
+ export declare function setDebugMode(enabled: boolean): void;
3
+ export declare function isDebugMode(): boolean;
4
+ export declare function getLogFilePath(): string;
5
+ export declare const logger: {
6
+ debug(msg: string): void;
7
+ info(msg: string): void;
8
+ warn(msg: string): void;
9
+ error(msg: string): void;
10
+ };
package/dist/logger.js ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Unified logger — always persists to ~/.blockrun/franklin-debug.log,
3
+ * optionally mirrors to stderr when debug mode is on.
4
+ *
5
+ * Why this exists: before this module, agent diagnostics were emitted with
6
+ * `if (config.debug) console.error(...)`. That meant `franklin logs` showed
7
+ * nothing in normal use because the events never hit the file. Now every
8
+ * level writes to disk; stderr mirroring is the opt-in part.
9
+ *
10
+ * Errors during a log write are swallowed — the agent loop must never die
11
+ * because the disk is full or the home dir is read-only.
12
+ */
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { BLOCKRUN_DIR } from './config.js';
16
+ const LOG_FILE = path.join(BLOCKRUN_DIR, 'franklin-debug.log');
17
+ // Strip ANSI escapes + carriage returns so the log stays grep-able.
18
+ const ANSI_RE = /\x1b\[[0-9;]*m|\x1b\][^\x07]*\x07|\r/g;
19
+ let debugMode = false;
20
+ let dirEnsured = false;
21
+ export function setDebugMode(enabled) {
22
+ debugMode = enabled;
23
+ }
24
+ export function isDebugMode() {
25
+ return debugMode;
26
+ }
27
+ export function getLogFilePath() {
28
+ return LOG_FILE;
29
+ }
30
+ function ensureDir() {
31
+ if (dirEnsured)
32
+ return;
33
+ try {
34
+ fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
35
+ dirEnsured = true;
36
+ }
37
+ catch { /* readonly mount / disk full — keep trying so a remount recovers */ }
38
+ }
39
+ function writeFile(level, msg) {
40
+ ensureDir();
41
+ try {
42
+ const clean = msg.replace(ANSI_RE, '');
43
+ fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] [${level.toUpperCase()}] ${clean}\n`);
44
+ }
45
+ catch { /* best-effort — never break the agent on log failure */ }
46
+ }
47
+ function writeStderr(msg) {
48
+ try {
49
+ process.stderr.write(msg + '\n');
50
+ }
51
+ catch { /* swallow */ }
52
+ }
53
+ export const logger = {
54
+ debug(msg) {
55
+ writeFile('debug', msg);
56
+ if (debugMode)
57
+ writeStderr(msg);
58
+ },
59
+ info(msg) {
60
+ writeFile('info', msg);
61
+ if (debugMode)
62
+ writeStderr(msg);
63
+ },
64
+ warn(msg) {
65
+ writeFile('warn', msg);
66
+ if (debugMode)
67
+ writeStderr(msg);
68
+ },
69
+ error(msg) {
70
+ writeFile('error', msg);
71
+ if (debugMode)
72
+ writeStderr(msg);
73
+ },
74
+ };
@@ -24,6 +24,12 @@ export interface AuditEntry {
24
24
  routingTier?: string;
25
25
  }
26
26
  export declare function appendAudit(entry: AuditEntry): void;
27
+ /**
28
+ * Trim the audit log to the last MAX_AUDIT_ENTRIES lines if it has grown
29
+ * past the cap. Exported so admin/debug tooling (and tests) can force a
30
+ * compaction without waiting for the next interval probe.
31
+ */
32
+ export declare function enforceRetention(): void;
27
33
  export declare function getAuditFilePath(): string;
28
34
  export declare function readAudit(): AuditEntry[];
29
35
  /** Pull the last user message from a Dialogue history, flatten, and strip newlines. */