Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 32 additions & 5 deletions src/agent/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,44 @@ import { getWalletAddress as getBaseWalletAddress } from '@blockrun/llm';
import { Keypair } from '@solana/web3.js';
import bs58 from 'bs58';
import { loadLearnings, decayLearnings, saveLearnings, formatForPrompt } from '../learnings/store.js';
import { isKeyMode } from '../payments/auth-mode.js';

// ─── System Instructions Assembly ──────────────────────────────────────────
// Composable prompt sections — each independently maintainable and conditionally includable.

function getCoreInstructions(): string {
return `You are Franklin, an autonomous AI agent with a wallet. You help users with software engineering, marketing campaigns, trading signals, and any task that benefits from an agent that can reason, act, and spend.
// Key mode has no wallet. getWalletKnowledgeSection() below already branches
// on this; these lines did not, so every key-mode turn briefed the model on
// a wallet it cannot read and a USDC balance it cannot spend — then pointed
// the Wallet tool at it, which returns a portal link instead. Keep the
// spend-without-hesitating posture either way; only the funding source
// changes.
const keyMode = isKeyMode();
const identity = keyMode
? 'an autonomous AI agent that pays its own way with prepaid BlockRun account credits'
: 'an autonomous AI agent with a wallet';
const walletTool = keyMode
? '- **Wallet** — reports that this session bills prepaid account credits and where to manage them. Use it for any "what\'s my balance / how much money / billing status" question instead of running \`franklin balance\` via Bash. Free, one call.'
: '- **Wallet** — read your own chain, address, and USDC balance. Use this for any "what\'s my balance / how much money / wallet status" question instead of running \`franklin balance\` via Bash. Free, one call, never costs USDC.';
const tradingMarket = keyMode
? '- **TradingMarket** — current stock / FX / crypto / commodity prices (BlockRun Gateway / Pyth; billed to account credits, $0.001/stock call, free for everything else).'
: '- **TradingMarket** — current stock / FX / crypto / commodity prices (BlockRun Gateway / Pyth; wallet pays automatically, $0.001/stock call, free for everything else).';
const spendPosture = keyMode
? 'Your brand is spending real money to get real answers; $0.001 for a stock quote is exactly what the account credits are for. Don\'t hesitate on cents.'
: 'Your brand is spending USDC to get real answers; $0.001 for a stock quote is exactly what the wallet is for. Don\'t hesitate on cents.';

return `You are Franklin, ${identity}. You help users with software engineering, marketing campaigns, trading signals, and any task that benefits from an agent that can reason, act, and spend.

You are an interactive agent — not a chatbot. Use the tools available to you to accomplish tasks. Your job is to be a highly capable collaborator who takes initiative, makes progress, and delivers results.

# Franklin has hands
You run with live tools by default:
- **Wallet** — read your own chain, address, and USDC balance. Use this for any "what's my balance / how much money / wallet status" question instead of running \`franklin balance\` via Bash. Free, one call, never costs USDC.
- **TradingMarket** — current stock / FX / crypto / commodity prices (BlockRun Gateway / Pyth; wallet pays automatically, $0.001/stock call, free for everything else).
${walletTool}
${tradingMarket}
- **ExaAnswer / ExaSearch / ExaReadUrls** — cited current-events answers, semantic web search, clean URL content.
- **WebSearch / WebFetch** — live web.

When a user asks for a current price, today's news, or any live-world state, **call the tool**. Refusal phrases like "I can't provide real-time data" or "check Yahoo Finance" are a bug — they belong to systems without tools. Your brand is spending USDC to get real answers; $0.001 for a stock quote is exactly what the wallet is for. Don't hesitate on cents.
When a user asks for a current price, today's news, or any live-world state, **call the tool**. Refusal phrases like "I can't provide real-time data" or "check Yahoo Finance" are a bug — they belong to systems without tools. ${spendPosture}

# System
- All text you output outside of tool use is displayed to the user. Use markdown for formatting.
Expand Down Expand Up @@ -470,7 +491,13 @@ const _instructionCache = new Map<string, string[]>();
* Result is memoized per workingDir for the process lifetime.
*/
export function assembleInstructions(workingDir: string, model?: string): string[] {
const cacheKey = model ? `${workingDir}::${model}` : workingDir;
// The pay mode is part of the key because getCoreInstructions() and
// getWalletKnowledgeSection() both branch on it. invalidateKey() can demote
// a session from key mode to wallet mode mid-process after a 401, and
// without this the model would keep being told it bills account credits
// long after Franklin went back to signing from the wallet.
const mode = isKeyMode() ? 'key' : 'wallet';
const cacheKey = model ? `${workingDir}::${model}::${mode}` : `${workingDir}::${mode}`;
const cached = _instructionCache.get(cacheKey);
if (cached) return cached;

Expand Down
51 changes: 51 additions & 0 deletions src/agent/secret-redact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,57 @@ export function redactSecrets(input: string): RedactionResult {
* Returns the names of env vars that were set, deduped, in stash order.
* Safe to call on an empty match list (no-op).
*/
/**
* Redact secrets from text Franklin did not author — tool output, not user
* input.
*
* `redactSecrets` above is the INPUT path: it scrubs what a user pastes into
* chat and hands the caller the raw values to stash on process.env. That is
* the wrong contract here. Tool output flows straight into the model prompt
* (over the network, to whichever gateway model serves the turn) and into the
* persisted session transcript on disk, so the value must not survive at all
* and there is nothing to stash.
*
* The case that forced this: `printenv` and `echo` are auto-approved by
* bash-guard as safe commands, so `printenv BLOCKRUN_API_KEY` needed no
* confirmation and returned the account bearer key as tool output. Stripping
* the variable from the Bash subprocess env (tools/bash.ts) closes that exact
* command; this closes the class — a key can still reach output via a config
* file, a `curl -v` trace, an MCP result, or a shell history dump.
*
* `extraLiterals` carries values known only at runtime, e.g. the configured
* API key when it does not match a published prefix shape. Empty and very
* short entries are ignored so a blank env var cannot blank out the text.
*/
export function redactSecretsInOutput(
input: string,
extraLiterals: readonly string[] = [],
): { text: string; labels: string[] } {
if (!input) return { text: input, labels: [] };
let text = input;
const labels: string[] = [];

for (const literal of extraLiterals) {
const value = literal?.trim();
// 8 is below any credential this guards and above the length at which a
// stray value would collide with ordinary output.
if (!value || value.length < 8) continue;
if (!text.includes(value)) continue;
text = text.split(value).join('[REDACTED:configured_credential]');
labels.push('configured_credential');
}

for (const { label, pattern } of SECRET_PATTERNS) {
pattern.lastIndex = 0;
if (!pattern.test(text)) continue;
pattern.lastIndex = 0;
text = text.replace(pattern, `[REDACTED:${label}]`);
labels.push(label);
}

return { text, labels: [...new Set(labels)] };
}

export function stashSecretsToEnv(matches: RedactionMatch[]): string[] {
const set: string[] = [];
for (const m of matches) {
Expand Down
19 changes: 19 additions & 0 deletions src/agent/streaming-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import type { HookEngine } from '../hooks/runner.js';
import type { HookInput } from '../hooks/types.js';
import { estimateSpendUsd, isSpendTool } from '../tools/spend-tools.js';
import { BLOCKRUN_DIR } from '../config.js';
import { redactSecretsInOutput } from './secret-redact.js';
import { resolvePayMode } from '../payments/auth-mode.js';
import { logger } from '../logger.js';

interface PendingTool {
Expand Down Expand Up @@ -337,6 +339,23 @@ export class StreamingExecutor {
logger.info(`[franklin] Slow tool: ${invocation.name} ${status} after ${(execElapsed / 1000).toFixed(1)}s${preview ? ` — ${preview.slice(0, 80)}` : ''}`);
}

// Scrub credentials before the output goes anywhere. This is the one
// funnel every tool result passes through, and it runs BEFORE the
// persist-to-disk branch below so a redacted body is what reaches the
// model, the transcript, and the on-disk overflow file alike.
//
// resolvePayMode() is memoised per process, so reading the configured
// key here costs nothing per tool call.
const payMode = resolvePayMode();
const redacted = redactSecretsInOutput(
result.output,
payMode.kind === 'key' ? [payMode.key] : [],
);
if (redacted.labels.length > 0) {
logger.warn(`[franklin] Redacted ${redacted.labels.join(', ')} from ${invocation.name} output`);
result = { ...result, output: redacted.text };
}

// Persist large results to disk with preview.
// Instead of just truncating, save the full result to disk so it can be re-read later.
if (result.output.length > PERSIST_THRESHOLD) {
Expand Down
25 changes: 18 additions & 7 deletions src/tools/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,15 +333,26 @@ function executeCommand(command: string, timeoutMs: number, ctx: ExecutionScope)
const shell = fs.existsSync('/bin/bash') ? '/bin/bash' : (process.env.SHELL || '/bin/sh');
let child: ReturnType<typeof spawn>;
try {
// The account bearer key is deliberately withheld from the subprocess.
// bash-guard auto-approves `printenv` and `echo` as safe, so
// `printenv BLOCKRUN_API_KEY` ran with no confirmation and returned the
// key as tool output — into the model prompt and the saved transcript.
// Wallet private keys are never in the environment at all; this gives
// the key the same treatment. Franklin's own gateway calls read
// process.env in-process and are unaffected, and a user who genuinely
// needs the key in a shell can export it there themselves.
const childEnv: NodeJS.ProcessEnv = {
...process.env,
FRANKLIN: '1', // Let scripts detect they're running inside Franklin
FRANKLIN_WORKDIR: ctx.workingDir,
RUNCODE: '1', // Backwards compat
RUNCODE_WORKDIR: ctx.workingDir,
};
delete childEnv.BLOCKRUN_API_KEY;

child = spawn(shell, ['-c', command], {
cwd: ctx.workingDir,
env: {
...process.env,
FRANKLIN: '1', // Let scripts detect they're running inside Franklin
FRANKLIN_WORKDIR: ctx.workingDir,
RUNCODE: '1', // Backwards compat
RUNCODE_WORKDIR: ctx.workingDir,
},
env: childEnv,
stdio: ['ignore', 'pipe', 'pipe'],
// Put the shell in its own process group (pgid = pid) so a timeout
// can SIGTERM the entire tree. Without this, signalling only the
Expand Down
15 changes: 10 additions & 5 deletions src/tools/modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ import {
SOLANA_NETWORK,
} from '@blockrun/llm';
import type { CapabilityHandler, CapabilityResult, ExecutionScope } from '../agent/types.js';
import { loadChain, VERSION} from '../config.js';
import { gatewayBase, gatewayHeaders } from '../payments/auth-mode.js';
import { loadChain, VERSION, DASHBOARD_URL } from '../config.js';
import { gatewayBase, gatewayHeaders, isKeyMode } from '../payments/auth-mode.js';
import { walletReservation, AMBIGUOUS_GRACE_MS, type ReservationToken } from '../wallet/reservation.js';
import { recordUsage } from '../stats/tracker.js';
import { logger } from '../logger.js';
Expand Down Expand Up @@ -278,8 +278,13 @@ function describeHeadroom(): string {
`unconfirmed (released automatically after the on-chain balance refreshes)`,
);
}
if (parts.length === 0) return 'Fund the wallet.';
return parts.join('; ') + ' — wait for those to clear or fund the wallet.';
// In key mode there is no wallet to fund — the ceiling is the prepaid
// balance, so send the user to the right place.
const topUp = isKeyMode()
? `Top up account credits at ${DASHBOARD_URL}.`
: 'Fund the wallet.';
if (parts.length === 0) return topUp;
return parts.join('; ') + ` — wait for those to clear, or ${topUp[0].toLowerCase()}${topUp.slice(1)}`;
}

function signalError(signal: AbortSignal): Error {
Expand Down Expand Up @@ -466,7 +471,7 @@ export const modalCreateCapability: CapabilityHandler = {
if (!reservation) {
return {
output:
`Insufficient USDC for ModalCreate (${tier}, ~${fmtUsd(price)}). ` +
`Insufficient ${isKeyMode() ? 'account credit' : 'USDC'} for ModalCreate (${tier}, ~${fmtUsd(price)}). ` +
describeHeadroom(),
isError: true,
};
Expand Down
15 changes: 12 additions & 3 deletions src/tools/sensitive-paths.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Wallet secret-material guard for the model-facing file tools.
* Secret-material guard for the model-facing file tools.
*
* Franklin IS the wallet: a steered model (prompt injection via a fetched page,
* MCP result, file contents, etc.) that can Read/Write/Edit arbitrary paths
Expand All @@ -12,14 +12,23 @@

import fs from 'node:fs';
import path from 'node:path';
import { BLOCKRUN_DIR } from '../config.js';
import { API_KEY_FILE, BLOCKRUN_DIR } from '../config.js';

/** Private-key / wallet-secret files under ~/.blockrun. */
/**
* Secret-material files under ~/.blockrun.
*
* Appended to, never reordered: test/local.mjs indexes this list positionally.
*/
const WALLET_KEY_FILES = [
path.join(BLOCKRUN_DIR, '.session'), // EVM private key (0x hex)
path.join(BLOCKRUN_DIR, '.solana-session'), // Solana secret key (base58)
path.join(BLOCKRUN_DIR, '.solana-session-key2'),
path.join(BLOCKRUN_DIR, 'solana-wallet.json'), // legacy { address, private_key }
// Account bearer key (brk_...). Not a private key, but it spends: it draws
// on a prepaid balance with no per-call signature and no chain-level
// ceiling, so `cat ~/.blockrun/api-key` is exfiltration in one Read. It
// belongs behind the same guard as the wallet keys above.
API_KEY_FILE,
];

// macOS (APFS) and Windows are case-INSENSITIVE by default, and fs.realpathSync
Expand Down
22 changes: 19 additions & 3 deletions src/wallet/reservation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

import { setupAgentWallet, setupAgentSolanaWallet } from '@blockrun/llm';
import { loadChain } from '../config.js';
import { fetchCreditBalance, isKeyMode } from '../payments/auth-mode.js';

export interface ReservationToken {
id: string;
Expand All @@ -47,7 +48,22 @@ const BALANCE_CACHE_MS = 5_000;
*/
export const AMBIGUOUS_GRACE_MS = 30_000;

async function readChainBalance(): Promise<number> {
async function readSpendableBalance(): Promise<number> {
// Key mode has no wallet to read. Gating on one made every Modal call fail
// with "Insufficient USDC — fund the wallet" for a user whose account
// credits were fine, because an unfunded (or absent) wallet reads $0.
//
// The right ceiling in key mode is the prepaid balance, so use it when the
// gateway reports one. `remainingUsd: null` means an ungated account with
// no ceiling, and a null balance means the gateway was unreachable — in
// both cases return Infinity rather than invent a limit. That is not
// "unbounded": the gateway still 402s when credits run out, and --max-spend
// still bounds the session. This layer only prevents Franklin from
// over-committing against a balance it can actually see.
if (isKeyMode()) {
const credit = await fetchCreditBalance().catch(() => null);
return credit?.remainingUsd ?? Number.POSITIVE_INFINITY;
}
if (loadChain() === 'solana') {
const client = await setupAgentSolanaWallet({ silent: true });
return client.getBalance();
Expand All @@ -61,7 +77,7 @@ class WalletReservationManager {
private ambiguous = new Map<string, { amountUsd: number; expiresAt: number }>();
private cachedBalance: { value: number; fetchedAt: number } | null = null;
private balanceFetchInflight: Promise<number> | null = null;
private balanceFetcher: () => Promise<number> = readChainBalance;
private balanceFetcher: () => Promise<number> = readSpendableBalance;

private async fetchBalance(): Promise<number> {
if (this.cachedBalance && Date.now() - this.cachedBalance.fetchedAt < BALANCE_CACHE_MS) {
Expand Down Expand Up @@ -181,7 +197,7 @@ class WalletReservationManager {
this.ambiguous.clear();
this.cachedBalance = null;
this.balanceFetchInflight = null;
this.balanceFetcher = fetcher ?? readChainBalance;
this.balanceFetcher = fetcher ?? readSpendableBalance;
}

/** Testing only — seed the balance cache so hold() never touches RPC. */
Expand Down
Loading
Loading