Skip to content

release: promote main to prod — publishes 0.10.4 (rf-f5is secret rules + rf-3n1i allowlist parity). - #251

Merged
Raftersecurity merged 3 commits into
prodfrom
main
Sep 13, 2026
Merged

release: promote main to prod — publishes 0.10.4 (rf-f5is secret rules + rf-3n1i allowlist parity).#251
Raftersecurity merged 3 commits into
prodfrom
main

Conversation

@Rome-1

@Rome-1 Rome-1 commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Promotes main to prod. Ready to merge — merging this publishes 0.10.4 to npm and PyPI.

The blocker named in the old title is CLEARED

This PR originally said "NEEDS 0.10.4 BUMP FIRST". That bump has landed:

dabe92a  release: v0.10.4 (#254)   merged to main, four checks green
main node/package.json + python/pyproject.toml + both gated ClawHub manifests -> 0.10.4
npm currently serves 0.10.3; 0.10.4 is unpublished, so the publish job has something to do

Verified two ways rather than assumed: version strings read straight off origin/main, and npm view @rafter-security/cli@0.10.4 confirms the version is free. The previous three attempts failed because main's version equalled the published version, so publish.yml ran and died at the registry with an error that never says "you forgot the bump."

What merging this ships

commit bead what
dabe92a version bump to 0.10.4
14eb1c6 rf-3n1i allowed_patterns implemented in python, placed under the global floor, two allowlist bypasses closed. Breaking-change gate: 1347-command differential, both runtimes, 0 changed verdicts
6839663 rf-f5is OpenAI + Supabase secret rules in the regex Write gate, both runtimes

Why this one matters more than a routine promotion

rf-f5is came from an external reporter under a disclosure clock. It has been merged to a public repo since 09-11, so the diff already names which key shapes were undetected and at what lengths — while no published package contains the fix. Merging this closes that window.

After merging

Confirm the registry actually moved, not just that the job went green: npm view @rafter-security/cli version should read 0.10.4. Checking the job's exit code is what let the last three gaps hide.

A follow-up PR will add a published-version check to validate-release — one npm view plus one PyPI call, with a RED control against an already-published version, a GREEN control against an unpublished one, and fail-closed when the registry is unreachable. It would have caught all three prior occurrences before the prod push.

Rome-1 and others added 2 commits September 12, 2026 13:45
…oth runtimes (rf-f5is) (#250)

External report (se-wagv, J / JJB1). The hook's Write gate is regex-only, and
secret-patterns had NO OpenAI rule at all — so `sk-proj-`, `sk-svcacct-`,
`sk-admin-` and legacy `sk-…T3BlbkFJ…` keys were ALLOWED through the gate at any
length, while `rafter secrets` caught them via betterleaks. Two engines
disagreeing, and the one guarding writes was the blind one. `sb_secret_`
(Supabase) was caught by neither, at any length.

Reproduced against the PUBLISHED 0.10.3 npm tarball before touching anything,
with controls so the miss is evidence rather than an empty result:

    MISSED    sb_secret_ (Supabase)
    MISSED    sk-proj- (OpenAI)
    MISSED    legacy sk- (OpenAI)
    DETECTED  CONTROL github token
    DETECTED  CONTROL aws key id

Three rules added to node/src/scanners/secret-patterns.ts and
python/rafter_cli/scanners/secret_patterns.py:

    sb_secret_[A-Za-z0-9_-]{20,}
    sk-(proj|svcacct|admin)-[A-Za-z0-9_-]{40,}
    sk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}

No `(?i)` on any of them, deliberately. These prefixes and their base62 bodies
are case-sensitive, and the convention in this file is that prefixed vendor
tokens — ghp_, AKIA, AIza, xox — match case-sensitively; only the descriptive
patterns (aws…, sk_live_) carry the flag. Lower-casing these would add false
positives and catch nothing real. Pinned by a test.

PARITY BY CONSTRUCTION. The fixtures live in a SHARED rf-f5is-key-fixtures.json
that both runtimes read, so the two assert on byte-identical input and cannot
drift — the same design the newline/heredoc battery uses. 10 tests per runtime
off 8 fixtures.

The keys are ASSEMBLED at runtime from a prefix, a fill character and a length
rather than stored literally. A file of real-shaped keys in this repo would be
flagged by rafter's own scanner — these very rules would see to it — and a
fixture that trips the product's CI is a fixture someone deletes.

GATE:
  red     all three shapes MISSED on the published 0.10.3 tarball, two controls
          DETECTED, so the scanner was working and these rules were simply absent
  green   all five positive shapes detected in BOTH runtimes, identical
  guards  a too-short sb_secret_, a too-short sk-proj- and prose mentioning "sk"
          all correctly match nothing — the length bounds are load-bearing
  mutation delete the three rules -> exactly the 5 positive rows go red and all
          3 guards stay green, so the guards are not vacuously matching
  suites  python 401 passed on the secret/scanner/pattern/hook selection;
          node 75 passed; typecheck clean

Does not touch betterleaks, which already caught the OpenAI shapes — this closes
the gap between the two engines rather than changing the one that worked.

Co-authored-by: secbolt/crew/goldwasser <hello@rafter.so>
…er the floor, and close two allowlist bypasses (rf-3n1i) (#246)

* feat(command-policy): positive allowlist, and make the documented key real

A paying customer asked for a way to exempt one known-safe command pattern
from the high/"ask" tier without lowering the global risk level. He had read
CLI_SPEC.md correctly: command_policy took mode, blocked_patterns and
require_approval, and nothing else.

Worse, node/resources/skills/rafter/docs/guardrails.md shipped a .rafter.yml
example using a `risk.allow` key -- "force low regardless of content" -- that
was never implemented. We advertised the exact feature he was asking for. He
went looking for it and found nothing.

His case: `git push --force-with-lease` to a feature branch, on a repo whose
main is protected server-side by GitHub rulesets, classifies high and prompts
every time. The dangerous version cannot land, so the prompt only ever fires
on the safe one. Lowering risk_level would also stop prompting for sudo and
curl | sh, which he wants to keep.

Adds command_policy.allowed_patterns (unanchored regex), evaluated after
blocked_patterns and before require_approval. Three properties keep an
allowlist on a guard rail from becoming a hole in it, and each has a test:

  1. blocked_patterns always wins -- an allow rule never re-opens what a deny
     rule closed.
  2. a `critical` command is never allowlistable.
  3. a match does not apply when the command contains a chain operator, so
     "git push" cannot wave through `rm -rf / && git push`. This mirrors the
     disqualification SAFE_PREFIX already carries in risk-rules.ts.

CHAIN_OPERATORS is exported from risk-rules.ts for (3).

Both docs now describe the key that exists, under the name it actually has.

9 new tests pass; the 123 existing command-interceptor and risk-rules tests
still pass. Note the chain-operator test asserts PARITY with an unconfigured
allowlist rather than riskLevel !== "low": the baseline classifier already
rates `git push | sh` low with no allowlist in play, because piping to a shell
is only caught for curl-shaped commands. That is a real pre-existing gap,
tracked separately, and not something this change introduced or can fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(command-policy): allowed_patterns was unreachable from .rafter.yml

The allowlist added in 4f94eaf never ran for a real user. policy-loader's
mapPolicy did not parse `allowed_patterns`, and ConfigManager.loadWithPolicy
did not copy it onto the merged config, so CommandInterceptor read a key that
nothing ever populated. Caught by the mayor (rf-3n1i), not by me.

The unit tests were green the whole time because they stub loadWithPolicy and
inject `allowedPatterns` straight into the config object — green over a path no
customer can take. Adding the key to the schema and the interceptor looked like
the whole job; it was half of it. Any new command_policy key needs a line in
mapPolicy AND in loadWithPolicy or it is documentation for a feature that does
not run, and both files now say so.

Adds command-policy-allowlist-e2e.test.ts, which writes a real .rafter.yml and
asserts the verdict a customer would get. If either mapping is dropped again it
goes red while the stubbed suite stays green, which is the point of it existing
separately.

That test immediately found a second, pre-existing crash: loadWithPolicy checked
for `config.agent` and then dereferenced `config.agent.commandPolicy.mode`. A
config file with an `agent` block but no `commandPolicy` — a partial or
hand-edited ~/.rafter/config.json, which is what is on this machine — throws
"TypeError: Cannot set properties of undefined (setting 'mode')" as soon as the
repo also has a .rafter.yml with a command_policy block. Now defaulted.

156 tests pass across the interceptor, policy-loader, policy-merge and
risk-rules suites; tsc clean. Still NOT pushed — public repo, awaiting approval.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(command-policy): implement allowed_patterns in python, and put it under the policy floor (rf-3n1i)

`command_policy.allowed_patterns` was documented in shared-docs/CLI_SPEC.md and
implemented in node only. Python parsed nothing, merged nothing and enforced
nothing, so for every python user the key was inert — which is worse than an
absent one, because the operator believes the allowlist is on. That is rf-3n1i,
and it is why poe correctly refused to ship the node half alone.

THE RESOLUTION CHANGED THE FEATURE, and this is the part to review.

Both commits were written against a main that predates #233's policy floor, so
the cherry-pick conflicted in config-manager. b3ba56d — mine — added
allowed_patterns to the OLD naive "policy wins" merge. Taken as written onto
current main it would have let a cloned repo ship

    command_policy: {allowed_patterns: [".*"]}

and wave through every non-critical command: a repo-controlled bypass of the
exact floor sable-nz4y/rf-adth exists to provide. My own commit, landing after
the floor, would have opened a hole in it.

So allowed_patterns is NOT unioned, and the asymmetry is deliberate:
blocked_patterns and require_approval are unioned because contributing to them
can only ADD restriction. An allowlist is a grant. The owner's list stands; a
project's is refused with a warning unless allowProjectOverride is set. Same
rule, both runtimes.

A MUTATION SWEEP FOUND A DEAD GUARD, in code I was mirroring rather than
writing. 4f94eaf advertises three safety properties; property 2, "a critical
command is never allowlistable", is enforced by the unconditional hard-block at
the top of evaluate() and NOT by the guard inside the allowlist loop. That guard
is unreachable in BOTH runtimes — node blocks at :42 and guards at :116 — and
deleting it leaves every test green. The behaviour is correct; the guard is
belt-and-braces. Kept, because it is the only protection if that early block is
ever narrowed, but now labelled so nobody mistakes it for the live mechanism.

Two of my own tests were vacuous for the same reason and are renamed to assert
the OUTCOME rather than the guard. A test named for an unreachable guard passes
with the guard deleted, which is the whole failure mode this repo keeps finding.

GATE:
  floor      project allowed_patterns REFUSED under the floor, APPLIED under
             allowProjectOverride, with a control proving the merge is live
             (blocked_patterns still unions) — so the refusal is not a dead path
  mutation   union the project allowlist -> the floor test goes RED; restore -> green
  interceptor  driven through the REAL evaluate(), not a replicated precedence:
             owner allowlist suppresses approval; blocked beats allowed; a chain
             operator disqualifies the match; critical is never allowlisted; and
             an unmatched command still needs approval (so it is not blanket-allow)
  rf-vnxs    an allowlist naming `^rafter agent config set` explicitly STILL
             cannot grant the disarm — the allowlist is not a fifth route to it
  suites     394 passed across the policy/config/interceptor/risk selection

9 new python tests. The node tests from the two picked commits come along
unchanged.

* test(command-policy): move the allowlist e2e tests onto the post-floor contract

The two e2e tests from b3ba56d asserted the PRE-floor behaviour — that a
project `.rafter.yml` carries allowed_patterns into the merged config and
suppresses the prompt. That contract is exactly what the floor changes, so they
failed, correctly, and the fix is to invert them rather than to weaken the
floor.

  - "carries allowed_patterns ... into the merged config" becomes "maps
    allowed_patterns from YAML, but the FLOOR refuses a project's grant". It
    still asserts the YAML mapping survives — dropping that mapping is what
    made the feature unreachable in the first place and must not regress — and
    adds that the merge refuses the grant.
  - a new test covers the other side: the project allowlist DOES apply once the
    owner sets allowProjectOverride.

THREE MORE WERE ABOUT TO PASS FOR THE WRONG REASON. Under the floor a project
allowlist never applies, so "still refuses a chained command", "still lets
blocked_patterns win" and "suppresses the prompt end to end" would all have gone
green while testing nothing — the allowlist they exercise was no longer in
play. Each now opts in via allowProjectOverride so it exercises the feature it
names. That is the same vacuous-green shape this file was written to prevent,
arriving through the back door of a behaviour change.

TWO FIXTURE BUGS WORTH KEEPING FIXED:
  - The suite read the DEVELOPER'S ~/.rafter/config.json, because getRafterDir()
    is os.homedir()-relative and nothing redirected HOME. The result depended on
    whose machine ran it. HOME now points at the temp dir.
  - The global-config helper was hand-rolled and partial, and a commandPolicy
    without blockedPatterns makes evaluate() throw `policy.blockedPatterns is
    not iterable` — a shape no real install has. It is built from
    getDefaultConfig() now.

That throw is worth a separate look: a hand-edited global config missing an
array raises inside the hook, and the hook's outer handler fails OPEN. Not
filed here because it needs the owner's own config to be malformed rather than
anything a repo controls, but it is the rf-htc8 shape.

GATE: 15 allowlist tests green both files; mutation — union the project
allowlist and the floor test goes RED, restore and it is green; 187 passed
across the policy-merge / floor / config-manager / interceptor / differential
selection.

* fix(command-policy): close two allowlist bypasses found by the rafter review (F1, F2)

The security gate earned its place. Two HIGH findings, both reproduced by
execution against the shipped hook, not by reading.

F1 — NEWLINE WAS NOT A STATEMENT SEPARATOR, and this one is mine. The chain
check kerckhoffs wrote, and I mirrored into python without questioning, was

    /[;|&]|&&|\|\|/

which omits `\n`. A newline has been a statement separator in risk-rules since
rf-6pqx — the fix I wrote — and the disagreeing regex sat six lines from the
tokenizer comment saying so. With `^git push origin feature/` allowlisted:

    git push --force origin main                       deny
    git push origin feature/x ⏎ git push --force main   ALLOW
    git status ⏎ chmod 777 /etc/shadow                  ALLOW

The adversary is the agent whose command is being gated; it writes the whole
string, so prefixing an allowlisted line costs one keystroke. The example in
our own docs waved through a force-push to main.

Fixed by deleting the second definition rather than widening it: `isChainedCommand`
/ `is_chained_command` asks the TOKENIZER, which already normalises `\n` to `;`.
One source of truth for "what separates two commands" — a hand-kept operator
list is what failed here, and widening it would have left the next one to find.

F2 — A SCALAR STRING BECAME A CHARACTER-WISE ALLOWLIST THAT ALLOWED EVERYTHING.
`rafter agent config set agent.commandPolicy.allowedPatterns '^git status'`
stores a bare string (json.loads fails, the raw value is kept) — the documented
way to configure the feature. Python then iterated its CHARACTERS, and the first,
`^`, matches every command: chmod 777 /etc/shadow, a force-push and sudo rm -rf
all went low/allowed. Every sibling key is validated; this one was added to
node's validator and not to python's, so it was a parity gap as well as a bypass.

Added the missing validator case beside its siblings AND a coercion at the
consumer in BOTH runtimes. The second half matters: node was credited as safe
because its validator catches the shape `config set` writes, but the node
consumer was equally unguarded — my parity test for F2 failed on node first,
which is how that surfaced. Validation upstream is not a reason to iterate an
untrusted shape downstream.

F5 — docs corrected. guardrails.md still stated "Merge order (most specific
wins): project .rafter.yml > global config", which the floor makes false, and
both docs enumerated chain operators while omitting `&` and the newline. The
operator list now describes what the tokenizer does instead of restating it.

F6 — the tests could not have caught F1. They exercised `&&`, `;` and `|` only,
so all 15 node and 9 python passed with the hole open. Added newline, CRLF and
scalar-string rows to both runtimes, plus a control that the allowlist still
works on a single statement — so the new rows must fail for the right reason.
Mutation-verified: restore the old regex and exactly the two newline tests go
red; restore the fix and all 13 pass.

NOT FIXED HERE, filed instead: F3 (command-policy patterns are never
compile-checked, so an invalid one silently degrades to a substring match and
the two regex engines disagree on which patterns are invalid) and F4 (a repo's
blocked_patterns are unioned into the floor and run unbounded — a nested
quantifier plus a 46-character command stalls the hook past 45s). Both predate
this diff; F3's helper gains a new consumer here, which is why the review
surfaced it. Fixing either during a security release, at speed, is how the
thing being fixed gets shipped broken.

GATE: 398 python passed, 203 node passed; both bypass tables now deny in both
runtimes with the legitimate single-statement allowlist still allowed;
typecheck clean.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: secbolt/crew/goldwasser <hello@rafter.so>
@Rome-1 Rome-1 mentioned this pull request Sep 13, 2026
Unblocks rf-f5is. #250 merged to a PUBLIC main on 2026-09-11 and the registries
have served 0.10.3 ever since — a fix disclosed and not shipped, for an
EXTERNALLY reported finding. Cutting the disclosure line from the PR body did
not undo the disclosure: merging to a public repo publishes a diff naming
exactly which key shapes were undetected and at what lengths.

CONTENTS
  6839663  #250  rf-f5is   OpenAI + Supabase rules in the regex engine
  14eb1c6  #246  rf-3n1i   command_policy.allowed_patterns in python, under the
                           policy floor, plus two allowlist bypasses closed

SCOPE OF THE BUMP — four files, found by searching for the current version
rather than by trusting the validator's list, because #238 exists precisely
because a partial bump PASSED validation once:

    node/package.json
    python/pyproject.toml
    node/resources/rafter-security-skill.md                 (gated ClawHub manifest)
    python/rafter_cli/resources/rafter-security-skill.md    (gated ClawHub manifest)

DELIBERATELY NOT BUMPED. The repo carries six other SKILL.md files with their
own frontmatter versions — rafter-code-review at 0.7.0, rafter-secure-design,
rafter-skill-review and rafter at 0.1.0/0.7.0. Those are independently
versioned skill resources, not package-version mirrors; moving them to 0.10.4
would be wrong, and "every manifest" does not mean every file with a version.
The two that ARE package mirrors are the two validate-release gates.

VERIFIED by running validate-release's own checks locally rather than trusting
the edit: node and python versions match at 0.10.4, and both gated skill
manifests match the package version. Plus the check validate-release does NOT
do and which is the one that actually bites — 0.10.4 is not already on the
registry. main's version equalling the published version is what made the last
two gaps unpublishable: validation passes and the publish job fails later, at
the registry, with an error that does not say "you forgot the bump".

Does not push prod. PR #251 (main -> prod) is open and is Rome's to merge.

Co-authored-by: secbolt/crew/goldwasser <hello@rafter.so>
@Rome-1 Rome-1 changed the title release: promote main to prod — rf-f5is secret rules + rf-3n1i allowlist parity (NEEDS 0.10.4 BUMP FIRST) release: promote main to prod — publishes 0.10.4 (rf-f5is secret rules + rf-3n1i allowlist parity). BUMP HAS LANDED, READY TO MERGE. Sep 13, 2026
@Raftersecurity Raftersecurity changed the title release: promote main to prod — publishes 0.10.4 (rf-f5is secret rules + rf-3n1i allowlist parity). BUMP HAS LANDED, READY TO MERGE. release: promote main to prod — publishes 0.10.4 (rf-f5is secret rules + rf-3n1i allowlist parity). B Sep 13, 2026
@Raftersecurity Raftersecurity changed the title release: promote main to prod — publishes 0.10.4 (rf-f5is secret rules + rf-3n1i allowlist parity). B release: promote main to prod — publishes 0.10.4 (rf-f5is secret rules + rf-3n1i allowlist parity). Sep 13, 2026
@Raftersecurity
Raftersecurity merged commit 84322e6 into prod Sep 13, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants