SLOPSHOPPER

ruflo-ai-team

Give Claude a tenant-isolated AI team that divides work, coordinates progress, recalls approved context, and returns evidence-backed results.

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

RuFlo AI Team

Give Claude a tenant-isolated AI team that divides work, coordinates progress, recalls approved context, and returns evidence-backed results.

RuFlo AI Team is a hosted MCP service (https://team.ruv.io/mcp) plus a Claude plugin with six skills, four commands, and four agent roles. It turns a reviewed goal into explicit team, run, task, memory, and evidence records. It coordinates work: it does not run agents, send messages, deploy software, execute shell commands, make purchases, or approve consequential actions. Claude (in Claude Code, Cowork, or claude.ai) does the work with its own tools and the service keeps the shared record. You stay the authority for anything with an external effect.

What you need

  • The RuFlo AI Team connector. It is included in the plugin; on first use you are asked to sign in (OAuth 2.1). Without sign-in, the skills cannot read or write team data, and Claude should say so.
  • Nothing to install locally for the hosted service. The plugin also contains a small local guard (see "As a mod", Claude Code only).

Examples

Slash commands work where your surface supports plugin commands (Claude Code, Cowork). Plain-language prompts work everywhere the connector is available, including claude.ai.

  1. Start a team. Claude Code or Cowork: /team-start prepare the release checklist for v2.0. claude.ai: "Use RuFlo AI Team to plan a release-readiness team for v2.0. Show me the roles, tasks and budget before creating anything."
  2. Check progress. /team-status (it lists your teams if you give no ID), or "What is blocked on my AI team and who owns each task?"
  3. Remember a decision. "Remember for my team that we only deploy on weekdays." Later: "What constraints did we record for the team?"
  4. Review the result. /team-review RUN_ID, or "Check run RUN_ID against its acceptance criteria and tell me which claims have evidence."
  5. Pause without losing anything. /team-stop TEAM_ID, or "Pause the team and export its evidence first."

How auth works and what leaves your machine

  • Sign-in is OAuth 2.1 with scopes team:read, team:write and team:run. The service derives your tenant from the verified token; no tool takes a tenant ID, and another tenant's IDs return the same not_found as an unknown ID.
  • Sent to team.ruv.io when a skill uses a tool: the text Claude puts into team names, objectives, run goals, task descriptions and results, memory text and search queries. Keep secrets and unnecessary personal data out of them. Your files, conversation history and local environment are not uploaded by the plugin; only what Claude passes to these tools is.
  • Stored in the service (Firestore), separated by tenant. Retention, access and deletion requests are covered by the privacy policy (https://team.ruv.io/privacy). Data is not sold or used to train general-purpose models.
  • The local guard hook (Claude Code only) makes no network calls.

Troubleshooting

  • "Not signed in", 401, or the tools are missing: connect the RuFlo AI Team connector and sign in again (Claude Code: /mcp; claude.ai and Cowork: Settings, Connectors).
  • 403 or insufficient_scope: the connection lacks team:write or team:run. Disconnect and reconnect, approving all three scopes.
  • not_found for an ID you just created: the ID belongs to a different account or tenant than the one signed in. Check which account is connected.
  • unsafe_content when remembering text: it matched a prompt-injection pattern. Rephrase it as a plain fact.
  • tasks_incomplete when completing a run: finish or remove the open tasks first.
  • Search says lexical-degraded: the service fell back to keyword search; results are still tenant-scoped.
  • A write was refused with a message about a secret (Claude Code): the local guard caught a key, token or password. Remove it, or turn the guard option off.
  • Support: https://team.ruv.io/support or https://github.com/ruvnet/ruflo/issues

Architecture

  • OAuth 2.1 resource server with RFC 9728 discovery and issuer/audience/scope verification.
  • Tenant identity derived only from verified token claims; no tool accepts a tenant ID.
  • Firestore is canonical storage; an in-memory store is used for tests and local development.
  • The default vector backend is the bounded, tenant-scoped lexical-degraded fallback. Set RUFLO_AI_TEAM_VECTOR=native only after the exact @ruvector/core binary passes the startup self-test; an unavailable or incompatible binding falls back explicitly and never claims semantic search.
  • Stored task, memory, and evidence content is provenance-labelled and nonce-fenced as untrusted data.
  • Fourteen focused tools, two prompts, a template resource, and a ChatGPT MCP Apps board.

The read-only team_board tool opens one compact ruOS-style workspace in ChatGPT. Its Teams, Runs, Tasks, and Evidence rail navigates inside the same card; selecting a run or pressing Refresh calls the same scoped tool through the MCP Apps bridge without asking ChatGPT to render another board. Evidence shows a private run summary; evidence_export provides the full bundle in chat. Its public HTML resource contains no tenant data; the tool requires team:read. Other tools remain data-only. Historical chat cards are immutable, so open a fresh chat after refreshing tools to see the current UI. run_complete requires team:run and refuses to complete a run until it has at least one task and every task is complete.

Public and protected surface

Discovery is deliberately public, so MCP clients and directory reviewers can list the service before a user signs in. Anything tenant-scoped requires OAuth.

Anonymous (no token)Requires OAuth (401 challenge with RFC 9728 metadata otherwise)
initialize, ping, tools/list, prompts/list, resources/listevery tools/call
resources/read of the static board UI (ui://ruflo-ai-team/board-v4.html)every other resources/read, including ruv://team/templates
/health, /.well-known/oauth-protected-resource[/mcp], /privacy, /terms, /support

The anonymous methods return only static definitions: tool schemas, prompt text, and the two static resource descriptors. They never return team, run, task, memory or evidence data. A bearer token that fails verification is treated as anonymous for these discovery methods only; it never downgrades a protected call.

Cross-origin (browser) reads are limited to an explicit allowlist. By default it covers https://chatgpt.com, https://chat.openai.com and https://claude.ai; set ALLOWED_ORIGINS (comma-separated origins) to replace it. The request origin is echoed back only when it is on the list, with Vary: Origin. No access-control-allow-origin header is sent for any other origin. ChatGPT and Claude call the endpoint server-to-server and the board UI makes no network requests, so the allowlist governs browser-based MCP clients only. Requests without an Origin header are unaffected.

Memory search reports lexical-degraded unless a compatible native RuVector binding passes the startup probe. The pinned @ruvector/core 0.1.32 package with its 0.1.30 optional native binding fails that probe in local validation with a dimension mismatch. Do not set RUFLO_AI_TEAM_VECTOR=native in production until a compatible binary is verified.

Local verification

npm install
npm test
npm run smoke

Run locally with RUFLO_AI_TEAM_STORE=memory npm start. Production requires the exact OAuth resource audience https://team.ruv.io/mcp, Firestore IAM, and the environment variables documented in deploy/cloud-run.yaml. ChatGPT connections registered before the team:* scope ceiling was added must be created again so dynamic client registration includes those scopes.

Compatibility

The plugin targets Ruflo / @claude-flow/cli v3.48 and pins its remote MCP contract at service version 0.1.x. Claude discovers skills, commands, and agents from the canonical plugin directories; the manifest intentionally contains no component arrays.

Namespace coordination

The plugin owns the ruflo-ai-team-* namespace. Tenant data is never separated by a user-supplied namespace: authorization derives the tenant and every repository operation requires it. This follows the ruflo-agentdb ADR-0001 namespace convention while treating namespaces as organization aids, not security boundaries.

Verification

bash plugins/ruflo-ai-team/scripts/smoke.sh runs structural checks and the Node test suite. The tests assert the exact tool inventory, complete annotations, OAuth challenges, scope errors, cross-tenant denial, bounded vector indexes, fenced retrieval, and evidence export.

Architecture decisions

As a mod

AI Team also ships as a function-hook mod (ADR-445 pattern; hooks in hooks/, loaded with the plugin). No network, no process, no model call.

  • Guard (default on): refuses a team write (memory_remember, task_create, task_update, run_create on the ruflo-ai-team server) 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.
  • /ai-team-mod: answered locally. /ai-team-mod status, /ai-team-mod scan <text>.
  • Status file: .claude-flow/ai-team-mod/status.json (version, updatedMs, counters), written at session start and whenever a call is refused; the console reads it.
  • Options (userConfig): guard (on by default).

Test: claude plugin validate plugins/ruflo-ai-team, claude plugin test plugins/ruflo-ai-team, and bash plugins/ruflo-ai-team/scripts/smoke.sh.

Source 6 files
hooks/register.ts 58 lines
1import 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 * AI Team as a mod (ADR-445 pattern): a tighten-only guard on what goes into team memory and task or run records (`memory_remember`, `task_create`, `task_update`, `run_create` on the ruflo-ai-team server), `/ai-team-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: 'ai-team-mod', description: 'AI Team mod: status, scan <text>' })
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  /** `/ai-team-mod` (a markdown command of the plugin cannot be answered by a hook, so the mod owns this name). */
56  on('command.run', { command: 'ai-team-mod' }, async (_$, e) => ({ text: answer(typeof e.args === 'string' ? e.args : '', { opts: s.opts, stats: s.stats }) }))
57}
58
hooks/command.ts 30 lines
1import { scan } from './screen'
2import type { ModOptions } from './options'
3import type { Stats } from './status'
4
5/** `/ai-team-mod` is answered locally and takes no model turn. */
6export type CommandDeps = { readonly opts: ModOptions; readonly stats: Stats }
7
8const HELP = ['/ai-team-mod status', '/ai-team-mod scan <text>'].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: /ai-team-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  return `Unknown: ${verb}\n${HELP}`
29}
30
hooks/guard.ts 24 lines
1import { 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
12/** True for the tool calls this plugin guards: what goes into team memory and task or run records (`memory_remember`, `task_create`, `task_update`, `run_create` on the ruflo-ai-team server). */
13export function owns(tool: string, input: unknown): boolean {
14  const parts = splitName(tool)
15  const name = parts?.tool ?? tool
16  return (parts?.server ?? '').includes('ruflo-ai-team') && ['memory_remember', 'task_create', 'task_update', 'run_create'].includes(name)
17}
18
19/** The reason a call is refused, or undefined when it may go. Never names or echoes the secret. */
20export function verdict(tool: string, input: unknown): string | undefined {
21  if (!owns(tool, input)) return undefined
22  return textsOf(input).some(hasSecret) ? "ruflo-ai-team: this team write holds what looks like a secret (a key, token or password). Team memory and tasks are shared and retained as evidence; store a reference, not the value." : undefined
23}
24
hooks/options.ts 19 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 = {
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}
19
hooks/status.ts 12 lines
1/** Counters the mod keeps for the session and writes to `.claude-flow/ai-team-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/ai-team-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: 'ai-team', guard: mode.guard, ...stats }, null, 2)}\n`
11}
12
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