Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
bf78e2c
fix(scanners): treat betterleaks null report as empty result set (#217)
Rome-1 Aug 27, 2026
5881204
fix(scan): keep --json stdout parseable on a zero-file diff scan
Rome-1 Aug 27, 2026
1b9221f
docs: correct stale "2 MCP resources" claim — rafter://docs was missing
Rome-1 Aug 27, 2026
41d5b13
test(node): make the legacy-gitleaks hint test hermetic
Rome-1 Aug 28, 2026
e5d4867
docs: expand Cursor recipe to match platform recipe standard (#215)
AlexChakmakian Aug 29, 2026
1c2cd57
Merge pull request #218 from Raftersecurity/fix/217-betterleaks-null-…
Rome-1 Aug 29, 2026
0996492
ci: run the test suite on external PRs into main (#219)
Rome-1 Aug 29, 2026
7e58d4c
fix: retry transient report-read failures during scan polling (sable-…
Rome-1 Sep 1, 2026
dbb4f60
ci: close three blind spots found investigating why CI missed sable-l…
Rome-1 Sep 1, 2026
efefff0
fix: stop sending the API key across redirects (sable-2s6p) (#223)
Rome-1 Sep 1, 2026
0a2ae5f
ci: run the Python tests on the release path (sable-cazq) (#222)
Rome-1 Sep 1, 2026
3856a9d
fix: an unreadable scan report is not a clean scan (sable-fgk7) (#224)
Rome-1 Sep 2, 2026
3ac4b0e
ci: fail when the suite shrinks or a file's tests all skip; name skip…
Rome-1 Sep 2, 2026
1ee4d26
test: run the real code instead of hand-copied mirrors (sable-1drb) (…
Rome-1 Sep 2, 2026
6d6aa33
fix(security): CLI ship set — classifier + install/verify hardening (…
Rome-1 Sep 9, 2026
28e0095
fix(release): bump ClawHub skill resource versions to 0.10.1
Rome-1 Sep 9, 2026
4738977
ci: fetch main in validate-release so the differential gate can run
Rome-1 Sep 9, 2026
31f9c11
fix(release): bump BOTH gated skill manifests, and give every full-su…
Rome-1 Sep 9, 2026
5409a84
ci: drop Node 18 from the vitest matrix; engines stays >=18
Rome-1 Sep 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,4 @@ Rafter is a security CLI for AI coding agents. It ships as two feature-identical
- Scanners use dual-engine: Betterleaks binary first, regex fallback. Patterns defined in `secret-patterns.ts` / `secret_patterns.py`
- Risk classification: critical > high > medium > low
- Audit log: JSONL format, append-only, documented schema in CLI_SPEC.md
- MCP server: 4 tools + 2 resources over stdio transport
- MCP server: 4 tools + 3 resources over stdio transport
108 changes: 108 additions & 0 deletions .github/scripts/classifier_battery_floor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Drift check for the command-classifier battery.

The battery and the differential catch different things, and CI needs both:

* The DIFFERENTIAL (PR vs origin/main) catches a REGRESSION — this branch
weaker than main. It is what a hand-written battery misses, because a
battery only asks the questions someone thought to ask.
* The BATTERY catches a MISSING fix and an OVER-BLOCK. It is what the
differential misses, because a fix absent on BOTH sides is not a permissive
*move* and shows up as nothing: the differential ran CLEAN against a branch
that had lost a live P0 fix entirely.

That makes the battery's CONTENTS load-bearing, and a load-bearing list nobody
can see shrink is a list that will shrink. This is sable-d2x2 rule C applied to
the gate itself rather than to the suite it guards.

Three assertions:

* the battery has not shrunk below the floor;
* it still has rows gating the UNDER-block direction (want includes
"critical") — the rows that catch a fix going missing;
* it still has rows gating the OVER-block direction (want is exactly
["low"]) — without them, blocking everything passes the gate. #230 was an
over-block report, so a battery with no low rows would have been happy to
ship it.

Lower the floor in the same PR that removes cases, so a shrink is a reviewed
decision rather than an accident.
"""
from __future__ import annotations

import argparse
import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
BATTERY = os.path.join(HERE, "..", "..", "rf-6pqx-newline-heredoc-battery.json")


def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--min-cases", type=int, required=True)
ap.add_argument("--min-critical", type=int, default=15)
ap.add_argument("--min-low", type=int, default=10)
ap.add_argument("--battery", default=BATTERY)
args = ap.parse_args()

try:
cases = json.load(open(args.battery))
except (OSError, ValueError) as e:
print(f"FAIL: battery unreadable at {args.battery}: {e}")
return 1

if not isinstance(cases, list) or not cases:
print(
"FAIL: battery is not a non-empty list — a gate with no cases "
"passes everything, which is worse than no gate at all"
)
return 1

critical = [c for c in cases if "critical" in c.get("want", [])]
low = [c for c in cases if c.get("want") == ["low"]]

failures = []
if len(cases) < args.min_cases:
failures.append(
f"battery shrank: {len(cases)} cases, floor is {args.min_cases}. "
"If cases were removed on purpose, lower the floor in the same PR."
)
if len(critical) < args.min_critical:
failures.append(
f"only {len(critical)} rows gate the under-block direction "
f"(want includes 'critical'), floor is {args.min_critical}. Those "
"are the rows that catch a fix going missing."
)
if len(low) < args.min_low:
failures.append(
f"only {len(low)} rows gate the over-block direction "
f"(want is exactly ['low']), floor is {args.min_low}. Without "
"those, blocking everything passes the gate."
)

# A malformed row is a row that cannot fail. Catch it here rather than
# letting a harness quietly skip it.
for i, c in enumerate(cases):
if not isinstance(c.get("cmd"), str) or not c["cmd"]:
failures.append(f"case {i} ({c.get('label', '?')!r}) has no command")
want = c.get("want")
if not isinstance(want, list) or not want:
failures.append(f"case {i} ({c.get('label', '?')!r}) has no expectations")

if failures:
print("FAIL: command-classifier battery drift")
for f in failures:
print(f" - {f}")
return 1

print(
f"PASS: battery has {len(cases)} cases "
f"({len(critical)} gate under-blocking, {len(low)} gate over-blocking)"
)
return 0


if __name__ == "__main__":
sys.exit(main())
170 changes: 170 additions & 0 deletions .github/scripts/test_floor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""Fail CI when the test suite quietly shrinks or a file's tests all skip.

sable-d2x2 detection rule C ("assert non-emptiness"). Two ways a green run can
be vacuous that the runner's own exit code does not catch:

1. A whole file's tests are skipped. sable-cazq: 40 parity tests were
`describe.skip`'d because Python was missing, and the release path exited
0. A total-count floor does NOT catch this (2112 - 40 is still a big
number); a per-file "every test skipped" rule does.
2. The suite shrinks sharply: a config change, a renamed directory, a broken
glob, and the runner cheerfully runs the 30 tests it found.

Reads a vitest JSON report (--vitest) or a pytest JUnit XML written with
`-o junit_family=xunit1` (--junit; xunit1 is what carries the per-test `file`
attribute). Exits 1 when:

* executed tests (passed + failed) < --min-executed, or
* any file has >= 1 test and every one of them was skipped, unless that
file is listed in --allow-all-skipped (a visible, reviewed exception).

Always writes the executed / skipped counts and every skipped test's name to
$GITHUB_STEP_SUMMARY when set, so a skip is never invisible even when it is
allowed. Stdlib only: this runs before any project dependency is guaranteed.
"""
from __future__ import annotations

import argparse
import json
import os
import sys
import xml.etree.ElementTree as ET
from collections import defaultdict

SKIPPED_STATES = {"skipped", "pending", "todo", "disabled"}


def read_vitest(path: str) -> dict[str, dict[str, list[str]]]:
"""{file: {"executed": [names], "skipped": [names]}} from a vitest JSON report."""
with open(path, encoding="utf-8") as fh:
report = json.load(fh)
files: dict[str, dict[str, list[str]]] = {}
cwd = os.getcwd() + os.sep
for result in report.get("testResults", []):
name = result.get("name", "")
rel = name[len(cwd):] if name.startswith(cwd) else name
bucket = files.setdefault(rel, {"executed": [], "skipped": []})
for case in result.get("assertionResults", []):
title = case.get("fullName") or case.get("title") or "<unnamed>"
state = case.get("status", "")
(bucket["skipped"] if state in SKIPPED_STATES else bucket["executed"]).append(title)
return files


def read_junit(path: str) -> dict[str, dict[str, list[str]]]:
"""Same shape from a pytest JUnit XML (xunit1 family, which carries `file`)."""
root = ET.parse(path).getroot()
files: dict[str, dict[str, list[str]]] = defaultdict(lambda: {"executed": [], "skipped": []})
missing_file_attr = 0
for case in root.iter("testcase"):
file_attr = case.get("file")
if not file_attr:
missing_file_attr += 1
# xunit2 drops `file`; fall back to the module part of classname so
# the per-file rule still has something to group by.
classname = case.get("classname", "")
parts = [p for p in classname.split(".") if p and not p[:1].isupper()]
file_attr = "/".join(parts) + ".py" if parts else "<unknown>"
title = f'{case.get("classname", "")}::{case.get("name", "")}'
skipped = case.find("skipped") is not None
(files[file_attr]["skipped"] if skipped else files[file_attr]["executed"]).append(title)
if missing_file_attr:
print(
f"::warning::{missing_file_attr} testcase(s) had no `file` attribute; "
"run pytest with `-o junit_family=xunit1` for exact per-file grouping.",
flush=True,
)
return dict(files)


def summarize(label: str, files: dict[str, dict[str, list[str]]], executed: int,
skipped: int, min_executed: int, all_skipped: list[str],
allowed: set[str]) -> str:
lines = [f"### Test floor — {label}", ""]
lines.append("| Executed | Skipped | Floor | Files |")
lines.append("|---------:|--------:|------:|------:|")
lines.append(f"| {executed} | {skipped} | {min_executed} | {len(files)} |")
lines.append("")
if all_skipped:
lines.append("**Files with every test skipped:**")
for f in all_skipped:
tag = " (allowed by --allow-all-skipped)" if f in allowed else " **← FAIL**"
lines.append(f"- `{f}`{tag}")
lines.append("")
if skipped:
lines.append("<details><summary>Skipped tests</summary>")
lines.append("")
for f, bucket in sorted(files.items()):
for name in bucket["skipped"]:
lines.append(f"- `{f}` — {name}")
lines.append("")
lines.append("</details>")
lines.append("")
return "\n".join(lines)


def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
src = ap.add_mutually_exclusive_group(required=True)
src.add_argument("--vitest", help="vitest JSON report (--reporter=json)")
src.add_argument("--junit", help="pytest JUnit XML (-o junit_family=xunit1 --junitxml=...)")
ap.add_argument("--min-executed", type=int, required=True,
help="fail if fewer than this many tests actually ran (passed + failed)")
ap.add_argument("--allow-all-skipped", default="",
help="comma-separated files allowed to have every test skipped")
ap.add_argument("--label", default=None, help="label for the step summary")
args = ap.parse_args(argv)

if args.vitest:
files = read_vitest(args.vitest)
label = args.label or "vitest"
else:
files = read_junit(args.junit)
label = args.label or "pytest"

allowed = {f.strip() for f in args.allow_all_skipped.split(",") if f.strip()}
executed = sum(len(b["executed"]) for b in files.values())
skipped = sum(len(b["skipped"]) for b in files.values())
all_skipped = sorted(f for f, b in files.items() if b["skipped"] and not b["executed"])
offending = [f for f in all_skipped if f not in allowed]

summary = summarize(label, files, executed, skipped, args.min_executed, all_skipped, allowed)
print(summary, flush=True)
step_summary = os.environ.get("GITHUB_STEP_SUMMARY")
if step_summary:
with open(step_summary, "a", encoding="utf-8") as fh:
fh.write(summary + "\n")

failed = False
if not files:
print(f"::error::{label}: the report lists no test files at all — nothing ran.", flush=True)
failed = True
if executed < args.min_executed:
print(
f"::error::{label}: only {executed} tests executed, floor is {args.min_executed}. "
"If tests were deliberately removed, lower the floor in the workflow in the same PR "
"so the shrink is a reviewed decision, not a silent one.",
flush=True,
)
failed = True
for f in offending:
print(
f"::error::{label}: every test in {f} was skipped ({len(files[f]['skipped'])} tests). "
"A file that runs nothing is a broken prerequisite, not a passing file. Fix the "
"prerequisite, or list the file in --allow-all-skipped with a reason in the workflow.",
flush=True,
)
failed = True
if not failed:
allowed_note = (
f", {len(all_skipped)} fully-skipped file(s) on the allow-list" if all_skipped
else ", no file fully skipped"
)
print(f"OK: {label}: {executed} executed (floor {args.min_executed}), "
f"{skipped} skipped{allowed_note}.", flush=True)
return 1 if failed else 0


if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
Loading
Loading