ADR lifecycle management — create, index, reconcile, supersede, check compliance, and link Architecture Decision Records to code via AgentDB hierarchical store…

ADR lifecycle management -- create, index, reconcile, supersede, and link Architecture Decision Records to code.
Manages Architecture Decision Records through their full lifecycle (proposed, accepted, deprecated, superseded). ADRs are stored as markdown files in docs/adr/ and indexed in AgentDB with causal edges tracking supersedes/amends/depends-on relationships. Includes compliance checking that scans git diffs for ADR violations, and reconciliation (adr-reindex) for ADRs deleted from disk (#2666).
claude --plugin-dir plugins/ruflo-adr
| Agent | Model | Role |
|---|---|---|
adr-architect | sonnet | ADR lifecycle management, code-ADR linking via grep/blame, AgentDB graph storage |
| Skill | Usage | Description |
|---|---|---|
adr-create | /adr-create <title> | Create a new ADR with sequential numbering and AgentDB registration |
adr-index | /adr-index | Build or rebuild the ADR index and dependency graph in AgentDB (add/update only — never removes) |
adr-review | /adr-review [--branch BRANCH] | Review code changes against accepted ADRs for compliance violations |
adr-verify | /adr-verify | Read back adr-patterns + adr-edges namespaces, surface dangling refs / supersede cycles / Unknown statuses; exits 1 on cycles |
adr-reindex | /adr-reindex | Reconcile a deleted ADR file: drop-and-rebuild adr-patterns + adr-edges from what's on disk right now |
# Lifecycle
adr create <title>
adr list
adr status <adr-id> <new-status>
adr supersede <old-id> <new-id>
# Compliance
adr check # Scan recent git changes for ADR violations
adr graph # Show ADR dependency graph
adr search <query> # Semantic search across ADRs
proposed --> accepted --> deprecated
\--> superseded by ADR-XXX
Relationships tracked as causal edges: supersedes, amends, depends-on, related.
Body relationship fields accept qualifiers such as Amends by scope: or Depends-on / confirms:, and Relates is an alias for Related. Wrapped lists may continue on lines containing only ADR references (including Markdown links) and separators. Narrative continuation is not scanned for relationships; declare additional relationships in another bold field.
@claude-flow/cli v3.6 major+minor.bash plugins/ruflo-adr/scripts/smoke.sh is the contract.This plugin owns the adr-patterns AgentDB namespace. It defers to ruflo-agentdb ADR-0001 §"Namespace convention" for naming rules. Reserved namespaces (pattern, claude-memories, default) MUST NOT be shadowed.
adr-patterns follows kebab-case <plugin-stem>-<intent> per the convention. The plugin uses it for semantic ADR search and for cross-project pattern transfer (via hooks_transfer in ruflo-intelligence).
bash plugins/ruflo-adr/scripts/smoke.sh
# Expected: "22 passed, 0 failed"
ADR-0001 — ruflo-adr plugin contract (pinning, namespace coordination, smoke as contract)ADR-0002 — Reconcile deleted ADRs (hard-delete primitive + drop-and-rebuild reindex)ruflo-agentdb — namespace convention owner; backing store for the ADR graphruflo-ddd — document domain decisions as ADRsruflo-sparc — Architecture phase (Phase 3) produces ADRsruflo-migrations — schema change decisions recorded as ADRsruflo-jujutsu — ADR-aware diff analysis on PRsruflo-intelligence — hooks_transfer ships ADR patterns across projectsMIT
ADR also ships as a function-hook mod (ADR-445 pattern; hooks in hooks/, loaded with the plugin). No network, no process, no model call.
agentdb_hierarchical-store, agentdb_causal-edge or memory_store into an adr* namespace) that holds a key, token or password. It only tightens: it never allows anything the session would deny, and the refusal never repeats the secret. Turn it off with the guard option./adr-mod: answered locally. /adr-mod status, /adr-mod scan <text>, /adr-mod format..claude-flow/adr-mod/status.json (version, updatedMs, counters), written at session start and whenever a call is refused; the console reads it.userConfig): guard (on by default).Test: claude plugin validate plugins/ruflo-adr, claude plugin test plugins/ruflo-adr, and bash plugins/ruflo-adr/scripts/smoke.sh.
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.
hooks/register.ts 58 lines1import type { Hook, Register } from 'claude-code'
2
3import { answer } from './command'
4import { owns, verdict } from './guard'
5import { readOptions } from './options'
6import { newStats, STATUS_PATH, statusText, type Stats } from './status'
7import type { ModOptions } from './options'
8
9type Dollar = Parameters<Hook<'session.start'>>[0]
10
11/** Everything one session of the mod keeps: its settings, counters and the project root. */
12type Session = { readonly opts: ModOptions; readonly stats: Stats; root?: string }
13
14async function flush($: Dollar, s: Session): Promise<void> {
15 if (s.root === undefined) return
16 try {
17 await $.fs.write(`${s.root}/${STATUS_PATH}`, statusText(s.stats, s.opts, await $.clock.now()))
18 } catch {
19 /* the status file is a courtesy */
20 }
21}
22
23/**
24 * ADR as a mod (ADR-445 pattern): a tighten-only guard on ADR writes (`agentdb_hierarchical-store`, `agentdb_causal-edge`, `memory_store` into an `adr*` namespace), `/adr-mod`, and a status file the console reads.
25 * No network, no process: only the hooks API.
26 */
27export const register: Register = (on, options) => {
28 const s: Session = { opts: readOptions(options), stats: newStats() }
29
30 on('session.start', async ($, e, next) => {
31 const result = await next(e)
32 s.root = (await $.session.root()) as string | undefined
33 s.stats.startedMs = await $.clock.now()
34 try {
35 await $.command.register({ name: 'adr-mod', description: 'ADR mod: status, scan <text>, format' })
36 } catch {
37 /* a name taken by another plugin must not stop the mod */
38 }
39 await flush($, s)
40 return result
41 })
42
43 if (s.opts.guard) {
44 on('tool.call', async ($, e, next) => {
45 if (!owns(e.tool, e)) return next(e)
46 s.stats.calls++
47 const reason = verdict(e.tool, e)
48 if (reason === undefined) return next(e)
49 s.stats.blocked++
50 await flush($, s)
51 return { deny: reason }
52 })
53 }
54
55 /** `/adr-mod` (a markdown command of the plugin cannot be answered by a hook, so the mod owns this name). */
56 on('command.run', { command: 'adr-mod' }, async (_$, e) => ({ text: answer(typeof e.args === 'string' ? e.args : '', { opts: s.opts, stats: s.stats }) }))
57}
58hooks/command.ts 32 lines1import { scan } from './screen'
2import type { ModOptions } from './options'
3import type { Stats } from './status'
4
5/** `/adr-mod` is answered locally and takes no model turn. */
6export type CommandDeps = { readonly opts: ModOptions; readonly stats: Stats }
7
8const HELP = ['/adr-mod status', '/adr-mod scan <text>', '/adr-mod format'].join('\n')
9
10export function answer(args: string, deps: CommandDeps): 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 return `guard ${opts.guard ? 'on' : 'off'} · calls guarded ${stats.calls} · blocked ${stats.blocked}`
19 }
20
21 if (verb === 'scan') {
22 if (arg === '') return 'usage: /adr-mod scan <text>'
23 const found = scan(arg)
24 const parts = [found.secrets.length ? `secrets: ${found.secrets.join(', ')}` : '', found.injection.length ? `injection phrasing: ${found.injection.join(', ')}` : ''].filter(Boolean)
25 return parts.length ? `The guard would refuse a write of that (${parts.join('; ')}).` : 'Nothing found: the guard would let that through.'
26 }
27
28 if (verb === 'format') return 'An ADR carries: Status (proposed, accepted, deprecated, superseded), Context, Decision, Consequences. Relations: supersedes, amends, depends-on, related. Create with /adr-create, index with /adr-index.'
29
30 return `Unknown: ${verb}\n${HELP}`
31}
32hooks/guard.ts 44 lines1import { hasSecret } from './screen'
2import { textsOf } from './screen'
3export { textsOf }
4
5/** `mcp__<server>__<tool>` into its two halves; tool names never hold a double underscore. */
6export function splitName(name: string): { server: string; tool: string } | undefined {
7 if (!name.startsWith('mcp__')) return undefined
8 const at = name.lastIndexOf('__')
9 return at > 5 ? { server: name.slice(5, at), tool: name.slice(at + 2) } : undefined
10}
11
12const namespaceOf = (input: unknown): string => {
13 const ns = (input as { namespace?: unknown } | null)?.namespace
14 return typeof ns === 'string' ? ns : ''
15}
16
17/** An ADR entry id as the plugin's skills write it: `mem:ADR-0007`, `ADR-0007`. */
18const ADR_ID = /^(?:mem:)?ADR-/i
19
20const field = (input: unknown, key: string): string => {
21 const v = (input as Record<string, unknown> | null)?.[key]
22 return typeof v === 'string' ? v : ''
23}
24
25/**
26 * True for the tool calls this plugin guards: ADR writes. `memory_store` into an `adr*` namespace; `agentdb_hierarchical-store` (which has no namespace
27 * field, so the entry is told apart by its `mem:ADR-NNN` key) and `agentdb_causal-edge` (by its `mem:ADR-NNN` ends), as the adr-create skill calls them.
28 */
29export function owns(tool: string, input: unknown): boolean {
30 const parts = splitName(tool)
31 const name = parts?.tool ?? tool
32 const adrNamespace = /adr/i.test(namespaceOf(input))
33 if (name === 'memory_store') return adrNamespace
34 if (name === 'agentdb_hierarchical-store') return adrNamespace || ADR_ID.test(field(input, 'key'))
35 if (name === 'agentdb_causal-edge') return adrNamespace || ADR_ID.test(field(input, 'sourceId')) || ADR_ID.test(field(input, 'targetId'))
36 return false
37}
38
39/** The reason a call is refused, or undefined when it may go. Never names or echoes the secret. */
40export function verdict(tool: string, input: unknown): string | undefined {
41 if (!owns(tool, input)) return undefined
42 return textsOf(input).some(hasSecret) ? "ruflo-adr: this ADR write holds what looks like a secret (a key, token or password). An ADR records a decision; name where the secret lives, not its value." : undefined
43}
44hooks/options.ts 19 lines1import type { PluginOptions } from 'claude-code'
2
3/** The plugin's `userConfig`, validated: a bad value is the default (guard on). */
4export type ModOptions = {
5 readonly guard: boolean
6}
7
8// BEGIN SHARED FLAG (generated by scripts/sync-mod-screen.mjs; edit plugins/ruflo-agentdb/hooks/options.ts)
9export const flag = (value: unknown, fallback: boolean) =>
10 value === true || value === 'true' || value === 'on' ? true : value === false || value === 'false' || value === 'off' ? false : fallback
11// END SHARED FLAG
12
13export function readOptions(options: PluginOptions | undefined): ModOptions {
14 const o = options ?? {}
15 return {
16 guard: flag(o.guard, true),
17 }
18}
19hooks/status.ts 12 lines1/** Counters the mod keeps for the session and writes to `.claude-flow/adr-mod/status.json` for the console. */
2export type Stats = { calls: number; blocked: number; startedMs: number }
3
4export const newStats = (): Stats => ({ calls: 0, blocked: 0, startedMs: 0 })
5
6export const STATUS_PATH = '.claude-flow/adr-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, mod: 'adr', guard: mode.guard, ...stats }, null, 2)}\n`
11}
12hooks/screen.ts 268 lines1/**
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