@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.
- package/dist/agent/loop.js +17 -0
- package/dist/agent/secret-redact.d.ts +72 -0
- package/dist/agent/secret-redact.js +240 -0
- package/package.json +1 -1
package/dist/agent/loop.js
CHANGED
|
@@ -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