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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.10.4] - 2026-09-12

### Security

- **OpenAI and Supabase keys are detected by the hook's Write gate** (rf-f5is; external report se-wagv). The Write gate is regex-only and `secret-patterns` carried **no OpenAI rule at all**, so `sk-proj-`, `sk-svcacct-`, `sk-admin-` and legacy `sk-…T3BlbkFJ…` keys were allowed straight through 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. Three rules added to both runtimes, matched case-sensitively in line with the other prefixed vendor tokens (`ghp_`, `AKIA`, `AIza`, `xox`); lower-casing them would add false positives and catch nothing real. Verified against the published 0.10.3 artifact before the fix, with controls, so the miss was evidence rather than an empty result.

- **`command_policy.allowed_patterns` works in Python, and cannot be granted by a project** (rf-3n1i). The key was documented in `shared-docs/CLI_SPEC.md` and implemented in Node only — for every Python user it parsed to nothing and enforced nothing, which is worse than an absent key because the operator believes the allowlist is on. Implementing it exposed a second problem: an allowlist is a **grant**, so unlike `blocked_patterns` and `require_approval` — which are unioned, because contributing to them can only add restriction — a project `.rafter.yml` must not contribute to it. Otherwise a cloned repo shipping `allowed_patterns: [".*"]` waves through every non-critical command, defeating the policy floor. The owner's list stands; a project's is refused unless `allowProjectOverride` is set.

- **Two allowlist bypasses closed** (rf-3n1i). A newline was not treated as a statement separator by the allowlist's chain check, so with `^git push origin feature/` allowlisted a second line ran unclassified; the check now asks the tokenizer, which has treated a newline as a separator since rf-6pqx, rather than keeping a second narrower definition. And a scalar-string `allowedPatterns` was iterated **character by character**, so a leading `^` matched every command and the allowlist allowed everything — the shape `rafter agent config set` actually writes. Guarded at the validator and at the consumer, in both runtimes.

## [0.10.3] - 2026-09-11

### Security
Expand Down
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.3",
"version": "0.10.4",
"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.3
version: 0.10.4
homepage: https://rafter.so
metadata:
openclaw:
Expand Down
33 changes: 28 additions & 5 deletions node/resources/skills/rafter/docs/guardrails.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,23 +24,46 @@ Every command (Bash-like tool call) gets classified into one of four tiers by `s
| `high` | Destructive or privileged (force push, `sudo`, broad file deletion, curl | sh) | **prompt** the agent / user for approval |
| `critical` | Likely irreversible damage (`rm -rf /`, DB drop, wiping .git, repo-wide chmod) | **block** hard |

Tiers are derived from regex patterns in `risk-rules.ts` (`CRITICAL_PATTERNS`, `HIGH_PATTERNS`, `MEDIUM_PATTERNS`) plus a `SAFE_PREFIX` allowlist. Presence of chain operators (`&&`, `||`, `;`, `|`) disqualifies the safe-prefix shortcut.
Tiers are derived from regex patterns in `risk-rules.ts` (`CRITICAL_PATTERNS`, `HIGH_PATTERNS`, `MEDIUM_PATTERNS`) plus a `SAFE_PREFIX` allowlist. A command holding more than one statement disqualifies the safe-prefix shortcut. Statement separators are whatever the tokenizer treats as one — `&&`, `||`, `;`, `|`, `&`, and a NEWLINE — rather than a hand-kept list; the newline was missing from an earlier hand-kept copy and that was a live bypass.

## Policy Overrides

`.rafter.yml` (project) and `~/.rafter/config.yml` (global) can override defaults:

```yaml
risk:
command_policy:
blocked_patterns:
- "terraform destroy"
require_approval:
- "^npm publish"
allow:
- "^pnpm run test" # force low regardless of content
allowed_patterns:
- "^pnpm run test" # force low, skipping the approval prompt
- "git push --force-with-lease"
```

Merge order (most specific wins): project `.rafter.yml` > global config > built-in defaults. Dump the effective merged policy with `rafter policy export`.
**On `allowed_patterns`.** Until 2026-09-07 this section documented a
`risk.allow` key that was never implemented — a customer went looking for it
and found nothing. The real key is `command_policy.allowed_patterns`, and it
now exists.

It is a positive allowlist for the known-safe command that trips a broad tier:
the motivating case is `git push --force-with-lease` to a feature branch on a
repo whose `main` is protected server-side, which classifies `high` and prompts
on every push even though the dangerous version cannot land. Dropping
`risk_level` to silence that is too blunt — it would also stop prompting for
`sudo` and `curl | sh`.

Three properties keep an allowlist from becoming a hole in the guard rail:

1. **`blocked_patterns` always wins.** An allow rule never re-opens what a deny
rule closed.
2. **`critical` is never allowlistable.** `rm -rf /`, a DB drop and wiping
`.git` stay blocked whatever the config says.
3. **Chain operators disqualify a match.** Patterns are unanchored, so without
this `"git push"` would wave through `rm -rf / && git push`. A chained
command is classified exactly as it would be with no allowlist configured.

Merge order: project `.rafter.yml` > global config > built-in defaults for most keys — but NOT for `command_policy`, which is a floor. A project policy may tighten command policy and never loosen it: `mode` is accepted only if at least as strict, `blocked_patterns` and `require_approval` are unioned, and `allowed_patterns` — being a grant rather than a restriction — is refused outright unless the machine owner sets `agent.commandPolicy.allowProjectOverride: true` in their global config. Dump the effective merged policy with `rafter policy export`.

## How to Interpret a Block

Expand Down
44 changes: 44 additions & 0 deletions node/src/core/command-interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
matchedCriticalPattern,
sanitizeCommandForMatching,
CommandRiskLevel,
isChainedCommand,
} from "./risk-rules.js";

export type { CommandRiskLevel } from "./risk-rules.js";
Expand Down Expand Up @@ -93,6 +94,49 @@ export class CommandInterceptor {
}
}

// Check the positive allowlist. Deliberately AFTER blockedPatterns and
// BEFORE requireApproval: a deny rule always wins, and an allow rule's
// whole job is to suppress the approval prompt for a known-safe command.
//
// Two guards keep an allowlist from becoming a hole in the guard rail:
//
// - A `critical` command is never allowlistable. NOTE: this guard is
// currently UNREACHABLE — evaluate() hard-blocks critical at the top of
// the method, before this loop — and a mutation sweep proved it:
// deleting the guard leaves every test green, in both runtimes. Kept as
// defence in depth, because it becomes the only protection the day that
// early block is narrowed. Do not write a test claiming to exercise it;
// such a test passes with the guard deleted.
// - A match does not apply when the command holds more than one
// statement. Patterns are unanchored by request, so without this
// "^git push" would wave through `rm -rf / && git push` — or, via the
// newline the original regex missed, anything on a second line.
// Defence in depth behind the config validator: a non-array here is not
// merely wrong, it is dangerous. A bare string iterates as CHARACTERS, and
// the first one of `"^git status"` is `^`, which matches every command —
// the allowlist would allow everything. The validator catches the shape
// `config set` writes; this catches every other way it could arrive.
const allowedPatterns = Array.isArray(policy.allowedPatterns) ? policy.allowedPatterns : [];
for (const pattern of allowedPatterns) {
if (!this.matchesPattern(command, pattern)) continue;

if (isChainedCommand(command)) {
// Fall through to normal classification rather than allowing.
break;
}
if (this.assessRisk(command) === "critical") {
break;
}
return {
command,
riskLevel: "low",
allowed: true,
requiresApproval: false,
reason: `Matches allowed pattern: ${pattern}`,
matchedPattern: pattern
};
}

// Check approval patterns
for (const pattern of policy.requireApproval) {
if (this.matchesPattern(command, pattern)) {
Expand Down
2 changes: 2 additions & 0 deletions node/src/core/config-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export function getDefaultConfig(): RafterConfig {
mode: "approve-dangerous",
blockedPatterns: [...DEFAULT_BLOCKED_PATTERNS],
requireApproval: [...DEFAULT_REQUIRE_APPROVAL],
// Empty by default: an allowlist is opt-in, per project.
allowedPatterns: [],
},
outputFiltering: {
redactSecrets: true,
Expand Down
42 changes: 37 additions & 5 deletions node/src/core/config-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ function validateConfig(raw: any): RafterConfig {
console.error('Warning: config "agent.commandPolicy.blockedPatterns" must be an array of strings — using default.');
cp.blockedPatterns = [...defaults.agent!.commandPolicy.blockedPatterns];
}
if (cp.allowedPatterns !== undefined && (!Array.isArray(cp.allowedPatterns) || !cp.allowedPatterns.every((v: any) => typeof v === "string"))) {
console.error('Warning: config "agent.commandPolicy.allowedPatterns" must be an array of strings — using default.');
cp.allowedPatterns = [...(defaults.agent!.commandPolicy.allowedPatterns ?? [])];
}
if (cp.requireApproval !== undefined && (!Array.isArray(cp.requireApproval) || !cp.requireApproval.every((v: any) => typeof v === "string"))) {
console.error('Warning: config "agent.commandPolicy.requireApproval" must be an array of strings — using default.');
cp.requireApproval = [...defaults.agent!.commandPolicy.requireApproval];
Expand Down Expand Up @@ -320,10 +324,22 @@ export class ConfigManager {
const policy = loadPolicy();
if (!policy) return config;

// Ensure agent block exists
// Ensure agent block exists, AND that commandPolicy inside it does.
//
// Checking only for `agent` was not enough. A config file that carries an
// `agent` block without a `commandPolicy` key — a partial or hand-edited
// ~/.rafter/config.json, which is a normal thing to have — reaches the
// assignments below and throws
// TypeError: Cannot set properties of undefined (setting 'mode')
// the moment the repo also has a .rafter.yml with a command_policy block.
// Found 2026-09-10 by an end-to-end test that loads a real config instead
// of a stub; the stubbed unit tests could not see it.
const agentDefaults = getDefaultConfig().agent!;
if (!config.agent) {
const defaults = getDefaultConfig();
config.agent = defaults.agent;
config.agent = agentDefaults;
}
if (!config.agent.commandPolicy) {
config.agent.commandPolicy = { ...agentDefaults.commandPolicy };
}

// Risk level
Expand Down Expand Up @@ -508,14 +524,15 @@ function unionPatterns(floor: string[], project: string[]): string[] {
* `allowOverride` is true the pre-sable-nz4y replace semantics are used.
*/
export function mergeCommandPolicy(
target: { mode: string; blockedPatterns: string[]; requireApproval: string[] },
project: { mode?: string; blockedPatterns?: string[]; requireApproval?: string[] },
target: { mode: string; blockedPatterns: string[]; requireApproval: string[]; allowedPatterns?: string[] },
project: { mode?: string; blockedPatterns?: string[]; requireApproval?: string[]; allowedPatterns?: string[] },
allowOverride: boolean
): void {
if (allowOverride) {
if (project.mode) target.mode = project.mode as any;
if (project.blockedPatterns) target.blockedPatterns = project.blockedPatterns;
if (project.requireApproval) target.requireApproval = project.requireApproval;
if (project.allowedPatterns) target.allowedPatterns = project.allowedPatterns;
return;
}

Expand All @@ -538,4 +555,19 @@ export function mergeCommandPolicy(
if (project.requireApproval) {
target.requireApproval = unionPatterns(target.requireApproval, project.requireApproval);
}

// allowedPatterns is NOT unioned, and that asymmetry is the whole point.
// Unioning blockedPatterns or requireApproval can only ever ADD restriction,
// so a project contributing to them is safe. An allowlist is the opposite:
// it is a grant. Union it and a cloned repo ships
// command_policy: { allowed_patterns: [".*"] }
// and waves every non-critical command through — which is precisely the
// bypass the floor exists to prevent (sable-nz4y / rf-adth). So the owner's
// allowlist stands and the project's is refused unless the owner has
// explicitly opted into project override.
if (project.allowedPatterns) {
console.error(
`Warning: project policy sets agent.commandPolicy.allowed_patterns, which can only loosen command policy — ignoring. Set agent.commandPolicy.allowProjectOverride: true in your global config to allow project policies to loosen command policy.`
);
}
}
18 changes: 18 additions & 0 deletions node/src/core/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,24 @@ export interface RafterConfig {
* floor: a project policy may tighten command policy, never loosen it.
*/
allowProjectOverride?: boolean;

/**
* Positive allowlist: unanchored regexes that force a command to `low`
* and skip the approval prompt. For the known-safe command that would
* otherwise trip a broad risk tier -- the motivating case being
* `git push --force-with-lease` to a feature branch on a repo whose
* main is protected server-side.
*
* Three properties make this safe to put on a guard rail, and all three
* are enforced in CommandInterceptor, not here:
* 1. blockedPatterns always wins. An allowlist 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`.
*/
allowedPatterns?: string[];
};
outputFiltering: {
redactSecrets: boolean;
Expand Down
10 changes: 10 additions & 0 deletions node/src/core/policy-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface PolicyFile {
mode?: string;
blockedPatterns?: string[];
requireApproval?: string[];
allowedPatterns?: string[];
};
scan?: {
excludePaths?: string[];
Expand Down Expand Up @@ -135,6 +136,9 @@ function mapPolicy(raw: Record<string, any>): PolicyFile {
if (Array.isArray(raw.command_policy.require_approval)) {
policy.commandPolicy.requireApproval = raw.command_policy.require_approval;
}
if (Array.isArray(raw.command_policy.allowed_patterns)) {
policy.commandPolicy.allowedPatterns = raw.command_policy.allowed_patterns;
}
}

if (raw.scan && typeof raw.scan === "object") {
Expand Down Expand Up @@ -323,6 +327,12 @@ function validatePolicy(policy: PolicyFile, raw: Record<string, any>): PolicyFil
delete policy.commandPolicy.requireApproval;
}
}
if (policy.commandPolicy.allowedPatterns !== undefined) {
if (!Array.isArray(policy.commandPolicy.allowedPatterns) || !policy.commandPolicy.allowedPatterns.every((v: any) => typeof v === "string")) {
console.error(`Warning: "command_policy.allowed_patterns" must be an array of strings — ignoring.`);
delete policy.commandPolicy.allowedPatterns;
}
}
}

if (policy.scan) {
Expand Down
24 changes: 24 additions & 0 deletions node/src/core/risk-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,30 @@ export function sanitizeCommandForMatching(command: string): string {
return sanitize(stripHeredocBodies(command), 0);
}

/**
* True if the command contains more than one statement.
*
* Asked of the TOKENIZER rather than a regex, deliberately. The first version
* of this was `/[;|&]|&&|\|\|/`, which omits the newline — and a newline has
* been a statement separator in this file since rf-6pqx, six lines from where
* that regex sat. The gap was reachable in one step: with `^git push origin
* feature/` allowlisted,
*
* git push origin feature/x
* git push --force origin main
*
* classified `allow`, because the chain check saw no operator. The agent being
* gated writes the whole string, so prefixing an allowlisted line is free.
*
* The tokenizer already normalises `\n` to `;`, so routing the question through
* it removes the second, narrower definition instead of widening it. One source
* of truth for "what separates two commands".
*/
export function isChainedCommand(command: string): boolean {
const { pieces } = tokenize(command);
return pieces.some((p) => p.op !== null && CHAIN_OPS.has(p.op));
}

/**
* Assess risk level of a command string.
*/
Expand Down
30 changes: 30 additions & 0 deletions node/src/scanners/secret-patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,36 @@ export const DEFAULT_SECRET_PATTERNS: Pattern[] = [
description: "Stripe Restricted API Key detected"
},

// OpenAI (rf-f5is / se-wagv, external report). The hook's Write gate is
// regex-only, and this file had NO OpenAI rule at all — so `sk-proj-` and
// legacy keys were ALLOWED through the gate at any length, while
// `rafter secrets` caught them via betterleaks. The two engines disagreed,
// and the one guarding writes was the blind one.
//
// No `(?i)`: these prefixes and their base62 bodies are case-sensitive, and
// the convention here is that prefixed vendor tokens (ghp_, AKIA, AIza, xox)
// match case-sensitively. Lower-casing them would only add false positives.
{
name: "OpenAI API Key",
regex: "sk-(proj|svcacct|admin)-[A-Za-z0-9_-]{40,}",
severity: "critical",
description: "OpenAI project/service/admin API key detected"
},
{
name: "OpenAI API Key (legacy)",
regex: "sk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}",
severity: "critical",
description: "OpenAI legacy API key detected"
},

// Supabase (rf-f5is / se-wagv). Detected by NEITHER engine at any length.
{
name: "Supabase Secret Key",
regex: "sb_secret_[A-Za-z0-9_-]{20,}",
severity: "critical",
description: "Supabase secret key detected"
},

// Twilio
{
name: "Twilio API Key",
Expand Down
Loading
Loading