@blockrun/franklin 3.15.9 → 3.15.10

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.
@@ -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';
@@ -476,6 +477,22 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
476
477
  input = cmdResult.rewritten;
477
478
  }
478
479
  }
480
+ // ── Secret redaction at the input boundary ──
481
+ // Catch GitHub PATs / API keys / private keys before they enter
482
+ // history, get persisted, or hit the model. Detected values are
483
+ // stashed on process.env (predictable name like GITHUB_TOKEN) so
484
+ // subsequent Bash tool calls can still use them via `$GITHUB_TOKEN`
485
+ // — the user keeps the convenience of "remember this credential"
486
+ // without the chat-history exposure that just happened.
487
+ const { redactedText, matches: secretMatches } = redactSecrets(input);
488
+ if (secretMatches.length > 0) {
489
+ const envVarsSet = stashSecretsToEnv(secretMatches);
490
+ onEvent({
491
+ kind: 'text_delta',
492
+ text: formatRedactionWarning(secretMatches, envVarsSet),
493
+ });
494
+ input = redactedText;
495
+ }
479
496
  lastUserInput = input;
480
497
  // Push the user's clean message; any harness-injected annotations
481
498
  // (pushback SYSTEM NOTE, prefetch context block) are applied AFTER
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.15.9",
3
+ "version": "3.15.10",
4
4
  "description": "Franklin — The AI agent with a wallet. Spends USDC autonomously to get real work done. Pay per action, no subscriptions.",
5
5
  "type": "module",
6
6
  "exports": {