SLOPSHOPPER

ruflo-protector

Project Anatole: an optional, learning watchdog for unattended agents. Deterministic OWASP-mapped rules, a per-project behavioural baseline, four modes (off…

newguardcommandtoasttimeragents
★ 74,184v0.1.1MITupdated 2026-10-07ruvnet/ruflo/plugins/ruflo-protector
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ruflo-protector
› 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 › /protector ⎿ ruflo-protector: /protector status ⎿ ruflo-protector: /protector list ⎿ ruflo-protector: /protector rule <id> <off|notify|block> ⎿ ruflo-protector: /protector mode <off|learn|notify|enforce> ⎿ ruflo-protector: /protector run ⎿ ruflo-protector: /protector replay ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

ruflo-protector: Project Anatole

Project Anatole is an optional, learning watchdog for unattended agents (/loop ticks, headless claude -p workers, overnight swarms). It is a mod (ADR-404 pattern) that watches what the agent does, applies a small set of deterministic, OWASP-mapped rules, remembers what is normal for the project, and leaves a record you can read when you come back. Design and threat model: ADR-453. Version 0.1.1, default off (not in any install bundle).

The technical id stays ruflo-protector (command /protector, files .claude-flow/protector-mod/, rule ids PR-001..013). "Project Anatole" is what a person sees.

What it does

  1. Rules (the only thing that can ever block): 13 narrow rules, each a pure function of a categorical event, each with OWASP LLM / Agentic-threat references. PR-001 secret to a network sink, PR-002 remote code piped into a shell, PR-003 persistence (Claude settings, hooks, rc files, cron, authorized_keys, git hooks), PR-006 destructive operations ship as block; the rest (PR-004 credential read then egress, PR-005 risky action after outside content, PR-007 rate burst, PR-008 new host with a body, PR-009 write outside the project, PR-010 peer message with override phrasing, PR-011 spawn escalation, PR-012 dependency from a URL, PR-013 system prompt to a network sink) ship as notify. /protector list shows each one.
  2. A baseline (notify only): per project, categorical and bounded (tool names, command heads, host classes, path areas, spawn types, call rate; at most 2,000 entries). A learned anomaly alerts only when it is corroborated by a risky action and the baseline is mature (200 events, 3 sessions, 24 hours). A novel read never alerts.
  3. A record: status.json, alerts.jsonl, baseline.json, rules.json, allow.json under .claude-flow/protector-mod/ (schemaVersion 1, bounded), a toast, and /ruflo-mods shows one protector: row.

No argument value, file content or prompt text is ever stored: events become tokens (Bash, git commit, github.com, src/auth). A secret-shaped token is replaced by redacted.

Modes

ModeEffect
offdoes nothing
learn (default once installed)records the baseline, says nothing; a rule hit is only counted
notifyalerts (file, toast), never blocks
enforcea rule marked block denies (never asks: nobody is there at 3 a.m.); everything else still only notifies

learn graduates to notify when the baseline is mature (option autoGraduate, default on). It never enables enforce by itself. A fail-open design: an error inside the protector never blocks a tool call; it sets degraded in the status file.

Keeping false positives out

ControlWhat it does
Learning periodno anomaly alert before 200 events, 3 sessions and 24 hours
Corroborationnovelty alone never alerts; only a risky action (egress with a body, write outside the project, unknown spawn)
Narrow rulesblocking rules are literal shapes; heuristic ones are notify only; PR-003 exempts the ruflo setup commands by exact argv
Clean-event learningthe baseline learns only events with no rule hit, no denial, and no tainted turn; a new host, outside-project area, command or spawn type counts only after two sessions
Dedup and rate limitone alert per fingerprint per session (repeats raise a count); at most 20 alerts an hour; the rest are counted
Feedback/protector ack <id> marks a false positive (fingerprint allowed, baseline taught); a block rule with more than 30% acked among 10+ alerts is demoted to notify and shown as auto-demoted
Shadow replay/protector replay re-scores the recorded ring (last 500 events) against the current rules and baseline, so you can check precision before enforce
Corpus gatetests/corpus.test.ts: 24 benign development traces must give zero alerts after a mature baseline; an attack trace per rule must be caught

Reading alerts

/protector alerts [n] or alerts.jsonl: {id, at, rule, owasp, severity, action: blocked|notified, tool, summary, fp, state, session}. summary is a shape ("remote code piped into an interpreter (Bash · head curl · host x.example)"), never a value; fp is a 12-hex fingerprint of (rule, command head, host or area). A block reason names the rule and the way out: /protector allow <fp>.

/protector

Answered locally, no model turn.

VerbDoes
statusone-screen summary
listevery rule: id, OWASP refs, severity, mode, hits, acked share
`rule <id> <off\notify\block>`change one rule's mode (writes rules.json)
`mode <off\learn\notify\enforce>`change the mode
runscan the project's .claude/settings*.json for exposure (secrets, pipe-to-shell hooks, bypassPermissions, wildcard allows)
replayshadow-score the event ring; what would fire in notify and in enforce
alerts [n]the last n alerts
ack <id> / unack <id>mark an alert a false positive (and teach the baseline) / undo
allow <fp>allow a fingerprint without an alert
reset-baselineforget the baseline and start learning again

Toasts (ADR-477)

Project Anatole draws its toasts through the shared toast policy (one copy of hooks/toast-policy.ts, kept identical across the ruflo mods by scripts/sync-toast-policy.mjs): a level prefix (› info, ✓ ok, ⚠ warn, ✗ error), one clean line of at most 120 characters with secrets masked, an identical toast not repeated for a minute, and at most four a minute per source (errors are held and counted, never dropped). A blocked call is an error, an alert that only notified is a warn, and the baseline graduating to notify is an info marked always-show.

The person's choice is the console's Settings → Interface and updates → Toasts: all, important (warnings and errors) or off, and a mute chip per source. Every toast, drawn or not, is kept as a digest the console shows on its Events page, flagged with what became of it; without the console nothing is written and the defaults apply.

Options

OptionDefaultEffect
modelearnthe starting mode (off, learn, notify, enforce); a mode set with /protector mode is saved in rules.json and wins
autoGraduateonmove learn to notify when the baseline is mature; never to enforce

Try it safely

  1. Install it (it is not in any default bundle) and leave the mode at learn. Work as usual for a few days: it only learns.
  2. When it graduates to notify, read /protector alerts after each unattended run and ack the false positives.
  3. /protector replay shows what enforce would have blocked on your recent work. Only then consider /protector mode enforce.
  4. ruflo-mods with modTrust = "refuse-risky" refuses a mod that hooks tool.check: add ruflo-protector@ruflo to modTrustAllow.

Events and what is not covered

Hooks: tool.check (every tool use; Bash, Write/Edit, WebFetch, MCP calls, SendMessage), agent.spawn, session.receive (peer messages, PR-010), turn.start / turn.complete, session.start / session.end, command.run. Not hooked, said plainly: the engine's http.fetch is a plugin's own request, not the model's, so model egress is seen through tool.check on WebFetch, WebSearch, network commands and remote MCP calls; prompt.submit is not used (the prompt for taint comparison comes from turn.start); a tool.check verdict that is ask is learned unless denied (the person approved or will decide). PR-004 follows cred reads through Read/Bash only, PR-007 counts tool.check calls per minute. The files are a courtesy rule: decisions use the mod's memory, never a file re-read mid-session, so editing rules.json or allow.json while a session runs changes nothing until the next session.

Performance

npx tsx plugins/ruflo-protector/scripts/bench.mjs: median added cost per tool call is under 1 ms (budget); measured medians are 5 to 15 microseconds for ordinary calls and about 70 microseconds for a 200 KB write.

Source 12 files
hooks/register.ts 207 lines
1import type { Hook, Register } from 'claude-code'
2
3import { answer } from './command'
4import { Engine } from './engine'
5import { readOptions } from './options'
6import { parseBaseline } from './baseline'
7import { CONFIG_FILES, scanConfig } from './scan'
8import { isTaintSource, normalise, recvEv, spawnEv } from './shapes'
9import { alertsText, allowText, FILES, overridesText, parseAllow, parseAlerts, parseOverrides, parseRing, ringText, statusText } from './store'
10import { INJECTION } from './screen'
11import { createToastKit, lineOf, type Toaster, type ToastLevel } from './toast-policy'
12
13type Dollar = Parameters<Hook<'session.start'>>[0]
14
15/** Read a project file as text; a missing or unreadable file is an empty string. */
16async function readText($: Dollar, root: string, rel: string): Promise<string> {
17  try {
18    return await $.fs.read(`${root}/${rel}`)
19  } catch {
20    return ''
21  }
22}
23
24async function write($: Dollar, e: Engine, rel: string, text: string): Promise<void> {
25  if (e.root === '') return
26  try {
27    await $.fs.write(`${e.root}/${rel}`, text)
28  } catch (err) {
29    e.degraded = `write failed: ${String((err as Error)?.message ?? err).slice(0, 60)}`
30  }
31}
32
33/** The files a person reads: status always, baseline and alerts when they changed, the rest on demand. */
34async function flush($: Dollar, e: Engine, all: boolean): Promise<void> {
35  const now = await $.clock.now()
36  await write($, e, FILES.status, statusText(e.status(), now))
37  if (!all) return
38  await write($, e, FILES.baseline, `${JSON.stringify(e.baseline)}\n`)
39  await write($, e, FILES.alerts, alertsText(e.alerts))
40}
41
42async function persistAll($: Dollar, e: Engine): Promise<void> {
43  await flush($, e, true)
44  await write($, e, FILES.rules, overridesText(e.overrides))
45  await write($, e, FILES.allow, allowText(e.allow))
46}
47
48
49/** A toast never changes a verdict: it goes through the policy (levels, dedupe, rate limit, the person's setting, a digest for the console), and a refusal is swallowed. */
50function toast($: Dollar, held: { toaster?: Toaster }, level: ToastLevel, text: string, always = false): void {
51  try {
52    if (held.toaster !== undefined) void held.toaster.toast({ level, text, ...(always && { always }) })
53    else $.ui.toast(lineOf(level, text))
54  } catch {
55    /* a refused toast never changes a verdict */
56  }
57}
58
59/**
60 * Project Anatole (ADR-453) as a mod: normalise each event into categorical tokens, apply the deterministic rules, learn only clean events, and report.
61 * Only a rule marked `block`, in `enforce`, ever denies. Any failure inside the protector passes the call through and sets `degraded`.
62 */
63export const register: Register = (on, options) => {
64  const opts = readOptions(options)
65  const e = new Engine(opts)
66  /** The shared toast policy (ADR-477), bound to the engine at session.start; until then a toast is drawn plainly. */
67  const held: { toaster?: Toaster } = {}
68
69  on('session.start', async ($, ev, next) => {
70    const result = await next(ev)
71    try {
72      e.root = ((await $.session.root()) as string | undefined) ?? ''
73      const at = (path: string) => (e.root === '' ? path : `${e.root}/${path}`)
74
75      held.toaster = createToastKit({
76        source: 'protector',
77        now: () => $.clock.now(),
78        show: (line, options) => $.ui.toast(line, options),
79        after: (ms, fn) => $.clock.after(ms, fn),
80        io: { read: path => $.fs.read(at(path)), write: (path, text) => $.fs.write(at(path), text), exists: path => $.fs.exists(at(path)) },
81      })
82      const now = await $.clock.now()
83      const sid = `${now.toString(36)}`
84      if (e.root !== '') {
85        e.load(parseOverrides(await readText($, e.root, FILES.rules)), parseAllow(await readText($, e.root, FILES.allow)), parseAlerts(await readText($, e.root, FILES.alerts)), parseBaseline(await readText($, e.root, FILES.baseline)), parseRing(await readText($, e.root, FILES.ring)))
86      }
87      e.begin(sid, e.root, now)
88      try {
89        await $.command.register({ name: 'protector', description: 'Project Anatole: status, list, mode, rule, run, replay, alerts, ack, allow' })
90      } catch {
91        /* a name taken by another plugin must not stop the mod */
92      }
93      await persistAll($, e)
94    } catch (err) {
95      e.degraded = `session start: ${String((err as Error)?.message ?? err).slice(0, 60)}`
96    }
97    return result
98  })
99
100  on('turn.start', async ($, ev, next) => {
101    try {
102      e.newTurn(typeof ev.text === 'string' ? ev.text : '')
103    } catch {
104      e.degraded = 'turn start'
105    }
106    return next(ev)
107  })
108
109  on('turn.complete', async ($, ev, next) => {
110    const result = await next(ev)
111    try {
112      const now = await $.clock.now()
113      if (e.graduate(now)) {
114        toast($, held, 'info', 'Project Anatole: the baseline is mature, so mode moved from learn to notify (never enforce). /protector mode learn to undo.', true)
115        await persistAll($, e)
116      } else await flush($, e, true)
117    } catch {
118      e.degraded = 'turn complete'
119    }
120    return result
121  })
122
123  on('session.end', async ($, ev, next) => {
124    try {
125      await persistAll($, e)
126      await write($, e, FILES.ring, ringText(e.ring))
127    } catch {
128      /* a courtesy */
129    }
130    return next(ev)
131  })
132
133  /** Every tool use: the chain runs first (it may already refuse), then the protector scores the call. A block is a deny; it never asks. */
134  on('tool.check', async ($, ev, next) => {
135    const chain = await next(ev)
136    if (e.mode === 'off') return chain
137    try {
138      const tool = typeof ev.tool === 'string' ? ev.tool : ''
139      const now = await $.clock.now()
140      const out = e.evaluate(normalise(tool, ev.input, e.root, now), now, chain.decision === 'deny')
141      if (isTaintSource(tool, ev.input)) e.tainted = true
142      if (out.alert) {
143        toast($, held, out.alert.action === 'blocked' ? 'error' : 'warn', `Project Anatole ${out.alert.action === 'blocked' ? 'blocked' : 'noticed'}: ${out.alert.rule} (${out.alert.severity}). /protector alerts`)
144        await flush($, e, true)
145      }
146      if (out.verdict === 'block' && chain.decision !== 'deny') return { decision: 'deny', reason: out.deny }
147    } catch (err) {
148      e.degraded = `tool.check: ${String((err as Error)?.message ?? err).slice(0, 60)}`
149    }
150    return chain
151  }).catch(($, ev, next) => next(ev))
152
153  on('agent.spawn', async ($, ev, next) => {
154    const result = await next(ev)
155    if (e.mode !== 'off') {
156      try {
157        const now = await $.clock.now()
158        const out = e.evaluate(spawnEv(String(ev.subagentType ?? ''), ev.permissionMode, now), now, false)
159        if (out.alert) {
160          toast($, held, 'warn', `Project Anatole noticed: ${out.alert.rule} (${out.alert.severity}). /protector alerts`)
161          await flush($, e, true)
162        }
163      } catch {
164        e.degraded = 'agent.spawn'
165      }
166    }
167    return result
168  })
169
170  on('session.receive', async ($, ev, next) => {
171    if (e.mode !== 'off' && ev.origin.kind !== 'bridge' && ev.origin.kind !== 'scheduled-trigger') {
172      try {
173        const names = ['override instructions', 'role reassignment', 'fake role tags', 'concealment']
174        const hit = INJECTION.some(([n, re]) => names.includes(n) && re.test(String(ev.text).slice(0, 20_000)))
175        if (hit) {
176          const now = await $.clock.now()
177          const out = e.evaluate(recvEv(true, now), now, false)
178          if (out.alert) {
179            toast($, held, 'warn', `Project Anatole noticed: ${out.alert.rule} (${out.alert.severity}). /protector alerts`)
180            await flush($, e, true)
181          }
182        }
183      } catch {
184        e.degraded = 'session.receive'
185      }
186    }
187    return next(ev)
188  })
189
190  on('command.run', { command: 'protector' }, async ($, ev) => {
191    const now = await $.clock.now()
192    const files: Record<string, string> = {}
193    if (typeof ev.args === 'string' && ev.args.trim().startsWith('run') && e.root !== '') {
194      for (const rel of CONFIG_FILES) {
195        const text = await readText($, e.root, rel)
196        if (text !== '') files[rel] = text
197      }
198    }
199    const before = JSON.stringify([e.overrides, e.allow, e.baseline.events])
200    let changed = false
201    const text = answer(typeof ev.args === 'string' ? ev.args : '', e, { now, persist: () => { changed = true }, scan: () => scanConfig(files) })
202    if (changed || before !== JSON.stringify([e.overrides, e.allow, e.baseline.events])) await persistAll($, e)
203    else await flush($, e, false)
204    return { text }
205  })
206}
207
hooks/command.ts 108 lines
1import { maturity } from './baseline'
2import type { Engine } from './engine'
3import { asMode, MODES } from './options'
4import { ruleById, RULES } from './rules'
5import { hitsOf } from './rules'
6import type { Ctx } from './rules'
7
8const HELP = [
9  '/protector status', '/protector list', '/protector rule <id> <off|notify|block>', '/protector mode <off|learn|notify|enforce>', '/protector run', '/protector replay',
10  '/protector alerts [n]', '/protector ack <id>', '/protector unack <id>', '/protector allow <fp>', '/protector reset-baseline',
11].join('\n')
12
13export type Effects = { readonly persist: () => void; readonly now: number; readonly scan: () => string[] }
14
15const line = (n: number, s: string) => `${s}${n === 1 ? '' : 's'}`
16
17/** `/protector` is answered locally and takes no model turn; every verb in the ADR table lives here. */
18export function answer(args: string, e: Engine, fx: Effects): string {
19  const [verb = '', a = '', b = ''] = args.trim().split(/\s+/)
20
21  if (verb === '' || verb === 'help') return HELP
22
23  if (verb === 'status') {
24    const s = e.status()
25    const m = maturity(e.baseline, fx.now)
26    const c = s.alertCounts
27    return [
28      `Project Anatole · mode ${e.mode}${e.mode === 'learn' ? ' (says nothing, only learns)' : ''}${s.degraded ? ` · DEGRADED: ${s.degraded}` : ''}`,
29      `baseline: ${m.mature ? 'mature' : `learning ${m.pct}%`} · ${e.baseline.events} events · ${e.baseline.sessions} ${line(e.baseline.sessions, 'session')}`,
30      `alerts: ${c.open} open (critical ${c.critical}, high ${c.high}, medium ${c.medium}) of ${c.total} · blocked ${s.blocked}${s.suppressed ? ` · ${s.suppressed} held back by the rate limit` : ''}`,
31      `rules: ${s.ruleCounts.total} (block ${s.ruleCounts.block}, notify ${s.ruleCounts.notify}, off ${s.ruleCounts.off}) · calls seen ${s.calls}`,
32    ].join('\n')
33  }
34
35  if (verb === 'list') {
36    return e.rows().map(r => `${r.rule.id}  ${r.rule.severity.padEnd(8)} ${r.mode.padEnd(6)} hits ${String(r.hits).padStart(3)} acked ${String(r.acked).padStart(3)}%  ${r.rule.owasp.join(',')}  ${r.rule.summary}${r.demoted ? '  [auto-demoted to notify]' : r.changed ? '  [changed from default]' : ''}`).join('\n')
37  }
38
39  if (verb === 'rule') {
40    const id = a.toUpperCase()
41    if (!ruleById(id) || !(b === 'off' || b === 'notify' || b === 'block')) return 'usage: /protector rule <PR-001..PR-013> <off|notify|block>'
42    e.setRule(id, b)
43    fx.persist()
44    return `${id} is now ${b}${b === 'block' ? ' (it only denies in enforce mode)' : ''}.`
45  }
46
47  if (verb === 'mode') {
48    const m = asMode(a)
49    if (!m) return `usage: /protector mode <${MODES.join('|')}>`
50    e.setMode(m)
51    fx.persist()
52    return `mode is now ${m}${m === 'enforce' ? '. Rules marked block will deny; everything else still only notifies. Try /protector replay first.' : ''}`
53  }
54
55  if (verb === 'alerts') {
56    const n = Math.max(1, Math.min(50, Number(a) || 10))
57    const rows = e.alerts.slice(-n).reverse()
58    return rows.length ? rows.map(x => `${x.id}  ${x.state.padEnd(7)} ${x.severity.padEnd(8)} ${x.rule}  ${x.action}${x.count && x.count > 1 ? ` x${x.count}` : ''}  fp ${x.fp}  ${x.summary}`).join('\n') : 'No alerts.'
59  }
60
61  if (verb === 'ack' || verb === 'unack') {
62    if (a === '') return `usage: /protector ${verb} <alert id>`
63    const r = verb === 'ack' ? e.ack(a, fx.now) : e.unack(a)
64    if (r === 'ok') fx.persist()
65    return r === 'ok' ? (verb === 'ack' ? `${a} marked as a false positive; its fingerprint is allowed and the baseline learned it.` : `${a} reopened.`) : `No alert ${a}.`
66  }
67
68  if (verb === 'allow') {
69    if (!/^[0-9a-f]{12}$/.test(a)) return 'usage: /protector allow <12-hex fingerprint> (it is in the alert and in the block reason)'
70    e.addAllow(a, e.alerts.find(x => x.fp === a)?.rule ?? '', fx.now, 'allowed')
71    fx.persist()
72    return `${a} is allowed: that exact (rule, command head, host or area) no longer alerts or blocks.`
73  }
74
75  if (verb === 'reset-baseline') {
76    e.resetBaseline()
77    fx.persist()
78    return 'Baseline forgotten. Project Anatole is learning again (rules still apply in notify and enforce).'
79  }
80
81  if (verb === 'run') {
82    const found = fx.scan()
83    return found.length ? `Configuration findings (${found.length}):\n${found.map(f => `- ${f}`).join('\n')}` : 'Configuration scan: nothing found in .claude/settings*.json.'
84  }
85
86  if (verb === 'replay') return replay(e, fx.now)
87
88  return `Unknown: ${verb}\n${HELP}`
89}
90
91/** Re-score the recorded event ring against the current rules and baseline; says what would fire in notify and in enforce. */
92function replay(e: Engine, now: number): string {
93  const mature = maturity(e.baseline, now).mature
94  let notify = 0
95  let enforce = 0
96  const by = new Map<string, number>()
97  e.ring.forEach((ev, i) => {
98    const c: Ctx = { baseline: e.baseline, mature, credRead: e.ring.slice(Math.max(0, i - 20), i + 1).some(x => x.flags.includes('cred-read')), promptWords: new Set(), calls1m: 0, spawns1m: 0 }
99    const hits = hitsOf(ev, c).filter(r => e.modeOf(r) !== 'off')
100    if (hits.length === 0) return
101    notify++
102    if (hits.some(r => e.modeOf(r) === 'block')) enforce++
103    for (const r of hits) by.set(r.id, (by.get(r.id) ?? 0) + 1)
104  })
105  const detail = RULES.filter(r => by.has(r.id)).map(r => `${r.id} x${by.get(r.id)}`).join(', ')
106  return `Replay of ${e.ring.length} recorded events (${mature ? 'mature' : 'immature'} baseline): ${notify} would notify, ${enforce} would be blocked in enforce${detail ? ` (${detail})` : ''}.`
107}
108
hooks/engine.ts 229 lines
1import { type Baseline, closeMinute, emptyBaseline, learn, maturity } from './baseline'
2import type { Mode, ModOptions } from './options'
3import { type Ctx, hitsOf, ruleById, RULES, type Rule, type RuleMode } from './rules'
4import type { Ev } from './shapes'
5import { type Alert, type AllowEntry, fingerprint, MAX_ALERTS, MAX_RING, MOD_VERSION, type Overrides, type Status } from './store'
6
7export type Verdict = 'allow' | 'note' | 'notify' | 'block'
8export type Outcome = { verdict: Verdict; deny?: string; alert?: Alert; rules: string[] }
9
10const HOUR = 3_600_000
11const MAX_ALERTS_PER_HOUR = 20
12const DEMOTE = { min: 10, share: 0.3, window: 50 }
13const SEV = { critical: 0, high: 1, medium: 2, low: 3 } as const
14
15/** Everything one session of the protector knows. Pure state and decisions: no `$`, no files. */
16export class Engine {
17  mode: Mode
18  baseline: Baseline = emptyBaseline()
19  overrides: Overrides = { rules: {} }
20  allow: AllowEntry[] = []
21  alerts: Alert[] = []
22  ring: Ev[] = []
23  degraded: false | string = false
24  calls = 0
25  blocked = 0
26  suppressed = 0
27  startedAt = 0
28  session = ''
29  root = ''
30  /** Per-turn facts; never persisted. */
31  credRead = false
32  tainted = false
33  promptWords = new Set<string>()
34  private seq = 0
35  private evs = new Map<string, Ev>()
36  private fpSeen = new Map<string, Alert>()
37  private stamps: number[] = []
38  private minute = { at: 0, calls: 0, spawns: 0 }
39  readonly hits: Record<string, number> = {}
40
41  constructor(private readonly opts: ModOptions) {
42    this.mode = opts.mode
43  }
44
45  begin(session: string, root: string, now: number) {
46    this.session = session
47    this.root = root
48    this.startedAt = now
49    this.baseline.sessions++
50    if (this.baseline.firstSeenAt === 0) this.baseline.firstSeenAt = now
51  }
52
53  load(overrides: Overrides, allow: AllowEntry[], alerts: Alert[], baseline: Baseline, ring: Ev[]) {
54    this.overrides = overrides
55    if (overrides.mode) this.mode = overrides.mode
56    this.allow = allow
57    this.alerts = alerts
58    this.baseline = baseline
59    this.ring = ring
60    for (const a of alerts) this.hits[a.rule] = (this.hits[a.rule] ?? 0) + 1
61  }
62
63  modeOf(rule: Rule): RuleMode {
64    return this.overrides.rules[rule.id]?.mode ?? rule.default
65  }
66
67  newTurn(prompt: string) {
68    this.credRead = false
69    this.tainted = false
70    this.promptWords = new Set(prompt.toLowerCase().split(/[^a-z0-9.-]+/).filter(w => w.length > 1).slice(0, 2000))
71  }
72
73  private tick(now: number, spawn: boolean) {
74    const at = Math.floor(now / 60_000)
75    if (at !== this.minute.at) {
76      if (this.minute.at !== 0) closeMinute(this.baseline, this.minute.calls)
77      this.minute = { at, calls: 0, spawns: 0 }
78    }
79    if (spawn) this.minute.spawns++
80    else this.minute.calls++
81  }
82
83  private ctx(now: number): Ctx {
84    return { baseline: this.baseline, mature: maturity(this.baseline, now).mature, credRead: this.credRead, promptWords: this.promptWords, calls1m: this.minute.calls, spawns1m: this.minute.spawns }
85  }
86
87  /**
88   * Score one event. `denied` is true when the chain beneath already refused the call (it is never learned). Returns the verdict (§4.3):
89   * only a rule marked `block`, in `enforce`, ever denies; a learned anomaly alone never does.
90   */
91  evaluate(ev: Ev, now: number, denied: boolean): Outcome {
92    this.calls++
93    this.tick(now, ev.k === 'spawn')
94    if (ev.flags.includes('cred-read')) this.credRead = true
95    ev.tainted = this.tainted
96    this.ring.push(ev)
97    if (this.ring.length > MAX_RING) this.ring.shift()
98    if (this.mode === 'off') return { verdict: 'allow', rules: [] }
99
100    const fired = hitsOf(ev, this.ctx(now))
101    const live = fired.filter(r => this.modeOf(r) !== 'off' && !this.allowed(fingerprintOf(r, ev)))
102    for (const r of fired) this.hits[r.id] = (this.hits[r.id] ?? 0) + 1
103    if (fired.length === 0 && !denied && !ev.tainted) learn(this.baseline, ev, this.session, now)
104    if (live.length === 0) return { verdict: 'allow', rules: [] }
105    const top = [...live].sort((a, b) => SEV[a.severity] - SEV[b.severity])[0] as Rule
106    const rules = live.map(r => r.id)
107    if (this.mode === 'learn') return { verdict: 'note', rules }
108
109    const block = this.mode === 'enforce' ? live.filter(r => this.modeOf(r) === 'block').sort((a, b) => SEV[a.severity] - SEV[b.severity])[0] : undefined
110    const chosen = block ?? top
111    const fp = fingerprintOf(chosen, ev)
112    const alert = this.raise(chosen, ev, fp, block ? 'blocked' : 'notified', now)
113    if (block) {
114      this.blocked++
115      return { verdict: 'block', rules, alert, deny: `ruflo-protector (Project Anatole) blocked this call: ${block.id} ${block.summary}. If it is intended, allow it with /protector allow ${fp} (or set mode notify).` }
116    }
117    return { verdict: alert ? 'notify' : 'note', rules, alert }
118  }
119
120  private allowed(fp: string) {
121    return this.allow.some(a => a.fp === fp)
122  }
123
124  /** One alert per fingerprint per session (a repeat only raises its count); at most 20 new alerts an hour; the rest are counted, not written. */
125  private raise(r: Rule, ev: Ev, fp: string, action: Alert['action'], now: number): Alert | undefined {
126    const prior = this.fpSeen.get(fp)
127    if (prior) {
128      prior.count = (prior.count ?? 1) + 1
129      return undefined
130    }
131    this.stamps = this.stamps.filter(t => now - t < HOUR)
132    if (this.stamps.length >= MAX_ALERTS_PER_HOUR) {
133      this.suppressed++
134      return undefined
135    }
136    this.stamps.push(now)
137    const shape = [ev.tool, ev.head && `head ${ev.head}`, ev.host && `host ${ev.host}`, ev.areas[0] && `area ${ev.areas[0]}`].filter(Boolean).join(' · ')
138    const a: Alert = {
139      id: `${this.session.slice(0, 6) || 's'}-${++this.seq}`, at: now, rule: r.id, owasp: [...r.owasp], severity: r.severity, action, tool: ev.tool.slice(0, 40),
140      summary: `${r.summary} (${shape})`.slice(0, 160), fp, state: 'open', session: this.session.slice(0, 12),
141    }
142    this.alerts.push(a)
143    if (this.alerts.length > MAX_ALERTS) this.alerts.shift()
144    this.fpSeen.set(fp, a)
145    this.evs.set(a.id, ev)
146    this.demoteIfNoisy(r.id)
147    return a
148  }
149
150  /** A rule whose recent alerts are mostly acked (more than 30%, at least 10 alerts) drops to notify. */
151  demoteIfNoisy(id: string): boolean {
152    const recent = this.alerts.filter(a => a.rule === id).slice(-DEMOTE.window)
153    const rule = ruleById(id)
154    if (!rule || recent.length < DEMOTE.min || this.modeOf(rule) !== 'block') return false
155    if (recent.filter(a => a.state === 'acked').length / recent.length <= DEMOTE.share) return false
156    this.overrides.rules[id] = { mode: 'notify', demoted: true }
157    return true
158  }
159
160  ack(id: string, now: number): 'ok' | 'missing' {
161    const a = this.alerts.find(x => x.id === id)
162    if (!a) return 'missing'
163    a.state = 'acked'
164    this.addAllow(a.fp, a.rule, now, 'acked')
165    const ev = this.evs.get(id)
166    if (ev) learn(this.baseline, { ...ev, flags: [] }, this.session, now)
167    this.demoteIfNoisy(a.rule)
168    return 'ok'
169  }
170
171  unack(id: string): 'ok' | 'missing' {
172    const a = this.alerts.find(x => x.id === id)
173    if (!a) return 'missing'
174    a.state = 'open'
175    this.allow = this.allow.filter(e => e.fp !== a.fp)
176    return 'ok'
177  }
178
179  addAllow(fp: string, rule: string, now: number, note: string) {
180    if (!this.allowed(fp)) this.allow.push({ fp, rule, at: now, note })
181    if (this.allow.length > 500) this.allow.shift()
182    for (const a of this.alerts) if (a.fp === fp && a.state === 'open') a.state = 'allowed'
183  }
184
185  /** Learn graduates to notify on maturity when `autoGraduate` is on; it never reaches enforce. */
186  graduate(now: number): boolean {
187    if (this.mode !== 'learn' || !this.opts.autoGraduate || !maturity(this.baseline, now).mature) return false
188    this.mode = 'notify'
189    this.overrides.mode = 'notify'
190    return true
191  }
192
193  setMode(m: Mode) {
194    this.mode = m
195    this.overrides.mode = m
196  }
197
198  setRule(id: string, mode: RuleMode) {
199    this.overrides.rules[id] = { mode }
200  }
201
202  resetBaseline() {
203    this.baseline = emptyBaseline()
204    this.baseline.sessions = 1
205    this.baseline.firstSeenAt = this.startedAt
206  }
207
208  status(): Status {
209    const open = this.alerts.filter(a => a.state === 'open')
210    const by = (s: string) => open.filter(a => a.severity === s).length
211    const modes = RULES.map(r => this.modeOf(r))
212    return {
213      mode: this.mode, calls: this.calls, blocked: this.blocked, startedAt: this.startedAt, updatedAt: 0, suppressed: this.suppressed, degraded: this.degraded, version: MOD_VERSION,
214      alertCounts: { open: open.length, critical: by('critical'), high: by('high'), medium: by('medium'), low: by('low'), total: this.alerts.length },
215      baseline: this.baseline, ruleCounts: { total: RULES.length, block: modes.filter(m => m === 'block').length, notify: modes.filter(m => m === 'notify').length, off: modes.filter(m => m === 'off').length },
216    }
217  }
218
219  /** Per-rule row for `/protector list`: hits and the acked share of the recent alerts. */
220  rows() {
221    return RULES.map(r => {
222      const mine = this.alerts.filter(a => a.rule === r.id)
223      return { rule: r, mode: this.modeOf(r), hits: this.hits[r.id] ?? 0, acked: mine.length ? Math.round((mine.filter(a => a.state === 'acked').length / mine.length) * 100) : 0, demoted: this.overrides.rules[r.id]?.demoted === true, changed: this.overrides.rules[r.id] !== undefined && this.overrides.rules[r.id]?.mode !== r.default }
224    })
225  }
226}
227
228export const fingerprintOf = (r: Rule, ev: Ev) => fingerprint(`${r.id}|${ev.head}|${ev.host || ev.areas[0] || ''}`)
229
hooks/options.ts 23 lines
1import type { PluginOptions } from 'claude-code'
2
3export type Mode = 'off' | 'learn' | 'notify' | 'enforce'
4export const MODES: readonly Mode[] = ['off', 'learn', 'notify', 'enforce']
5
6/** The plugin's `userConfig`, validated: a bad value is the default (learn, graduate on). */
7export type ModOptions = {
8  readonly mode: Mode
9  readonly autoGraduate: boolean
10}
11
12// BEGIN SHARED FLAG (generated by scripts/sync-mod-screen.mjs; edit plugins/ruflo-agentdb/hooks/options.ts)
13export const flag = (value: unknown, fallback: boolean) =>
14  value === true || value === 'true' || value === 'on' ? true : value === false || value === 'false' || value === 'off' ? false : fallback
15// END SHARED FLAG
16
17export const asMode = (value: unknown): Mode | undefined => MODES.find(m => m === value)
18
19export function readOptions(options: PluginOptions | undefined): ModOptions {
20  const o = options ?? {}
21  return { mode: asMode(o.mode) ?? 'learn', autoGraduate: flag(o.autoGraduate, true) }
22}
23
hooks/baseline.ts 126 lines
1import type { Ev } from './shapes'
2
3/** A learned token: how often, when, and in how many distinct sessions (`s`, capped; `l` is the last session's short id). */
4export type Entry = { n: number; first: number; last: number; s: number; l: string }
5export type Counts = Record<string, Entry>
6
7export type Baseline = {
8  schemaVersion: 1
9  events: number
10  sessions: number
11  firstSeenAt: number
12  updatedAt: number
13  tools: Counts
14  commands: Counts
15  hosts: Counts
16  areas: Counts
17  spawns: Counts
18  rate: { p50: number; p95: number; max: number }
19  /** Closed per-minute call counts, the sample the rate figures come from (bounded). */
20  mins: number[]
21}
22
23export const CAP = 2000
24export const MATURE = { events: 200, sessions: 3, ms: 24 * 3_600_000 }
25const MAX_MINS = 600
26
27export const emptyBaseline = (): Baseline => ({
28  schemaVersion: 1, events: 0, sessions: 0, firstSeenAt: 0, updatedAt: 0,
29  tools: {}, commands: {}, hosts: {}, areas: {}, spawns: {}, rate: { p50: 0, p95: 0, max: 0 }, mins: [],
30})
31
32const size = (b: Baseline) => Object.keys(b.tools).length + Object.keys(b.commands).length + Object.keys(b.hosts).length + Object.keys(b.areas).length + Object.keys(b.spawns).length
33
34/** Count one token; a new session id bumps the distinct-session figure. */
35function touch(map: Counts, tok: string, at: number, session: string) {
36  const e = map[tok]
37  if (e === undefined) map[tok] = { n: 1, first: at, last: at, s: 1, l: session }
38  else {
39    e.n++
40    e.last = at
41    if (e.l !== session) {
42      e.s = Math.min(e.s + 1, 255)
43      e.l = session
44    }
45  }
46}
47
48/** Drop the least recently seen entries until the whole baseline holds at most CAP tokens. */
49function evict(b: Baseline) {
50  let over = size(b) - CAP
51  if (over <= 0) return
52  const all = (['tools', 'commands', 'hosts', 'areas', 'spawns'] as const).flatMap(k => Object.entries(b[k]).map(([t, e]) => ({ k, t, last: e.last })))
53  all.sort((x, y) => x.last - y.last)
54  for (const v of all) {
55    if (over-- <= 0) break
56    delete b[v.k][v.t]
57  }
58}
59
60/** Teach the baseline one CLEAN event (the caller has checked no rule fired, nothing was denied, the turn was not tainted). */
61export function learn(b: Baseline, ev: Ev, session: string, at: number) {
62  if (b.firstSeenAt === 0) b.firstSeenAt = at
63  b.events++
64  b.updatedAt = at
65  touch(b.tools, ev.tool, at, session)
66  if (ev.k === 'spawn' && ev.head !== '') touch(b.spawns, ev.head, at, session)
67  for (const h of ev.heads) touch(b.commands, h, at, session)
68  if (ev.host !== '') touch(b.hosts, ev.host, at, session)
69  for (const a of ev.areas) touch(b.areas, a, at, session)
70  evict(b)
71}
72
73export const maturity = (b: Baseline, now: number) => {
74  const r = [b.events / MATURE.events, b.sessions / MATURE.sessions, b.firstSeenAt === 0 ? 0 : (now - b.firstSeenAt) / MATURE.ms].map(x => Math.max(0, Math.min(1, x)))
75  return { pct: Math.round((r.reduce((a, c) => a + c, 0) / 3) * 100), mature: r.every(x => x >= 1) }
76}
77
78/** A token is known when it was seen; a risky class needs two different sessions before it counts (the slow-boil defence). */
79export const known = (map: Counts, tok: string, risky: boolean) => {
80  const e = map[tok]
81  return e !== undefined && (!risky || e.s >= 2)
82}
83
84/** Fold a closed minute's call count into the rate sample and recompute p50/p95/max. */
85export function closeMinute(b: Baseline, calls: number) {
86  if (calls <= 0) return
87  b.mins.push(calls)
88  if (b.mins.length > MAX_MINS) b.mins.shift()
89  const s = [...b.mins].sort((x, y) => x - y)
90  const at = (q: number) => s[Math.min(s.length - 1, Math.floor(q * s.length))] ?? 0
91  b.rate = { p50: at(0.5), p95: at(0.95), max: s[s.length - 1] ?? 0 }
92}
93
94function counts(v: unknown): Counts {
95  const out: Counts = {}
96  if (v === null || typeof v !== 'object') return out
97  for (const [t, e] of Object.entries(v as Record<string, unknown>).slice(0, CAP)) {
98    const o = e as Partial<Entry> | null
99    if (o && typeof o.n === 'number') out[t.slice(0, 40)] = { n: o.n, first: Number(o.first) || 0, last: Number(o.last) || 0, s: Math.min(Number(o.s) || 1, 255), l: String(o.l ?? '').slice(0, 12) }
100  }
101  return out
102}
103
104/** A baseline file read back; anything that is not a schemaVersion 1 baseline starts a fresh one. */
105export function parseBaseline(text: string): Baseline {
106  try {
107    const o = JSON.parse(text) as Partial<Baseline>
108    if (o.schemaVersion !== 1) return emptyBaseline()
109    const b = emptyBaseline()
110    b.events = Number(o.events) || 0
111    b.sessions = Number(o.sessions) || 0
112    b.firstSeenAt = Number(o.firstSeenAt) || 0
113    b.updatedAt = Number(o.updatedAt) || 0
114    for (const k of ['tools', 'commands', 'hosts', 'areas', 'spawns'] as const) b[k] = counts(o[k])
115    b.mins = Array.isArray(o.mins) ? o.mins.filter((x): x is number => typeof x === 'number').slice(-MAX_MINS) : []
116    closeMinute(b, 0)
117    if (b.mins.length > 0) {
118      const s = [...b.mins].sort((x, y) => x - y)
119      b.rate = { p50: s[Math.floor(s.length / 2)] ?? 0, p95: s[Math.min(s.length - 1, Math.floor(0.95 * s.length))] ?? 0, max: s[s.length - 1] ?? 0 }
120    }
121    return b
122  } catch {
123    return emptyBaseline()
124  }
125}
126
hooks/scan.ts 23 lines
1import { hasSecret } from './screen'
2
3/** Paths `/protector run` reads (relative to the project root). */
4export const CONFIG_FILES = ['.claude/settings.json', '.claude/settings.local.json'] as const
5
6/**
7 * Configuration exposure findings over the project's Claude files. Names and paths only: a matched value is never returned.
8 * `files` maps a project-relative path to its text (a missing file is simply absent).
9 */
10export function scanConfig(files: Readonly<Record<string, string>>): string[] {
11  const out: string[] = []
12  for (const [path, text] of Object.entries(files)) {
13    const t = text.slice(0, 200_000)
14    if (hasSecret(t)) out.push(`${path}: holds a secret-shaped value`)
15    if (/\b(?:curl|wget)\b[^|\n"]{0,200}\|\s*(?:sudo\s+)?(?:ba|z)?sh\b/i.test(t)) out.push(`${path}: a hook or command pipes remote code into a shell`)
16    if (/"defaultMode"\s*:\s*"bypassPermissions"/.test(t)) out.push(`${path}: defaultMode is bypassPermissions (nothing asks before a tool runs)`)
17    if (/"(?:Bash|WebFetch)\(\*\)"|"Bash"\s*[,\]]/.test(t) && /"allow"/.test(t)) out.push(`${path}: allow list grants every Bash or WebFetch call`)
18    if (/"hooks"\s*:/.test(t) && /\bhttps?:\/\//.test(t.slice(t.indexOf('"hooks"')))) out.push(`${path}: a hook section names a network address`)
19    if (/"enableAllProjectMcpServers"\s*:\s*true/.test(t)) out.push(`${path}: enableAllProjectMcpServers is on (a cloned repo can add MCP servers)`)
20  }
21  return out
22}
23
hooks/shapes.ts 179 lines
1import { hasRootDelete } from './rootdelete'
2import { hasSecret, INJECTION, textsOf } from './screen'
3
4/** What an event is, as a closed set of tokens. No argument value, file content or prompt text ever sits in one. */
5export type Risk = 'read' | 'write' | 'exec' | 'net' | 'spawn' | 'other'
6export type Flag = 'secret' | 'pipe-shell' | 'persist' | 'cred-read' | 'destroy' | 'dep-url' | 'sysprompt' | 'override' | 'escalate' | 'body'
7
8export type Ev = {
9  readonly k: 'tool' | 'spawn' | 'recv'
10  readonly tool: string
11  readonly head: string
12  readonly heads: readonly string[]
13  readonly host: string
14  readonly areas: readonly string[]
15  readonly risk: Risk
16  readonly flags: readonly Flag[]
17  tainted: boolean
18  at: number
19}
20
21/** Local setup commands that touch Claude configuration by design: matched whole (exact argv), never by prefix. */
22export const EXEMPT_ARGV: readonly string[] = [
23  'npx ruflo init', 'npx ruflo@latest init', 'npx claude-flow init', 'npx @claude-flow/cli@latest init', 'npx @claude-flow/cli@latest init --wizard',
24  'npx @claude-flow/cli@latest doctor --fix', 'npx @claude-flow/cli@latest daemon start', 'claude mcp add claude-flow -- npx -y @claude-flow/cli@latest',
25]
26
27const SUBCMD = new Set(['git', 'npm', 'npx', 'cargo', 'docker', 'kubectl', 'gh', 'pnpm', 'yarn', 'pip', 'pip3', 'go', 'gcloud', 'systemctl', 'claude'])
28const NET_HEADS = new Set(['curl', 'wget', 'nc', 'ncat', 'netcat', 'telnet', 'socat', 'scp', 'sftp', 'ftp', 'http', 'xh'])
29const WRAPPERS = new Set(['sudo', 'time', 'env', 'nohup', 'command', 'exec', 'nice', 'doas'])
30const LOCAL_MCP = /ruflo|claude-flow|ruv-swarm|ruvector|ide|console|plugin_ruflo|rulake|ruos|agentdb/i
31const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
32const READ_TOOLS = new Set(['Read', 'Glob', 'Grep', 'LS', 'NotebookRead'])
33const TAINT_TOOLS = new Set(['WebFetch', 'WebSearch'])
34
35/** A token worth keeping: short, plain characters, and never a secret. Anything else is a coarse placeholder. */
36export function token(t: string): string {
37  if (t.length > 24 || !/^[A-Za-z0-9._+@-]+$/.test(t)) return 'other'
38  return hasSecret(t) ? 'redacted' : t
39}
40
41const PERSIST_PATH = /(?:^|\/)(?:\.claude\/(?:settings[\w.-]*\.json|hooks\/|helpers\/)|\.claude-flow\/protector-mod\/(?:rules|allow)\.json|\.(?:bashrc|zshrc|profile|bash_profile|zprofile|zshenv)|\.ssh\/authorized_keys|\.git\/hooks\/|crontab)/
42const CRED_PATH = /(?:^|\/)(?:\.ssh\/|\.aws\/|\.config\/gcloud|\.azure\/|\.gnupg\/|\.mozilla\/|\.config\/(?:google-chrome|chromium)|Library\/(?:Keychains|Application Support\/(?:Google|Firefox)))/
43const WRITE_OP = /(?:>>?|\btee\b|\bsed\s+-i|\bcp\b|\bmv\b|\bln\s+-s|\bchmod\b|\binstall\b|\btruncate\b)/
44const PATH_KEYS = ['file_path', 'path', 'notebook_path', 'filePath'] as const
45
46/** The registrable domain of a host, or a class word; never a path or a query. */
47export function hostClass(host: string): string {
48  const h = host.toLowerCase().replace(/^\[|\]$/g, '')
49  if (h === 'localhost' || h === '::1' || /^127\./.test(h) || h.endsWith('.localhost')) return 'local'
50  if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(h) || h.includes(':')) return 'ip'
51  const parts = h.split('.').filter(Boolean)
52  if (parts.length < 2) return token(h)
53  const tld = parts[parts.length - 1] as string
54  const sld = parts[parts.length - 2] as string
55  const take = tld.length === 2 && sld.length <= 3 && parts.length > 2 ? 3 : 2
56  return token(parts.slice(-take).join('.'))
57}
58
59function hostsIn(text: string): string[] {
60  const out: string[] = []
61  for (const m of text.matchAll(/\b[a-z][a-z0-9+.-]{1,12}:\/\/(?:[^\s/@'"]+@)?(\[[0-9a-f:]+\]|[^\s/:'"?#]+)/gi)) out.push(hostClass(m[1] as string))
62  return [...new Set(out)].slice(0, 4)
63}
64
65/** The heads of a shell command (first word and, for known tools, the subcommand) per simple command, at most six. */
66export function headsOf(command: string): string[] {
67  const out: string[] = []
68  for (const seg of command.split(/&&|\|\||[;|\n]/).slice(0, 12)) {
69    const words = seg.trim().split(/\s+/).filter(Boolean)
70    let i = 0
71    while (i < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i] as string) || WRAPPERS.has(words[i] as string))) i++
72    const first = (words[i] ?? '').replace(/^.*\//, '')
73    if (first === '') continue
74    const head = token(first)
75    const sub = SUBCMD.has(head) && /^[a-z][a-z-]{1,15}$/.test(words[i + 1] ?? '') ? ` ${words[i + 1]}` : ''
76    out.push(head + sub)
77    if (out.length >= 6) break
78  }
79  return out
80}
81
82/** The area of a path: the first two directory levels under the project root, or a coarse class outside it. Never a full path. */
83export function areaOf(path: string, root: string): string {
84  const base = root.replace(/\/+$/, '')
85  if (path.startsWith('/') && base !== '' && path !== base && !path.startsWith(`${base}/`)) {
86    if (/^\/tmp\b|^\/var\/tmp\b/.test(path)) return 'outside:tmp'
87    if (/^\/etc\b/.test(path)) return 'outside:etc'
88    if (/^\/(?:usr|opt|var|bin|sbin|lib\w*)\b/.test(path)) return 'outside:sys'
89    return /^\/(?:home|Users|root)\b/.test(path) ? 'outside:home' : 'outside:other'
90  }
91  const rel = path.startsWith(`${base}/`) ? path.slice(base.length + 1) : path.replace(/^\.\//, '')
92  const dirs = rel.split('/').slice(0, -1).slice(0, 2).map(d => (/^[A-Za-z0-9._-]{1,24}$/.test(d) && !hasSecret(d) ? d : '*'))
93  return dirs.length === 0 ? '.' : dirs.join('/')
94}
95
96const inputOf = (input: unknown): Record<string, unknown> => (input !== null && typeof input === 'object' ? (input as Record<string, unknown>) : {})
97const pathsOf = (i: Record<string, unknown>): string[] => PATH_KEYS.map(k => i[k]).filter((v): v is string => typeof v === 'string')
98
99function bashFlags(command: string, root: string, flags: Set<Flag>, netHead: boolean) {
100  const low = command.toLowerCase()
101  const plain = !/[;&|<>`$()]/.test(command)
102  const exempt = plain && EXEMPT_ARGV.includes(command.trim().replace(/\s+/g, ' '))
103  if (/\b(?:curl|wget)\b[^|\n]{0,200}\|\s*(?:sudo\s+)?(?:ba|z|da|k)?sh\b|\bbase64\s+(?:-d|--decode)\b[^|\n]{0,200}\|\s*(?:sudo\s+)?(?:ba|z)?sh\b|\b(?:ba|z)?sh\s+<\(\s*(?:curl|wget)\b|\beval\s+["']?\$\(\s*(?:curl|wget)\b|\bpython3?\s+-c\b[^\n]{0,200}\b(?:urlopen|urllib|requests\.get)\b/.test(low)) flags.add('pipe-shell')
104  const home = low.replace(/(?:~|\$\{?home\}?)(?=\/\*)/g, '').replace(/(^|[\s'"=])(?:~|\$\{?home\}?)\/?(?=[\s'";&|)]|$)/g, '$1/')
105  const rooted = root === '' ? home : home.split(root.toLowerCase()).join('/')
106  const forceMain = /\bgit\s+push\b[^;&|\n]*(?:--force\b|--force-with-lease\b|\s-[a-z]*f\b|\s\+)[^;&|\n]*\b(?:main|master|trunk)\b/.test(low)
107  if (hasRootDelete(rooted) || forceMain || /\bdrop\s+(?:database|schema)\b/.test(low) || /\bmkfs(?:\.\w+)?\s|\bwipefs\b|\bdd\b[^;&|\n]*\bof=\/dev\/(?:sd|nvme|hd|vd|mmcblk|disk)|\bshred\b[^;&|\n]*\/dev\//.test(low)) flags.add('destroy')
108  if (!exempt && (/\bcrontab\b(?!\s+-l)/.test(low) || (WRITE_OP.test(command) && PERSIST_PATH.test(command)))) flags.add('persist')
109  if (CRED_PATH.test(command) && /\b(?:cat|less|more|head|tail|cp|tar|zip|grep|base64|xxd|strings|scp|rsync|openssl)\b/.test(low)) flags.add('cred-read')
110  if (/\b(?:npm|pnpm|yarn)\s+(?:i|install|add)\b[^;&|\n]*(?:git\+|github:|\.tgz\b|https?:\/\/)|\bpip3?\s+install\b[^;&|\n]*(?:git\+|https?:\/\/|--(?:extra-)?index-url)|\bcargo\s+install\b[^;&|\n]*--git\b/.test(low)) flags.add('dep-url')
111  if (netHead && /(?:^|\s)(?:-d|--data(?:-\w+)?|-F|--form|-T|--upload-file|--json|-X\s*(?:POST|PUT|PATCH))\b/.test(command)) flags.add('body')
112}
113
114/** Normalise one tool call into a categorical record; the only place raw input is read. */
115export function normalise(tool: string, input: unknown, root: string, at: number): Ev {
116  const i = inputOf(input)
117  const flags = new Set<Flag>()
118  let risk: Risk = 'other'
119  let heads: string[] = []
120  let host = ''
121  const areas = new Set<string>()
122  const mcp = tool.startsWith('mcp__')
123  const server = mcp ? tool.slice(5, Math.max(5, tool.lastIndexOf('__'))) : ''
124  const texts = textsOf(input)
125  const command = typeof i.command === 'string' ? i.command : ''
126  let net = false
127
128  if (tool === 'Bash' || tool === 'PowerShell') {
129    heads = headsOf(command)
130    net = heads.some(h => NET_HEADS.has(h.split(' ')[0] as string))
131    risk = net ? 'net' : 'exec'
132    const found = hostsIn(command)
133    host = found.find(h => h !== 'local') ?? found[0] ?? ''
134    if (host === 'local' && net) risk = 'exec'
135    bashFlags(command, root, flags, net)
136  } else if (TAINT_TOOLS.has(tool)) {
137    risk = 'net'
138    net = true
139    host = hostsIn(typeof i.url === 'string' ? i.url : '')[0] ?? ''
140  } else if (WRITE_TOOLS.has(tool)) {
141    risk = 'write'
142    for (const p of pathsOf(i)) {
143      areas.add(areaOf(p, root))
144      if (PERSIST_PATH.test(p)) flags.add('persist')
145    }
146  } else if (READ_TOOLS.has(tool)) {
147    risk = 'read'
148    for (const p of pathsOf(i)) {
149      areas.add(areaOf(p, root))
150      if (CRED_PATH.test(p)) flags.add('cred-read')
151    }
152  } else if (mcp) {
153    net = !LOCAL_MCP.test(server)
154    risk = net ? 'net' : 'other'
155    host = net ? token(server.replace(/^plugin_/, '').slice(0, 24)) : ''
156  } else if (tool === 'SendMessage') {
157    if (texts.some(t => INJECTION.some(([n, re]) => ['override instructions', 'role reassignment', 'fake role tags', 'concealment'].includes(n) && re.test(t.slice(0, 20_000))))) flags.add('override')
158  }
159  if (net && texts.some(hasSecret)) flags.add('secret')
160  if (net && texts.some(t => /<system-reminder>|<\/?system>|You are Claude Code, Anthropic's|BEGIN SYSTEM PROMPT/i.test(t.slice(0, 50_000)))) flags.add('sysprompt')
161  return { k: 'tool', tool: mcp ? `mcp:${token(server.slice(0, 24))}` : token(tool), head: heads[0] ?? '', heads, host, areas: [...areas].slice(0, 4), risk, flags: [...flags], tainted: false, at }
162}
163
164/** The tools whose results come from outside the trust boundary. */
165export const isTaintSource = (tool: string, input: unknown): boolean =>
166  TAINT_TOOLS.has(tool) || (tool.startsWith('mcp__') && !LOCAL_MCP.test(tool.slice(5))) || (READ_TOOLS.has(tool) && outsideOnly(input))
167
168function outsideOnly(input: unknown): boolean {
169  const ps = pathsOf(inputOf(input))
170  return ps.length > 0 && ps.every(p => p.startsWith('/tmp/') || p.startsWith('/var/tmp/'))
171}
172
173export function spawnEv(type: string, permissionMode: string | undefined, at: number): Ev {
174  const esc = permissionMode === 'bypassPermissions' || permissionMode === 'dontAsk'
175  return { k: 'spawn', tool: 'Agent', head: token(type), heads: [], host: '', areas: [], risk: 'spawn', flags: esc ? ['escalate'] : [], tainted: false, at }
176}
177
178export const recvEv = (override: boolean, at: number): Ev => ({ k: 'recv', tool: 'recv', head: '', heads: [], host: '', areas: [], risk: 'other', flags: override ? ['override'] : [], tainted: false, at })
179
hooks/store.ts 132 lines
1import type { Baseline } from './baseline'
2import { maturity } from './baseline'
3import type { Mode } from './options'
4import { asMode } from './options'
5import { RULES, type RuleMode, type Severity } from './rules'
6import type { Ev } from './shapes'
7
8export const DIR = '.claude-flow/protector-mod'
9export const FILES = { status: `${DIR}/status.json`, rules: `${DIR}/rules.json`, baseline: `${DIR}/baseline.json`, alerts: `${DIR}/alerts.jsonl`, allow: `${DIR}/allow.json`, ring: `${DIR}/ring.json` } as const
10
11export const MAX_ALERTS = 150
12export const MAX_RING = 500
13
14export type AlertState = 'open' | 'acked' | 'allowed'
15export type Alert = {
16  id: string
17  at: number
18  rule: string
19  owasp: string[]
20  severity: Severity
21  action: 'blocked' | 'notified'
22  tool: string
23  /** A shape, never a value; at most 160 characters. */
24  summary: string
25  /** 12 hex characters over (rule, head, host or area). */
26  fp: string
27  state: AlertState
28  session: string
29  count?: number
30}
31export type AllowEntry = { fp: string; rule: string; at: number; note: string }
32export type Overrides = { mode?: Mode; rules: Record<string, { mode: RuleMode; demoted?: boolean }> }
33
34export type Status = {
35  mode: Mode
36  calls: number
37  blocked: number
38  startedAt: number
39  updatedAt: number
40  alertCounts: { open: number; critical: number; high: number; medium: number; low: number; total: number }
41  baseline: Baseline
42  ruleCounts: { total: number; block: number; notify: number; off: number }
43  degraded: false | string
44  suppressed: number
45  version: string
46}
47
48export const MOD_VERSION = '0.1.1'
49
50export function statusText(s: Status, now: number): string {
51  const m = maturity(s.baseline, now)
52  const o = s.alertCounts
53  const summary = `Project Anatole · ${s.mode} · ${o.open} open alert${o.open === 1 ? '' : 's'} · ${s.blocked} blocked · baseline ${m.mature ? 'mature' : `learning ${m.pct}%`}${s.degraded ? ` · degraded: ${s.degraded}` : ''}`
54  return `${JSON.stringify({
55    // `version` and the two `*Ms` keys are what the console's mod scan (ADR-446) requires: without them the Mods page lists this file as refused.
56    schemaVersion: 1, version: 1, name: 'protector', modVersion: s.version, mode: s.mode, guard: s.mode !== 'off', calls: s.calls, blocked: s.blocked,
57    startedAt: s.startedAt, startedMs: s.startedAt, updatedAt: now, updatedMs: now, summary: summary.slice(0, 200), alerts: o,
58    baseline: { state: m.mature ? 'mature' : 'learning', maturity: m.pct, events: s.baseline.events, sessions: s.baseline.sessions, firstSeenAt: s.baseline.firstSeenAt },
59    rules: s.ruleCounts, degraded: s.degraded,
60  }, null, 2)}\n`
61}
62
63export const alertsText = (alerts: readonly Alert[]) => (alerts.length ? `${alerts.map(a => JSON.stringify(a)).join('\n')}\n` : '')
64
65export function parseAlerts(text: string): Alert[] {
66  const out: Alert[] = []
67  for (const line of text.split('\n').slice(-200)) {
68    try {
69      const a = JSON.parse(line) as Partial<Alert>
70      if (typeof a.id === 'string' && typeof a.rule === 'string' && typeof a.fp === 'string') {
71        out.push({ ...(a as Alert), summary: String(a.summary ?? '').slice(0, 160), state: a.state === 'acked' || a.state === 'allowed' ? a.state : 'open' })
72      }
73    } catch {
74      /* a torn or foreign line is skipped */
75    }
76  }
77  return out.slice(-MAX_ALERTS)
78}
79
80export const allowText = (entries: readonly AllowEntry[]) => `${JSON.stringify({ schemaVersion: 1, entries }, null, 2)}\n`
81
82export function parseAllow(text: string): AllowEntry[] {
83  try {
84    const o = JSON.parse(text) as { schemaVersion?: number; entries?: unknown }
85    if (o.schemaVersion !== 1 || !Array.isArray(o.entries)) return []
86    return o.entries.filter((e): e is AllowEntry => !!e && typeof (e as AllowEntry).fp === 'string' && /^[0-9a-f]{12}$/.test((e as AllowEntry).fp)).slice(0, 500)
87  } catch {
88    return []
89  }
90}
91
92export const overridesText = (o: Overrides) => `${JSON.stringify({ schemaVersion: 1, ...(o.mode ? { mode: o.mode } : {}), rules: o.rules }, null, 2)}\n`
93
94export function parseOverrides(text: string): Overrides {
95  try {
96    const o = JSON.parse(text) as { schemaVersion?: number; mode?: unknown; rules?: Record<string, { mode?: unknown; demoted?: unknown }> }
97    if (o.schemaVersion !== 1) return { rules: {} }
98    const rules: Overrides['rules'] = {}
99    for (const r of RULES) {
100      const m = o.rules?.[r.id]?.mode
101      if (m === 'off' || m === 'notify' || m === 'block') rules[r.id] = { mode: m, ...(o.rules?.[r.id]?.demoted === true ? { demoted: true } : {}) }
102    }
103    const mode = asMode(o.mode)
104    return { ...(mode ? { mode } : {}), rules }
105  } catch {
106    return { rules: {} }
107  }
108}
109
110/** The ring as categorical records only (no flags beyond the closed set), for `/protector replay`. */
111export const ringText = (ring: readonly Ev[]) => `${JSON.stringify({ schemaVersion: 1, events: ring.slice(-MAX_RING) })}\n`
112
113export function parseRing(text: string): Ev[] {
114  try {
115    const o = JSON.parse(text) as { schemaVersion?: number; events?: unknown }
116    return o.schemaVersion === 1 && Array.isArray(o.events) ? (o.events as Ev[]).filter(e => e && typeof e.tool === 'string' && Array.isArray(e.flags)).slice(-MAX_RING) : []
117  } catch {
118    return []
119  }
120}
121
122/** 12 hex characters of FNV-1a over the key; a fingerprint, not a secret. */
123export function fingerprint(key: string): string {
124  let a = 0x811c9dc5
125  let b = 0x01000193
126  for (let i = 0; i < key.length; i++) {
127    a = Math.imul(a ^ key.charCodeAt(i), 0x01000193) >>> 0
128    b = Math.imul(b ^ key.charCodeAt(i), 0x85ebca6b) >>> 0
129  }
130  return (a.toString(16).padStart(8, '0') + b.toString(16).padStart(8, '0')).slice(0, 12)
131}
132
hooks/screen.ts 261 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  ['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
258export const scan = (text: string): Findings => screenWith(SECRETS, text)
259
260export const hasSecret = (text: string) => hasSecretIn(SECRETS, text)
261
hooks/toast-policy.ts 462 lines
1/**
2 * Toast policy (ADR-477): levels, one-line washing, de-duplication, a per-source rate limit, the person's setting, and digests for
3 * the console's Events page. One CANONICAL source, plugins/ruflo-mods/hooks/toast/policy.ts; every other plugin carries a byte-identical
4 * copy at hooks/toast-policy.ts, written and checked by scripts/sync-toast-policy.mjs (a plugin ships alone through the marketplace and
5 * cannot import a sibling at run time). Edit the canonical file, run `node scripts/sync-toast-policy.mjs`, never a copy.
6 *
7 * Dependency-free and engine-free: no `$`, no import. The engine is reached only through the functions a plugin hands in, so every
8 * rule here is a plain function a test can drive with a fake clock. Nothing in this file throws to its caller: a refused toast, an
9 * unreadable setting or a failed write is a result, never a crash.
10 */
11
12export type ToastLevel = 'info' | 'ok' | 'warn' | 'error'
13/** all: every level; important: warn and error, plus a toast marked `always`; off: none (all are still recorded). */
14export type ToastMode = 'all' | 'important' | 'off'
15export type ToastPrefs = { mode: ToastMode; muted: readonly string[] }
16/** Why a toast was or was not drawn. `shown` is the only one that drew. `coalesced` is an error held for one later line. */
17export type Why = 'shown' | 'deduped' | 'rate-limited' | 'coalesced' | 'muted' | 'off' | 'filtered' | 'away' | 'refused'
18
19export const TOAST_SOURCES = ['console', 'swarm', 'protector', 'mods'] as const
20export const TOAST_MODES: readonly ToastMode[] = ['all', 'important', 'off']
21export const LEVELS: readonly ToastLevel[] = ['info', 'ok', 'warn', 'error']
22export const DEFAULT_PREFS: ToastPrefs = { mode: 'all', muted: [] }
23export const PREFIX: Readonly<Record<ToastLevel, string>> = { info: '›', ok: '✓', warn: '⚠', error: '✗' }
24
25/** A whole toast line, prefix included, is never longer than this. */
26export const LINE_MAX = 120
27/** An identical (source, text) is not drawn again inside this window. */
28export const DEDUPE_MS = 60_000
29/** A source draws at most RATE_MAX toasts in RATE_WINDOW_MS; an error past that is held and said once, with a count. */
30export const RATE_MAX = 4
31export const RATE_WINDOW_MS = 60_000
32/** Digests kept per source (a ring), and how long the setting is believed before the file is read again. */
33export const RING_MAX = 60
34export const PREFS_TTL_MS = 4_000
35export const CONSOLE_TTL_MS = 30_000
36/** The setting (written by the console's Settings) and the folder of per-source digest files (read by the console). */
37export const CONSOLE_DIR = '.claude-flow/console'
38export const PREFS_FILE = `${CONSOLE_DIR}/toast-prefs.json`
39export const TOAST_DIR = `${CONSOLE_DIR}/toasts`
40export const MASK = '‹masked›'
41
42// ------------------------------------------------------------------------------------------------------------ washing
43
44const ESCAPES = new RegExp('\\u001b\\][^\\u0007\\u001b]*(?:\\u0007|\\u001b\\\\)|\\u009d[^\\u0007\\u009c]*[\\u0007\\u009c]|(?:\\u001b\\[|\\u009b)[0-9;?]*[ -/]*[@-~]', 'g')
45/** White space of every kind (and the line and paragraph separators) becomes one space. */
46const SPACES = new RegExp('[\\s\\u0085\\u2028\\u2029]+', 'g')
47/** A control, zero-width, bidi or tag character is deleted, not spaced: `sk-ant-AAAA<NUL>BBBB` is one credential. */
48const DROPPED = new RegExp('[\\u0000-\\u0008\\u000e-\\u001f\\u007f-\\u0084\\u0086-\\u009f\\u00ad\\u034f\\u061c\\u115f\\u1160\\u17b4\\u17b5\\u180b-\\u180f\\u200b-\\u200f\\u202a-\\u202e\\u2060-\\u206f\\u3164\\ufe00-\\ufe0f\\ufeff\\uffa0\\ufff9-\\ufffb]|[\\u{e0000}-\\u{e0fff}]', 'gu')
49const SECRETISH = new RegExp(
50  [
51    String.raw`\b(?:sk|pk|ghp|gho|ghs|github_pat|xox[abprs]|xapp|AKIA|ASIA|AIza)[-_A-Za-z0-9]{12,}`,
52    String.raw`\b(?:glpat|npm|hf|dop_v1|shpat|whsec|rk_live|sk_live|ya29)[-_.][-_.A-Za-z0-9]{12,}`,
53    String.raw`\bBearer\s+\S{8,}`,
54    String.raw`\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.?[A-Za-z0-9_-]*`,
55    String.raw`\b[A-Za-z0-9+_-]{32,}={0,2}`,
56    String.raw`-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)`,
57    String.raw`\b[a-z][a-z0-9+.-]*://[^\s/:@]+:[^\s/@]+@`,
58    String.raw`(?:key|token|secret|passw(?:or)?d|pwd|passphrase|credential|authorization|cookie)["']?\s*[=:]\s*(?:(?:Bearer|Basic|Token)\s+)?(?:"[^"]*"|'[^']*'|\S+)`,
59    String.raw`(?<![A-Za-z0-9])pass["']?\s*=\s*(?:"[^"]*"|'[^']*'|\S+)`,
60    String.raw`(?:^|\s)--?(?:token|password|passwd|pwd|secret|api-?key|auth(?:orization)?|access-?key|client-?secret)(?:=|\s+)\S+`,
61  ].join('|'),
62  'gi',
63)
64const HOME_PATH = /\/(?:home|Users)\/[^/\s'"]+/g
65const EMAIL = /\b[\w.+-]{1,64}@[A-Za-z0-9-]{1,63}(?:\.[A-Za-z0-9-]{1,63})+\b/g
66
67/** One line of at most `max` characters: escapes and control characters gone, white space collapsed, credentials, e-mail addresses and home paths masked, an ellipsis where it was cut. Never throws; a non-string is ''. */
68export function tidy(value: unknown, max: number = LINE_MAX): string {
69  if (typeof value !== 'string') return ''
70
71  const washed = value.slice(0, 4096).replace(ESCAPES, '').replace(SPACES, ' ').replace(DROPPED, '').trim()
72  const masked = washed.replace(SECRETISH, MASK).replace(EMAIL, MASK).replace(HOME_PATH, '~')
73
74  return masked.length > max ? `${masked.slice(0, Math.max(0, max - 1))}…` : masked
75}
76
77/** The line a toast draws: its level's prefix, a space, the washed text; the whole is at most LINE_MAX. '' when there is no text. */
78export function lineOf(level: ToastLevel, text: unknown): string {
79  const body = tidy(text, LINE_MAX - 2)
80
81  return body === '' ? '' : `${PREFIX[level]} ${body}`
82}
83
84// ------------------------------------------------------------------------------------------------------------ the setting
85
86const SOURCE_NAME = /^[a-z][a-z0-9-]{0,23}$/
87
88/** The setting from its file's text: anything unreadable, or an unknown mode, is the default (all, nothing muted). At most 8 names are muted. */
89export function parsePrefs(text: string | null | undefined): ToastPrefs {
90  if (typeof text !== 'string' || text.length > 4096) return DEFAULT_PREFS
91
92  try {
93    const o: unknown = JSON.parse(text)
94
95    if (typeof o !== 'object' || o === null || Array.isArray(o)) return DEFAULT_PREFS
96
97    const r = o as { mode?: unknown; muted?: unknown }
98    const mode = TOAST_MODES.find(candidate => candidate === r.mode) ?? 'all'
99    const muted = Array.isArray(r.muted) ? [...new Set(r.muted.filter((name): name is string => typeof name === 'string' && SOURCE_NAME.test(name)))].slice(0, 8) : []
100
101    return { mode, muted }
102  } catch {
103    return DEFAULT_PREFS
104  }
105}
106
107export const encodePrefs = (prefs: ToastPrefs): string => `${JSON.stringify({ v: 1, mode: prefs.mode, muted: [...new Set(prefs.muted)].filter(name => SOURCE_NAME.test(name)).slice(0, 8) })}\n`
108
109// ------------------------------------------------------------------------------------------------------------ digests
110
111/** What is kept of every toast, drawn or not: masked, short, and flagged with what became of it. */
112export type Digest = { t: number; source: string; level: ToastLevel; text: string; shown: boolean; why: Why; /** How many identical, consecutive ones this stands for (absent: one). */ n?: number }
113
114const WHYS: readonly Why[] = ['shown', 'deduped', 'rate-limited', 'coalesced', 'muted', 'off', 'filtered', 'away', 'refused']
115
116/** Adds a digest to a ring (newest last). An identical neighbour (source, text, outcome) is counted, not repeated. Mutates `ring`. */
117export function pushDigest(ring: Digest[], d: Digest, cap: number = RING_MAX): void {
118  const last = ring[ring.length - 1]
119
120  if (last !== undefined && last.source === d.source && last.text === d.text && last.why === d.why && last.level === d.level) {
121    last.n = (last.n ?? 1) + 1
122    last.t = d.t
123  } else ring.push({ ...d })
124
125  if (ring.length > cap) ring.splice(0, ring.length - cap)
126}
127
128export const encodeRing = (ring: readonly Digest[]): string =>
129  ring.map(d => `${JSON.stringify({ v: 1, t: Math.round(d.t), s: d.source, l: d.level, x: d.text, w: d.why, ...(d.n !== undefined && d.n > 1 && { n: d.n }) })}\n`).join('')
130
131/** The digests in a ring file's text; a line that is not ours, or is cut off, is skipped. Text is washed again: a file is not trusted. */
132export function decodeRing(text: string | null | undefined): Digest[] {
133  if (typeof text !== 'string' || text === '') return []
134
135  const out: Digest[] = []
136
137  for (const line of text.slice(-300_000).split('\n')) {
138    if (line.length < 2 || line.length > 600 || line[0] !== '{') continue
139
140    try {
141      const o = JSON.parse(line) as Record<string, unknown>
142      const level = LEVELS.find(candidate => candidate === o.l)
143      const why = WHYS.find(candidate => candidate === o.w)
144      const source = typeof o.s === 'string' && SOURCE_NAME.test(o.s) ? o.s : undefined
145      const body = tidy(o.x, LINE_MAX)
146
147      if (o.v !== 1 || typeof o.t !== 'number' || !Number.isFinite(o.t) || level === undefined || why === undefined || source === undefined || body === '') continue
148      out.push({ t: o.t, source, level, text: body, shown: why === 'shown', why, ...(typeof o.n === 'number' && o.n > 1 && o.n < 1e6 && { n: Math.floor(o.n) }) })
149    } catch {
150      /* a half-written line */
151    }
152  }
153
154  return out
155}
156
157// ------------------------------------------------------------------------------------------------------------ the toaster
158
159type Maybe<T> = T | Promise<T>
160
161const isThenable = (value: unknown): value is Promise<unknown> => typeof value === 'object' && value !== null && typeof (value as { then?: unknown }).then === 'function'
162
163/** `f` over a value that may be a promise: synchronous when the value is, and a failure becomes `fallback()`, never a throw. */
164function chain<T, U>(value: Maybe<T>, f: (v: T) => Maybe<U>, fallback: () => Maybe<U>): Maybe<U> {
165  try {
166    return isThenable(value) ? ((value as Promise<T>).then(f, fallback) as Maybe<U>) : f(value as T)
167  } catch {
168    return fallback()
169  }
170}
171
172export type ToastInput = {
173  level?: ToastLevel
174  text: string
175  timeoutMs?: number
176  /** Passes the `important` filter whatever its level (still muted by `off` and by a per-source mute). */
177  always?: boolean
178  /** Drawn only when the person is away: held back (and recorded) when `away()` answers false; drawn as usual when the host cannot say. */
179  awayOnly?: boolean
180}
181
182export type ToasterDeps = {
183  source: string
184  /** Milliseconds; may be a promise (`$.clock.now()`). The whole toast is synchronous when this and `prefs` are. */
185  now: () => Maybe<number>
186  /** Draws the line (`$.ui.toast`). May throw: that is `refused`. */
187  show: (line: string, options: { timeoutMs?: number }) => void
188  prefs?: () => Maybe<ToastPrefs>
189  /** Gets every digest, drawn or not. Fire and forget. */
190  persist?: (digest: Digest) => unknown
191  away?: () => boolean | undefined
192  /** Schedules the release of held errors; without it they are released by the next toast or `release()`. */
193  after?: (ms: number, fn: () => void) => unknown
194  dedupeMs?: number
195  rateMax?: number
196  windowMs?: number
197}
198
199export type Toaster = {
200  /** Decides, draws or holds, records. Resolves to what became of it; never rejects. */
201  toast: (input: ToastInput) => Maybe<Why | 'empty'>
202  /** Draws held errors once the window has room: `✗ <first> … and N more`. */
203  release: () => Maybe<void>
204}
205
206export function createToaster(deps: ToasterDeps): Toaster {
207  const dedupeMs = deps.dedupeMs ?? DEDUPE_MS
208  const rateMax = deps.rateMax ?? RATE_MAX
209  const windowMs = deps.windowMs ?? RATE_WINDOW_MS
210  const seen = new Map<string, number>()
211  let shownAt: number[] = []
212  let held: { text: string; n: number; timeoutMs?: number } | null = null
213  let isScheduled = false
214
215  const record = (d: Digest): void => {
216    try {
217      const r = deps.persist?.(d)
218
219      if (isThenable(r)) r.catch(() => undefined)
220    } catch {
221      /* a failed record never changes what was shown */
222    }
223  }
224
225  const draw = (now: number, line: string, timeoutMs: number | undefined): boolean => {
226    try {
227      deps.show(line, timeoutMs === undefined ? {} : { timeoutMs })
228      shownAt.push(now)
229
230      return true
231    } catch {
232      return false
233    }
234  }
235
236  const room = (now: number): boolean => {
237    shownAt = shownAt.filter(at => now - at < windowMs)
238
239    return shownAt.length < rateMax
240  }
241
242  const freeHeld = (now: number): void => {
243    if (held === null || !room(now)) return
244
245    const { text, n, timeoutMs } = held
246
247    held = null
248    draw(now, lineOf('error', n > 1 ? `${text} … and ${n - 1} more` : text), timeoutMs)
249  }
250
251  const arm = (now: number): void => {
252    if (isScheduled || deps.after === undefined || held === null) return
253
254    isScheduled = true
255
256    const wait = Math.max(50, windowMs - (now - (shownAt[0] ?? now)) + 50)
257
258    const lost = (): void => {
259      isScheduled = false
260    }
261
262    try {
263      const timer = deps.after(wait, () => {
264        isScheduled = false
265        void releaseNow()
266      })
267
268      if (isThenable(timer)) timer.catch(lost)
269    } catch {
270      lost()
271    }
272  }
273
274  /** A held error is said only while the setting still lets this source draw: switching toasts off, or muting the source, drops what waits (it is already recorded). */
275  const releaseAt = (now: number, prefs: ToastPrefs): void => {
276    if (prefs.mode === 'off' || prefs.muted.includes(deps.source)) held = null
277    freeHeld(now)
278    arm(now)
279  }
280
281  /** The work of a call at a moment: the setting is read, then `then` runs with it; a setting that cannot be read is the default. */
282  const withPrefs = <T>(then: (prefs: ToastPrefs) => T): Maybe<T> => {
283    let asked: Maybe<ToastPrefs>
284
285    try {
286      asked = deps.prefs?.() ?? DEFAULT_PREFS
287    } catch {
288      return then(DEFAULT_PREFS)
289    }
290
291    return chain(asked, then, () => then(DEFAULT_PREFS))
292  }
293
294  /** A clock that fails (a refused `clock.now`) is the wall clock: the toast is still decided. */
295  const atNow = <T>(then: (now: number) => Maybe<T>, fallback: () => T): Maybe<T> => {
296    let clock: Maybe<number>
297
298    try {
299      clock = deps.now()
300    } catch {
301      clock = Date.now()
302    }
303
304    return chain(clock, then, () => chain(Date.now(), then, fallback))
305  }
306
307  const releaseNow = (): Maybe<void> => atNow(now => withPrefs(prefs => releaseAt(now, prefs)), () => undefined)
308
309  const run = (now: number, prefsIn: ToastPrefs, input: ToastInput): Why | 'empty' => {
310    const level = LEVELS.find(candidate => candidate === input.level) ?? 'info'
311    const text = tidy(input.text, LINE_MAX - 2)
312
313    if (text === '') return 'empty'
314
315    const prefs = parsePrefs(JSON.stringify(prefsIn))
316    let why: Why
317
318    releaseAt(now, prefs)
319
320    if (prefs.mode === 'off') why = 'off'
321    else if (prefs.muted.includes(deps.source)) why = 'muted'
322    else if (prefs.mode === 'important' && (level === 'info' || level === 'ok') && input.always !== true) why = 'filtered'
323    else if (input.awayOnly === true && deps.away?.() === false) why = 'away'
324    else {
325      const last = seen.get(text)
326
327      if (last !== undefined && now - last < dedupeMs) why = 'deduped'
328      else if (room(now)) {
329        why = draw(now, lineOf(level, text), input.timeoutMs) ? 'shown' : 'refused'
330        if (why === 'shown') seen.set(text, now)
331      } else if (level === 'error') {
332        // An error is never dropped: it waits, and the next free slot says it once with a count.
333        held = held === null ? { text, n: 1, ...(input.timeoutMs !== undefined && { timeoutMs: input.timeoutMs }) } : { ...held, n: held.n + 1 }
334        seen.set(text, now)
335        why = 'coalesced'
336        arm(now)
337      } else why = 'rate-limited'
338    }
339
340    if (seen.size > 200) for (const [key, at] of seen) if (now - at >= dedupeMs) seen.delete(key)
341
342    record({ t: now, source: deps.source, level, text, shown: why === 'shown', why })
343
344    return why
345  }
346
347  return {
348    toast: input => atNow(now => withPrefs(prefs => run(now, prefs, input)), () => 'refused' as const),
349    release: releaseNow,
350  }
351}
352
353// ------------------------------------------------------------------------------------------------------------ the kit: the setting and the digests through files
354
355/** The three file calls a plugin has (`$.fs.read`, `$.fs.write`, `$.fs.exists`), relative to the project. Each may be refused. */
356export type ToastIo = { read: (path: string) => Promise<string>; write: (path: string, text: string) => Promise<void>; exists: (path: string) => Promise<boolean> }
357
358export type KitDeps = Omit<ToasterDeps, 'prefs' | 'persist'> & {
359  /** Without files the toaster draws by the defaults and records nothing on disk. */
360  io?: ToastIo
361  prefsTtlMs?: number
362}
363
364/**
365 * A toaster whose setting is the console's file (read at most every few seconds; none means all, nothing muted) and whose digests go
366 * to this source's own ring file under the console's folder, rewritten whole (a plugin has no append), one write in flight at a time.
367 * Nothing is written unless that folder exists: it is the console's, and its own `.gitignore` covers it. Without the console a plugin
368 * runs on the defaults and leaves no file.
369 */
370export function createToastKit(deps: KitDeps): Toaster {
371  const { io } = deps
372  const ttl = deps.prefsTtlMs ?? PREFS_TTL_MS
373  let prefs: ToastPrefs = DEFAULT_PREFS
374  let prefsAt = -Infinity
375  const ring: Digest[] = []
376  let isLoaded = false
377  let isDirty = false
378  let isWriting = false
379  let isConsole = false
380  let consoleAt = -Infinity
381
382  /**
383   * One write in flight at a time, the newest ring in it: digests that arrive meanwhile are written by the next pass, so a burst costs
384   * a few writes, not one each, and the last digest is always on disk. No timer: a toast is never lost to a debounce that did not fire.
385   */
386  const flush = async (): Promise<void> => {
387    if (io === undefined || isWriting) return
388
389    isWriting = true
390
391    try {
392      while (isDirty) {
393        isDirty = false
394
395        if (!isLoaded) {
396          isLoaded = true
397          const old = decodeRing(await io.read(`${TOAST_DIR}/${deps.source}.jsonl`).catch(() => ''))
398
399          ring.unshift(...old.filter(d => d.source === deps.source).slice(-RING_MAX))
400          if (ring.length > RING_MAX) ring.splice(0, ring.length - RING_MAX)
401        }
402
403        await io.write(`${TOAST_DIR}/${deps.source}.jsonl`, encodeRing(ring))
404      }
405    } catch {
406      /* the digest is a courtesy: a refused write is dropped */
407    } finally {
408      isWriting = false
409    }
410  }
411
412  /** Whether the console's folder exists, believed for a while: no console, no digest. */
413  const hasConsole = (now: number): Promise<boolean> => {
414    if (now - consoleAt < CONSOLE_TTL_MS) return Promise.resolve(isConsole)
415
416    consoleAt = now
417
418    return (io as ToastIo).exists(CONSOLE_DIR).then(
419      yes => (isConsole = yes),
420      () => (isConsole = false),
421    )
422  }
423
424  const readPrefs = (): Maybe<ToastPrefs> => {
425    if (io === undefined) return DEFAULT_PREFS
426
427    const at = (now: number): Maybe<ToastPrefs> => {
428      if (now - prefsAt < ttl) return prefs
429
430      prefsAt = now
431
432      return io.read(PREFS_FILE).then(
433        text => (prefs = parsePrefs(text)),
434        () => (prefs = DEFAULT_PREFS),
435      )
436    }
437
438    return chain(
439      deps.now(),
440      at,
441      () => at(Date.now()),
442    )
443  }
444
445  return createToaster({
446    ...deps,
447    prefs: readPrefs,
448    persist: d => {
449      if (io === undefined) return
450
451      // The ring is seeded from the file on the first write; digests made before it are kept in order.
452      return hasConsole(d.t).then(yes => {
453        if (!yes) return
454
455        pushDigest(ring, d)
456        isDirty = true
457        void flush()
458      })
459    },
460  })
461}
462
hooks/rules.ts 77 lines
1import { type Baseline, known } from './baseline'
2import { EXEMPT_ARGV, type Ev } from './shapes'
3
4export type Severity = 'critical' | 'high' | 'medium' | 'low'
5export type RuleMode = 'off' | 'notify' | 'block'
6/** `hard` rules are literal shapes and never consult the baseline; `corr` rules correlate two events; `anomaly` rules need a mature baseline and a risky action. */
7export type Kind = 'hard' | 'corr' | 'anomaly'
8
9/** What a rule may look at besides the event: the baseline, this turn's facts and the last minute's counts. */
10export type Ctx = {
11  readonly baseline: Baseline
12  readonly mature: boolean
13  readonly credRead: boolean
14  readonly promptWords: ReadonlySet<string>
15  readonly calls1m: number
16  readonly spawns1m: number
17}
18
19export type Rule = {
20  readonly id: string
21  readonly owasp: readonly string[]
22  readonly severity: Severity
23  readonly default: Exclude<RuleMode, 'off'>
24  readonly kind: Kind
25  readonly summary: string
26  /** Exact-argv commands the rule does not fire on (see shapes.ts EXEMPT_ARGV); empty where the ADR names none. */
27  readonly exempt: readonly string[]
28  readonly fires: (ev: Ev, c: Ctx) => boolean
29}
30
31const has = (ev: Ev, f: Ev['flags'][number]) => ev.flags.includes(f)
32const named = (c: Ctx, w: string) => c.promptWords.has(w) || [...c.promptWords].some(p => p.endsWith(`.${w}`))
33const hostNamed = (ev: Ev, c: Ctx) => ev.host !== '' && [...c.promptWords].some(p => p === ev.host || p.endsWith(`.${ev.host}`) || ev.host.endsWith(`.${p}`))
34
35/** PR-005: a risky action after outside content, one the person's own prompt did not name. */
36function taintedUnasked(ev: Ev, c: Ctx): boolean {
37  if (!ev.tainted) return false
38  if (ev.risk === 'net') return !hostNamed(ev, c) && !(c.mature && ev.host !== '' && known(c.baseline.hosts, ev.host, true))
39  if (!c.mature) return false
40  if (ev.risk === 'exec') return ev.heads.some(h => !known(c.baseline.commands, h, true) && !named(c, h.split(' ')[0] as string))
41  if (ev.risk === 'write') return ev.areas.some(a => a.startsWith('outside:') && !known(c.baseline.areas, a, true))
42  return false
43}
44
45/** PR-007: a spawn fan-out of 8 in a minute, or a call rate over three times the baseline p95 (and over 60). */
46function tooFast(ev: Ev, c: Ctx): boolean {
47  if (!c.mature) return false
48  if (ev.k === 'spawn') return c.spawns1m >= 8
49  return c.calls1m > Math.max(60, 3 * c.baseline.rate.p95)
50}
51
52const R = (id: string, owasp: string[], severity: Severity, def: 'block' | 'notify', kind: Kind, summary: string, fires: Rule["fires"], exempt: readonly string[] = []): Rule => ({ id, owasp, severity, default: def, kind, summary, exempt, fires })
53
54
55
56/** Version 1 of the catalogue (ADR-453 §5). Each rule is a pure function of the normalised event. */
57export const RULES: readonly Rule[] = [
58  R('PR-001', ['LLM02', 'T2'], 'critical', 'block', 'hard', 'a secret-shaped value sent to a network sink', ev => has(ev, 'secret')),
59  R('PR-002', ['T11', 'LLM06'], 'critical', 'block', 'hard', 'remote code piped into an interpreter', ev => has(ev, 'pipe-shell')),
60  R('PR-003', ['T3', 'T11', 'LLM06'], 'high', 'block', 'hard', 'persistence or self-modification (Claude config, hooks, rc files, cron, authorized_keys, git hooks)', ev => has(ev, 'persist'), EXEMPT_ARGV),
61  R('PR-004', ['LLM02', 'T3'], 'high', 'notify', 'corr', 'credential store read, then network egress in the same turn', (ev, c) => c.credRead && ev.risk === 'net' && ev.host !== 'local'),
62  R('PR-005', ['LLM01', 'T6'], 'high', 'notify', 'corr', 'a risky action in a tainted turn that the prompt did not ask for', taintedUnasked),
63  R('PR-006', ['LLM06', 'T2'], 'critical', 'block', 'hard', 'destructive operation (root/home/project delete, force push to default branch, DROP DATABASE, disk wipe)', ev => has(ev, 'destroy')),
64  R('PR-007', ['LLM10', 'T4'], 'medium', 'notify', 'anomaly', 'tool-call rate or agent-spawn fan-out far above the baseline', tooFast),
65  R('PR-008', ['LLM02'], 'medium', 'notify', 'anomaly', 'a network host never seen in the baseline, carrying a body', (ev, c) => c.mature && ev.risk === 'net' && has(ev, 'body') && ev.host !== '' && ev.host !== 'local' && !known(c.baseline.hosts, ev.host, true)),
66  R('PR-009', ['LLM06', 'T3'], 'medium', 'notify', 'anomaly', 'a write outside the project root in an area the baseline has never touched', (ev, c) => c.mature && ev.risk === 'write' && ev.areas.some(a => a.startsWith('outside:') && !known(c.baseline.areas, a, true))),
67  R('PR-010', ['T12', 'LLM01'], 'high', 'notify', 'hard', 'an inter-agent message carrying instruction-override phrasing', ev => has(ev, 'override')),
68  R('PR-011', ['T13', 'T3'], 'medium', 'notify', 'anomaly', 'an agent spawn that escalates permissions or is of a type never used', (ev, c) => ev.k === 'spawn' && (has(ev, 'escalate') || (c.mature && !known(c.baseline.spawns, ev.head, true)))),
69  R('PR-012', ['LLM03'], 'medium', 'notify', 'hard', 'a dependency installed from a git URL, tarball or non-default index', ev => has(ev, 'dep-url')),
70  R('PR-013', ['LLM07'], 'high', 'notify', 'hard', 'system prompt or instruction markers sent to a network sink', ev => has(ev, 'sysprompt')),
71]
72
73export const ruleById = (id: string): Rule | undefined => RULES.find(r => r.id === id)
74
75/** The rules an event trips, in catalogue order. */
76export const hitsOf = (ev: Ev, c: Ctx): Rule[] => RULES.filter(r => r.fires(ev, c))
77
hooks/rootdelete.ts 72 lines
1/** Copied from plugins/ruflo-mods/hooks/guard/dangerous-command.ts (ADR-452 root-delete scanner); keep in sync, never import across plugins. */
2// Keep this literal-word scanner in sync with the classic helpers/fallback.
3// It joins quote fragments and escapes, but never evaluates expansions or links.
4export function hasRootDelete(command: string, depth = 0): boolean {
5  let word = '', quote = '', started = false, redirect = false
6  let inRm = false, optionsEnded = false, recursive = false, force = false, root = false
7  const isRoot = (operand: string) => {
8    if (!operand.startsWith('/')) return false
9    const parts: string[] = []
10    for (const part of operand.split('/')) {
11      if (!part || part === '.') continue
12      if (part === '..') parts.pop()
13      else parts.push(part)
14    }
15    return parts.length === 0 || /[*?\[]/.test(parts[0])
16  }
17  const finishWord = () => {
18    if (!started) return false
19    // Literal shell strings (e.g. sh -c 'rm -rf /') also carried the old guard.
20    // Bound rescanning to four levels; beyond that retain its conservative check.
21    if (word.includes('rm') && /[\s;&|()]/.test(word)) {
22      if (depth < 4 ? hasRootDelete(word, depth + 1) : word.includes('rm -rf /')) return true
23    }
24    if (!inRm) inRm = word === 'rm' || word.endsWith('/rm')
25    else if (!optionsEnded && word === '--') optionsEnded = true
26    else if (!optionsEnded && word.startsWith('-')) {
27      recursive = recursive || word === '--recursive' || /^-[a-z]*r[a-z]*$/.test(word)
28      force = force || word === '--force' || /^-[a-z]*f[a-z]*$/.test(word)
29    } else root = root || isRoot(word)
30    word = ''; started = false
31    return inRm && recursive && force && root
32  }
33  const finishCommand = () => {
34    const denied = inRm && recursive && force && root
35    inRm = optionsEnded = recursive = force = root = false
36    return denied
37  }
38  for (let i = 0; i < command.length; i++) {
39    const char = command[i]
40    const redirectionAmpersand = char === '&' && (redirect || command[i + 1] === '>')
41    redirect = false
42    if (quote) {
43      if (char === quote) quote = ''
44      else if (quote === '"' && char === '\\' && i + 1 < command.length &&
45        (command[i + 1] === '"' || command[i + 1] === '\\' || command[i + 1] === '$' ||
46          command.charCodeAt(i + 1) === 96 || command[i + 1] === '\n')) {
47        const next = command[++i]
48        if (next !== '\n') word += next
49      } else word += char
50      continue
51    }
52    if (char === '\\' && i + 1 < command.length) {
53      const next = command[++i]
54      if (next !== '\n') { word += next; started = true }
55    } else if (char === '"' || char === "'") {
56      quote = char; started = true
57    } else if (char === '#' && !started) {
58      while (i < command.length && command[i] !== '\n') i++
59      if (finishCommand()) return true
60    } else if (char === ' ' || char === '\t' || char === '\r' || char === '\n' ||
61      ';|&()<>'.includes(char)) {
62      if (finishWord()) return true
63      // Redirections separate words, but later operands still belong to rm.
64      redirect = char === '<' || char === '>'
65      if (!redirectionAmpersand && (char === '\n' || ';|&()'.includes(char)) && finishCommand()) return true
66    } else {
67      word += char; started = true
68    }
69  }
70  return finishWord() || finishCommand()
71}
72