SLOPSHOPPER

ruflo-sparc

SPARC methodology — Specification, Pseudocode, Architecture, Refinement, Completion phases with gate checks

newguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ruflo-sparc
› 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 › /sparc-mod ⎿ ruflo-sparc: /sparc-mod status ⎿ ruflo-sparc: /sparc-mod scan <text> ⎿ ruflo-sparc: /sparc-mod phases ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

ruflo-sparc

SPARC methodology -- Specification, Pseudocode, Architecture, Refinement, Completion phases with quality gates between each phase.

Overview

Drives features through a rigorous five-phase development lifecycle. Each phase has a quality gate that must pass before advancing. The orchestrator spawns specialized agents per phase (ruflo-core:researcher, ruflo-goals:goal-planner, ruflo-swarm:architect, ruflo-core:coder, ruflo-testgen:tester, ruflo-core:reviewer) and stores all artifacts and gate results in memory for traceability.

Installation

claude --plugin-dir plugins/ruflo-sparc

Agents

AgentModelRole
sparc-orchestratorsonnetOrchestrate 5-phase SPARC lifecycle, enforce gate checks, spawn phase-specific agents, track state in memory

Skills

SkillUsageDescription
sparc-spec/ruflo-sparc:sparc-spec <feature-description>Run the Specification phase -- gather requirements, define acceptance criteria, identify constraints
sparc-implement/ruflo-sparc:sparc-implementRun the Architecture and Implementation phases -- design modules, write pseudocode, implement, test
sparc-refine/ruflo-sparc:sparc-refineRun Refinement and Completion -- review code, improve coverage, validate against spec, generate docs

Commands (5 subcommands)

/ruflo-sparc:ruflo-sparc init <feature>         # Initialize a new SPARC workflow
/ruflo-sparc:ruflo-sparc status                 # Show current phase and gate history
/ruflo-sparc:ruflo-sparc advance                # Attempt gate check, advance to next phase
/ruflo-sparc:ruflo-sparc phase <phase-name>     # Jump to a specific phase (spec/pseudo/arch/refine/complete)
/ruflo-sparc:ruflo-sparc report                 # Generate full SPARC methodology report with traceability matrix

The 5 Phases

PhaseNameGate CriteriaSpawned Agent
1Specification>= 3 acceptance criteria, constraints, edge casesresearcher
2PseudocodeCovers all ACs, error paths explicit, complexity annotatedplanner
3ArchitectureAll constraints addressed, typed API contracts, no circular depssystem-architect
4RefinementAll ACs have passing tests, review approved, coverage >= 80%coder + tester
5CompletionAll tests green, docs complete, deployment checklist verifiedreviewer

Memory Namespaces

NamespacePurpose
sparc-stateCurrent phase tracking per feature
sparc-phasesPhase artifacts (specs, pseudocode, ADRs, reports)
sparc-gatesGate check results and history
patternsLearned SPARC execution patterns

Compatibility

  • CLI: pinned to @claude-flow/cli v3.6 major+minor.
  • Verification: bash plugins/ruflo-sparc/scripts/smoke.sh is an offline structure and documentation check (manifest, commands, skills, agent and command-name references). It does not call any MCP tool or run a SPARC workflow, so a pass does not prove the workflow state machine works.

Phase-to-plugin alignment

Each SPARC phase has a canonical handoff plugin that owns its deeper tooling:

PhaseOwnerWhat it provides
Specificationruflo-goals (deep-research)Multi-source research orchestration to gather requirements
Pseudocoderuflo-sparc (this plugin)Pseudocode generation + complexity annotation
Architectureruflo-adr + ruflo-dddADR creation + bounded-context modeling
Refinementruflo-jujutsu + ruflo-testgenDiff-aware refactor + test gap analysis
Completionruflo-docsAuto-generated documentation

This plugin orchestrates the lifecycle; sibling plugins do the deep work per phase.

Namespace coordination

This plugin owns three AgentDB namespaces, all kebab-case compliant per ruflo-agentdb ADR-0001 §"Namespace convention":

NamespacePurpose
sparc-stateCurrent phase tracking per feature
sparc-phasesPhase artifacts (specs, pseudocode, ADRs, reports)
sparc-gatesGate check results and history

Additionally, the shared patterns (plural) namespace is consumed (not owned) for cross-feature SPARC pattern learning — note the pluralization (different from the singular pattern ReasoningBank target). Reserved namespaces (pattern, claude-memories, default) MUST NOT be shadowed.

Verification

bash plugins/ruflo-sparc/scripts/smoke.sh
# Expected: "11 passed, 0 failed"

Architecture Decisions

Related Plugins

  • ruflo-agentdb — namespace convention owner; backing store for sparc-state/-phases/-gates
  • ruflo-goals — Specification phase deep-research
  • ruflo-adr -- Architecture decisions recorded as ADRs in Phase 3
  • ruflo-ddd -- Architecture phase uses DDD bounded context patterns
  • ruflo-jujutsu -- Refinement phase diff analysis
  • ruflo-docs -- Completion phase documentation generation

License

MIT

As a mod

This plugin also loads as a function-hook mod (ADR-445 pattern, hooks/hooks.json → register.ts). No network, no process spawn, no model call.

  • Guard (default on, tighten-only). It refuses to store a spec or phase artifact that holds a secret in a sparc* memory namespace. The refusal never repeats the secret.
  • Status file. .claude-flow/sparc-mod/status.json ({version: 1, updatedMs, guard, checked, blocked, seen}), written at session start and when a counter changes.
  • /sparc-mod answers locally: status, scan <text>, phases. (The plugin's own commands are prompt commands, which a hook cannot answer, so the mod has its own name.)
  • Option. guard (on | off, default on) in the plugin's userConfig.

Test it: claude plugin validate plugins/ruflo-sparc, claude plugin test plugins/ruflo-sparc, bash plugins/ruflo-sparc/scripts/smoke.sh.

Native Codex hooks

The separate .codex-plugin/plugin.json selects hooks/codex-hooks.json, replacing the Claude-only module entry for Codex. Claude's manifest and register.ts remain unchanged. The synchronous native PreToolUse adapter bundles the existing pure guard and shared secret screen; it preserves their tool/namespace scope and emits the native permission-denial envelope before a guarded write. SessionStart and guarded calls maintain private per-session counters in PLUGIN_DATA; the existing version-1 project status file remains a best-effort courtesy view. Status failure does not permit a denied write. Guarding is enabled for the native adapter.

Existing skills and command files remain available. Claude's dynamic $.command.register / command.run mod interception has no native command-hook equivalent: Codex does not get that interception. The same deterministic local command helpers are available explicitly via node <plugin-root>/hooks/codex-hook.cjs --command status (and the helper's existing subcommands). This is guard/status compatibility, not full SDK-mod parity.

Maintainers rebuild the committed standalone bundles with ESBUILD=<esbuild executable> node scripts/build-codex-mod-hooks.mjs, then run node --test tests/plugins/codex-mod-hooks.test.mjs. Set ESBUILD in that test run to check byte reproducibility using the same installed builder. Run scripts/sync-mod-screen.mjs --check before bundling when the shared screen changes. The adapters need Node.js and contain no runtime SDK dependency.

This source change requires a released upstream package or an explicitly owned source projection to reach installations. It does not patch foreign cache entries, pin upstream updates, disable plugins, or claim an already published repair.

Release both host manifests with the same patch version. Codex 0.160's published remote-bundle sync skips downloading a release whose version equals the installed version; merging same-version source does not establish automatic cache refresh. Its explicit native plugin install replaces the cached root atomically even at the same version. An owned projection must therefore refresh its upstream source generation and explicitly reinstall it; refreshing a marketplace catalog alone is not installation proof. No foreign cache entry should be edited in place.

Source 6 files
hooks/register.ts 61 lines
1import type { Hook, Register } from 'claude-code'
2
3import { answer } from './command'
4import { verdict, watched } 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, its 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, s.opts, await $.clock.now()))
17  } catch {
18    /* the status file is a courtesy */
19  }
20}
21
22/**
23 * SPARC as a mod (ADR-445 pattern): a guard that keeps secrets out of specs and phase artifacts stored in sparc memory namespaces, `/sparc-mod`, and a status file the console reads. No network, no process: only tools already connected.
24 */
25export const register: Register = (on, options) => {
26  const s: Session = { opts: readOptions(options), stats: newStats() }
27
28  on('session.start', async ($, e, next) => {
29    const result = await next(e)
30    s.root = (await $.session.root()) as string | undefined
31    try {
32      await $.command.register({ name: 'sparc-mod', description: 'SPARC mod: status, scan <text>, phases' })
33    } catch {
34      /* a name taken by another plugin must not stop the mod */
35    }
36    await flush($, s)
37    return result
38  })
39
40  // Tighten-only: a deny, or the event unchanged. Only this plugin's own tools are looked at.
41  on('tool.call', async ($, e, next) => {
42    const label = watched(e.tool, e)
43    if (label === undefined) return next(e)
44    s.stats.checked++
45    s.stats.seen[label] = (s.stats.seen[label] ?? 0) + 1
46    const reason = s.opts.guard ? verdict(e.tool, e) : undefined
47    if (reason !== undefined) {
48      s.stats.blocked++
49      s.stats.lastReason = reason.slice(0, 160)
50    }
51    await flush($, s)
52    return reason === undefined ? next(e) : { deny: reason }
53  })
54
55  /** `/sparc-mod` (the plugin's own commands are prompt commands, which no hook can answer). */
56  on('command.run', { command: 'sparc-mod' }, async ($, e) => {
57    const args = typeof e.args === 'string' ? e.args : ''
58    return { text: await answer(args, { opts: s.opts, stats: s.stats, tools: async () => (await $.tool.list()).map(t => t.name) }) }
59  })
60}
61
hooks/command.ts 32 lines
1import { secretsIn } from './screen'
2import type { ModOptions } from './options'
3import type { Stats } from './status'
4
5/** `/sparc-mod` is answered locally and takes no model turn. */
6export type CommandDeps = { readonly opts: ModOptions; readonly stats: Stats; readonly tools: () => Promise<readonly string[]> }
7
8const HELP = ['/sparc-mod status', '/sparc-mod scan <text>', '/sparc-mod phases'].join('\n')
9
10export async function answer(args: string, deps: CommandDeps): Promise<string> {
11  const [verb = '', ...rest] = args.trim().split(/\s+/)
12  const arg = rest.join(' ')
13  const { opts, stats } = deps
14
15  if (verb === '' || verb === 'help') return HELP
16
17  if (verb === 'status') {
18    const seen = Object.entries(stats.seen).map(([k, n]) => `${k} ${n}`).join(' · ')
19    return [`guard ${opts.guard ? 'on' : 'off'} · checked ${stats.checked} · blocked ${stats.blocked}`, seen === '' ? 'no sparc-artifact calls this session' : seen].join('\n')
20  }
21
22  if (verb === 'scan') {
23    if (arg === '') return 'usage: /sparc-mod scan <text>'
24    const found = secretsIn(arg)
25    return found.length ? `The guard would refuse that (looks like: ${found.join(', ')}).` : 'Nothing found: the guard would let that through.'
26  }
27
28  if (verb === 'phases') return ['S specification', 'P pseudocode', 'A architecture', 'R refinement', 'C completion', 'Artifacts go in memory namespaces starting with sparc; the guard screens them for secrets.'].join('\n')
29
30  return `Unknown: ${verb}\n${HELP}`
31}
32
hooks/guard.ts 29 lines
1import { hasSecret } from './screen'
2import { textsOf } from './screen'
3export { textsOf }
4
5/** The tool's short name: `mcp__<server>__<tool>` to `<tool>`. */
6export const shortName = (name: string) => (name.startsWith('mcp__') && name.lastIndexOf('__') > 5 ? name.slice(name.lastIndexOf('__') + 2) : name)
7
8const field = (input: unknown, key: string): string => {
9  const v = typeof input === 'object' && input !== null ? (input as Record<string, unknown>)[key] : undefined
10  return typeof v === 'string' ? v : ''
11}
12
13const NS = /^sparc/i
14const WRITERS = new Set(['memory_store', 'agentdb_hierarchical-store', 'agentdb_pattern-store'])
15
16/** The label of a write into a sparc namespace, else undefined: the guard only watches those. */
17export function watched(tool: string, input: unknown): string | undefined {
18  if (!WRITERS.has(shortName(tool))) return undefined
19  return NS.test(field(input, 'namespace')) ? 'sparc artifact write' : undefined
20}
21
22/** The reason a sparc artifact write is refused, or undefined when it may go. Never names or echoes the secret. */
23export function verdict(tool: string, input: unknown): string | undefined {
24  if (watched(tool, input) === undefined) return undefined
25  return textsOf(input).some(hasSecret)
26    ? 'ruflo-sparc: this spec or artifact holds what looks like a secret (a key, token or password). Write a placeholder and name where the real value lives.'
27    : undefined
28}
29
hooks/options.ts 14 lines
1import type { PluginOptions } from 'claude-code'
2
3/** The plugin's `userConfig`, validated: a bad value is the default (guard on). */
4export type ModOptions = { readonly guard: 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  return { guard: flag((options ?? {}).guard, true) }
13}
14
hooks/status.ts 12 lines
1/** Counters the mod keeps for the session and writes to `.claude-flow/sparc-mod/status.json` for the console. */
2export type Stats = { checked: number; blocked: number; seen: Record<string, number>; lastReason?: string }
3
4export const newStats = (): Stats => ({ checked: 0, blocked: 0, seen: {} })
5
6export const STATUS_PATH = '.claude-flow/sparc-mod/status.json'
7
8/** The file's text; `version` lets the console refuse a shape it does not know. */
9export function statusText(stats: Stats, mode: { guard: boolean }, nowMs: number): string {
10  return `${JSON.stringify({ version: 1, updatedMs: nowMs, guard: mode.guard, ...stats }, null, 2)}\n`
11}
12
hooks/screen.ts 269 lines
1/**
2 * Secret screening for the SPARC mod (copied from the ADR-445 AgentDB screen). Findings are NAMES only: the matched text is never
3 * 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
265/** Names of every secret shape found in `text`. Cost is linear in the capped input. */
266export const secretsIn = (text: string): string[] => names(SECRETS, bare(text))
267
268export const hasSecret = (text: string) => hasSecretIn(SECRETS, text)
269