@blockrun/franklin 3.15.8 → 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/error-classifier.js +22 -0
- package/dist/agent/evaluator.js +30 -3
- package/dist/agent/loop.js +64 -2
- package/dist/agent/secret-redact.d.ts +72 -0
- package/dist/agent/secret-redact.js +240 -0
- package/package.json +1 -1
|
@@ -111,6 +111,28 @@ export function classifyAgentError(message) {
|
|
|
111
111
|
suggestion: 'The model is overloaded. Try /model to switch, or wait and /retry.',
|
|
112
112
|
};
|
|
113
113
|
}
|
|
114
|
+
// Reasoning / thinking-mode format errors — NOT transient.
|
|
115
|
+
// DeepSeek V4 family and similar thinking-enabled models reject requests
|
|
116
|
+
// when the message history's reasoning_content fields don't match the
|
|
117
|
+
// upstream's expected shape (typically: tool-call assistant messages must
|
|
118
|
+
// carry reasoning_content; non-tool-call ones must not, or vice versa).
|
|
119
|
+
// The fix is to drop the polluting history, not to swap models — every
|
|
120
|
+
// thinking-enabled model has the same constraint just with different
|
|
121
|
+
// specifics. /clear forces a fresh context that won't have the bad shape.
|
|
122
|
+
// Classified BEFORE the generic schema branch below so we surface the
|
|
123
|
+
// right suggestion.
|
|
124
|
+
if (includesAny(err, [
|
|
125
|
+
'reasoning_content',
|
|
126
|
+
'reasoning content',
|
|
127
|
+
'thinking mode must',
|
|
128
|
+
'message format incompatible',
|
|
129
|
+
'reasoning_format_error',
|
|
130
|
+
])) {
|
|
131
|
+
return {
|
|
132
|
+
category: 'schema', label: 'Schema', isTransient: false, maxRetries: 0,
|
|
133
|
+
suggestion: 'Thinking-mode history is incompatible with this model. Use /clear to reset and retry, or /model to switch to a non-thinking model.',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
114
136
|
// Schema / tool-definition errors — NOT transient, retrying won't help.
|
|
115
137
|
// These can be wrapped in 5xx responses (e.g. '503: 400 Invalid schema'),
|
|
116
138
|
// so classify them BEFORE the generic server-error branch below.
|
package/dist/agent/evaluator.js
CHANGED
|
@@ -65,9 +65,36 @@ Flag as tool-use refusal:
|
|
|
65
65
|
|
|
66
66
|
VERDICT: GROUNDED | PARTIAL | UNGROUNDED
|
|
67
67
|
|
|
68
|
-
If not GROUNDED, list each issue on its own line starting with "- " and the tool that should have been called
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
If not GROUNDED, list each issue on its own line starting with "- " and the tool that should have been called.
|
|
69
|
+
|
|
70
|
+
## Picking the right tool — strict domain rules
|
|
71
|
+
|
|
72
|
+
**Default for any factual claim:** WebSearch or ExaSearch. These are the
|
|
73
|
+
right answer for the OVERWHELMING majority of "the model said a number it
|
|
74
|
+
didn't look up" cases — current events, statistics, prices for non-crypto
|
|
75
|
+
goods (real estate, retail, salaries), people, companies, news, etc.
|
|
76
|
+
|
|
77
|
+
**Use specialized tools ONLY when the claim's domain matches:**
|
|
78
|
+
- TradingMarket / TradingSignal — ONLY for cryptocurrency tickers (BTC, ETH, SOL, etc). Never for stocks, real estate, currencies, commodities outside crypto.
|
|
79
|
+
- DefiLlamaProtocol / DefiLlamaYields / DefiLlamaPrice — ONLY for DeFi protocols, TVL, yields, on-chain token prices.
|
|
80
|
+
- SearchX — ONLY for X.com / Twitter posts and accounts.
|
|
81
|
+
- ExaAnswer — research questions where you want a synthesized answer with citations.
|
|
82
|
+
- WebFetch — claims that quote a SPECIFIC URL the model already named.
|
|
83
|
+
|
|
84
|
+
**Anti-patterns to never produce:**
|
|
85
|
+
- Real-estate price → TradingMarket (TradingMarket is crypto-only — wrong domain)
|
|
86
|
+
- Stock ticker → TradingMarket (also crypto-only — use WebSearch instead)
|
|
87
|
+
- Generic news / statistics → TradingMarket (use WebSearch)
|
|
88
|
+
- Person's biography → TradingMarket (use WebSearch)
|
|
89
|
+
|
|
90
|
+
When unsure: name **WebSearch**. It's the safe default for factual grounding.
|
|
91
|
+
|
|
92
|
+
## Format examples
|
|
93
|
+
|
|
94
|
+
- Claim: "<the ungrounded part, quoted briefly>" → missing tool: WebSearch
|
|
95
|
+
- Claim: "BTC at $67k" → missing tool: TradingMarket
|
|
96
|
+
- Claim: "Westlake $/sqft is $719" → missing tool: WebSearch
|
|
97
|
+
- Refusal: "<the refusal phrase, quoted briefly>" → should have called: WebSearch
|
|
71
98
|
|
|
72
99
|
Empty line between verdict and list. No other text. No preamble. No apology. Be terse.`;
|
|
73
100
|
// ─── Trigger policy ──────────────────────────────────────────────────────
|
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';
|
|
@@ -241,6 +242,39 @@ export function looksLikeGatewayErrorAsText(parts) {
|
|
|
241
242
|
return { match: false, message: '' };
|
|
242
243
|
return { match: true, message: m[1].trim() };
|
|
243
244
|
}
|
|
245
|
+
/**
|
|
246
|
+
* Domain check for the grounding-retry force-tool path. A specialized tool
|
|
247
|
+
* (TradingMarket, DefiLlama*, jupiter*, base0x*, SearchX) should only be
|
|
248
|
+
* pinned by tool_choice when the user prompt actually references that
|
|
249
|
+
* tool's domain — otherwise we let the smart generator pick from any tool.
|
|
250
|
+
*
|
|
251
|
+
* The motivating bug: a real-estate question ("可以还价 20% 吗") had its
|
|
252
|
+
* answer flagged as ungrounded for citing $/sqft figures. The cheap
|
|
253
|
+
* evaluator model picked TradingMarket as the missing tool because it
|
|
254
|
+
* was the first example in the evaluator prompt. Forcing TradingMarket
|
|
255
|
+
* (a crypto-only tool) on a housing question made the retry useless.
|
|
256
|
+
*
|
|
257
|
+
* This function returns false for specialized tools when the prompt has
|
|
258
|
+
* no matching domain keywords; the caller falls back to "any" tool.
|
|
259
|
+
* General-purpose tools (WebSearch, ExaSearch, ExaAnswer, WebFetch,
|
|
260
|
+
* ExaReadUrls) always pass — they're domain-agnostic.
|
|
261
|
+
*/
|
|
262
|
+
function isToolRelevantToPrompt(toolName, promptLower) {
|
|
263
|
+
// Crypto trading tools — need a ticker, "crypto", "coin", "swap", etc.
|
|
264
|
+
if (/^(Trading|DefiLlama|Jupiter|Base0x|Base0xGasless)/i.test(toolName)) {
|
|
265
|
+
return /\b(btc|eth|sol|xrp|doge|usdc|usdt|crypto|coin|token|defi|tvl|yield|swap|jupiter|uniswap|pump\.fun|solana|base chain|polygon|ethereum|币|代币|链上|做空|做多)\b/i.test(promptLower);
|
|
266
|
+
}
|
|
267
|
+
// X.com search — need an @handle, "twitter", "tweet", "X.com"
|
|
268
|
+
if (/^SearchX$/i.test(toolName) || /^PostToX$/i.test(toolName)) {
|
|
269
|
+
return /(@\w+|twitter|x\.com|tweet|推特)/i.test(promptLower);
|
|
270
|
+
}
|
|
271
|
+
// Image / video / music gen — need a creative-content request
|
|
272
|
+
if (/^(ImageGen|VideoGen|MusicGen)$/i.test(toolName)) {
|
|
273
|
+
return /\b(image|picture|photo|video|clip|music|song|generate|create|render|draw|画|图|视频|音乐|歌)\b/i.test(promptLower);
|
|
274
|
+
}
|
|
275
|
+
// General-purpose / file / shell tools — always relevant.
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
244
278
|
/**
|
|
245
279
|
* Calculate backoff delay with jitter to avoid thundering herd.
|
|
246
280
|
* Base: exponential (2^attempt * 1000ms), jitter: ±25%.
|
|
@@ -443,6 +477,22 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
443
477
|
input = cmdResult.rewritten;
|
|
444
478
|
}
|
|
445
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
|
+
}
|
|
446
496
|
lastUserInput = input;
|
|
447
497
|
// Push the user's clean message; any harness-injected annotations
|
|
448
498
|
// (pushback SYSTEM NOTE, prefetch context block) are applied AFTER
|
|
@@ -1349,11 +1399,23 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
1349
1399
|
// Hard enforcement: set tool_choice so the model can't fabricate
|
|
1350
1400
|
// citations in lieu of running tools (the round-2 failure mode
|
|
1351
1401
|
// from the Tampa→Miami log). If the evaluator named exactly one
|
|
1352
|
-
// available tool
|
|
1402
|
+
// available tool AND that tool's domain matches the user's
|
|
1403
|
+
// prompt, pin to it; otherwise force "any" tool use and let
|
|
1404
|
+
// the generator pick the right one.
|
|
1405
|
+
//
|
|
1406
|
+
// Domain validation guards against the cheap evaluator model
|
|
1407
|
+
// hallucinating a wrong specialized tool (e.g., suggesting
|
|
1408
|
+
// TradingMarket for a real-estate question because the prompt
|
|
1409
|
+
// listed it as the first example tool). Specialized tools —
|
|
1410
|
+
// crypto trading, DeFi, swap quotes, X.com search — only get
|
|
1411
|
+
// pinned when their domain keywords appear in the user prompt;
|
|
1412
|
+
// otherwise we drop down to "any tool" and let the smart
|
|
1413
|
+
// generator model decide based on tool descriptions.
|
|
1353
1414
|
const namedTools = extractMissingToolNames(gResult);
|
|
1354
1415
|
const availableNames = new Set(buildCallToolDefs().map(t => t.name));
|
|
1355
1416
|
const matched = namedTools.filter(n => availableNames.has(n));
|
|
1356
|
-
|
|
1417
|
+
const promptForDomainCheck = (lastUserInput || '').toLowerCase();
|
|
1418
|
+
if (matched.length === 1 && isToolRelevantToPrompt(matched[0], promptForDomainCheck)) {
|
|
1357
1419
|
forceToolChoiceNextRound = { type: 'tool', name: matched[0] };
|
|
1358
1420
|
}
|
|
1359
1421
|
else if (availableNames.size > 0) {
|
|
@@ -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