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

## [Unreleased]

## [0.8.6] - 2026-06-13

### Added
- **Config-driven hook off-switch** (sable-bnl). The PreToolUse hook can now be disabled at runtime without uninstalling it — `RAFTER_DISABLE_HOOKS` (whole hook), `RAFTER_DISABLE_SECRET_SCAN`, and `RAFTER_DISABLE_COMMAND_POLICY` env vars (`1`/`true`/`yes`/`on` = off; `0`/`false` = force-on), or the global `~/.rafter/config.json` `agent.hooks.{enabled,secretScan,commandPolicy}` keys. Env overrides global; default enabled; a corrupt config or unrecognized value fails safe to enabled. **Honored only from these trusted, machine-owner-owned sources — never from project-local `.rafter.yml`** (a `rafter-secure-design` trust-boundary decision): otherwise cloning a hostile repo that ships `hooks: { enabled: false }` would silently disable a victim's secret scanning and command interception. `rafter agent status` (and `--json` `hook_control`) now report the effective state and which source set it. Node + Python, with cross-runtime parity tests including the security negative (a project-local disable attempt is ignored).
- **`shared-docs/CONFIG.md`** — consolidated, code-verified reference for the global (`~/.rafter/config.json`) and project (`.rafter.yml`) config layers: full key sets, the trust boundary, and a toggle matrix mapping every on/off switch to the code that enforces it.

### Fixed
- **CWE-367 TOCTOU in the betterleaks scanner** (sable-t0q). `betterleaks.ts` built its temp report path from a predictable `Date.now()` name in the shared tmpdir; replaced with `fs.mkdtempSync` (a private `0700` dir, created atomically) plus best-effort cleanup in `finally` (which also fixes prior temp-file leaks on error paths). Brings Node to parity with Python's existing `tempfile.TemporaryDirectory`.

### Notes
- Audit (sable-59s) surfaced that `agent.outputFiltering.redactSecrets` / `blockPatterns` are validated but **not enforced** at runtime (the PostToolUse hook always redacts) — tracked as sable-y2z; documented as a known gap in `CONFIG.md`.

## [0.8.5] - 2026-06-10

### Fixed
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.8.5",
"version": "0.8.6",
"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.8.5
version: 0.8.6
homepage: https://rafter.so
metadata:
openclaw:
Expand Down
37 changes: 37 additions & 0 deletions node/src/commands/agent/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { fileURLToPath } from "url";
import { getRafterDir, getAuditLogPath, getBinDir } from "../../core/config-defaults.js";
import { AuditLogger } from "../../core/audit-logger.js";
import { ConfigManager } from "../../core/config-manager.js";
import { resolveHookControl } from "../../core/hook-control.js";
import { BinaryManager } from "../../utils/binary-manager.js";
import { SkillManager } from "../../utils/skill-manager.js";

Expand All @@ -18,6 +19,13 @@ interface AgentStatusJson {
betterleaks_available: boolean;
config_path: string;
audit_log_path: string;
/** Runtime hook enablement (the trusted-source off-switch). source ∈ default|global-config|env. */
hook_control: {
hook_enabled: boolean;
secret_scan_enabled: boolean;
command_policy_enabled: boolean;
source: { hook: string; secret_scan: string; command_policy: string };
};
}

export function createStatusCommand(): Command {
Expand Down Expand Up @@ -51,6 +59,22 @@ export function createStatusCommand(): Command {
console.log(`\nConfig: not found — run: rafter agent init`);
}

// --- Hook off-switch (trusted-source only: env / global config) ---
{
const c = resolveHookControl();
const describe = (on: boolean, src: string) =>
on ? "active" : `DISABLED (via ${src === "env" ? "RAFTER_DISABLE_* env" : "global config"})`;
if (!c.hookEnabled) {
console.log(`Hooks: ${describe(false, c.source.hook)}`);
} else if (!c.secretScanEnabled || !c.commandPolicyEnabled) {
console.log(`Hooks: active (partial)`);
console.log(` secret scan: ${describe(c.secretScanEnabled, c.source.secretScan)}`);
console.log(` command policy: ${describe(c.commandPolicyEnabled, c.source.commandPolicy)}`);
} else {
console.log(`Hooks: active`);
}
}

// --- Betterleaks ---
const exeExt = process.platform === "win32" ? ".exe" : "";
const localBetterleaks = path.join(getBinDir(), `betterleaks${exeExt}`);
Expand Down Expand Up @@ -216,6 +240,19 @@ function buildStatusJson(home: string, configPath: string, auditPath: string): A
betterleaks_available: isBetterleaksAvailable(),
config_path: formatHomePath(configPath, home),
audit_log_path: formatHomePath(auditPath, home),
hook_control: (() => {
const c = resolveHookControl();
return {
hook_enabled: c.hookEnabled,
secret_scan_enabled: c.secretScanEnabled,
command_policy_enabled: c.commandPolicyEnabled,
source: {
hook: c.source.hook,
secret_scan: c.source.secretScan,
command_policy: c.source.commandPolicy,
},
};
})(),
};
}

Expand Down
57 changes: 36 additions & 21 deletions node/src/commands/hook/pretool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { RegexScanner, ScanResult } from "../../scanners/regex-scanner.js";
import { AuditLogger } from "../../core/audit-logger.js";
import { ConfigManager } from "../../core/config-manager.js";
import { applySuppressions, Suppression } from "../../core/custom-patterns.js";
import { resolveHookControl, HookControl } from "../../core/hook-control.js";
import { collectSuppressions, applyExcludePaths } from "../agent/scan.js";
import type { ScanIgnoreRule } from "../../core/config-schema.js";
import { execSync, ExecSyncOptionsWithStringEncoding } from "child_process";
Expand Down Expand Up @@ -175,43 +176,57 @@ function normalizeInput(raw: Record<string, any>, format: HookFormat): HookInput
function evaluateToolCall(payload: HookInput): HookDecision {
const { tool_name, tool_input } = payload;

// Honor the (trusted-source-only) hook off-switch before doing any work.
// Master switch off → allow everything; otherwise the two concerns are gated
// independently inside evaluateBash (command policy + git-commit secret scan).
const control = resolveHookControl();
if (!control.hookEnabled) return { decision: "allow" };

if (tool_name === "Bash") {
return evaluateBash(tool_input?.command || "");
return evaluateBash(tool_input?.command || "", control);
}

if (tool_name === "Write" || tool_name === "Edit") {
if (!control.secretScanEnabled) return { decision: "allow" };
return evaluateWrite(tool_input || {});
}

return { decision: "allow" };
}

function evaluateBash(command: string): HookDecision {
const interceptor = new CommandInterceptor();
function evaluateBash(command: string, control: HookControl): HookDecision {
const audit = new AuditLogger();
const evaluation = interceptor.evaluate(command);

// Blocked — hard deny
if (!evaluation.allowed && !evaluation.requiresApproval) {
audit.logCommandIntercepted(command, false, "blocked", evaluation.reason);
return {
decision: "deny",
reason: formatBlockedMessage(command, evaluation),
};
}
// Command-risk interception — gated by commandPolicy. When disabled, skip the
// block/approval logic but still fall through to the staged-secret scan below
// (a user may keep secret scanning while silencing command prompts).
if (control.commandPolicyEnabled) {
const interceptor = new CommandInterceptor();
const evaluation = interceptor.evaluate(command);

// Requires approval — deny (agent can't provide interactive approval)
if (evaluation.requiresApproval) {
audit.logCommandIntercepted(command, false, "blocked", evaluation.reason);
return {
decision: "deny",
reason: formatApprovalMessage(command, evaluation),
};
// Blocked — hard deny
if (!evaluation.allowed && !evaluation.requiresApproval) {
audit.logCommandIntercepted(command, false, "blocked", evaluation.reason);
return {
decision: "deny",
reason: formatBlockedMessage(command, evaluation),
};
}

// Requires approval — deny (agent can't provide interactive approval)
if (evaluation.requiresApproval) {
audit.logCommandIntercepted(command, false, "blocked", evaluation.reason);
return {
decision: "deny",
reason: formatApprovalMessage(command, evaluation),
};
}
}

// Git commit/push — scan staged files for secrets
// Git commit/push — scan staged files for secrets. Gated by secretScan so the
// git-commit secret check survives `commandPolicy` being disabled on its own.
const trimmed = command.trim();
if (trimmed.startsWith("git commit") || trimmed.startsWith("git push")) {
if (control.secretScanEnabled && (trimmed.startsWith("git commit") || trimmed.startsWith("git push"))) {
const scanResult = scanStagedFiles();
if (scanResult.secretsFound) {
// Audit per file so the log records WHICH file + pattern, not a bare count.
Expand Down
20 changes: 20 additions & 0 deletions node/src/core/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,26 @@ export interface RafterConfig {
webhook?: string;
minRiskLevel?: 'high' | 'critical';
};
/**
* Runtime enable/disable for the PreToolUse / pre-commit hook. Distinct from
* `components["<platform>.hooks"]` (which tracks whether a hook is *installed*
* in a platform's settings) — this gates whether an installed hook actually
* acts. Default (undefined) = enabled.
*
* SECURITY: by design this is honored ONLY from the global
* `~/.rafter/config.json` (machine-owner-owned) and the `RAFTER_DISABLE_*`
* env vars — NEVER from project-local `.rafter.yml`, so a hostile repo can't
* ship a config that silently disables a victim's hook (see hook-control.ts).
* That is why this field lives on RafterConfig but NOT on PolicyFile.
*/
hooks?: {
/** Master switch. false = the hook allows everything (no scan, no command policy). */
enabled?: boolean;
/** Disable only the secret scan on Write/Edit/staged content; keep command policy. */
secretScan?: boolean;
/** Disable only command-risk interception on Bash; keep secret scanning. */
commandPolicy?: boolean;
};
scan?: {
excludePaths?: string[];
customPatterns?: ScanCustomPattern[];
Expand Down
107 changes: 107 additions & 0 deletions node/src/core/hook-control.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { ConfigManager } from "./config-manager.js";
import { RafterConfig } from "./config-schema.js";

/**
* Where the effective hook setting came from — surfaced by `rafter agent status`
* so "why didn't the hook fire?" is always answerable (secure-design D4).
*/
export type HookControlSource = "default" | "global-config" | "env";

export interface HookControl {
/** Master: when false the hook allows everything (no scan, no command policy). */
hookEnabled: boolean;
/** Whether the Write/Edit/staged secret scan runs. */
secretScanEnabled: boolean;
/** Whether Bash command-risk interception runs. */
commandPolicyEnabled: boolean;
/** Attribution for each decision, for status/audit. */
source: {
hook: HookControlSource;
secretScan: HookControlSource;
commandPolicy: HookControlSource;
};
}

/**
* Parse a tri-state from an env var. Returns true (disable), false (force-enable),
* or undefined (unset / unrecognized → defer to config). Deliberately strict:
* only explicit, well-known tokens count, so a stray value fails safe to "defer"
* rather than silently disabling a security control (secure-design D2).
*/
function envTriState(raw: string | undefined): boolean | undefined {
if (raw == null) return undefined;
const v = raw.trim().toLowerCase();
if (v === "1" || v === "true" || v === "yes" || v === "on") return true; // disable
if (v === "0" || v === "false" || v === "no" || v === "off") return false; // force-enable
return undefined;
}

/**
* Resolve whether the hook (and its sub-parts) should act.
*
* SECURITY (secure-design D1): the disable signal is honored ONLY from trusted,
* machine-owner-owned sources — the global `~/.rafter/config.json` and the
* `RAFTER_DISABLE_*` env vars. It is NEVER read from project-local `.rafter.yml`,
* so cloning a hostile repo cannot silently disable a victim's secret scanning or
* command interception. That is enforced structurally: this function calls
* `ConfigManager.load()` (global config only), NOT `loadWithPolicy()` (which
* merges `.rafter.yml`), and `hooks` is absent from the PolicyFile schema.
*
* Precedence (D5): env var overrides global config. Default (D2): enabled; an
* unreadable config or unrecognized value fails safe to enabled.
*/
export function resolveHookControl(opts?: {
config?: RafterConfig;
env?: NodeJS.ProcessEnv;
}): HookControl {
const env = opts?.env ?? process.env;

let cfg: RafterConfig | undefined = opts?.config;
if (!cfg) {
try {
cfg = new ConfigManager().load();
} catch {
// Unreadable/corrupt global config must not disable the hook — fail safe.
cfg = undefined;
}
}
const h = cfg?.agent?.hooks;

// Resolve one axis: env wins over global; absent → default `true` (enabled).
// `globalDisabled` is the config saying `<key>: false` (disabled) or
// `hooks.<sub>: true` meaning "disable this sub-part".
const resolve = (
envVal: boolean | undefined,
globalDisabled: boolean | undefined,
): { enabled: boolean; source: HookControlSource } => {
if (envVal !== undefined) return { enabled: !envVal, source: "env" };
if (globalDisabled === true) return { enabled: false, source: "global-config" };
return { enabled: true, source: "default" };
};

// Master switch. Global form: `agent.hooks.enabled === false` disables.
const hook = resolve(
envTriState(env.RAFTER_DISABLE_HOOKS),
h?.enabled === false ? true : undefined,
);

// Sub-parts. Global form: `agent.hooks.secretScan === false` disables that part.
// A disabled master switch forces every sub-part off regardless of its own setting.
const secretScan = hook.enabled
? resolve(envTriState(env.RAFTER_DISABLE_SECRET_SCAN), h?.secretScan === false ? true : undefined)
: { enabled: false, source: hook.source };
const commandPolicy = hook.enabled
? resolve(envTriState(env.RAFTER_DISABLE_COMMAND_POLICY), h?.commandPolicy === false ? true : undefined)
: { enabled: false, source: hook.source };

return {
hookEnabled: hook.enabled,
secretScanEnabled: secretScan.enabled,
commandPolicyEnabled: commandPolicy.enabled,
source: {
hook: hook.source,
secretScan: secretScan.source,
commandPolicy: commandPolicy.source,
},
};
}
Loading
Loading