Neural trading via npx neural-trader — self-learning strategies, Rust/NAPI backtesting, 112+ MCP tools, swarm coordination, and portfolio optimization. As a…

Neural trading strategies powered by neural-trader (v2.7+) — self-learning LSTM/Transformer/N-BEATS models, Rust/NAPI backtesting (8-19x faster), 112+ MCP tools, swarm coordination, and portfolio optimization.
Wraps the neural-trader npm package as a Ruflo plugin with 4 specialized agents, 9 skills, and comprehensive CLI commands. Adds AgentDB memory persistence, SONA trajectory learning, and swarm-coordinated execution on top of neural-trader's Rust/NAPI engine.
Heavy jobs — multi-year walk-forward backtests, large Monte-Carlo runs, parameter sweeps, LSTM/Transformer/N-BEATS training — can be dispatched to an Anthropic Managed Agent cloud container instead of running locally: see the trader-cloud-backtest skill, the /trader cloud <backtest|train|sweep> command, and ADR-117 (built on the managed_agent_* runtime from the ruflo-agent plugin / ADR-115; needs ANTHROPIC_API_KEY + Managed Agents beta — degrades to the local trader-backtest skill without it).
✅
neural-trader@^2.8.11(shipped 2026-05-14; see post-mortem gist and announcement #1981). Resolves four compounding bugs that made the package unusable since v2.5.0:
- Install-hook fork-bomb (#1974 — 120 GB RAM on Apple Silicon) — fixed in 2.7.2 (neural-trader#109).
require('neural-trader')always threwCannot find module './src/cli/lib/napi-loader-shared'— missing files restored in 2.7.5 (neural-trader#111).cargo buildaborted onaarch64-apple-darwin(fasthash-sysx86-only SIMD) — placeholder hash replaced with stdlibDefaultHasherin 2.7.5.- npm tarball claimed 5 platform binaries, shipped 1 —
darwin-arm64+darwin-x64added in 2.7.6.The 2.8.x line additionally adds a flag-style CLI dispatcher (
--backtest,--signal scan,--risk assess,--portfolio optimize,--regime,--train,--predict,--strategy-create) running real math (SMA crossover / RSI mean-reversion / pairs z-score / adaptive regime-switching / multi-indicator RSI+MACD+Bollinger) against real Yahoo Finance data via--live. Supports--walk-forward,--monte-carlo,--symbols A,B,C,--benchmark SPY,--optimize --param "name:min:max:step". The runtime smoke (scripts/runtime-smoke.sh) covers 20 documented entry points end-to-end (basic commands + Kelly + walk-forward + Monte Carlo + optimize + multi-symbol + pairs + adaptive + multi-indicator).
# Recommended — keeps --ignore-scripts as defense in depth. The install
# hook is safe in 2.7.2+, but --ignore-scripts protects against any
# future install-script regression on this or transitive packages.
npm install --ignore-scripts neural-trader@^2.8.11
| Platform | Native binding in 2.8.11 |
|---|---|
linux-x64-gnu | ✅ |
darwin-arm64 (Apple Silicon) | ✅ first shipped in 2.7.5 |
darwin-x64 (Intel Macs) | ✅ first shipped in 2.7.6 |
linux-arm64-gnu | ⏳ JS surface works, native calls throw at runtime |
win32-x64-msvc | ⏳ JS surface works, native calls throw at runtime |
🚨 If you must use an older version (
neural-trader@2.7.1or below — same fork-bomb risk on every non-linux-x64 host) always pass--ignore-scriptsto skip the malicious install hook. Thelinux-x64binary is hardcoded inline in the tarball, so onlinux-x64the package still works after--ignore-scripts. On other platforms--ignore-scriptsat least won't fork-bomb your machine.# Only needed for neural-trader <= 2.7.1 — DO NOT run plain `npm install # neural-trader@<=2.7.1` on macOS / Windows / linux-arm64. npm install --ignore-scripts neural-trader@<=2.7.1
The audit-neural-trader-safety.mjs CI guard in this repo still enforces --ignore-scripts on every documented invocation — defense in depth in case anyone pins to an older version in a lockfile.
claude --plugin-dir plugins/ruflo-neural-trader
neural-trader exposes 112+ MCP tools for direct Claude Desktop access:
claude mcp add neural-trader -- npx neural-trader mcp start
| Agent | Model | Role |
|---|---|---|
trading-strategist | opus | Strategy design, LSTM/Transformer training, Z-score anomaly detection, backtest orchestration |
risk-analyst | sonnet | VaR/CVaR assessment, Kelly criterion sizing, circuit breakers, correlation monitoring |
market-analyst | sonnet | Regime detection, technical indicators (RSI/MACD/Bollinger), sector analysis, correlation |
backtest-engineer | sonnet | Walk-forward validation, Monte Carlo simulation, parameter optimization, benchmark comparison |
| Skill | Usage | Description |
|---|---|---|
trader-backtest | /trader-backtest <strategy> --symbol SPY | Rust/NAPI backtest with walk-forward validation |
trader-signal | /trader-signal [--strategy NAME] | Z-score anomaly detection signal generation |
trader-portfolio | /trader-portfolio [--risk-target 0.15] | Mean-variance portfolio optimization |
trader-regime | /trader-regime [--symbol SPY] | Market regime detection and classification |
trader-train | /trader-train lstm --symbol TSLA | Train neural prediction models |
trader-risk | /trader-risk [--symbol AAPL] | VaR, position sizing, circuit breaker status |
trader-cloud-backtest | /trader cloud backtest <strategy> --symbol SPY | Dispatch a heavy backtest / training / sweep to an Anthropic Managed Agent cloud container (ADR-117) |
trader-explain | /trader-explain --signal <id> | Regulator-grade feature attribution for LSTM/Transformer signals via single-entry PageRank (ADR-126 Phase 6 / ADR-123) |
trader-portfolio-cg | /trader-portfolio-cg [--risk-target 0.15] | Mean-variance portfolio optimization via Conjugate Gradient — 40-60× faster than the legacy Neumann path (ADR-126 Phase 3 / ADR-123 Wedge 8) |
trader-portfolio-cg | /trader-portfolio-cg [--portfolio-id ID] | Conjugate-Gradient mean-variance solve via mcp__ruflo-sublinear__solve — 40-60× faster than the legacy Neumann path (ADR-126 Phase 3, ADR-123 Wedge 8) |
trader-explain | /trader-explain <signalId> [--top-k 10] [--seed 42] | Regulator-grade feature attribution via single-entry PageRank — ranks the top-K features that drove an LSTM/Transformer signal; reproducible across runs (ADR-126 Phase 6) |
# Strategy management
trader strategy create <name> --type <momentum|mean-reversion|pairs|adaptive>
trader backtest <strategy> --symbol <TICKER> --period <range>
# Neural model training
trader train <lstm|transformer|nbeats> --symbol <TICKER>
# Signal generation
trader signal scan [--strategy <name>] [--symbols <TICKERS>]
# Market analysis
trader regime --symbol <TICKER>
trader indicators --symbol <TICKER> --indicators rsi,macd,bollinger
trader correlation --symbols <TICKERS> --window 30d
# Risk & portfolio
trader risk assess [--symbol <TICKER>]
trader portfolio optimize [--risk-target <number>]
# Live trading
trader live --broker <name> [--swarm enabled]
# History
trader history
| Model | Type | Use Case |
|---|---|---|
| LSTM | Recurrent | Sequence prediction, price forecasting |
| Transformer | Attention | Multi-variate pattern recognition |
| N-BEATS | Decomposition | Trend/seasonality decomposition |
npx neural-trader --model lstm --symbol TSLA --confidence 0.95
npx neural-trader --model transformer --symbol BTC-USD --predict
npx neural-trader --model nbeats --symbol SPY --decompose
| Strategy | CLI Flag | Entry Logic |
|---|---|---|
| Momentum | --strategy momentum | RSI + MACD confirmation |
| Mean-reversion | --strategy mean-reversion | Z-score > 2.0, Bollinger extremes |
| Pairs trading | --strategy pairs | Cointegration spread divergence |
| Multi-indicator | --strategy multi-indicator | RSI + MACD + Bollinger combined |
| Adaptive | --strategy adaptive | Auto-switches by regime |
| Regime | Indicators | Recommended Strategy |
|---|---|---|
| Bull trending | ADX > 25, price > 200 SMA | Momentum, trend-following |
| Bear trending | ADX > 25, price < 200 SMA | Short momentum, hedging |
| Ranging | ADX < 20, Bollinger squeeze | Mean-reversion |
| High volatility | VIX > 25, ATR expanding | Reduce size, widen stops |
| Transitioning | Divergences forming | Wait for confirmation |
neural-trader Z-score composite scoring on OHLCV: anomalyScore = min(1, meanZ / 3)
| Type | Detection | Market Interpretation |
|---|---|---|
| spike | maxZ > 5 | Breakout / gap |
| drift | 1-2 dims sustained | Sustained trend |
| flatline | all near zero | Consolidation |
| oscillation | alternating | Range-bound |
| pattern-break | moderate Z, multi-dim | Regime change |
| cluster-outlier | >50% dims high | Multi-factor dislocation |
| Breaker | Trigger | Action |
|---|---|---|
| Daily loss | Drawdown > 3%/day | Halt new entries |
| Weekly loss | Drawdown > 5%/week | Reduce sizes 50% |
| Correlation spike | Portfolio corr > 0.85 | Reduce correlated positions |
| Volatility regime | VIX > 2x historical | Minimum position sizes |
| Max positions | Open > limit | Block new entries |
| Concentration | Any position > 10% | Force trim |
| Feature | Command |
|---|---|
| Walk-forward | --walk-forward --train-window 6M --test-window 1M |
| Monte Carlo | --monte-carlo --simulations 1000 |
| Parameter optimization | --optimize --param "entry_z:1.5:3.0:0.25" |
| Multi-symbol | --symbols "AAPL,MSFT,GOOGL" |
| Benchmark comparison | --benchmark SPY |
neural-trader uses Rust/NAPI bindings for zero-overhead performance:
@claude-flow/cli v3.6 major+minor.npx neural-trader (Rust/NAPI bindings — 112+ MCP tools).bash plugins/ruflo-neural-trader/scripts/smoke.sh is the contract.This plugin owns five AgentDB namespaces (kebab-case, follows the convention from ruflo-agentdb ADR-0001 §"Namespace convention"). The canonical five-namespace set is defined by ADR-126 Phase 1:
| Namespace | Purpose |
|---|---|
trading-strategies | Strategy definitions, parameters, regime-condition mappings (loaded by trader-backtest, trader-signal) |
trading-backtests | Historical backtest results indexed by strategy + timestamp (long-lived; signed in ADR-126 Phase 4) |
trading-risk | Risk model state, VaR/CVaR snapshots, circuit-breaker triggers |
trading-analysis | Market-analyst output — regime classifications, technical-indicator summaries, model-training results |
trading-signals | Short-lived signal events (intraday; TTL applied in ADR-126 Phase 2) |
Note: the namespace prefix is trading- (the actual intent) rather than neural-trader- (the plugin stem). This is a deliberate ergonomic choice — trading is the load-bearing concern downstream consumers reason about. Reserved namespaces (pattern, claude-memories, default) MUST NOT be shadowed.
All access via memory_* (namespace-routed). No agentdb_hierarchical-* or agentdb_pattern-store with namespace arguments — the plugin uses the correct routing throughout.
This plugin relies on @claude-flow/memory@3.0.0-alpha.18 for the lifecycle guarantees defined in ADR-125 and wired by ADR-126 Phase 2:
@claude-flow/memory@3.0.0-alpha.18 (ADR-125 Phase 3) snapshots the HNSW index to a .hnsw sidecar file, so neural-trader process restarts no longer rebuild the strategy / regime similarity index from scratch. No plugin-side change is required to benefit; routing is automatic through MemoryService.search().market-analyst regime-similarity queries automatically become hybrid (dense ANN + sparse FTS5 keyword, reciprocal-rank-fused and MMR-diversified) via the same MemoryService.search() path (ADR-125 Phase 5). When the embedding generator is unavailable, retrieval gracefully degrades to keyword-only rather than throwing.trader-signal writes to trading-signals with expiresAt: now + 24h. The MemoryConsolidator.sweepExpired() pass (ADR-125 Phase 4) removes them from all indexes — including HNSW — when they expire. Long-running ruflo sessions no longer accumulate stale intraday signals.trader-backtest proactively deletes prior entries for the same (strategyId, paramsHash) before storing a fresh one. The same outcome is also produced asynchronously by the MemoryConsolidator.dedup('keep-newest') background pass that runs every 6 hours.sweepExpired + dedup + compactHnsw), and also on MemoryService.close(). No plugin-side wiring is required.The new trader-portfolio-cg skill solves the mean-variance problem Σ · x = μ via Conjugate Gradient instead of the legacy Neumann series. CG is provably optimal for symmetric positive-definite inputs (covariance matrices are SPD by construction), and the upstream sublinear-time-solver@1.7.0 benchmark shows ~816 ns CG vs ~50 µs Neumann at n=256 — a measured 40-60× speedup (ADR-123 §162 Row 8).
When it's used: any time the team wants optimal portfolio weights — call /trader-portfolio-cg instead of /trader-portfolio. The skill reads the current covariance and expected-return vector from npx neural-trader --portfolio current --json, dispatches to mcp__ruflo-sublinear__solve (when the ruflo-sublinear plugin is registered), and writes weights with provenance metadata (method: 'cg-sublinear' | 'cg-local' | 'neumann-fallback') to the trading-risk namespace.
How to disable: set RUFLO_NEURAL_TRADER_DISABLE_CG=1 to skip the CG path entirely and fall back to the legacy npx neural-trader --portfolio optimize route. Useful for A/B validation or when an upstream covariance regression breaks SPD.
Parity guarantee: ||cg_solution − neumann_solution||_∞ < 1e-4 on every benchmark seed — verified by benchmarks/portfolio-cg.bench.mjs and asserted by scripts/smoke-neural-trader-portfolio-cg.mjs.
Local fallback: the adapter (src/sublinear-adapter.ts + .mjs mirror) ships a self-contained ~50-LOC CG kernel so the skill works even before the ruflo-sublinear plugin lands on the IPFS registry. The same call site picks up the full native-WASM speedup automatically once mcp__ruflo-sublinear__solve is registered in the runtime.
# Run the bench yourself:
node plugins/ruflo-neural-trader/benchmarks/portfolio-cg.bench.mjs
# Run the contract smoke:
node scripts/smoke-neural-trader-portfolio-cg.mjs
The new trader-explain skill closes the regulator-grade interpretability gap that LSTM / Transformer trading signals leave by default. Given a signalId, it builds a feature-contribution graph (nodes = features, edges = co-attention weights, source = signal output) and runs single-entry forward-push PageRank to produce a top-K ranked list of the features that most influenced the model's prediction.
When to use it: any time a trading signal needs an audit trail — pre-trade risk review, regulator filings under the EU AI Act (Article 13 — transparency for high-risk AI) or SEC Reg-AI interpretability guidance, post-mortem of a stop-loss event, or input to the risk-analyst before a paper→live promotion. Call /trader-explain <signalId> (or pass --top-k 10 --seed 42 to control ranking depth + reproducibility).
Output: a SignedAttributionArtifact written to the canonical trading-analysis namespace, plus a markdown summary surfaced to the agent. The artifact is Ed25519-signed using the same scheme as Phase 4 backtest artifacts — the verifier pins to a trusted public key (CWE-347 / #1922), so downstream consumers can refuse any tampered or unsigned artifact.
// Schema — plugins/ruflo-neural-trader/src/signed-attribution.ts
interface SignedAttributionArtifact {
schema: 'ruflo-neural-trader-attribution/v1';
signalId: string;
modelId: string; // e.g. 'lstm-v3', 'transformer-attn8h-v2'
features: Array<{
name: string; // e.g. 'rsi_14', 'attention_head_3', 'price_close_t-7'
score: number; // PageRank score in [0, 1]
rank: number; // 1-indexed
}>;
graphMetadata: {
nodeCount: number;
edgeCount: number;
pageRankIterations: number;
seed: number; // load-bearing — same seed → same ordering
};
generatedAt: string;
witnessPublicKey: string;
witnessSignature: string;
}
Example markdown surfaced to the agent:
## Feature attribution for signal `sig-momentum-spy-20260519-001` (model: transformer-attn8h-v2)
| Rank | Feature | Score |
|------|----------------------|-------|
| 1 | rsi_14 | 0.42 |
| 2 | attention_head_3 | 0.21 |
| 3 | price_close_t-7 | 0.13 |
| 4 | macd_signal | 0.09 |
- PageRank iterations: 18
- Graph: 17 nodes, 42 edges
- Seed: 42 (reproducible — same seed → same ordering)
- Path: local | mcp
- Signature: ed25519:abcd…
Reproducibility guarantee: two runs with the same signalId + same --seed produce byte-identical rank ordering. Asserted by scripts/smoke-neural-trader-feature-attribution.mjs (the smoke runs the local seeded PageRank twice and compares scores element-wise).
Local fallback: when mcp__ruflo-sublinear__page-rank-entry is not registered in the runtime, the skill falls through to a ~30-LOC seeded power-iteration kernel that ships in src/signed-attribution.mjs. Same math, same ordering for the same seed. The native-WASM path picks up automatically once ruflo-sublinear lands on the IPFS registry.
--explain fallback: if the installed neural-trader build doesn't yet expose --predict --explain --json, the skill degrades to a z-score-magnitude heuristic over the signal's input vector and tags the artifact attribution_method: "input-zscore-fallback" so downstream consumers can filter it out for regulator-facing reports.
# Run the smoke yourself:
node scripts/smoke-neural-trader-feature-attribution.mjs
bash plugins/ruflo-neural-trader/scripts/smoke.sh
# Expected: "11 passed, 0 failed"
ADR-0001 — ruflo-neural-trader plugin contract (already-compliant namespaces, 4-namespace claim, smoke as contract)ADR-117 — run heavy jobs (walk-forward / Monte-Carlo / sweep / training) on the Managed Agent cloud runtime (the trader-cloud-backtest skill + /trader cloud command), with cost-optimization rulesADR-115 — the managed_agent_* cloud runtime that ADR-117 builds on (lives in the ruflo-agent plugin)ruflo-agent — the managed_agent_* cloud agent runtime used by the trader-cloud-backtest skill (ADR-115 / ADR-117)ruflo-agentdb — namespace convention owner; backing storeruflo-market-data — OHLCV data ingestion and candlestick pattern detection (feeds trading-strategies)ruflo-ruvector — HNSW indexing for strategy pattern similarity searchruflo-cost-tracker — PnL tracking and cost attributionruflo-observability — Strategy performance dashboardsMIT
Function-hook mod (ADR-445 pattern, hooks/register.ts). It adds, with no network, no process spawning and no model call:
execute_trade, place_order, live_*, close_all) that lacks confirm: true (or paper: true / dryRun: true). The deny reason names the rule, never the value./trader-mod: local status and scan <text> (secret check, same rules as the guard); status also shows live orders refused..claude-flow/trader-mod/status.json ({version:1, updatedMs, guard, checked, blocked, ...}), written at session start and when counters change.Per-prompt context is deliberately not added: this plugin has nothing worth attaching to every prompt.
| Option | Default | Effect | ||||
|---|---|---|---|---|---|---|
guard | on | refuse the calls above | liveGuard | on | refuse an unconfirmed live order |
Test: claude plugin validate plugins/ruflo-neural-trader, claude plugin test plugins/ruflo-neural-trader, bash plugins/ruflo-neural-trader/scripts/smoke.sh.
hooks/register.ts 61 lines1import type { Hook, Register } from 'claude-code'
2
3import { answer } from './command'
4import { verdict } from './guard'
5import { readOptions, type ModOptions } from './options'
6import { newStats, STATUS_PATH, statusText, type Stats } from './status'
7
8type Dollar = Parameters<Hook<'session.start'>>[0]
9
10/** Everything one session of the mod keeps: its settings, counters and the project root. */
11type Session = { readonly opts: ModOptions; readonly stats: Stats; root?: string }
12
13async function flush($: Dollar, s: Session): Promise<void> {
14 if (s.root === undefined) return
15 try {
16 await $.fs.write(`${s.root}/${STATUS_PATH}`, statusText(s.stats, { guard: s.opts.guard, liveGuard: s.opts.liveGuard }, await $.clock.now()))
17 } catch {
18 /* the status file is a courtesy */
19 }
20}
21
22/**
23 * ruflo-neural-trader as a mod (ADR-445 pattern): a tighten-only guard on this plugin's own tools, `/trader-mod`, and a status file the console
24 * reads. No network, no process spawning, no model call: only tools already connected.
25 */
26export const register: Register = (on, options) => {
27 const s: Session = { opts: readOptions(options), stats: newStats() }
28
29 on('session.start', async ($, e, next) => {
30 const result = await next(e)
31 s.root = (await $.session.root()) as string | undefined
32 try {
33 await $.command.register({ name: 'trader-mod', description: 'ruflo-neural-trader mod: status, scan <text>' })
34 } catch {
35 /* a name taken by another plugin must not stop the mod */
36 }
37 await flush($, s)
38 return result
39 })
40
41 if (s.opts.guard) {
42 on('tool.call', async ($, e, next) => {
43 const before = s.stats.checked
44 const reason = verdict(e.tool, e, s.opts, s.stats)
45 if (reason === undefined) {
46 if (s.stats.checked !== before) await flush($, s)
47 return next(e)
48 }
49 s.stats.blocked++
50 await flush($, s)
51 return { deny: reason }
52 })
53 }
54
55 /** `/trader-mod` (the plugin's own command is a prompt command, which no hook can answer). */
56 on('command.run', { command: 'trader-mod' }, async (_$, e) => {
57 const args = typeof e.args === 'string' ? e.args : ''
58 return { text: answer(args, s.opts, s.stats) }
59 })
60}
61hooks/command.ts 29 lines1import { secretsIn } from './screen'
2import type { ModOptions } from './options'
3import type { Stats } from './status'
4
5const HELP = ['/trader-mod status', '/trader-mod scan <text>'].join('\n')
6
7/** `/trader-mod` is answered locally and takes no model turn. */
8export function answer(args: string, opts: ModOptions, stats: Stats): string {
9 const [verb = '', ...rest] = args.trim().split(/\s+/)
10 const arg = rest.join(' ')
11
12 if (verb === '' || verb === 'help') return HELP
13
14 if (verb === 'status') {
15 return [
16 `guard ${opts.guard ? 'on' : 'off'} · live-order guard ${opts.liveGuard ? 'on' : 'off'}`,
17 `checked ${stats.checked} · blocked ${stats.blocked} · live orders refused ${stats.liveBlocked}${stats.lastBlock ? ` · last: ${stats.lastBlock}` : ''}`,
18 ].join('\n')
19 }
20
21 if (verb === 'scan') {
22 if (arg === '') return 'usage: /trader-mod scan <text>'
23 const found = secretsIn(arg)
24 return found.length ? `The guard would refuse a write or call carrying that (${found.join(', ')}).` : 'Nothing secret-shaped found: the guard would let that through.'
25 }
26
27 return `Unknown: ${verb}\n${HELP}`
28}
29hooks/guard.ts 59 lines1import { hasSecret, secretsIn, textsOf } from './screen'
2import type { ModOptions } from './options'
3import type { Stats } from './status'
4
5const WRITERS = new Set(['memory_store', 'agentdb_pattern-store', 'agentdb_hierarchical-store', 'agentdb_batch'])
6// Order placement on a neural-trader MCP server (`neural-trader mcp start`), however the plugin names the server.
7// Covers the package's own names too: execute_trade, execute_multi_asset_trade, place_prediction_order_tool. Paper and simulated tools are not orders.
8const ORDER = /^(?!.*(?:paper|simulat|dry))(?:execute|place|submit)(?:[_-][a-z]+)*[_-]?(?:trade|order|bet)s?(?:[_-]tool)?$|^live[_-]?(?:trade|order|execute)|^(?:close|cancel)[_-]?all/i
9
10const toolOf = (name: string) => (name.startsWith('mcp__') ? name.slice(name.lastIndexOf('__') + 2) : name)
11const serverOf = (name: string) => (name.startsWith('mcp__') ? name.slice(5, name.lastIndexOf('__')) : '')
12const isTrader = (name: string) => /neural[-_]?trader/i.test(serverOf(name))
13
14/** Whether the call states, in its own input, that it is a paper trade or carries an explicit confirm. */
15function confirmed(input: unknown): boolean {
16 const top = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {}
17 const inner = typeof top.input === 'object' && top.input !== null ? (top.input as Record<string, unknown>) : {}
18 return [top, inner].some(o => o.confirm === true || o.confirmed === true || o.paper === true || o.dryRun === true || o.dry_run === true)
19}
20
21/** This plugin's namespaces. A `memory_store` outside them is another plugin's write: still screened, but its refusal must not claim it. */
22const OWN_NS = /^(?:trading|neural-trader|trader)/i
23const namespaceOf = (input: unknown): string | undefined => {
24 const top = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {}
25 const inner = typeof top.input === 'object' && top.input !== null ? (top.input as Record<string, unknown>) : {}
26 return [top.namespace, inner.namespace].find((n): n is string => typeof n === 'string')
27}
28const foreignStore = (tool: string, input: unknown): boolean => /(?:^|__)memory_store$/.test(tool) && !OWN_NS.test(namespaceOf(input) ?? '')
29/** The refusal for a secret in another plugin's `memory_store`: what was found and which namespace, never an owner and never content. */
30function foreignRefusal(input: unknown): string {
31 const ns = namespaceOf(input)
32 const label = (ns ?? '').replace(/[^\w.:-]/g, '').slice(0, 40)
33 const where = !ns ? 'no namespace' : label && !hasSecret(ns) && !hasSecret(label) ? `namespace "${label}"` : 'a namespace not shown here'
34 return `ruflo-neural-trader: a secret-shaped value (a key, token or password) was found in a memory write; the call targeted ${where}. Store a reference to where it lives, not the value.`
35}
36
37/**
38 * The reason a call is refused, or undefined when it may go. Two rules: a memory write or trader call holding a secret is refused
39 * (broker keys must not be stored or echoed), and a live order call without an explicit `confirm: true` (or `paper`/`dryRun`) is refused.
40 * Names the rule, never echoes the value.
41 */
42export function verdict(tool: string, input: unknown, opts: ModOptions, stats: Stats): string | undefined {
43 const trader = isTrader(tool)
44 if (!trader && !WRITERS.has(toolOf(tool))) return undefined
45 stats.checked++
46 const found = textsOf(input).flatMap(secretsIn)
47 if (found.length > 0) {
48 stats.lastBlock = found[0]
49 if (!trader && foreignStore(tool, input)) return foreignRefusal(input)
50 return 'ruflo-neural-trader: this call holds what looks like a secret (a broker key, token or password). Keep credentials in the environment and pass a reference, not the value.'
51 }
52 if (opts.liveGuard && trader && ORDER.test(toolOf(tool)) && !confirmed(input)) {
53 stats.liveBlocked++
54 stats.lastBlock = 'live order without confirm'
55 return 'ruflo-neural-trader: a live order needs an explicit confirm: true in the call (or paper: true to simulate). Re-run the paper backtest and risk gate first.'
56 }
57 return undefined
58}
59hooks/options.ts 18 lines1import type { PluginOptions } from 'claude-code'
2
3/** The plugin's `userConfig`, validated: a bad value is the default (both guards on). */
4export type ModOptions = {
5 readonly guard: boolean
6 readonly liveGuard: boolean
7}
8
9// BEGIN SHARED FLAG (generated by scripts/sync-mod-screen.mjs; edit plugins/ruflo-agentdb/hooks/options.ts)
10export const flag = (value: unknown, fallback: boolean) =>
11 value === true || value === 'true' || value === 'on' ? true : value === false || value === 'false' || value === 'off' ? false : fallback
12// END SHARED FLAG
13
14export function readOptions(options: PluginOptions | undefined): ModOptions {
15 const o = options ?? {}
16 return { guard: flag(o.guard, true), liveGuard: flag(o.liveGuard, true) }
17}
18hooks/status.ts 19 lines1/** Counters the mod keeps for the session and writes to `.claude-flow/trader-mod/status.json` for the console. */
2export type Stats = {
3 checked: number
4 blocked: number
5 /** Live order calls refused for want of an explicit confirm. */
6 liveBlocked: number
7 /** Why the last call was refused: a rule name, never the text that tripped it. */
8 lastBlock?: string
9}
10
11export const newStats = (): Stats => ({ checked: 0, blocked: 0, liveBlocked: 0 })
12
13export const STATUS_PATH = '.claude-flow/trader-mod/status.json'
14
15/** The file's text; `version` lets the console refuse a shape it does not know. */
16export function statusText(stats: Stats, mode: Record<string, unknown>, nowMs: number): string {
17 return `${JSON.stringify({ version: 1, updatedMs: nowMs, ...mode, ...stats }, null, 2)}\n`
18}
19hooks/screen.ts 262 lines1/**
2 * Pure text screening for the ruflo-neural-trader mod (ADR-445 pattern). Findings are NAMES only: the matched text is never returned, logged or
3 * counted by value, so a deny reason cannot leak what it caught.
4 */
5
6// BEGIN SHARED SCREEN (generated from plugins/ruflo-agentdb/hooks/screen.ts by scripts/sync-mod-screen.mjs; do not edit in a copy)
7export type Rules = readonly (readonly [string, RegExp])[]
8
9/** The secret shapes every mod screens for. A plugin adds its own after these, outside the markers. */
10export const COMMON_SECRETS: Rules = [
11 ['private key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
12 ['aws access key', /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
13 ['github token', /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{40,})\b/],
14 ['slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
15 ['slack webhook', /\bhooks\.slack\.com\/services\/T[A-Z0-9]{6,}\/B[A-Z0-9]{6,}\/[A-Za-z0-9]{16,}/],
16 ['google api key', /\bAIza[0-9A-Za-z_-]{35}\b/],
17 ['anthropic or openai key', /\bsk-(?:(?:ant|proj|svcacct|admin)-[A-Za-z0-9_-]{20,}|(?=[A-Za-z]{0,40}\d)[A-Za-z0-9]{32,})/],
18 ['stripe key', /\b[rs]k_live_[A-Za-z0-9]{16,}/],
19 ['npm token', /\bnpm_[A-Za-z0-9]{36}\b/],
20 ['huggingface token', /\bhf_[A-Za-z0-9]{30,}\b/],
21 ['sendgrid key', /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/],
22 ['twilio key', /\bSK[0-9a-f]{32}\b/],
23 ['jwt', /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/],
24 ['bearer token', /\bBearer\s+([A-Za-z0-9._~+/=-]{24,})/],
25 ['database url with credentials', /\b[a-z][a-z0-9+.-]{1,20}:\/\/[^\s:@/]+:[^\s@/]{3,}@[^\s/]+/i],
26]
27
28export const INJECTION: Rules = [
29 ['override instructions', /\b(?:ignore|disregard|forget|override)\b[^.\n]{0,40}\b(?:previous|prior|above|earlier|all|any|system)\b[^.\n]{0,30}\b(?:instructions?|rules?|prompts?|guidelines?)\b/i],
30 ['role reassignment', /\byou are (?:now|no longer)\b|\bact as (?:an? )?(?:unrestricted|jailbroken)\b/i],
31 ['new instructions', /\b(?:new|updated|real) (?:system )?instructions?\s*:/i],
32 ['fake role tags', /<\/?\s*(?:system|assistant|developer|instructions?)\s*>|^\s*(?:system|assistant)\s*:/im],
33 ['concealment', /\bdo not (?:tell|inform|mention|reveal)[^.\n]{0,30}\b(?:user|human|operator)\b/i],
34 ['exfiltration', /\b(?:exfiltrate|send|post|upload)\b[^.\n]{0,50}\b(?:secrets?|credentials?|tokens?|api keys?|\.env)\b/i],
35 ['shell pipe', /\b(?:curl|wget)\b[^|\n]{0,200}\|\s*(?:sudo\s+)?(?:ba|z)?sh\b/i],
36]
37
38// C0/C1 controls (keeping tab and newline), DEL, soft hyphen, combining grapheme joiner, Arabic letter mark, Hangul and Mongolian fillers/separators,
39// zero-width, bidi (overrides and isolates) and invisible-format characters, variation selectors; built with escapes, never raw.
40const INVISIBLE = new RegExp(
41 '[\\u0000-\\u0008\\u000b-\\u001f\\u007f-\\u009f\\u00ad\\u034f\\u061c\\u115f\\u1160\\u17b4\\u17b5\\u180b-\\u180e\\u200b-\\u200f\\u2028-\\u202e\\u2060-\\u206f\\u3164\\ufe00-\\ufe0f\\ufeff\\uffa0\\ufff9-\\ufffb]',
42 'g',
43)
44
45/** Longest input scanned in one pass; a longer one keeps its head and tail halves. One regex pass per rule, so cost stays linear. */
46const MAX_SCAN = 200_000
47
48/** Input bounded to MAX_SCAN characters with invisible characters removed, so none can hide a secret or a phrase. */
49export const bare = (text: string) =>
50 (text.length > MAX_SCAN ? text.slice(0, MAX_SCAN / 2) + '\n' + text.slice(-MAX_SCAN / 2) : text).replace(INVISIBLE, '')
51
52// A value is a secret CANDIDATE only when it is not a reference (env var, call, identifier path, placeholder, secret-manager path) and its
53// shape is random enough: at least two character classes, one of them a digit or symbol, and Shannon entropy of at least 2.5 bits per character.
54const PLACEHOLDER = /placeholder|your[-_ ]|example|changeme|change[-_]?me|redacted|dummy|replace[-_]?me|insert[-_]|\*{3,}|x{5,}|\.{3}|^(?:none|null|undefined|true|false)$/i
55const REFERENCE =
56 /^(?:\$(?:\{[^}]*\}|\(|[A-Za-z_]\w*$)|%[^%]*%$|<[^>]*>$|\{\{|process\.env|os\.environ|env[.[]|import\.meta|System\.getenv|secrets?\.|vault:|op:\/\/|ref\+|arn:|projects\/[^/]+\/secrets\/|gcp:|kms:|aws:|file:)/i
57const CALL = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\(|^[A-Za-z_$][\w$]*\[/
58const IDENT_PATH = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/
59const UUID = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i
60const NAME_LIKE = /^[a-z][a-z0-9]*(?:[-_./][a-z0-9]+){2,}$/
61
62function entropy(v: string): number {
63 const counts = new Map<string, number>()
64 for (const ch of v) counts.set(ch, (counts.get(ch) ?? 0) + 1)
65 let h = 0
66 for (const n of counts.values()) h -= (n / v.length) * Math.log2(n / v.length)
67 return h
68}
69
70/** True when `v` is a name, call, path or placeholder rather than a literal credential. */
71function isReference(v: string): boolean {
72 if (PLACEHOLDER.test(v) || REFERENCE.test(v) || CALL.test(v) || IDENT_PATH.test(v) || URL_NO_CREDS.test(v)) return true
73 return NAME_LIKE.test(v) && v.replace(/\D/g, '').length / v.length < 0.15
74}
75
76export function plausibleSecret(v: string): boolean {
77 if (v.length < 8 || v.length > 256 || /\s/.test(v) || UUID.test(v) || isReference(v)) return false
78 const symbol = /[^A-Za-z0-9]/.test(v)
79 const digit = /\d/.test(v)
80 const classes = [/[a-z]/.test(v), /[A-Z]/.test(v), digit, symbol].filter(Boolean).length
81 return classes >= 2 && (digit || symbol) && entropy(v) >= 2.5
82}
83
84/** Under a secret-named key a literal this long is a secret even with one character class or a UUID shape, unless it is a clear reference. */
85const KEYED_MIN = 20
86const PLACEHOLDER_WORD = /(?:^|[^a-z])(?:your|placeholder|changeme|change[-_]?me|example|redacted|dummy|replace[-_]?me|insert)(?:[^a-z]|$)|\*{3,}|x{5,}|\.{3}|^(?:none|null|undefined|true|false)$/i
87const URL_NO_CREDS = /^[a-z][a-z0-9+.-]{1,20}:\/\/[^\s@]*$/i
88
89/** Three or more lowercase hyphen-separated words (no hex or digit-only run of 8+, few digits), such as my-k8s-secret-name-for-database. */
90function hyphenName(v: string): boolean {
91 const parts = v.split('-')
92 return parts.length >= 3 && parts.every(p => /^[a-z0-9]{2,}$/.test(p) && !/^[0-9a-f]{8,}$/.test(p)) && v.replace(/\D/g, '').length / v.length < 0.15
93}
94
95function keyedSecret(v: string): boolean {
96 if (v.length < KEYED_MIN || v.length > 256 || /\s/.test(v)) return false
97 return !(PLACEHOLDER_WORD.test(v) || REFERENCE.test(v) || CALL.test(v) || IDENT_PATH.test(v) || URL_NO_CREDS.test(v) || (!UUID.test(v) && hyphenName(v)))
98}
99
100const KEY_NAME = /(?:api[_-]?key|secret|token|passw(?:or)?d|passwd|pwd|credential|private[_-]?key|auth(?!or))s?[A-Za-z0-9_-]{0,40}["']?\s*[:=]\s*/gi
101const QUOTED = /(["'\x60])((?:(?!\1)[^\n]){1,256})\1/y
102const BARE_VALUE = /[^\s"'\x60,;]{1,256}/y
103const QUERY_VALUE = /[^\s"'\x60,;&]{1,256}/y
104
105/** A secret-named key assigned a literal value: env style, JSON, YAML, code. Values that are calls, references or placeholders do not count. */
106function assignmentSecret(text: string): boolean {
107 let valueEnd = 0
108 for (const m of text.matchAll(KEY_NAME)) {
109 if (m.index < valueEnd) continue // a key-looking word inside the previous value, such as secretsmanager in an ARN
110 const at = m.index + m[0].length
111 let back = m.index
112 while (back > 0 && m.index - back < 64 && /[A-Za-z0-9_.-]/.test(text.charAt(back - 1))) back--
113 const re = /["'\x60]/.test(text.charAt(at)) ? QUOTED : /[?&]/.test(text.charAt(back - 1)) ? QUERY_VALUE : BARE_VALUE
114 re.lastIndex = at
115 const hit = re.exec(text)
116 const v = hit && (hit[2] ?? hit[0])
117 valueEnd = hit ? at + hit[0].length : at
118 if (v && (plausibleSecret(v) || keyedSecret(v))) return true
119 }
120 return false
121}
122
123/** A password in a URL's userinfo that is not a placeholder such as user:password or ${DB_PASSWORD}. */
124function urlCredential(url: string): boolean {
125 const pass = /^[^:]+:\/\/[^\s:@/]+:([^\s@/]+)@/.exec(url)?.[1]
126 if (!pass || /\$\{|\{\{|%\(|%s/.test(pass)) return false
127 return !/^(?:password|passwd|pass|pwd|secret|changeme|dbpassword|db_password|\$\w*|<.*>|\{.*\}|\*+|x+)$/i.test(pass) && !PLACEHOLDER.test(pass)
128}
129
130const CHECKS: Readonly<Record<string, (m: RegExpMatchArray) => boolean>> = {
131 'bearer token': m => !isReference(m[1] ?? ''),
132 'database url with credentials': m => urlCredential(m[0]),
133 'database url with password': m => urlCredential(m[0]),
134}
135const globals = new WeakMap<RegExp, RegExp>()
136
137function matches(name: string, re: RegExp, text: string): boolean {
138 if (name === 'key assignment') return assignmentSecret(text)
139 const check = CHECKS[name]
140 if (!check) return re.test(text)
141 let g = globals.get(re)
142 if (!g) globals.set(re, (g = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g')))
143 for (const m of text.matchAll(g)) if (check(m)) return true
144 return false
145}
146
147/**
148 * The text textsOf appends when it had to drop input (a node, character or per-string budget ran out). It is never matched against a rule:
149 * `names` reports it as a finding of its own, so every guard that asks "is there a secret in these texts" refuses what it could not read in full.
150 */
151export const TRUNCATED = 'ruflo-screen: input exceeded the screening budget'
152export const TRUNCATED_NAME = 'input too large to screen'
153
154/** Names of the rules that match `text` (already bare'd). A rule named 'key assignment' is judged by assignmentSecret, whatever its regex. */
155export const names = (rules: Rules, text: string) => text === TRUNCATED ? [TRUNCATED_NAME] : rules.filter(([name, re]) => matches(name, re, text)).map(([name]) => name)
156
157export type Findings = { readonly secrets: readonly string[]; readonly injection: readonly string[] }
158
159/** Names of every secret shape in `secrets` and every injection phrase found in `text`. Cost is linear in the capped input. */
160export function screenWith(secrets: Rules, text: string): Findings {
161 const bounded = bare(text)
162 return { secrets: names(secrets, bounded), injection: names(INJECTION, bounded) }
163}
164
165export const hasSecretIn = (secrets: Rules, text: string) => names(secrets, bare(text)).length > 0
166
167/** Makes stored text safe to show: no control or bidi characters, whitespace collapsed, at most `max` characters. */
168export function tidy(text: string, max: number): string {
169 const flat = text.replace(INVISIBLE, '').replace(/\s+/g, ' ').trim()
170 return flat.length > max ? `${flat.slice(0, Math.max(0, max - 1))}…` : flat
171}
172/** Bounds for textsOf: nodes visited, characters returned, the longest string read in full, the size of one returned chunk, chunk overlap. */
173export type TextLimits = { readonly nodes?: number; readonly chars?: number; readonly perString?: number }
174const NODES = 20_000
175const CHARS = 2_000_000
176const PER_STRING = 1_500_000
177const OVERLAP = 2_048
178const BARE_KEY_MIN = 8
179
180/** A string as texts the screen can read whole: one text up to MAX_SCAN, else overlapping MAX_SCAN windows so a secret anywhere is inside one. */
181function windows(text: string, out: string[]): void {
182 if (text.length <= MAX_SCAN) {
183 out.push(text)
184 return
185 }
186 for (let at = 0; ; at += MAX_SCAN - OVERLAP) {
187 out.push(text.slice(at, at + MAX_SCAN))
188 if (at + MAX_SCAN >= text.length) return
189 }
190}
191
192/**
193 * Every string in a tool input, for the screen to read: iterative (no recursion, so nesting 5000 deep cannot overflow the stack) and
194 * breadth-first (siblings before depth, so a long list cannot hide a nested value). A string under an object key comes back as `key=value`,
195 * so a secret-named key is judged with its value; a key whose value is not a string is returned bare. Strings longer than the screen window
196 * come back as overlapping windows; one over `perString` keeps its head and tail. Work is bounded by `nodes` slots and `chars` characters.
197 * Anything dropped (slots or characters ran out, or a string lost its middle) is reported by a final TRUNCATED text, which `names` and
198 * `hasSecretIn` count as a finding, so the screen fails closed instead of passing what it did not read.
199 */
200export function textsOf(input: unknown, limits: TextLimits = {}): string[] {
201 const out: string[] = []
202 let slots = limits.nodes ?? NODES
203 let chars = limits.chars ?? CHARS
204 const perString = limits.perString ?? PER_STRING
205 let truncated = false
206 const take = (text: string): void => {
207 if (chars <= 0) {
208 truncated = true
209 return
210 }
211 if (text.length <= Math.min(perString, chars)) {
212 chars -= text.length
213 windows(text, out)
214 return
215 }
216 truncated = true
217 const half = Math.floor(Math.min(perString, chars) / 2)
218 chars -= 2 * half
219 windows(text.slice(0, half), out)
220 windows(text.slice(-half), out)
221 }
222 const queue: unknown[] = [input]
223 let head = 0
224 for (; head < queue.length && chars > 0; head++) {
225 const node = queue[head]
226 if (typeof node === 'string') take(node)
227 else if (Array.isArray(node)) {
228 let i = 0
229 for (; i < node.length && slots > 0; i++, slots--) if (i in node) queue.push(node[i])
230 if (i < node.length) truncated = true
231 } else if (typeof node === 'object' && node !== null) {
232 for (const k in node) {
233 if (slots-- <= 0) {
234 truncated = true
235 break
236 }
237 if (!Object.prototype.hasOwnProperty.call(node, k)) continue
238 const v = (node as Record<string, unknown>)[k]
239 if (typeof v === 'string') queue.push(k + '=' + v)
240 else {
241 if (k.length >= BARE_KEY_MIN) take(k)
242 queue.push(v)
243 }
244 }
245 }
246 }
247 if (queue.length > head) truncated = true
248 if (truncated) out.push(TRUNCATED)
249 return out
250}
251// END SHARED SCREEN
252
253const SECRETS: Rules = [
254 ...COMMON_SECRETS,
255 ['key assignment', /\b[A-Za-z0-9_-]*(?:api[_-]?key|secret|token|passw(?:or)?d|credential)s?["']?\s*[:=]\s*["']?[A-Za-z0-9/+=_.-]{16,}/i],
256]
257
258/** Names of every secret shape found in `text`. Cost is linear in the capped input. */
259export const secretsIn = (text: string): string[] => names(SECRETS, bare(text))
260
261export const hasSecret = (text: string) => hasSecretIn(SECRETS, text)
262