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

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 idsPR-001..013). "Project Anatole" is what a person sees.
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.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.
| Mode | Effect |
|---|---|
off | does nothing |
learn (default once installed) | records the baseline, says nothing; a rule hit is only counted |
notify | alerts (file, toast), never blocks |
enforce | a 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.
| Control | What it does |
|---|---|
| Learning period | no anomaly alert before 200 events, 3 sessions and 24 hours |
| Corroboration | novelty alone never alerts; only a risky action (egress with a body, write outside the project, unknown spawn) |
| Narrow rules | blocking rules are literal shapes; heuristic ones are notify only; PR-003 exempts the ruflo setup commands by exact argv |
| Clean-event learning | the 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 limit | one 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 gate | tests/corpus.test.ts: 24 benign development traces must give zero alerts after a mature baseline; an attack trace per rule must be caught |
/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>.
/protectorAnswered locally, no model turn.
| Verb | Does | |||
|---|---|---|---|---|
status | one-screen summary | |||
list | every 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 |
run | scan the project's .claude/settings*.json for exposure (secrets, pipe-to-shell hooks, bypassPermissions, wildcard allows) | |||
replay | shadow-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-baseline | forget the baseline and start learning again |
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.
| Option | Default | Effect |
|---|---|---|
mode | learn | the starting mode (off, learn, notify, enforce); a mode set with /protector mode is saved in rules.json and wins |
autoGraduate | on | move learn to notify when the baseline is mature; never to enforce |
learn. Work as usual for a few days: it only learns.notify, read /protector alerts after each unattended run and ack the false positives./protector replay shows what enforce would have blocked on your recent work. Only then consider /protector mode enforce.ruflo-mods with modTrust = "refuse-risky" refuses a mod that hooks tool.check: add ruflo-protector@ruflo to modTrustAllow.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.
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.
hooks/register.ts 207 lines1import 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}
207hooks/command.ts 108 lines1import { 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}
108hooks/engine.ts 229 lines1import { 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] || ''}`)
229hooks/options.ts 23 lines1import 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}
23hooks/baseline.ts 126 lines1import 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}
126hooks/scan.ts 23 lines1import { 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}
23hooks/shapes.ts 179 lines1import { 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 })
179hooks/store.ts 132 lines1import 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}
132hooks/screen.ts 261 lines1/**
2 * Pure text screening for the AgentDB mod (ADR-445). Two jobs: find secrets (so none is stored) and find prompt-injection phrasing (so
3 * retrieved memory cannot instruct the model). Findings are NAMES only: the matched text is never returned, logged or counted by value.
4 */
5
6// BEGIN SHARED SCREEN (generated from plugins/ruflo-agentdb/hooks/screen.ts by scripts/sync-mod-screen.mjs; do not edit in a copy)
7export type Rules = readonly (readonly [string, RegExp])[]
8
9/** The secret shapes every mod screens for. A plugin adds its own after these, outside the markers. */
10export const COMMON_SECRETS: Rules = [
11 ['private key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
12 ['aws access key', /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
13 ['github token', /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{40,})\b/],
14 ['slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
15 ['slack webhook', /\bhooks\.slack\.com\/services\/T[A-Z0-9]{6,}\/B[A-Z0-9]{6,}\/[A-Za-z0-9]{16,}/],
16 ['google api key', /\bAIza[0-9A-Za-z_-]{35}\b/],
17 ['anthropic or openai key', /\bsk-(?:(?:ant|proj|svcacct|admin)-[A-Za-z0-9_-]{20,}|(?=[A-Za-z]{0,40}\d)[A-Za-z0-9]{32,})/],
18 ['stripe key', /\b[rs]k_live_[A-Za-z0-9]{16,}/],
19 ['npm token', /\bnpm_[A-Za-z0-9]{36}\b/],
20 ['huggingface token', /\bhf_[A-Za-z0-9]{30,}\b/],
21 ['sendgrid key', /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/],
22 ['twilio key', /\bSK[0-9a-f]{32}\b/],
23 ['jwt', /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/],
24 ['bearer token', /\bBearer\s+([A-Za-z0-9._~+/=-]{24,})/],
25 ['database url with credentials', /\b[a-z][a-z0-9+.-]{1,20}:\/\/[^\s:@/]+:[^\s@/]{3,}@[^\s/]+/i],
26]
27
28export const INJECTION: Rules = [
29 ['override instructions', /\b(?:ignore|disregard|forget|override)\b[^.\n]{0,40}\b(?:previous|prior|above|earlier|all|any|system)\b[^.\n]{0,30}\b(?:instructions?|rules?|prompts?|guidelines?)\b/i],
30 ['role reassignment', /\byou are (?:now|no longer)\b|\bact as (?:an? )?(?:unrestricted|jailbroken)\b/i],
31 ['new instructions', /\b(?:new|updated|real) (?:system )?instructions?\s*:/i],
32 ['fake role tags', /<\/?\s*(?:system|assistant|developer|instructions?)\s*>|^\s*(?:system|assistant)\s*:/im],
33 ['concealment', /\bdo not (?:tell|inform|mention|reveal)[^.\n]{0,30}\b(?:user|human|operator)\b/i],
34 ['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)
261hooks/toast-policy.ts 462 lines1/**
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}
462hooks/rules.ts 77 lines1import { 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))
77hooks/rootdelete.ts 72 lines1/** 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