SLOPSHOPPER

ruflo-intelligence

User-facing surface for Ruflo's self-learning system: 6 neural_* + 10 hooks_intelligence_* + 6 routing/meta hooks + 3 hooks_model-* + 4 SONA/MicroLoRA tools…

newguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ruflo-intelligence
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /intelligence-mod ⎿ ruflo-intelligence: /intelligence-mod status ⎿ ruflo-intelligence: /intelligence-mod scan <text> ⎿ ruflo-intelligence: /intelligence-mod stats ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

ruflo-intelligence

User-facing surface for Ruflo's self-learning system. Wraps 29 intelligence-related MCP tools across four families into discoverable skills, commands, and the canonical 4-step pipeline (RETRIEVE → JUDGE → DISTILL → CONSOLIDATE). Coordinates with ruflo-agentdb (namespace convention), ruflo-ruvector (trajectory recording substrate), and ruflo-browser (consumes trajectory hooks for session replay).

Status: ADR-0001 implemented. Tool names and parameters in this plugin are checked against @claude-flow/cli 3.56.x; the package manifest is version truth.

Install

/plugin marketplace add ruvnet/ruflo
/plugin install ruflo-intelligence@ruflo

Compatibility

  • CLI: checked against @claude-flow/cli 3.56.x (mcp exec, hooks, neural flags as in --help).
  • Verification: bash plugins/ruflo-intelligence/scripts/smoke.sh is the contract.

Tool inventory

FamilyCountSource
neural_*6v3/@claude-flow/cli/src/mcp-tools/neural-tools.ts
hooks_intelligence_* (incl. dispatcher + reset)10v3/@claude-flow/cli/src/mcp-tools/hooks-tools.ts
Routing & meta hooks (hooks_route, hooks_explain, hooks_pretrain, hooks_build-agents, hooks_metrics, hooks_transfer)6hooks-tools.ts
hooks_model-* (3-tier routing)3hooks-tools.ts
ruvllm_sona_* + ruvllm_microlora_*4v3/@claude-flow/cli/src/mcp-tools/ruvllm-tools.ts
Total29—

The 4-step intelligence pipeline

CLAUDE.md describes the V3 intelligence loop as four discrete phases. This plugin operationalizes them:

StepWhat happensTools
RETRIEVEPull relevant patterns + past trajectories from HNSW indexhooks_intelligence_pattern-search, agentdb_pattern-search, agentdb_semantic-route
JUDGEScore retrieved candidates with verdicts (success / failure / partial)hooks_intelligence_attention, neural_predict, hooks_explain
DISTILLExtract the key learnings via LoRA / SONA adaptationruvllm_sona_adapt, ruvllm_microlora_adapt, neural_train, hooks_intelligence_learn
CONSOLIDATEPrevent catastrophic forgetting via EWC++hooks_intelligence_learn (EWC++ cycle), agentdb_consolidate, neural_compress

For an end-to-end run:

hooks_pretrain
  → hooks_intelligence_trajectory-start
    → (each step) hooks_intelligence_trajectory-step
  → hooks_intelligence_trajectory-end
  → hooks_intelligence_learn
  → ruvllm_sona_adapt    # DISTILL
  → agentdb_consolidate  # CONSOLIDATE
  → neural_compress      # storage efficiency

Cross-project pattern transfer

Two mechanisms, chosen by where the patterns are (see the intelligence-transfer skill):

# Copy patterns from a sibling project on disk (MCP: hooks_transfer { sourcePath, filter, minConfidence })
npx @claude-flow/cli@latest hooks transfer from-project --source ../other-project --min-confidence 0.7

# Browse, download and publish through the IPFS pattern marketplace
npx @claude-flow/cli@latest hooks transfer store search --query routing
npx @claude-flow/cli@latest hooks transfer store download --name PATTERN_NAME

hooks_transfer itself only does the project-to-project copy; it has no store/load/CID action. Publishing to the marketplace is public and needs PINATA_API_KEY and PINATA_API_SECRET; scan for PII first.

Hook integration

Several Claude Code hooks fire intelligence-side writes:

HookTool invokedTarget
pre-taskhooks_route + hooks_intelligence_pattern-searchRETRIEVE phase
post-task (pass --task and --agent)records the outcome and, with both flags, the routing outcome (#2785)feeds JUDGE/DISTILL
pretrain (one-shot)hooks_pretrain → seeds memory_store --namespace patternsBootstrap, writes to patterns namespace (plural)
Trajectory hooks (ruvector substrate)intelligence_trajectory-*Recorded by ruflo-ruvector; consumed by this plugin's pattern-store

Pluralization gotcha: ReasoningBank fallback writes to pattern (singular). The pretrain hook writes to patterns (plural). They are different namespaces. See ruflo-agentdb ADR-0001 §"Namespace convention" for the canonical contract.

Namespace coordination with ruflo-agentdb

This plugin defers to ruflo-agentdb ADR-0001 for namespace conventions. Three reserved namespaces are read by the intelligence pipeline:

NamespaceRead bySource
patternhooks_intelligence_pattern-search, agentdb_pattern-searchReasoningBank fallback target
patterns (plural)hooks_pretrain, neural_train corpusdistinct from pattern
claude-memoriesmemory_search_unified (default include)Claude Code auto-memory bridge

Do not invent new top-level namespaces for intelligence purposes — the convention is owned upstream.

EWC++ consolidation

The plugin claims EWC++ consolidation; here's how to actually invoke it:

  1. At trajectory end, call hooks_intelligence_learn to register the outcome.
  2. Periodically (or after N task completions), call agentdb_consolidate to fold patterns into the long-term store under EWC++ semantics.
  3. hooks_intelligence_learn already runs an EWC++ consolidation cycle by default (consolidate defaults to true). ruvllm_microlora_adapt takes { loraId, quality } and has no consolidate option.

Without these calls, fresh trajectories overwrite older patterns without protection — the system "forgets". The pipeline diagram above bakes consolidation into step 4 deliberately.

MoE (Mixture of Experts) routing

hooks_intelligence is a status call: it reports the SONA / MoE / HNSW state from the memory store and echoes the mode it was given. It does not switch architectures; use it to inspect, and enableSona / enableMoe / forceTraining to shape what it reports or trigger a training cycle. Conventional mode labels:

ModeWhen to use
balanced (default)General-purpose: SONA + HNSW retrieval, no MoE specialization
sonaSingle-domain specialization with SONA adaptation
moeMulti-domain expert routing — recommended when tasks span 3+ distinct domains
hnswPure pattern retrieval, no online adaptation

Inspect with npx @claude-flow/cli@latest mcp exec -t hooks_intelligence -p '{"showStatus": true}'.

Commands

  • /intelligence — Dashboard: stats, metrics, model-tier distribution, routing rationale on demand
  • /neural — Neural training and prediction (train, status, patterns, predict, optimize, compress)

Skills

  • neural-train — Train SONA + MicroLoRA patterns from successful tasks
  • intelligence-route — Route tasks using learned patterns; produces a hooks_explain rationale
  • intelligence-transfer — Publish/fetch patterns via IPFS (hooks_transfer)

As a mod (0.4.2)

A function-hook mod ships beside the skills (ADR-445 pattern). Needs a Claude Code with mods (2.1.287+); older builds ignore it. No network, no process spawning: it only tightens calls to this plugin's own tools and reads through tools already connected.

PieceDefaultWhat it does
Write guardonRefuses a secret in a learned pattern, trajectory step/end, neural_train or hooks_transfer call, or a memory_store into a pattern namespace. A transfer also refuses an email address (IPFS is public).
Reset confirmonRefuses hooks_intelligence-reset unless the call carries confirm: true.
/intelligence-mod—status, scan <text>, stats (calls hooks_intelligence_stats through the connected tool); answered locally, no model call.
Status file—.claude-flow/intelligence-mod/status.json (version, updatedMs, mode flags and counters); written at session start and when a counter changes.

Options (userConfig): guard on\|off, confirmReset on\|off. Refusals never echo the value they matched.

claude plugin test plugins/ruflo-intelligence   # 11 tests

Architecture Decisions

Related Plugins

  • ruflo-agentdb — substrate for HNSW + namespace contract; agentdb_pattern-* is this plugin's storage backend
  • ruflo-ruvector — trajectory hooks substrate; intelligence_trajectory-* calls land in ruvector's persisted trajectories
  • ruflo-browser — consumes trajectory hooks for session replay (ADR-0001 there)
  • ruflo-daa — Dynamic Agentic Architecture; cognitive patterns feed routing as inputs

License

MIT

Source 7 files
hooks/register.ts 76 lines
1import type { Hook, Register } from 'claude-code'
2
3import { answer } from './command'
4import { verdict } from './guard'
5import { modeOf, readOptions, type ModOptions } from './options'
6import { newStats, STATUS_PATH, statusText, type Stats } from './status'
7import { findTool } from './tools'
8
9const TEXT_CAP = 20_000
10
11type Dollar = Parameters<Hook<'session.start'>>[0]
12
13/** Everything one session of the mod keeps: its settings, its counters and the project root. */
14type Session = { readonly opts: ModOptions; readonly stats: Stats; root?: string }
15
16async function flush($: Dollar, s: Session): Promise<void> {
17  if (s.root === undefined) return
18  try {
19    await $.fs.write(`${s.root}/${STATUS_PATH}`, statusText(s.stats, modeOf(s.opts), await $.clock.now()))
20  } catch {
21    /* the status file is a courtesy */
22  }
23}
24
25/** A read-only call through an already-connected tool: its first text block, or undefined when absent or errored. */
26async function peek($: Dollar, suffix: string, args: Record<string, unknown>): Promise<string | undefined> {
27  const hit = findTool(await $.tool.list(), suffix)
28  if (!hit) return undefined
29  const res = await $.mcp.call(hit.server, hit.tool, args)
30  return res.isError ? undefined : (res.content.find(b => b.type === 'text')?.text ?? '').slice(0, TEXT_CAP)
31}
32
33/**
34 * Intelligence as a mod (ADR-445 pattern): a tighten-only guard on this plugin's own tools, `/intelligence-mod`, and a status file the console
35 * reads. No network, no process spawning: only tools already connected.
36 */
37export const register: Register = (on, options) => {
38  const s: Session = { opts: readOptions(options), stats: newStats() }
39
40  on('session.start', async ($, e, next) => {
41    const result = await next(e)
42    s.root = (await $.session.root()) as string | undefined
43    try {
44      await $.command.register({ name: 'intelligence-mod', description: 'Intelligence mod: status, scan <text>, stats' })
45    } catch {
46      /* a name taken by another plugin must not stop the mod */
47    }
48    await flush($, s)
49    return result
50  })
51
52  if (s.opts.guard) {
53    on('tool.call', async ($, e, next) => {
54      const reason = verdict(e.tool, e, s.opts)
55      if (reason === undefined) return next(e)
56      s.stats.blocked++
57      s.stats.lastDenied = e.tool
58      await flush($, s)
59      return { deny: reason }
60    })
61  }
62
63  /** `/intelligence-mod` is answered locally and takes no model turn. */
64  on('command.run', { command: 'intelligence-mod' }, async ($, e) => {
65    s.stats.commands++
66    const text = await answer(typeof e.args === 'string' ? e.args : '', {
67      opts: s.opts,
68      stats: s.stats,
69      peek: (suffix, args) => peek($, suffix, args),
70      toolNames: async () => (await $.tool.list()).map(t => t.name),
71    })
72    await flush($, s)
73    return { text }
74  })
75}
76
hooks/command.ts 41 lines
1import { scan, tidy } from './screen'
2import type { ModOptions } from './options'
3import type { Stats } from './status'
4
5export type CommandDeps = {
6  readonly opts: ModOptions
7  readonly stats: Stats
8  readonly peek: (suffix: string, args: Record<string, unknown>) => Promise<string | undefined>
9  readonly toolNames: () => Promise<readonly string[]>
10}
11
12const HELP = ['/intelligence-mod status', '/intelligence-mod scan <text>', '/intelligence-mod stats'].join('\n')
13
14/** `/intelligence-mod` is answered locally and takes no model turn (the plugin's `/intelligence` and `/neural` are prompt commands). */
15export async function answer(args: string, deps: CommandDeps): Promise<string> {
16  const [verb = '', ...rest] = args.trim().split(/\s+/)
17  const arg = rest.join(' ')
18  const { opts, stats } = deps
19
20  if (verb === '' || verb === 'help') return HELP
21
22  if (verb === 'status') {
23    return `guard ${opts.guard ? 'on' : 'off'} · reset needs confirm ${opts.confirmReset ? 'on' : 'off'}\ncalls blocked ${stats.blocked} · scans ${stats.scans} · commands ${stats.commands}${stats.lastDenied ? ` · last denied ${stats.lastDenied}` : ''}`
24  }
25
26  if (verb === 'scan') {
27    if (arg === '') return 'usage: /intelligence-mod scan <text>'
28    stats.scans++
29    const found = scan(arg).secrets
30    return found.length ? `The guard would refuse a learning write of that (secrets: ${found.join(', ')}).` : 'No secret shape found: the guard would let that be learned.'
31  }
32
33  if (verb === 'stats') {
34    const text = await deps.peek('hooks_intelligence_stats', {})
35    if (text === undefined) return 'No intelligence tool is connected (or it errored). Connect the ruflo MCP server and try again.'
36    return text.trim() === '' ? 'No intelligence stats reported.' : tidy(text, 1200)
37  }
38
39  return `Unknown: ${verb}\n${HELP}`
40}
41
hooks/guard.ts 34 lines
1import { hasSecret, textsOf } from './screen'
2import { bare, namespaceOf } from './tools'
3import type { ModOptions } from './options'
4
5/** Learning writes: a secret in a stored pattern, trajectory or training row would be learned, recalled and, via transfer, published. */
6const LEARNERS = new Set(['hooks_intelligence_pattern-store', 'hooks_intelligence_trajectory-step', 'hooks_intelligence_trajectory-end', 'neural_train', 'hooks_transfer', 'memory_store'])
7const PATTERN_NS = /^(?:patterns?|intelligence|sona|neural|trajector)/i
8
9// `hooks_transfer` publishes to IPFS, which is public; an email address is personal data the plugin's own skill says to strip first.
10const EMAIL = /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9-]{1,63}(?:\.[A-Za-z0-9-]{1,63})*\.[A-Za-z]{2,}/
11
12const isReset = (name: string) => name.replace(/_/g, '-') === 'hooks-intelligence-reset'
13
14/** The reason an intelligence call is refused, or undefined when it may go. Never names or echoes the value. */
15export function verdict(tool: string, input: unknown, opts: ModOptions): string | undefined {
16  const name = bare(tool)
17
18  if (isReset(name)) {
19    if (!opts.confirmReset || (input as { confirm?: unknown } | null)?.confirm === true) return undefined
20    return 'ruflo-intelligence: resetting the intelligence store erases every learned pattern. Ask the user first; once they agree, call again with confirm: true.'
21  }
22
23  if (!LEARNERS.has(name)) return undefined
24  if (name === 'memory_store' && !PATTERN_NS.test(namespaceOf(input))) return undefined
25  const texts = textsOf(input)
26  if (texts.some(hasSecret)) {
27    return 'ruflo-intelligence: this learning write holds what looks like a secret (a key, token or password). Learned patterns are recalled and can be published, so leave it out.'
28  }
29  if (name === 'hooks_transfer' && texts.some(t => EMAIL.test(t))) {
30    return 'ruflo-intelligence: this transfer holds an email address, and IPFS is public. Strip personal data first (aidefence_has_pii), then publish.'
31  }
32  return undefined
33}
34
hooks/options.ts 17 lines
1import type { PluginOptions } from 'claude-code'
2
3/** The plugin's `userConfig`, validated: a bad value is the default (both on). */
4export type ModOptions = { readonly guard: boolean; readonly confirmReset: boolean }
5
6// BEGIN SHARED FLAG (generated by scripts/sync-mod-screen.mjs; edit plugins/ruflo-agentdb/hooks/options.ts)
7export const flag = (value: unknown, fallback: boolean) =>
8  value === true || value === 'true' || value === 'on' ? true : value === false || value === 'false' || value === 'off' ? false : fallback
9// END SHARED FLAG
10
11export function readOptions(options: PluginOptions | undefined): ModOptions {
12  const o = options ?? {}
13  return { guard: flag(o.guard, true), confirmReset: flag(o.confirmReset, true) }
14}
15
16export const modeOf = (o: ModOptions): Record<string, boolean> => ({ guard: o.guard, confirmReset: o.confirmReset })
17
hooks/status.ts 17 lines
1/** Counters the mod keeps for the session and writes to `.claude-flow/intelligence-mod/status.json` for the console. */
2export type Stats = {
3  blocked: number
4  scans: number
5  commands: number
6  lastDenied?: string
7}
8
9export const newStats = (): Stats => ({ blocked: 0, scans: 0, commands: 0 })
10
11export const STATUS_PATH = '.claude-flow/intelligence-mod/status.json'
12
13/** The file's text: mode flags plus the counters; `version` lets the console refuse a shape it does not know. */
14export function statusText(stats: Stats, mode: Record<string, boolean>, nowMs: number): string {
15  return `${JSON.stringify({ version: 1, updatedMs: nowMs, ...mode, ...stats }, null, 2)}\n`
16}
17
hooks/tools.ts 24 lines
1import type { ToolInfo } from 'claude-code'
2
3/** `mcp__<server>__<tool>` into its two halves; tool names never hold a double underscore. */
4export function splitName(name: string): { server: string; tool: string } | undefined {
5  if (!name.startsWith('mcp__')) return undefined
6  const at = name.lastIndexOf('__')
7  return at > 5 ? { server: name.slice(5, at), tool: name.slice(at + 2) } : undefined
8}
9
10/** The bare tool name: `mcp__srv__memory_store` and `memory_store` both give `memory_store`. */
11export const bare = (name: string) => splitName(name)?.tool ?? name
12
13/** A connected MCP tool whose bare name is `suffix`, split into what `$.mcp.call` takes. */
14export function findTool(tools: readonly ToolInfo[], suffix: string): { server: string; tool: string } | undefined {
15  const hit = tools.find(t => t.mcp && bare(t.name) === suffix)
16  return hit && splitName(hit.name)
17}
18
19/** The tool input's `namespace` field when it is a string, else ''. */
20export const namespaceOf = (input: unknown): string => {
21  const ns = (input as { namespace?: unknown } | null)?.namespace
22  return typeof ns === 'string' ? ns : ''
23}
24
hooks/screen.ts 268 lines
1/**
2 * Pure text screening for the AgentDB mod (ADR-445). Two jobs: find secrets (so none is stored) and find prompt-injection phrasing (so
3 * retrieved memory cannot instruct the model). Findings are NAMES only: the matched text is never returned, logged or counted by value.
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  // `.env` has no word start before its dot, so it is matched on its own ("upload the .env file", "send ./config.env",
35  // ".envrc"); a dot inside a word does not end the phrase. A model's token counts are not credentials: --max-tokens,
36  // /oauth/token and "token usage/count/limit/budget/window" are left out (a file named token, /run/token, is not).
37  ['exfiltration', /\b(?:exfiltrate|send|post|upload|e-?mail)\b(?:[^.\n]|\.(?=\S)){0,50}(?:\b(?:secrets?|credentials?|api keys?)\b|(?:(?<!max[_-]?)\btokens\b|(?<!oauth\/)\btoken\b)(?![\s_-]*(?:usage|counts?|limits?|budgets?|window)\b)|\.env(?:rc)?\b)/i],
38  ['shell pipe', /\b(?:curl|wget)\b[^|\n]{0,200}\|\s*(?:sudo\s+)?(?:ba|z)?sh\b/i],
39]
40
41// C0/C1 controls (keeping tab and newline), DEL, soft hyphen, combining grapheme joiner, Arabic letter mark, Hangul and Mongolian fillers/separators,
42// zero-width, bidi (overrides and isolates) and invisible-format characters, variation selectors; built with escapes, never raw.
43const INVISIBLE = new RegExp(
44  '[\\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]',
45  'g',
46)
47
48/** Longest input scanned in one pass; a longer one keeps its head and tail halves. One regex pass per rule, so cost stays linear. */
49const MAX_SCAN = 200_000
50
51/** Input bounded to MAX_SCAN characters with invisible characters removed, so none can hide a secret or a phrase. */
52export const bare = (text: string) =>
53  (text.length > MAX_SCAN ? text.slice(0, MAX_SCAN / 2) + '\n' + text.slice(-MAX_SCAN / 2) : text).replace(INVISIBLE, '')
54
55// A value is a secret CANDIDATE only when it is not a reference (env var, call, identifier path, placeholder, secret-manager path) and its
56// 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.
57const PLACEHOLDER = /placeholder|your[-_ ]|example|changeme|change[-_]?me|redacted|dummy|replace[-_]?me|insert[-_]|\*{3,}|x{5,}|\.{3}|^(?:none|null|undefined|true|false)$/i
58const REFERENCE =
59  /^(?:\$(?:\{[^}]*\}|\(|[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
60const CALL = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\(|^[A-Za-z_$][\w$]*\[/
61const IDENT_PATH = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/
62const UUID = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i
63const NAME_LIKE = /^[a-z][a-z0-9]*(?:[-_./][a-z0-9]+){2,}$/
64/** A ./ ../ or ~/ path to a file with an extension names where a secret lives; it is not one. */
65const RELATIVE_FILE = /^(?:\.{1,2}|~)\/[A-Za-z0-9_./-]*\.[A-Za-z0-9]{1,8}$/
66/** A secret-manager reference: NAME:latest, or name-with-separators:3, optionally as NAME=ref (--set-secrets=NAME=ref:latest). A bare literal:3 is not excused. */
67const SECRET_REF = /^(?:[A-Za-z][A-Za-z0-9_-]*=)?(?:[A-Za-z][A-Za-z0-9]*(?:[_-][A-Za-z0-9]+)*:latest|[A-Za-z][A-Za-z0-9]*(?:[_-][A-Za-z0-9]+)+:\d{1,4})$/
68
69function entropy(v: string): number {
70  const counts = new Map<string, number>()
71  for (const ch of v) counts.set(ch, (counts.get(ch) ?? 0) + 1)
72  let h = 0
73  for (const n of counts.values()) h -= (n / v.length) * Math.log2(n / v.length)
74  return h
75}
76
77/** True when `v` is a name, call, path or placeholder rather than a literal credential. */
78function isReference(v: string): boolean {
79  if (PLACEHOLDER.test(v) || REFERENCE.test(v) || CALL.test(v) || IDENT_PATH.test(v) || URL_NO_CREDS.test(v) || RELATIVE_FILE.test(v) || SECRET_REF.test(v)) return true
80  return NAME_LIKE.test(v) && v.replace(/\D/g, '').length / v.length < 0.15
81}
82
83export function plausibleSecret(v: string): boolean {
84  if (v.length < 8 || v.length > 256 || /\s/.test(v) || UUID.test(v) || isReference(v)) return false
85  const symbol = /[^A-Za-z0-9]/.test(v)
86  const digit = /\d/.test(v)
87  const classes = [/[a-z]/.test(v), /[A-Z]/.test(v), digit, symbol].filter(Boolean).length
88  return classes >= 2 && (digit || symbol) && entropy(v) >= 2.5
89}
90
91/** 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. */
92const KEYED_MIN = 20
93const 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
94const URL_NO_CREDS = /^[a-z][a-z0-9+.-]{1,20}:\/\/[^\s@]*$/i
95
96/** 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. */
97function hyphenName(v: string): boolean {
98  const parts = v.split('-')
99  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
100}
101
102function keyedSecret(v: string): boolean {
103  if (v.length < KEYED_MIN || v.length > 256 || /\s/.test(v)) return false
104  return !(PLACEHOLDER_WORD.test(v) || REFERENCE.test(v) || CALL.test(v) || IDENT_PATH.test(v) || URL_NO_CREDS.test(v) || RELATIVE_FILE.test(v) || SECRET_REF.test(v) || (!UUID.test(v) && hyphenName(v)))
105}
106
107const 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
108const QUOTED = /(["'\x60])((?:(?!\1)[^\n]){1,256})\1/y
109const BARE_VALUE = /[^\s"'\x60,;]{1,256}/y
110const QUERY_VALUE = /[^\s"'\x60,;&]{1,256}/y
111
112/** A secret-named key assigned a literal value: env style, JSON, YAML, code. Values that are calls, references or placeholders do not count. */
113function assignmentSecret(text: string): boolean {
114  let valueEnd = 0
115  for (const m of text.matchAll(KEY_NAME)) {
116    if (m.index < valueEnd) continue // a key-looking word inside the previous value, such as secretsmanager in an ARN
117    const at = m.index + m[0].length
118    let back = m.index
119    while (back > 0 && m.index - back < 64 && /[A-Za-z0-9_.-]/.test(text.charAt(back - 1))) back--
120    const re = /["'\x60]/.test(text.charAt(at)) ? QUOTED : /[?&]/.test(text.charAt(back - 1)) ? QUERY_VALUE : BARE_VALUE
121    re.lastIndex = at
122    const hit = re.exec(text)
123    const v = hit && (hit[2] ?? hit[0])
124    valueEnd = hit ? at + hit[0].length : at
125    if (v && (plausibleSecret(v) || keyedSecret(v))) return true
126  }
127  return false
128}
129
130/** A password in a URL's userinfo that is not a placeholder such as user:password or ${DB_PASSWORD}. */
131function urlCredential(url: string): boolean {
132  const pass = /^[^:]+:\/\/[^\s:@/]+:([^\s@/]+)@/.exec(url)?.[1]
133  if (!pass || /\$\{|\{\{|%\(|%s/.test(pass)) return false
134  return !/^(?:password|passwd|pass|pwd|secret|changeme|dbpassword|db_password|\$\w*|<.*>|\{.*\}|\*+|x+)$/i.test(pass) && !PLACEHOLDER.test(pass)
135}
136
137const CHECKS: Readonly<Record<string, (m: RegExpMatchArray) => boolean>> = {
138  'bearer token': m => !isReference(m[1] ?? ''),
139  'database url with credentials': m => urlCredential(m[0]),
140  'database url with password': m => urlCredential(m[0]),
141}
142const globals = new WeakMap<RegExp, RegExp>()
143
144function matches(name: string, re: RegExp, text: string): boolean {
145  if (name === 'key assignment') return assignmentSecret(text)
146  const check = CHECKS[name]
147  if (!check) return re.test(text)
148  let g = globals.get(re)
149  if (!g) globals.set(re, (g = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g')))
150  for (const m of text.matchAll(g)) if (check(m)) return true
151  return false
152}
153
154/**
155 * 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:
156 * `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.
157 */
158export const TRUNCATED = 'ruflo-screen: input exceeded the screening budget'
159export const TRUNCATED_NAME = 'input too large to screen'
160
161/** Names of the rules that match `text` (already bare'd). A rule named 'key assignment' is judged by assignmentSecret, whatever its regex. */
162export const names = (rules: Rules, text: string) => text === TRUNCATED ? [TRUNCATED_NAME] : rules.filter(([name, re]) => matches(name, re, text)).map(([name]) => name)
163
164export type Findings = { readonly secrets: readonly string[]; readonly injection: readonly string[] }
165
166/** Names of every secret shape in `secrets` and every injection phrase found in `text`. Cost is linear in the capped input. */
167export function screenWith(secrets: Rules, text: string): Findings {
168  const bounded = bare(text)
169  return { secrets: names(secrets, bounded), injection: names(INJECTION, bounded) }
170}
171
172export const hasSecretIn = (secrets: Rules, text: string) => names(secrets, bare(text)).length > 0
173
174/** Makes stored text safe to show: no control or bidi characters, whitespace collapsed, at most `max` characters. */
175export function tidy(text: string, max: number): string {
176  const flat = text.replace(INVISIBLE, '').replace(/\s+/g, ' ').trim()
177  return flat.length > max ? `${flat.slice(0, Math.max(0, max - 1))}…` : flat
178}
179/** Bounds for textsOf: nodes visited, characters returned, the longest string read in full, the size of one returned chunk, chunk overlap. */
180export type TextLimits = { readonly nodes?: number; readonly chars?: number; readonly perString?: number }
181const NODES = 20_000
182const CHARS = 2_000_000
183const PER_STRING = 1_500_000
184const OVERLAP = 2_048
185const BARE_KEY_MIN = 8
186
187/** 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. */
188function windows(text: string, out: string[]): void {
189  if (text.length <= MAX_SCAN) {
190    out.push(text)
191    return
192  }
193  for (let at = 0; ; at += MAX_SCAN - OVERLAP) {
194    out.push(text.slice(at, at + MAX_SCAN))
195    if (at + MAX_SCAN >= text.length) return
196  }
197}
198
199/**
200 * Every string in a tool input, for the screen to read: iterative (no recursion, so nesting 5000 deep cannot overflow the stack) and
201 * 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`,
202 * 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
203 * come back as overlapping windows; one over `perString` keeps its head and tail. Work is bounded by `nodes` slots and `chars` characters.
204 * Anything dropped (slots or characters ran out, or a string lost its middle) is reported by a final TRUNCATED text, which `names` and
205 * `hasSecretIn` count as a finding, so the screen fails closed instead of passing what it did not read.
206 */
207export function textsOf(input: unknown, limits: TextLimits = {}): string[] {
208  const out: string[] = []
209  let slots = limits.nodes ?? NODES
210  let chars = limits.chars ?? CHARS
211  const perString = limits.perString ?? PER_STRING
212  let truncated = false
213  const take = (text: string): void => {
214    if (chars <= 0) {
215      truncated = true
216      return
217    }
218    if (text.length <= Math.min(perString, chars)) {
219      chars -= text.length
220      windows(text, out)
221      return
222    }
223    truncated = true
224    const half = Math.floor(Math.min(perString, chars) / 2)
225    chars -= 2 * half
226    windows(text.slice(0, half), out)
227    windows(text.slice(-half), out)
228  }
229  const queue: unknown[] = [input]
230  let head = 0
231  for (; head < queue.length && chars > 0; head++) {
232    const node = queue[head]
233    if (typeof node === 'string') take(node)
234    else if (Array.isArray(node)) {
235      let i = 0
236      for (; i < node.length && slots > 0; i++, slots--) if (i in node) queue.push(node[i])
237      if (i < node.length) truncated = true
238    } else if (typeof node === 'object' && node !== null) {
239      for (const k in node) {
240        if (slots-- <= 0) {
241          truncated = true
242          break
243        }
244        if (!Object.prototype.hasOwnProperty.call(node, k)) continue
245        const v = (node as Record<string, unknown>)[k]
246        if (typeof v === 'string') queue.push(k + '=' + v)
247        else {
248          if (k.length >= BARE_KEY_MIN) take(k)
249          queue.push(v)
250        }
251      }
252    }
253  }
254  if (queue.length > head) truncated = true
255  if (truncated) out.push(TRUNCATED)
256  return out
257}
258// END SHARED SCREEN
259
260const SECRETS: Rules = [
261  ...COMMON_SECRETS,
262  ['key assignment', /\b(?:api[_-]?key|secret|token|passw(?:or)?d|credential)s?["']?\s*[:=]\s*["']?[A-Za-z0-9/+=_.-]{16,}/i],
263]
264
265export const scan = (text: string): Findings => screenWith(SECRETS, text)
266
267export const hasSecret = (text: string) => hasSecretIn(SECRETS, text)
268