release: promote main to prod — publishes 0.10.4 (rf-f5is secret rules + rf-3n1i allowlist parity). - #251
Merged
Conversation
…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>
Merged
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>
Raftersecurity
approved these changes
Sep 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Promotes
maintoprod. 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:
Verified two ways rather than assumed: version strings read straight off
origin/main, andnpm view @rafter-security/cli@0.10.4confirms the version is free. The previous three attempts failed becausemain's version equalled the published version, sopublish.ymlran and died at the registry with an error that never says "you forgot the bump."What merging this ships
dabe92a14eb1c6allowed_patternsimplemented in python, placed under the global floor, two allowlist bypasses closed. Breaking-change gate: 1347-command differential, both runtimes, 0 changed verdicts6839663Why 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 versionshould 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— onenpm viewplus 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.