Needs-you queue (approvals, questions, failures, finished-unread) across Claude Code, Codex and Ruflo
sessions, a browser grouped by repository and worktree, and a preview of the selected session. Local,
read-only discovery (no model call, no process); ambiguous identity goes to an unassigned bucket;
finished work clears only after its preview was drawn; transcript text never reaches a model, the
state, the disk or a text answer. ruflo-mods gains an optional counts-only attention row.
Co-Authored-By: RuFlo <ruv@ruv.net>
One dependency-free policy (canonical in ruflo-mods, byte-identical copies in console,
swarm and protector, checked by scripts/sync-toast-policy.mjs and each smoke.sh): level
prefixes, one washed line of at most 120 characters, 60 s de-duplication, four toasts a
minute per source with errors held and counted, the person's all/important/off setting and
per-source mute (console Settings, mirrored to a file the others read), and a digest of
every toast kept for the console's Events page. All call sites migrated; new toasts for a
mission that finishes or fails and a swarm subagent that fails or a loop that is stuck.
Versions: console 0.37.0, swarm 0.3.4, protector 0.1.1, mods 0.3.16.
Co-Authored-By: RuFlo <ruv@ruv.net>
$'\x2f' and $'\x72m' name the root and rm without any expansion, and a
\' inside $'...' does not close the string. The scanner now decodes ANSI-C
escapes (octal, \x, \u/\U, \c, the single-letter set, NUL ends the string)
in all four copies, and the parity table gains wrapper, flag-spelling,
benign look-alike and documented-limit rows.
Co-Authored-By: RuFlo <ruv@ruv.net>
The scan reports hook patterns as written, so tool.*, prompt.*, agent.* or
!tool.describe hooked every risky event without matching a RISKY_EVENTS key.
Co-Authored-By: RuFlo <ruv@ruv.net>
refuse-risky admitted a user-tier mod that ran host commands through
$.process.spawn (process.run was listed) or through any MCP tool via
$.mcp.call, and one that steered every prompt (prompt.submit) or every
subagent (agent.spawn). Observe told the person "no risky calls or hooks".
The risk sets now cover every call in the engine's $ API that reaches
outside the session, and the two hooks from #3787. Observe-mode logging is
keyed on provenance plus the risk set, so a hot reload that gains a risky
call or hook is named again ("and every reload"), and the same risk is not
repeated.
(cherry picked from commit 566bfdc8a20409f763d312e1ae34bcd0fc6c0515)
Adversarial review follow-up: the list test only covered the conditions it
named, and the doc comment claimed smoke.sh guarded drift (it checks names,
not conditions). The matrix test calls register() with a recording on() for
256 option combinations and compares.
(cherry picked from commit d857b4d136)
registeredEvents() listed them as always registered, so with default options
the probe counted session.measure as "fired" with no ruflo hook running and
listed agent.spawn as "never fired" although the mod never hooked it (and
plugin.register under modTrust: off).
(cherry picked from commit 3a146c08dc)
Projects initialised before hook-handler.cjs (e.g. settings-generator at
d7212d25b wrote `npx claude-flow@v3alpha hooks route --task "$PROMPT"`) run
the CLI's own hooks command, which never reads RUFLO_MODS_OWNS. The mod saw
no classic route/post-edit there, owned both, and every prompt was routed
twice (the CLI still prints its block with the handshake set, verified on
3.52.0). A settings hook running claude-flow / @claude-flow/cli / ruflo
`hooks route|post-edit` now makes the mod stand down for that event.
On Windows the generated hook falls back to
%USERPROFILE%\.claude\helpers\hook-handler.cjs; helperHonours now checks it
beside the project root and $HOME.
(cherry picked from commit 4dec48e9f5)
Fourth review (differential vs main over 154k inputs): two secret shapes
main caught were missed after the earlier tightening - a Google key glued to
"-suffix" (the trailing lookahead rejected it; \b now also ends it) and a
JWT after a '-' (the new lookbehind; the rule now starts at the first eyJ
after a '-' or the run start, still once per run and linear). A later window
that starts mid-line gets a lead character so ^ cannot treat a benign
"system:" there as a role tag. Inbound and outbound now build the windows
once and pass them to scanWindows(). Two tests (each fails on the previous
head); corpus unchanged.
(cherry picked from commit 5e9cec209c)
Third review: stripping every default-ignorable character and NFKC folding
joined what followed to the word before it, so a leading \b or lookbehind
failed: "x" + soft hyphen + "Ignore all previous instructions", a circled
digit before AKIA..., and a zero-width space before an SSN all passed, where
main caught them (129 of 1,093 sampled inputs).
windows() now screens three readings: the folded one, main's literal one
(only its original invisible set removed) and a spaced one (invisible
characters become spaces), each whitespace-collapsed and windowed, so cost
stays linear. The role-tag rule is back to main's \s* (linear after the
collapse; it caught "system\n:"), and the key rule accepts doubled or mixed
separators again (my--token=). One test pins seven of these cases; it fails
on the previous head.
(cherry picked from commit 000f677c36)
Second review: once the whole text is screened, two older rules that were
only ever run on a 20k prefix became the cost: `fake role tags` (^\s* with
m) is quadratic on runs of newlines (1 MB of "\n": 16.8 s) and `jwt` (\beyJ)
restarts at every '-' ("-eyJ" x 1 MB: 8.2 s). Unbounded \s in shell pipe,
key assignment and bearer also let padding carry a match across a window.
- bare() collapses each whitespace run to one space or newline before
windowing (linear; screening only tests text, never returns it).
- jwt starts once per token run; the anchored role-tag half uses [ \t].
- key assignment accepts a CLI flag prefix again (--password=, --token=),
which main caught.
Three tests pin these (1 MB of blank lines and "-eyJ" under 1.5 s, a pipe
padded with 1.1k-25k spaces at window boundaries, CLI flags); each fails on
the previous head.
(cherry picked from commit 2ffd534404)
Adversarial review of the previous commit:
- the suffix group after the keyword backtracked against it: 1 MB of
'token_' took 6.4 s through scan() (10 MB: 71 s), and with the 20k cap gone
a peer controlled the input. The keyword must now end the name, optionally
followed by _key / _access_key; 1 MB of 'token_' screens in milliseconds.
- the same group flagged config names that only start with a keyword
(TOKEN_URL=, MAX_TOKENS=, session_token_ttl=, CREDENTIALS_FILE=); those
pass again, and an all-digit value is no longer a credential.
- a later window began mid-word, so its lookbehind, \b and ^ saw a false
start of text ('q' * 18976 + 'secret=...' was refused, unpadded it passes).
Later windows now start after whitespace within 256 characters; the 1,024
overlap still covers every rule.
Three tests pin these; each fails on the previous head.
(cherry picked from commit 60a5f05340)
The shared screen behind deliveryScreen, compactCarry and guidance safeText:
- scanned only the first 20,000 characters, so padding pushed an injection
or a secret past it. It now scans the whole text in overlapping 20k
windows (1k overlap, far longer than any rule spans): still linear.
- stripped a fixed list of invisible characters. It now strips every
default-ignorable code point (soft hyphen, CGJ, Mongolian VS, variation
selectors, bidi isolates, tags) plus C0/C1 and line/paragraph separators,
then NFKC-folds fullwidth forms.
- missed GITHUB_TOKEN= / aws_secret_access_key = / "client_secret": (the
keyword inside an _ or - separated name), PGP armored private keys, Google
keys ending in '-', and Slack xapp- tokens.
delivery/screen.ts reuses the shared windows instead of its own copy of the
invisible set and the 20k slice. The 50/50 acceptance corpus is unchanged
(50/50 caught, 1/50 benign flagged, as before).
(cherry picked from commit b1cc41c887)
Re-review follow-ups: the engine contract says the process also goes on
under another session after a resume (no session.start), so the rollup
resets on reason resume as on clear (new test). The cost reset stays on
clear only until /resume cost behaviour is measured live. The cost reset
now redraws the status line, which otherwise showed the old rung until the
next prompt.
(cherry picked from commit 927799ae24)
Adversarial review: the engine contract (2.1.289 types) says session.start
fires once per process or fresh load, "never /clear"; /clear is seen as
session.end with reason clear and no session.start follows. The previous
commit reset on session.start, so it never ran on a real /clear, and its
tests faked one with an `as never` cast.
- rollup: after writing the ending session's record, a clear resets the
counters and the written flag, so the next session gets its own record.
- cost: a session.end matcher on reason clear restarts the ladder and the
hard stop. Registered after the rollup, which records the rung first. Any
other end reason keeps the hard stop (new test).
- tests replay the real sequence: session.end(clear), work, session.end.
Live (2.1.289, --plugin-dir, tmux): prompt, /clear, prompt, /exit now writes
two ledger records; main writes one.
(cherry picked from commit 3630744aea)
/clear ends a session and starts the next in the same process without
re-running register, so the process-scoped state carried over:
- sessionRollup set `written` once and never reset it, so every session after
the first /clear went unrecorded (measured live on 2.1.289), and its
counters accumulated. The rollup now resets on session.start and records
routed/tightened as this session's share.
- the cost ladder kept the previous session's announced rung and HARD_STOP
level, though the engine counts the new session's cost from zero
($0.07 -> $0.06 across /clear, measured live). The ladder and the hard stop
now start over at session.start; within a session a rung is still told once.
The engine refuses a second matcher-less session.start, so the cost hook uses
the same match-everything surface matcher as the rollup hook.
(cherry picked from commit 0f9f1b99f2)
Adversarial review follow-ups in the same class:
- flushObservations read and parsed in one try and applied isMissing to the
parse error, so a corrupt queue quoting ENOENT was overwritten, against its
own "corrupt bytes are never replaced". Read and parse are separate now.
- isMissing matched ENOENT anywhere in the message, including the quoted
path (a project under .../ENOENT-x/). It now takes the path and ignores
it; cachedFile and the queue pass theirs.
(cherry picked from commit 5044d007f3)
cachedFile applied isMissing() to parse() exceptions as well as to the
fs.read rejection. JSON.parse and parseProjection both put the file's own
text in their message, so an enforced policy projection containing "ENOENT"
(torn JSON, a stray log line, version: "ENOENT") read as "no policy" and the
guard allowed what the rule denies. Only the read may say absent now; every
parse failure is an error, so the guard fails closed as ADR-450 T10 says.
(cherry picked from commit 5a0a5a4cf9)
Real-bash oracle (bwrap, read-only root, logging fake rm; positive controls
detected) over a 63,696-command differential between main and this branch:
- a substitution inside double quotes is now dropped from the word, as its
empty output is, so "`true`"-rf reads as -rf; a placeholder is kept only
when a # follows, so bash -c "`true`#x; rm -rf /" is still read as one
word and caught;
- 1,260 commands main denies and this branch allows: 0 really delete
(bash parses each differently than main assumed);
- of 1,500 sampled commands this branch allows with a root operand, the
201 that really delete are all $(...) substitutions (stated follow-up, 189)
or $- expansions inside sh -c (12); main misses every one too.
Three parity rows added (309 pass). All four copies alike; the generator's
String.raw source keeps no backtick in comments.
(cherry picked from commit 4f2ac8ba28)
Third review (by reading; its oracle run was cut short): the unquoted
backtick branch did not mark the word as started, so a # right after an
empty substitution was read as a comment and the rest of the line skipped,
`true`#x; rm -rf / among it; bash keeps # inside the word. main denied this
(the backtick was a word character there). Confirmed: the new parity row
fails on the previous head and passes now.
An unterminated backtick (quoted or not) is now literal text, as main read
it, instead of a substitution that swallows the rest of the input. Five rows
added (306 pass); all four copies alike.
(cherry picked from commit 6f515a37a2)
Second review, against a real-bash oracle (sandboxed, logging fake rm) over
12k generated commands:
- the backtick body dropped every backslash; bash removes one only before
$, a backtick or a backslash (and " inside double quotes) and drops
backslash-newline, so escaped operands and line continuations inside a
substitution slipped through;
- a backtick inside "..." was kept as text, not scanned, so an empty
substitution inside a quoted operand hid the delete;
- a word was rescanned only if it contained "rm", so a nested shell whose
rm was split by a substitution or a backslash was never read.
One substitution helper now serves both contexts, and any word with a
separator or a backtick is rescanned (still bounded at four levels). All
four copies alike. The reviewer's oracle: misses 632 -> 26 (24 are the $(...)
follow-up, 2 real expansions), 0 regressions vs main. Five deny rows and one
allow row added to the parity table (301 pass; on the previous head exactly
the five new deny rows fail).
(cherry picked from commit feb0df1e73)
Adversarial review: making the backtick a command separator reset the rm
state, so an empty substitution among rm's operands hid the delete. Four
commands main denied were allowed (rm -rf `true` /, rm `` -rf /,
rm -rf /tmp/x `:` /, rm -r `:` -f /); the shell drops the empty word, so
each runs as a root delete.
The backtick is no longer a separator. Its body is scanned as its own
command (bounded at four levels, as other rescans), and the enclosing word
and rm state are left alone. All four copies changed alike; the parity
table gains those four rows plus two safe look-alikes (295 pass; on the
previous head exactly the four new deny rows fail).
(cherry picked from commit d4b0b739c3)
The #3698 scanner kept `$` in front of a quote, did not separate on a
backtick, and accepted only the exact `--recursive`/`--force`. So
``echo `rm -rf /` ``, `$'rm' -rf /`, `bash -c $'rm -rf /'` and
`rm --r --f /*` passed the mod and both classic pre-bash helpers. GNU rm
runs each of them as a root delete.
- A backtick separates words and ends a command, as `(` and `)` already do,
and a quoted word that contains one is rescanned.
- A `$` immediately before `'` or `"` is ANSI-C/locale quoting, not part of the word.
- Any unambiguous `--` prefix of --recursive / --force counts as that flag,
as getopt_long does.
The same change is applied to all four copies: the mod, the generator
fallback and both shipped hook-handler.cjs. Fifteen cases are added to the
#3698 parity table (10 deny, 5 safe look-alikes).
(cherry picked from commit ad44701617)
#3787: under modTrust=refuse-risky a user-tier mod whose only hook was prompt.submit (the documented injection path at that tier) or
agent.spawn loaded, because neither was in the risk sets. Both are now named and refused; observe still only names them.
#3790: the [INTELLIGENCE] block put remembered entries into the prompt cut to 80 characters but otherwise as they were. They now pass
through the same control and bidi cleaning the research guard uses.
Both new tests fail without the change.
Co-Authored-By: RuFlo <ruv@ruv.net>
Adds the missing toolHints, agentTrim and agentTrimKeep rows, evidence pointers, drops the stale console 'pending' line, and adds smoke step 13. 0.3.11 chosen because loop/probe-label (0.3.10) is not merged yet.
Co-Authored-By: RuFlo <ruv@ruv.net>
session.compact appends one bounded (600 char) block of validated words and ids to the
compaction instructions: live swarm id/topology/agent count, open claim ids with status,
last route agent, budget and policy words. Default off, framed as data, screened for
secret shapes and injection phrases, skipped for subagent compactions, fails open.
Co-Authored-By: RuFlo <ruv@ruv.net>
Default off. Counters and fixed-vocabulary fields only, capped at 50 records /
512 bytes each / 32 KB, pruned on write; corrupt ledger reads as empty; shown as
a sessions: line in /ruflo-mods. Observability only, fails open.
Co-Authored-By: RuFlo <ruv@ruv.net>
Observability only: one pass-through hook on every event counts which of the
events the mod registered fired; /ruflo-mods gains a bounded probe line and
the heartbeat lists the engine version and registered events. Never denies,
rewrites or delays; fails open.
Co-Authored-By: RuFlo <ruv@ruv.net>
Drops peer/relay deliveries with injection phrasing and refuses outgoing
messages carrying a secret shape or SSN. In-process, tighten-only, fail
open; the person's own Remote Control prompts are never screened.
Co-Authored-By: RuFlo <ruv@ruv.net>