SLOPSHOPPER

ruflo-ddd

Domain-Driven Design scaffolding — bounded contexts, aggregate roots, domain events, value objects, repositories, and anti-corruption layers; navigable domain…

newguardcommand
★ 74,184v0.3.4MITupdated 2026-10-08ruvnet/ruflo/plugins/ruflo-ddd
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ruflo-ddd
› 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 › /ddd-mod ⎿ ruflo-ddd: /ddd-mod status ⎿ ruflo-ddd: /ddd-mod scan <text> ⎿ ruflo-ddd: /ddd-mod contexts ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

ruflo-ddd

Domain-Driven Design scaffolding -- bounded contexts, aggregate roots, domain events, and anti-corruption layers.

Overview

Transforms business domains into well-structured bounded contexts with aggregate roots, value objects, domain events, repositories, and anti-corruption layers. Stores the domain model as a navigable graph in AgentDB with hierarchical nodes and causal edges for context dependencies.

Installation

claude --plugin-dir plugins/ruflo-ddd

Agents

AgentModelRole
domain-modelersonnetMap domains to bounded contexts, design aggregates with invariants, define domain events, generate ACL interfaces

Skills

SkillUsageDescription
ddd-context/ddd-context <context-name>Create a bounded context with standard directory structure
ddd-aggregate/ddd-aggregate <context> <aggregate-name>Scaffold an aggregate root with entity, value objects, repository, events, and test stubs
ddd-validate/ddd-validateDetect cross-context import violations and aggregate invariant issues

Commands (6 subcommands)

# Context management
ddd context create <name>
ddd context list

# Aggregate scaffolding
ddd aggregate <context> <name>
ddd event <context> <name>

# Validation & visualization
ddd validate                 # Check domain boundary violations
ddd map                      # Visualize context map with relationships

Directory Structure per Context

src/<context-name>/
  domain/
    entities/           # Entities and aggregate root
    value-objects/       # Immutable value objects
    events/             # Domain events
    services/           # Domain services
    repositories/       # Repository interfaces
  application/          # Use cases / application services
  infrastructure/       # Repository implementations, ACL adapters
  index.ts              # Public API of the context

Context Relationships

Detected via import analysis: upstream/downstream, ACL, shared kernel, published language. Boundary violations (direct cross-context imports) are flagged by ddd validate.

Compatibility

  • CLI: pinned to @claude-flow/cli v3.6 major+minor.
  • Verification: bash plugins/ruflo-ddd/scripts/smoke.sh is the contract.

Namespace coordination

This plugin owns the ddd-patterns AgentDB namespace (kebab-case, follows the convention from ruflo-agentdb ADR-0001 §"Namespace convention"). Reserved namespaces (pattern, claude-memories, default) MUST NOT be shadowed.

ddd-patterns stores reusable bounded-context shapes, aggregate templates, and event vocabularies for cross-project reuse. Accessed via memory_* tools (namespace-routed).

Verification

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

Architecture Decisions

Related Plugins

  • ruflo-agentdb — namespace convention owner; backing store for the domain graph
  • ruflo-adr -- Document domain decisions as Architecture Decision Records
  • ruflo-sparc -- Architecture phase leverages DDD bounded context patterns
  • ruflo-migrations -- Align migration boundaries with aggregate roots

License

MIT

As a mod

Since this version the plugin is also a function-hook mod (ADR-445 pattern; needs Claude Code 2.1.287 or later). It never calls the network or spawns a process, and it only tightens: it can refuse a call, never allow one.

  • Guard (default on): refuses domain-model memory writes (ddd-* keys and context:/aggregate: hierarchy edges) that hold a key, token or password. The reason names the kind of secret, never the value.
  • /ddd-mod: answered locally, no model turn: status, scan <text> (would the guard refuse this?), and contexts (folders under src/, src/contexts, src/modules with a domain/ layer).
  • Status file: .claude-flow/ddd-mod/status.json ({version, updatedMs, guard, blocked}), written at session start and when a call is blocked.
  • Option: guard (on | off, default on) in the plugin's userConfig.

Test it: claude plugin test plugins/ruflo-ddd and bash plugins/ruflo-ddd/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 63 lines
1import 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, s.opts.guard, await $.clock.now()))
17  } catch {
18    /* the status file is a courtesy */
19  }
20}
21
22/**
23 * ruflo-ddd as a mod (ADR-445 pattern): a tighten-only guard on this plugin's own tools, `/ddd-mod` and a status file. No network, no process
24 * spawning: 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: 'ddd-mod', description: 'ruflo-ddd mod: status, scan <text>, plus a read-only listing' })
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 reason = verdict(e.tool, e)
44      if (reason === undefined) return next(e)
45      s.stats.blocked++
46      await flush($, s)
47      return { deny: reason }
48    })
49  }
50
51  /** `/ddd-mod` is answered locally and takes no model turn. */
52  on('command.run', { command: 'ddd-mod' }, async ($, e) => {
53    const args = typeof e.args === 'string' ? e.args : ''
54    const text = await answer(args, {
55      guard: s.opts.guard,
56      stats: s.stats,
57      tools: async () => (await $.tool.list()).map(t => t.name),
58      list: path => $.fs.list(path),
59    })
60    return { text }
61  })
62}
63
hooks/command.ts 53 lines
1import type { FsEntry } from 'claude-code'
2
3import { secretNames } from './screen'
4import type { Stats } from './status'
5
6/** `/ddd-mod` is answered locally and takes no model turn. Everything it reads is already connected; nothing is written. */
7export type CommandDeps = {
8  readonly guard: boolean
9  readonly stats: Stats
10  readonly tools: () => Promise<readonly string[]>
11  readonly list: (path: string) => Promise<readonly FsEntry[]>
12}
13
14const HELP = ['/ddd-mod status', '/ddd-mod scan <text>', '/ddd-mod contexts'].join('\n')
15
16export async function answer(args: string, deps: CommandDeps): Promise<string> {
17  const [verb = '', ...rest] = args.trim().split(/\s+/)
18  const arg = rest.join(' ')
19
20  if (verb === '' || verb === 'help') return HELP
21
22  if (verb === 'status') return `guard ${deps.guard ? 'on' : 'off'} · calls blocked ${deps.stats.blocked}`
23
24  if (verb === 'scan') {
25    if (arg === '') return 'usage: /ddd-mod scan <text>'
26    const found = secretNames(arg)
27    return found.length ? `The guard would refuse that (${found.join(', ')}).` : 'Nothing found: the guard would let that through.'
28  }
29
30  if (verb === 'contexts') {
31    const found: string[] = []
32    for (const base of ['src', 'src/contexts', 'src/modules']) {
33      let entries: readonly FsEntry[]
34      try {
35        entries = await deps.list(base)
36      } catch {
37        continue
38      }
39      if (entries.some(e => e.kind === 'dir' && e.name === 'domain') && base === 'src') found.push('src (single domain)')
40      for (const dir of entries.filter(e => e.kind === 'dir' && e.name !== 'domain').slice(0, 40)) {
41        try {
42          if ((await deps.list(`${base}/${dir.name}`)).some(e => e.kind === 'dir' && e.name === 'domain')) found.push(`${base}/${dir.name}`)
43        } catch {
44          /* unreadable: not a context */
45        }
46      }
47    }
48    return found.length ? `${found.length} bounded context${found.length === 1 ? '' : 's'} (a folder with a domain/ layer):\n${found.map(f => `- ${f}`).join('\n')}` : 'No bounded context found under src/, src/contexts or src/modules.'
49  }
50
51  return `Unknown: ${verb}\n${HELP}`
52}
53
hooks/guard.ts 22 lines
1import { hasSecret, textsOf } from './screen'
2
3/** The tool name without its `mcp__<server>__` prefix. */
4const tail = (name: string) => (name.startsWith('mcp__') ? name.slice(name.lastIndexOf('__') + 2) : name)
5
6const WRITERS = new Set(['memory_store', 'agentdb_hierarchical-store'])
7const DOMAIN = /^(?:ddd|domain)-|^(?:context|aggregate):/
8
9/** The DDD skills' own entries: a key named ddd-* or domain-*, or a hierarchy edge whose ends are context:* or aggregate:*. */
10function isDomainWrite(input: unknown): boolean {
11  const o = (typeof input === 'object' && input !== null ? input : {}) as Record<string, unknown>
12  return ['key', 'parent', 'child'].some(k => typeof o[k] === 'string' && DOMAIN.test(o[k] as string))
13}
14
15/** The reason a domain-model memory write is refused, or undefined when it may go. Never names or echoes the secret. */
16export function verdict(tool: string, input: unknown): string | undefined {
17  if (!WRITERS.has(tail(tool)) || !isDomainWrite(input)) return undefined
18  return textsOf(input).some(hasSecret)
19    ? 'ruflo-ddd: this domain-model entry holds what looks like a secret (a key, token or password). The model records contexts and aggregates, not credentials.'
20    : undefined
21}
22
hooks/options.ts 12 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 const readOptions = (options: PluginOptions | undefined): ModOptions => ({ guard: flag((options ?? {}).guard, true) })
12
hooks/status.ts 11 lines
1/** Counters the mod keeps for the session and writes to `.claude-flow/ddd-mod/status.json`. */
2export type Stats = { blocked: number }
3
4export const newStats = (): Stats => ({ blocked: 0 })
5
6export const STATUS_PATH = '.claude-flow/ddd-mod/status.json'
7
8/** The file's text; `version` lets a reader refuse a shape it does not know. */
9export const statusText = (stats: Stats, guard: boolean, nowMs: number): string =>
10  `${JSON.stringify({ version: 1, updatedMs: nowMs, guard, ...stats }, null, 2)}\n`
11
hooks/screen.ts 262 lines
1/**
2 * Pure secret screening for this mod (copied from ruflo-agentdb's ADR-445 screen.ts, injection rules dropped). Findings are NAMES only: the
3 * 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  ['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(?: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 secretNames = (text: string): string[] => names(SECRETS, bare(text))
260
261export const hasSecret = (text: string) => hasSecretIn(SECRETS, text)
262