@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 +2 -0
- package/assets/franklin-vscode-banner.png +0 -0
- package/dist/agent/loop.js +33 -34
- package/dist/agent/secret-redact.d.ts +72 -0
- package/dist/agent/secret-redact.js +240 -0
- package/dist/agent/tool-guard.js +16 -2
- package/dist/logger.d.ts +10 -0
- package/dist/logger.js +74 -0
- package/dist/stats/audit.d.ts +6 -0
- package/dist/stats/audit.js +40 -0
- package/dist/stats/insights.d.ts +19 -0
- package/dist/stats/insights.js +23 -0
- package/dist/tools/index.js +6 -0
- package/dist/tools/modal.d.ts +66 -0
- package/dist/tools/modal.js +639 -0
- package/dist/wallet/reservation.d.ts +51 -0
- package/dist/wallet/reservation.js +105 -0
- package/package.json +1 -1
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
|
+
[](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
|
```
|
|
Binary file
|
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';
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1018
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/agent/tool-guard.js
CHANGED
|
@@ -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.',
|
package/dist/logger.d.ts
ADDED
|
@@ -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
|
+
};
|
package/dist/stats/audit.d.ts
CHANGED
|
@@ -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. */
|