From 04b9a1fa1e759f014aac169b20028c75617f9140 Mon Sep 17 00:00:00 2001 From: raftercli/crew/orwell Date: Wed, 10 Jun 2026 00:44:30 +0000 Subject: [PATCH 1/5] test(node): guard redundant per-file dist rebuilds to fix parallel race (sable-cs8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit global-setup.ts already builds dist/ once before the (parallel) vitest suite, but brief/completion/ci-init/github-action each re-ran `pnpm run build` unconditionally in beforeAll. With fileParallelism enabled, those concurrent tsc runs rewrite dist/ while other workers spawn `node dist/index.js` — so a spawn landing mid-rewrite loads a half-written module and dies with e.g. "SyntaxError: ... does not provide an export named createUpdateBetterleaksCommand" (observed in CI on tests/brief.test.ts > exits 1 for unknown topic). Guard each redundant build on !existsSync(dist/index.js), matching the pattern agent-commands/agent-init-hermes/scan-exclude-paths already use. dist is guaranteed present from globalSetup, so the guards skip on every normal run and nothing rewrites dist during parallel execution. Verified: the 4 files + a 5th CLI-spawning file run green together (223 tests). Co-Authored-By: Claude Opus 4.8 --- node/tests/brief.test.ts | 11 +++++++---- node/tests/ci-init.test.ts | 11 +++++++---- node/tests/completion.test.ts | 12 ++++++++---- node/tests/github-action.test.ts | 11 +++++++---- 4 files changed, 29 insertions(+), 16 deletions(-) diff --git a/node/tests/brief.test.ts b/node/tests/brief.test.ts index 2625cf5d..48c62cae 100644 --- a/node/tests/brief.test.ts +++ b/node/tests/brief.test.ts @@ -37,14 +37,17 @@ function rafter( } beforeAll(() => { - try { + // dist is built once by tests/global-setup.ts before the (parallel) suite. + // Only build here as a fallback if it's somehow missing — rebuilding + // unconditionally rewrites dist while other parallel workers are spawning + // `node dist/index.js`, which intermittently loads a half-written module + // (e.g. "does not provide an export named ..."). + if (!existsSync(CLI)) { execFileSync("pnpm", ["run", "build"], { cwd: path.resolve(__dirname, ".."), stdio: "ignore", - timeout: 30000, + timeout: 60000, }); - } catch { - // dist may already exist } }, 60000); diff --git a/node/tests/ci-init.test.ts b/node/tests/ci-init.test.ts index 12d7d398..2665ecf2 100644 --- a/node/tests/ci-init.test.ts +++ b/node/tests/ci-init.test.ts @@ -32,14 +32,17 @@ function rafter( let tmpDir: string; beforeAll(() => { - try { + // dist is built once by tests/global-setup.ts before the (parallel) suite. + // Only build here as a fallback if it's somehow missing — rebuilding + // unconditionally rewrites dist while other parallel workers are spawning + // `node dist/index.js`, which intermittently loads a half-written module + // (e.g. "does not provide an export named ..."). + if (!fs.existsSync(CLI)) { execFileSync("pnpm", ["run", "build"], { cwd: path.resolve(__dirname, ".."), stdio: "ignore", - timeout: 30000, + timeout: 60000, }); - } catch { - // dist may already exist } }, 60000); diff --git a/node/tests/completion.test.ts b/node/tests/completion.test.ts index 6f1165dd..34e06e91 100644 --- a/node/tests/completion.test.ts +++ b/node/tests/completion.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeAll } from "vitest"; import { execFileSync } from "child_process"; +import { existsSync } from "fs"; import path from "path"; const CLI = path.resolve(__dirname, "../dist/index.js"); @@ -25,14 +26,17 @@ function rafter( } beforeAll(() => { - try { + // dist is built once by tests/global-setup.ts before the (parallel) suite. + // Only build here as a fallback if it's somehow missing — rebuilding + // unconditionally rewrites dist while other parallel workers are spawning + // `node dist/index.js`, which intermittently loads a half-written module + // (e.g. "does not provide an export named ..."). + if (!existsSync(CLI)) { execFileSync("pnpm", ["run", "build"], { cwd: path.resolve(__dirname, ".."), stdio: "ignore", - timeout: 30000, + timeout: 60000, }); - } catch { - // dist may already exist } }, 60000); diff --git a/node/tests/github-action.test.ts b/node/tests/github-action.test.ts index c61e8993..415db730 100644 --- a/node/tests/github-action.test.ts +++ b/node/tests/github-action.test.ts @@ -34,14 +34,17 @@ function rafter( } beforeAll(() => { - try { + // dist is built once by tests/global-setup.ts before the (parallel) suite. + // Only build here as a fallback if it's somehow missing — rebuilding + // unconditionally rewrites dist while other parallel workers are spawning + // `node dist/index.js`, which intermittently loads a half-written module + // (e.g. "does not provide an export named ..."). + if (!fs.existsSync(CLI)) { execFileSync("pnpm", ["run", "build"], { cwd: path.resolve(__dirname, ".."), stdio: "ignore", - timeout: 30000, + timeout: 60000, }); - } catch { - // dist may already exist } }, 60000); From eae0f286f99aa774e060c35f99426323a65aff6c Mon Sep 17 00:00:00 2001 From: orwell Date: Thu, 11 Jun 2026 23:37:10 +0000 Subject: [PATCH 2/5] test+fix: de-flake CI suite + close betterleaks temp-file TOCTOU (sable-6d6/784/83b/t0q) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sable-6d6: test_all_21_patterns_present asserted ==21 but the HashiCorp Vault token (#159) brought it to 22 — a pre-existing red test. Renamed to test_all_patterns_present with a floor (>=22) + uniqueness check so adding a pattern no longer breaks it. Node has no count assertion (verified). - sable-784: platform-integration Gemini tests flaked with ENOENT on settings.json. Root cause: a full `agent init` install runs ~8-24s, but runCli's spawnSync timeout was 15s — under concurrent suite load it tipped over, returned status:null, and a downstream readFileSync hit a file the killed child never wrote. Raised the default timeout to 60s (covers every init call) and added runCliOk() which asserts exit 0 before reading, turning an opaque ENOENT into "CLI failed: " and catching real regressions. - sable-83b: notifications.test.ts + audit-logger-lifecycle.test.ts used arbitrary 50-100ms setTimeout sleeps for fire-and-forget webhook dispatch, flaky under CI load. Positive assertions now poll via vi.waitFor; negative assertions use a deterministic setImmediate microtask flush (DNS is mocked, so there are no real timers/IO to wait on). - sable-t0q (CWE-367): betterleaks.ts built a temp report path from a predictable Date.now() name in the shared tmpdir. Replaced with fs.mkdtempSync (private 0700 dir, atomic), report.json inside it, best-effort rmSync cleanup in finally (also fixes prior temp-file leaks on throw paths). Brings Node to parity with Python's existing TemporaryDirectory. rafter-reviewed. Co-Authored-By: Claude Opus 4.8 --- node/src/scanners/betterleaks.ts | 36 +++++++++++++++-------- node/tests/audit-logger-lifecycle.test.ts | 23 ++++++++------- node/tests/notifications.test.ts | 27 ++++++++++------- node/tests/platform-integration.test.ts | 27 ++++++++++++++--- python/tests/test_pattern_engine.py | 9 ++++-- 5 files changed, 83 insertions(+), 39 deletions(-) diff --git a/node/src/scanners/betterleaks.ts b/node/src/scanners/betterleaks.ts index 92ef9140..6f4a9178 100644 --- a/node/src/scanners/betterleaks.ts +++ b/node/src/scanners/betterleaks.ts @@ -1,6 +1,5 @@ import { execFile } from "child_process"; import { promisify } from "util"; -import { randomBytes } from "crypto"; import { BinaryManager } from "../utils/binary-manager.js"; import { PatternMatch } from "../core/pattern-engine.js"; import fs from "fs"; @@ -87,7 +86,11 @@ export class BetterleaksScanner { throw new Error("Betterleaks not available"); } - const tmpReport = path.join(os.tmpdir(), `betterleaks-${Date.now()}-${randomBytes(6).toString("hex")}.json`); + // mkdtempSync atomically creates a private 0700 dir, so the report path + // can't be pre-created/symlinked by another user on the shared tmpdir — + // avoids the TOCTOU race a predictable Date.now() filename allowed (CWE-367). + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "betterleaks-")); + const tmpReport = path.join(tmpDir, "report.json"); try { // `--` ensures a target path beginning with `-` isn't parsed as a flag by betterleaks. @@ -102,8 +105,6 @@ export class BetterleaksScanner { const results = this.parseResults(tmpReport); - fs.unlinkSync(tmpReport); - return { file: filePath, matches: results.map(r => this.convertToPatternMatch(r)) @@ -112,7 +113,6 @@ export class BetterleaksScanner { // Betterleaks exits with --exit-code (default 1) when leaks found — read report before cleanup if (e.code === 1 && fs.existsSync(tmpReport)) { const results = this.parseResults(tmpReport); - fs.unlinkSync(tmpReport); return { file: filePath, @@ -120,11 +120,15 @@ export class BetterleaksScanner { }; } - if (fs.existsSync(tmpReport)) { - fs.unlinkSync(tmpReport); - } - throw new Error(`Betterleaks scan failed: ${e.message}`); + } finally { + // Best-effort cleanup — a cleanup failure must never mask the scan's + // real result or its original error. + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // ignore + } } } @@ -155,7 +159,9 @@ export class BetterleaksScanner { throw new Error("Betterleaks not available"); } - const tmpReport = path.join(os.tmpdir(), `betterleaks-${Date.now()}-${randomBytes(6).toString("hex")}.json`); + // Private 0700 dir, atomically created — see scanFile for the TOCTOU rationale (CWE-367). + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "betterleaks-")); + const tmpReport = path.join(tmpDir, "report.json"); const subcommand = opts?.useGit ? "git" : "dir"; try { @@ -170,13 +176,11 @@ export class BetterleaksScanner { } const results = this.parseResults(tmpReport); - fs.unlinkSync(tmpReport); return this.groupByFile(results); } catch (e: any) { if (fs.existsSync(tmpReport)) { const results = this.parseResults(tmpReport); - fs.unlinkSync(tmpReport); if (e.code === 1) { return this.groupByFile(results); @@ -184,6 +188,14 @@ export class BetterleaksScanner { } throw new Error(`Betterleaks scan failed: ${e.message}`); + } finally { + // Best-effort cleanup — a cleanup failure must never mask the scan's + // real result or its original error. + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // ignore + } } } diff --git a/node/tests/audit-logger-lifecycle.test.ts b/node/tests/audit-logger-lifecycle.test.ts index 68239fcb..ea08bd5b 100644 --- a/node/tests/audit-logger-lifecycle.test.ts +++ b/node/tests/audit-logger-lifecycle.test.ts @@ -6,6 +6,12 @@ import path from "path"; import { AuditLogger } from "../src/core/audit-logger.js"; import { ConfigManager } from "../src/core/config-manager.js"; +// Flush pending microtasks + the check phase so the fire-and-forget webhook +// dispatch (validateWebhookUrl → fetch) settles before a negative assertion. +// DNS is mocked, so there are no real timers/IO — one setImmediate boundary is +// deterministic, unlike the arbitrary setTimeout sleeps it replaces (sable-83b). +const flushAsync = () => new Promise((resolve) => setImmediate(resolve)); + /** * Full lifecycle tests for the AuditLogger with real JSONL files. * Covers: creation, entry schema, convenience methods, read/filter, @@ -334,10 +340,8 @@ describe("AuditLogger lifecycle", () => { const logger = new AuditLogger(logPath); logger.logCommandIntercepted("rm -rf /", false, "blocked", "dangerous"); - // Wait for async webhook - await new Promise(r => setTimeout(r, 100)); - - expect(fetchSpy).toHaveBeenCalled(); + // Poll until the async webhook lands + await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalled()); const callArgs = fetchSpy.mock.calls[0]; expect(callArgs[0]).toBe("https://example.com/webhook"); const body = JSON.parse((callArgs[1] as any).body); @@ -353,13 +357,12 @@ describe("AuditLogger lifecycle", () => { const logger = new AuditLogger(logPath); // Low risk — should not fire logger.logCommandIntercepted("ls", true, "allowed"); - await new Promise(r => setTimeout(r, 50)); + await flushAsync(); expect(fetchSpy).not.toHaveBeenCalled(); // High risk — should fire logger.logCommandIntercepted("rm -rf /", false, "blocked", "dangerous"); - await new Promise(r => setTimeout(r, 100)); - expect(fetchSpy).toHaveBeenCalled(); + await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalled()); }); it("payload format includes all required fields", async () => { @@ -370,7 +373,7 @@ describe("AuditLogger lifecycle", () => { const logger = new AuditLogger(logPath); logger.logCommandIntercepted("rm -rf /", false, "blocked", "danger", "claude-code"); - await new Promise(r => setTimeout(r, 100)); + await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalled()); const body = JSON.parse((fetchSpy.mock.calls[0][1] as any).body); expect(body).toHaveProperty("event"); @@ -392,7 +395,7 @@ describe("AuditLogger lifecycle", () => { const logger = new AuditLogger(logPath); logger.logCommandIntercepted("rm -rf /", false, "blocked", "dangerous"); - await new Promise(r => setTimeout(r, 100)); + await flushAsync(); // Log should still be written const entries = logger.read(); @@ -405,7 +408,7 @@ describe("AuditLogger lifecycle", () => { // Default config has no webhook const logger = new AuditLogger(logPath); logger.logCommandIntercepted("rm -rf /", false, "blocked", "dangerous"); - await new Promise(r => setTimeout(r, 50)); + await flushAsync(); expect(fetchSpy).not.toHaveBeenCalled(); }); }); diff --git a/node/tests/notifications.test.ts b/node/tests/notifications.test.ts index 104f01ed..a0e54b4a 100644 --- a/node/tests/notifications.test.ts +++ b/node/tests/notifications.test.ts @@ -6,6 +6,13 @@ import fs from "fs"; import path from "path"; import os from "os"; +// Flush all pending microtasks + the check phase so the fire-and-forget webhook +// dispatch (validateWebhookUrl → fetch) has fully settled before a negative +// assertion. The dispatch has no real timers/IO (DNS is mocked or the host is an +// IP literal), so one setImmediate boundary is deterministic — unlike the +// arbitrary 50ms sleeps that flaked under CI load (sable-83b). +const flushAsync = () => new Promise((resolve) => setImmediate(resolve)); + describe("Webhook Notifications", () => { const testDir = path.join(os.tmpdir(), `rafter-notif-test-${Date.now()}`); const testLogPath = path.join(testDir, "audit.jsonl"); @@ -84,10 +91,8 @@ describe("Webhook Notifications", () => { logger.logCommandIntercepted("git push --force", false, "blocked", "High-risk", "claude-code"); - // fetch is fire-and-forget, give it a tick - await new Promise(r => setTimeout(r, 50)); - - expect(fetchSpy).toHaveBeenCalledOnce(); + // fetch is fire-and-forget — poll until the webhook dispatch lands + await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledOnce()); const [url, opts] = fetchSpy.mock.calls[0]; expect(url).toBe("https://hooks.example.com/test"); expect(opts.method).toBe("POST"); @@ -118,7 +123,7 @@ describe("Webhook Notifications", () => { logger.logSecretDetected("config.js", "AWS Key", "blocked", "claude-code"); - await new Promise(r => setTimeout(r, 50)); + await flushAsync(); expect(fetchSpy).not.toHaveBeenCalled(); }); @@ -138,7 +143,7 @@ describe("Webhook Notifications", () => { logger.logCommandIntercepted("ls -la", true, "allowed", undefined, "claude-code"); - await new Promise(r => setTimeout(r, 50)); + await flushAsync(); expect(fetchSpy).not.toHaveBeenCalled(); }); @@ -151,7 +156,7 @@ describe("Webhook Notifications", () => { logger.logSecretDetected("config.js", "AWS Key", "blocked", "claude-code"); - await new Promise(r => setTimeout(r, 50)); + await flushAsync(); expect(fetchSpy).not.toHaveBeenCalled(); }); @@ -172,13 +177,12 @@ describe("Webhook Notifications", () => { // High-risk should NOT trigger logger.logPolicyOverride("bypass", "sudo rm", "claude-code"); - await new Promise(r => setTimeout(r, 50)); + await flushAsync(); expect(fetchSpy).not.toHaveBeenCalled(); // Critical should trigger logger.logSecretDetected("config.js", "AWS Key", "blocked"); - await new Promise(r => setTimeout(r, 50)); - expect(fetchSpy).toHaveBeenCalledOnce(); + await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledOnce()); }); it("should silently ignore fetch failures", async () => { @@ -201,7 +205,8 @@ describe("Webhook Notifications", () => { logger.logSecretDetected("config.js", "AWS Key", "blocked"); }).not.toThrow(); - await new Promise(r => setTimeout(r, 50)); + // Drain the rejected fetch's .catch so the failure is handled + await flushAsync(); // Verify the audit log was still written despite webhook failure const logContent = fs.readFileSync(testLogPath, "utf-8"); diff --git a/node/tests/platform-integration.test.ts b/node/tests/platform-integration.test.ts index 800ea723..b02be58c 100644 --- a/node/tests/platform-integration.test.ts +++ b/node/tests/platform-integration.test.ts @@ -35,10 +35,15 @@ function cleanupDir(dir: string) { * Run the CLI with a fake HOME directory. * Returns { stdout, stderr, exitCode }. */ +// A full `agent init` install (config + skills + MCP registration + hooks) runs +// ~8-24s on its own — the old 15s default left no headroom, so under concurrent +// suite load spawnSync would time out, return status:null, and a downstream +// readFileSync would ENOENT on the file the killed child never wrote (sable-784). +// 60s gives ample margin while still bounding a genuine hang. function runCli( args: string, homeDir: string, - timeout = 15_000 + timeout = 60_000 ): { stdout: string; stderr: string; exitCode: number } { const result = spawnSync(`node ${CLI_ENTRY} ${args}`, { cwd: PROJECT_ROOT, @@ -59,6 +64,20 @@ function runCli( }; } +// Run the CLI and assert it exited 0 before the caller reads files it wrote. +// spawnSync waits for child exit, so a 0 exit code means writeFileSync has +// flushed to disk — guarding here turns an opaque downstream ENOENT into a +// legible "CLI failed: " and catches a real install regression that a +// delayed/absent write would otherwise mask (sable-784). +function runCliOk(args: string, homeDir: string, timeout?: number) { + const result = runCli(args, homeDir, timeout); + expect( + result.exitCode, + `CLI \`${args}\` failed (exit ${result.exitCode}): ${result.stderr}` + ).toBe(0); + return result; +} + describe("Platform Integration — MCP Installs via CLI", () => { let testHomeDir: string; @@ -1377,7 +1396,7 @@ describe("Platform Integration — MCP Installs via CLI", () => { it("should install BeforeTool/AfterTool hooks to settings.json", () => { fs.mkdirSync(path.join(testHomeDir, ".gemini"), { recursive: true }); - runCli("agent init --with-gemini", testHomeDir); + runCliOk("agent init --with-gemini", testHomeDir); const settings = JSON.parse( fs.readFileSync(path.join(testHomeDir, ".gemini", "settings.json"), "utf-8") @@ -1408,8 +1427,8 @@ describe("Platform Integration — MCP Installs via CLI", () => { it("should deduplicate hooks on repeated installs", () => { fs.mkdirSync(path.join(testHomeDir, ".gemini"), { recursive: true }); - runCli("agent init --with-gemini", testHomeDir); - runCli("agent init --with-gemini", testHomeDir); + runCliOk("agent init --with-gemini", testHomeDir); + runCliOk("agent init --with-gemini", testHomeDir); const settings = JSON.parse( fs.readFileSync(path.join(testHomeDir, ".gemini", "settings.json"), "utf-8") diff --git a/python/tests/test_pattern_engine.py b/python/tests/test_pattern_engine.py index 8e24cf09..273db1d6 100644 --- a/python/tests/test_pattern_engine.py +++ b/python/tests/test_pattern_engine.py @@ -292,5 +292,10 @@ def test_scan_with_position(): assert found[0].line == 2 -def test_all_21_patterns_present(): - assert len(DEFAULT_SECRET_PATTERNS) == 21 +def test_all_patterns_present(): + # Floor assertion, not an exact count: the pattern list only grows, so a + # hardcoded `== N` goes stale every time a pattern is added (sable-6d6 — + # was 21, broke when the HashiCorp Vault token brought it to 22). + assert len(DEFAULT_SECRET_PATTERNS) >= 22 + names = [p.name for p in DEFAULT_SECRET_PATTERNS] + assert len(names) == len(set(names)), "pattern names must be unique" From 146e211469178539f0e41a5de2dcc195fb87764b Mon Sep 17 00:00:00 2001 From: Rome-1 Date: Fri, 12 Jun 2026 19:01:58 +0000 Subject: [PATCH 3/5] feat(hook): config-driven hook off-switch + config audit & reference (sable-qli/bnl/59s/29w) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a runtime off-switch for the PreToolUse hook, honored ONLY from trusted, machine-owner-owned sources — the RAFTER_DISABLE_HOOKS / RAFTER_DISABLE_SECRET_SCAN / RAFTER_DISABLE_COMMAND_POLICY env vars and the global ~/.rafter/config.json `agent.hooks.{enabled,secretScan,commandPolicy}` keys. NEVER from project-local .rafter.yml: a rafter-secure-design trust-boundary pass established that honoring a disable flag from a repo-shipped file would let a hostile clone silently disable a victim's secret scanning + command interception. Enforced structurally — the resolver reads ConfigManager.load() (global only), not loadWithPolicy(), and `hooks` is absent from the PolicyFile schema. - New shared resolver: node/src/core/hook-control.ts + python/.../hook_control.py. Env overrides global (D5); default enabled; corrupt config / unrecognized value fails safe to enabled (D2). Granular: whole-hook, secret-scan-only, command- policy-only. The git-commit staged-secret scan (inside evaluateBash) stays gated by secretScan, command interception by commandPolicy. - `rafter agent status` (+ --json `hook_control`) reports effective state + source (default | global-config | env), so "why didn't the hook fire?" is answerable (D4). - Node + Python parity (D6), with cross-runtime tests incl. the security negative (a project-local .rafter.yml/.rafter/config.yml disable attempt is ignored). - shared-docs/CONFIG.md: consolidated, code-verified config reference (global vs project layers, trust boundary, toggle matrix mapping each switch to the code that enforces it). CLI_SPEC + CHANGELOG updated. Audit (sable-59s) also surfaced that agent.outputFiltering.redactSecrets/blockPatterns are validated but never enforced (PostToolUse always redacts) — filed sable-y2z, documented as a known gap in CONFIG.md. rafter-secure-design gated; rafter-code-review CLEAN (trust boundary, fail-safe, parity, granularity all verified). 39 new tests green both runtimes. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 10 ++ node/src/commands/agent/status.ts | 37 +++++ node/src/commands/hook/pretool.ts | 57 ++++--- node/src/core/config-schema.ts | 20 +++ node/src/core/hook-control.ts | 107 ++++++++++++ node/tests/hook-control.test.ts | 86 ++++++++++ node/tests/hook-offswitch-integration.test.ts | 78 +++++++++ python/rafter_cli/commands/agent.py | 33 ++++ python/rafter_cli/commands/hook.py | 46 ++++-- python/rafter_cli/core/config_manager.py | 10 ++ python/rafter_cli/core/config_schema.py | 17 ++ python/rafter_cli/core/hook_control.py | 101 ++++++++++++ python/tests/test_hook_config.py | 4 +- python/tests/test_hook_control.py | 140 ++++++++++++++++ shared-docs/CLI_SPEC.md | 13 +- shared-docs/CONFIG.md | 156 ++++++++++++++++++ 16 files changed, 877 insertions(+), 38 deletions(-) create mode 100644 node/src/core/hook-control.ts create mode 100644 node/tests/hook-control.test.ts create mode 100644 node/tests/hook-offswitch-integration.test.ts create mode 100644 python/rafter_cli/core/hook_control.py create mode 100644 python/tests/test_hook_control.py create mode 100644 shared-docs/CONFIG.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ccb14a2..f124151c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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 diff --git a/node/src/commands/agent/status.ts b/node/src/commands/agent/status.ts index 8f052cf6..954b7e72 100644 --- a/node/src/commands/agent/status.ts +++ b/node/src/commands/agent/status.ts @@ -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"; @@ -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 { @@ -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}`); @@ -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, + }, + }; + })(), }; } diff --git a/node/src/commands/hook/pretool.ts b/node/src/commands/hook/pretool.ts index b551dfa3..50960943 100644 --- a/node/src/commands/hook/pretool.ts +++ b/node/src/commands/hook/pretool.ts @@ -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"; @@ -175,43 +176,57 @@ function normalizeInput(raw: Record, 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. diff --git a/node/src/core/config-schema.ts b/node/src/core/config-schema.ts index eed6b7a0..26a47c12 100644 --- a/node/src/core/config-schema.ts +++ b/node/src/core/config-schema.ts @@ -87,6 +87,26 @@ export interface RafterConfig { webhook?: string; minRiskLevel?: 'high' | 'critical'; }; + /** + * Runtime enable/disable for the PreToolUse / pre-commit hook. Distinct from + * `components[".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[]; diff --git a/node/src/core/hook-control.ts b/node/src/core/hook-control.ts new file mode 100644 index 00000000..f62b581e --- /dev/null +++ b/node/src/core/hook-control.ts @@ -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 `: false` (disabled) or + // `hooks.: 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, + }, + }; +} diff --git a/node/tests/hook-control.test.ts b/node/tests/hook-control.test.ts new file mode 100644 index 00000000..23d20459 --- /dev/null +++ b/node/tests/hook-control.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest"; +import { resolveHookControl } from "../src/core/hook-control.js"; +import type { RafterConfig } from "../src/core/config-schema.js"; + +// Build a minimal RafterConfig with an optional hooks block. +function cfg(hooks?: RafterConfig["agent"] extends infer A ? any : never): RafterConfig { + return { version: "1", initialized: "", agent: { hooks } } as unknown as RafterConfig; +} + +describe("resolveHookControl — defaults (fail-safe)", () => { + it("no config, no env → everything enabled, source=default", () => { + const c = resolveHookControl({ config: cfg(undefined), env: {} }); + expect(c.hookEnabled).toBe(true); + expect(c.secretScanEnabled).toBe(true); + expect(c.commandPolicyEnabled).toBe(true); + expect(c.source).toEqual({ hook: "default", secretScan: "default", commandPolicy: "default" }); + }); + + it("unrecognized env value does NOT disable (fails safe to enabled)", () => { + const c = resolveHookControl({ config: cfg(undefined), env: { RAFTER_DISABLE_HOOKS: "maybe" } }); + expect(c.hookEnabled).toBe(true); + expect(c.source.hook).toBe("default"); + }); +}); + +describe("resolveHookControl — env disable", () => { + for (const v of ["1", "true", "yes", "on", "TRUE", " On "]) { + it(`RAFTER_DISABLE_HOOKS=${JSON.stringify(v)} disables the whole hook`, () => { + const c = resolveHookControl({ config: cfg(undefined), env: { RAFTER_DISABLE_HOOKS: v } }); + expect(c.hookEnabled).toBe(false); + expect(c.secretScanEnabled).toBe(false); + expect(c.commandPolicyEnabled).toBe(false); + expect(c.source.hook).toBe("env"); + }); + } + + it("RAFTER_DISABLE_SECRET_SCAN only disables the secret scan", () => { + const c = resolveHookControl({ config: cfg(undefined), env: { RAFTER_DISABLE_SECRET_SCAN: "1" } }); + expect(c.hookEnabled).toBe(true); + expect(c.secretScanEnabled).toBe(false); + expect(c.commandPolicyEnabled).toBe(true); + expect(c.source.secretScan).toBe("env"); + }); + + it("RAFTER_DISABLE_COMMAND_POLICY only disables command policy", () => { + const c = resolveHookControl({ config: cfg(undefined), env: { RAFTER_DISABLE_COMMAND_POLICY: "1" } }); + expect(c.hookEnabled).toBe(true); + expect(c.commandPolicyEnabled).toBe(false); + expect(c.secretScanEnabled).toBe(true); + }); +}); + +describe("resolveHookControl — global config disable", () => { + it("agent.hooks.enabled=false disables the whole hook (source=global-config)", () => { + const c = resolveHookControl({ config: cfg({ enabled: false }), env: {} }); + expect(c.hookEnabled).toBe(false); + expect(c.source.hook).toBe("global-config"); + }); + + it("agent.hooks.secretScan=false disables only the secret scan", () => { + const c = resolveHookControl({ config: cfg({ secretScan: false }), env: {} }); + expect(c.hookEnabled).toBe(true); + expect(c.secretScanEnabled).toBe(false); + expect(c.commandPolicyEnabled).toBe(true); + expect(c.source.secretScan).toBe("global-config"); + }); + + it("enabled=true (explicit) is treated as on, not a disable signal", () => { + const c = resolveHookControl({ config: cfg({ enabled: true }), env: {} }); + expect(c.hookEnabled).toBe(true); + }); +}); + +describe("resolveHookControl — precedence (env wins over global)", () => { + it("env force-enable (0/false) overrides a global disable", () => { + const c = resolveHookControl({ config: cfg({ enabled: false }), env: { RAFTER_DISABLE_HOOKS: "0" } }); + expect(c.hookEnabled).toBe(true); + expect(c.source.hook).toBe("env"); + }); + + it("env disable overrides a global enable", () => { + const c = resolveHookControl({ config: cfg({ enabled: true }), env: { RAFTER_DISABLE_HOOKS: "1" } }); + expect(c.hookEnabled).toBe(false); + expect(c.source.hook).toBe("env"); + }); +}); diff --git a/node/tests/hook-offswitch-integration.test.ts b/node/tests/hook-offswitch-integration.test.ts new file mode 100644 index 00000000..cfef8df4 --- /dev/null +++ b/node/tests/hook-offswitch-integration.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { spawnSync } from "child_process"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +// End-to-end coverage of the config-driven hook off-switch through the REAL +// built CLI (`rafter hook pretool`). The security-critical assertion is that a +// project-local `.rafter.yml` can NOT disable the hook (secure-design D1). + +const PROJECT_ROOT = path.resolve(__dirname, ".."); +const CLI_ENTRY = path.join(PROJECT_ROOT, "dist", "index.js"); + +const SECRET_WRITE = JSON.stringify({ + tool_name: "Write", + tool_input: { content: "aws_key = AKIAIOSFODNN7EXAMPLE" }, +}); + +function runHook(cwd: string, env: Record = {}): { decision: string } { + const r = spawnSync(`node ${CLI_ENTRY} hook pretool`, { + cwd, + input: SECRET_WRITE, + encoding: "utf-8", + shell: true, + timeout: 30_000, + // Isolate HOME so the developer's real ~/.rafter/config.json can't change the result. + env: { ...process.env, HOME: cwd, XDG_CONFIG_HOME: path.join(cwd, ".config"), ...env }, + }); + const out = JSON.parse(r.stdout || "{}"); + return { decision: out.hookSpecificOutput?.permissionDecision ?? "unknown" }; +} + +describe("hook off-switch — end to end via the real CLI", () => { + let dir: string; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "rafter-offswitch-")); + }); + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it("default: a staged secret is DENIED", () => { + expect(runHook(dir).decision).toBe("deny"); + }); + + it("RAFTER_DISABLE_HOOKS=1: ALLOWED", () => { + expect(runHook(dir, { RAFTER_DISABLE_HOOKS: "1" }).decision).toBe("allow"); + }); + + it("RAFTER_DISABLE_SECRET_SCAN=1: ALLOWED (secret scan off)", () => { + expect(runHook(dir, { RAFTER_DISABLE_SECRET_SCAN: "1" }).decision).toBe("allow"); + }); + + // ── The security property ──────────────────────────────────────────────── + it("SECURITY: a project-local .rafter.yml CANNOT disable the hook", () => { + // A hostile repo ships every plausible disable shape in project-local config. + fs.writeFileSync( + path.join(dir, ".rafter.yml"), + "hooks:\n enabled: false\n secretScan: false\n commandPolicy: false\n", + ); + // Also the backend flat-file location, which is likewise repo-local. + fs.mkdirSync(path.join(dir, ".rafter"), { recursive: true }); + fs.writeFileSync(path.join(dir, ".rafter", "config.yml"), "hooks:\n enabled: false\n"); + + // Still denied — the project-local file is never a trusted disable source. + expect(runHook(dir).decision).toBe("deny"); + }); + + it("SECURITY: global config (trusted) CAN disable — ALLOWED", () => { + // The user-owned ~/.rafter/config.json (HOME is isolated to `dir`) is trusted. + fs.mkdirSync(path.join(dir, ".rafter"), { recursive: true }); + fs.writeFileSync( + path.join(dir, ".rafter", "config.json"), + JSON.stringify({ version: "1", agent: { hooks: { enabled: false } } }), + ); + expect(runHook(dir).decision).toBe("allow"); + }); +}); diff --git a/python/rafter_cli/commands/agent.py b/python/rafter_cli/commands/agent.py index 1d1238be..a99b3738 100644 --- a/python/rafter_cli/commands/agent.py +++ b/python/rafter_cli/commands/agent.py @@ -3174,6 +3174,26 @@ def status( else: print(f"\nConfig: not found — run: rafter agent init") + # --- Hook off-switch (trusted-source only: env / global config) --- + from ..core.hook_control import resolve_hook_control + + c = resolve_hook_control() + + def _describe(on: bool, src: str) -> str: + if on: + return "active" + via = "RAFTER_DISABLE_* env" if src == "env" else "global config" + return f"DISABLED (via {via})" + + if not c.hook_enabled: + print(f"Hooks: {_describe(False, c.source_hook)}") + elif not c.secret_scan_enabled or not c.command_policy_enabled: + print("Hooks: active (partial)") + print(f" secret scan: {_describe(c.secret_scan_enabled, c.source_secret_scan)}") + print(f" command policy: {_describe(c.command_policy_enabled, c.source_command_policy)}") + else: + print("Hooks: active") + # --- Betterleaks --- bm = BinaryManager() bl_local = bm.get_betterleaks_path() @@ -3309,6 +3329,9 @@ def status( def _agent_status_json(config_path: Path, audit_path: Path) -> dict[str, Any]: + from ..core.hook_control import resolve_hook_control + + c = resolve_hook_control() return { "installed": config_path.exists(), "version": __version__, @@ -3317,6 +3340,16 @@ def _agent_status_json(config_path: Path, audit_path: Path) -> dict[str, Any]: "betterleaks_available": _betterleaks_available(), "config_path": _format_home_path(config_path), "audit_log_path": _format_home_path(audit_path), + "hook_control": { + "hook_enabled": c.hook_enabled, + "secret_scan_enabled": c.secret_scan_enabled, + "command_policy_enabled": c.command_policy_enabled, + "source": { + "hook": c.source_hook, + "secret_scan": c.source_secret_scan, + "command_policy": c.source_command_policy, + }, + }, } diff --git a/python/rafter_cli/commands/hook.py b/python/rafter_cli/commands/hook.py index 2ebb7000..9007b173 100644 --- a/python/rafter_cli/commands/hook.py +++ b/python/rafter_cli/commands/hook.py @@ -294,24 +294,29 @@ def _scan_staged_files() -> dict: return empty -def _evaluate_bash(command: str) -> dict: - interceptor = CommandInterceptor() +def _evaluate_bash(command: str, control) -> dict: audit = AuditLogger() - evaluation = interceptor.evaluate(command) - # Blocked — hard deny - if not evaluation.allowed and not evaluation.requires_approval: - audit.log_command_intercepted(command, False, "blocked", evaluation.reason) - return {"decision": "deny", "reason": _format_blocked_message(command, evaluation)} + # Command-risk interception — gated by command_policy. When disabled, skip the + # block/approval logic but still fall through to the staged-secret scan below. + if control.command_policy_enabled: + interceptor = CommandInterceptor() + evaluation = interceptor.evaluate(command) - # Requires approval — deny (hook can't prompt interactively) - if evaluation.requires_approval: - audit.log_command_intercepted(command, False, "blocked", evaluation.reason) - return {"decision": "deny", "reason": _format_approval_message(command, evaluation)} + # Blocked — hard deny + if not evaluation.allowed and not evaluation.requires_approval: + audit.log_command_intercepted(command, False, "blocked", evaluation.reason) + return {"decision": "deny", "reason": _format_blocked_message(command, evaluation)} - # Git commit/push — scan staged files + # Requires approval — deny (hook can't prompt interactively) + if evaluation.requires_approval: + audit.log_command_intercepted(command, False, "blocked", evaluation.reason) + return {"decision": "deny", "reason": _format_approval_message(command, evaluation)} + + # Git commit/push — scan staged files. Gated by secret_scan so the git-commit + # secret check survives command_policy being disabled on its own. trimmed = command.strip() - if trimmed.startswith(("git commit", "git push")): + if control.secret_scan_enabled and trimmed.startswith(("git commit", "git push")): result = _scan_staged_files() if result["secrets_found"]: # Audit per file so the log records WHICH file + pattern, not a bare count. @@ -389,10 +394,21 @@ def pretool( if not isinstance(tool_input, dict): tool_input = {} + # Honor the (trusted-source-only) hook off-switch before doing any work. + from ..core.hook_control import resolve_hook_control + + control = resolve_hook_control() + if not control.hook_enabled: + _write_pretool_decision({"decision": "allow"}, format) + return + if tool_name == "Bash": - decision = _evaluate_bash(tool_input.get("command", "")) + decision = _evaluate_bash(tool_input.get("command", ""), control) elif tool_name in ("Write", "Edit"): - decision = _evaluate_write(tool_input) + if not control.secret_scan_enabled: + decision = {"decision": "allow"} + else: + decision = _evaluate_write(tool_input) else: decision = {"decision": "allow"} diff --git a/python/rafter_cli/core/config_manager.py b/python/rafter_cli/core/config_manager.py index 92763fbb..7ca5335a 100644 --- a/python/rafter_cli/core/config_manager.py +++ b/python/rafter_cli/core/config_manager.py @@ -300,6 +300,7 @@ def _from_dict(cls, d: dict) -> RafterConfig: CommandPolicyConfig, EnvironmentConfig, EnvironmentsConfig, + HooksConfig, NotificationsConfig, OutputFilteringConfig, ScanConfig, @@ -335,6 +336,15 @@ def _from_dict(cls, d: dict) -> RafterConfig: ) ), ), + hooks=HooksConfig( + enabled=(agent_raw.get("hooks") or {}).get("enabled"), + secret_scan=(agent_raw.get("hooks") or {}).get( + "secret_scan", (agent_raw.get("hooks") or {}).get("secretScan") + ), + command_policy=(agent_raw.get("hooks") or {}).get( + "command_policy", (agent_raw.get("hooks") or {}).get("commandPolicy") + ), + ), components=agent_raw.get("components") or {}, ) diff --git a/python/rafter_cli/core/config_schema.py b/python/rafter_cli/core/config_schema.py index 0100e13f..0cbcf323 100644 --- a/python/rafter_cli/core/config_schema.py +++ b/python/rafter_cli/core/config_schema.py @@ -125,6 +125,22 @@ class EnvironmentsConfig: )) +@dataclass +class HooksConfig: + """Runtime enable/disable for the PreToolUse hook. Distinct from + ``components[".hooks"]`` (install state) — this gates whether an + installed hook actually acts. ``None`` = unset (defaults to enabled). + + SECURITY: honored ONLY from the global ``~/.rafter/config.json`` + the + ``RAFTER_DISABLE_*`` env vars, NEVER from project-local ``.rafter.yml`` — so a + hostile repo can't ship a config that disables a victim's hook. That is why + this lives on RafterConfig/AgentConfig but is absent from the policy schema. + """ + enabled: bool | None = None + secret_scan: bool | None = None + command_policy: bool | None = None + + @dataclass class AgentConfig: risk_level: RiskLevel = "moderate" @@ -135,6 +151,7 @@ class AgentConfig: audit: AuditConfig = field(default_factory=AuditConfig) notifications: NotificationsConfig = field(default_factory=NotificationsConfig) scan: ScanConfig = field(default_factory=ScanConfig) + hooks: HooksConfig = field(default_factory=HooksConfig) # Fine-grained per-component install state (set by `rafter agent enable/disable`). # Keys are component IDs like "claude-code.hooks"; values are `{enabled, updatedAt}`. components: dict = field(default_factory=dict) diff --git a/python/rafter_cli/core/hook_control.py b/python/rafter_cli/core/hook_control.py new file mode 100644 index 00000000..fa96d936 --- /dev/null +++ b/python/rafter_cli/core/hook_control.py @@ -0,0 +1,101 @@ +"""Resolve whether the PreToolUse hook (and its sub-parts) should act. + +Mirror of node/src/core/hook-control.ts — keep the two in lockstep. + +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. Enforced structurally: this reads ``ConfigManager().load()`` +(global only), NOT ``load_with_policy()``, and ``hooks`` is absent from the policy +schema. Precedence (D5): env overrides global. Default (D2): enabled; an unreadable +config or unrecognized value fails safe to enabled. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass + +from .config_schema import RafterConfig + + +@dataclass +class HookControl: + hook_enabled: bool + secret_scan_enabled: bool + command_policy_enabled: bool + # Attribution per axis: "default" | "global-config" | "env" + source_hook: str + source_secret_scan: str + source_command_policy: str + + +def _env_tristate(raw: str | None) -> bool | None: + """True = disable, False = force-enable, None = unset/unrecognized (defer). + + Deliberately strict so a stray value fails safe to "defer" rather than + silently disabling a security control (secure-design D2). + """ + if raw is None: + return None + v = raw.strip().lower() + if v in ("1", "true", "yes", "on"): + return True + if v in ("0", "false", "no", "off"): + return False + return None + + +def resolve_hook_control( + config: RafterConfig | None = None, + env: dict | None = None, +) -> HookControl: + env = env if env is not None else os.environ + + cfg = config + if cfg is None: + try: + from .config_manager import ConfigManager + + cfg = ConfigManager().load() + except Exception: + # Unreadable/corrupt global config must not disable the hook — fail safe. + cfg = None + h = cfg.agent.hooks if cfg is not None else None + + def resolve(env_val: bool | None, global_disabled: bool | None) -> tuple[bool, str]: + # env wins over global; absent → default enabled. + if env_val is not None: + return (not env_val, "env") + if global_disabled is True: + return (False, "global-config") + return (True, "default") + + hook_enabled, hook_src = resolve( + _env_tristate(env.get("RAFTER_DISABLE_HOOKS")), + True if (h is not None and h.enabled is False) else None, + ) + + if hook_enabled: + ss_enabled, ss_src = resolve( + _env_tristate(env.get("RAFTER_DISABLE_SECRET_SCAN")), + True if (h is not None and h.secret_scan is False) else None, + ) + cp_enabled, cp_src = resolve( + _env_tristate(env.get("RAFTER_DISABLE_COMMAND_POLICY")), + True if (h is not None and h.command_policy is False) else None, + ) + else: + # A disabled master switch forces every sub-part off. + ss_enabled, ss_src = False, hook_src + cp_enabled, cp_src = False, hook_src + + return HookControl( + hook_enabled=hook_enabled, + secret_scan_enabled=ss_enabled, + command_policy_enabled=cp_enabled, + source_hook=hook_src, + source_secret_scan=ss_src, + source_command_policy=cp_src, + ) diff --git a/python/tests/test_hook_config.py b/python/tests/test_hook_config.py index ff566e6b..f328d93f 100644 --- a/python/tests/test_hook_config.py +++ b/python/tests/test_hook_config.py @@ -26,6 +26,7 @@ _scan_staged_files, ) from rafter_cli.core.config_schema import get_default_config +from rafter_cli.core.hook_control import resolve_hook_control FAKE_AWS_KEY = "AKIAIOSFODNN7EXAMPLE" @@ -68,7 +69,8 @@ def test_exclude_paths_allows_commit(self, git_repo): assert result["files"] == 0 # End-to-end: the commit is allowed, matching `rafter secrets`. - assert _evaluate_bash('git commit -m "add"')["decision"] == "allow" + control = resolve_hook_control(config=get_default_config(), env={}) + assert _evaluate_bash('git commit -m "add"', control)["decision"] == "allow" def test_ignore_rule_suppresses_pattern(self, git_repo): (git_repo / "fixtures.env").write_text(f"AWS_ACCESS_KEY_ID={FAKE_AWS_KEY}\n") diff --git a/python/tests/test_hook_control.py b/python/tests/test_hook_control.py new file mode 100644 index 00000000..7b4c7a94 --- /dev/null +++ b/python/tests/test_hook_control.py @@ -0,0 +1,140 @@ +"""Mirror of node/tests/hook-control.test.ts + hook-offswitch-integration.test.ts. + +Keep the two in lockstep — same cases, same expectations (parity invariant). +""" + +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +from rafter_cli.core.config_schema import AgentConfig, HooksConfig, RafterConfig +from rafter_cli.core.hook_control import resolve_hook_control + + +def _cfg(hooks: HooksConfig | None) -> RafterConfig: + return RafterConfig(agent=AgentConfig(hooks=hooks or HooksConfig())) + + +# ── Defaults (fail-safe) ────────────────────────────────────────────────── +def test_default_all_enabled(): + c = resolve_hook_control(config=_cfg(None), env={}) + assert (c.hook_enabled, c.secret_scan_enabled, c.command_policy_enabled) == (True, True, True) + assert (c.source_hook, c.source_secret_scan, c.source_command_policy) == ("default",) * 3 + + +def test_unrecognized_env_does_not_disable(): + c = resolve_hook_control(config=_cfg(None), env={"RAFTER_DISABLE_HOOKS": "maybe"}) + assert c.hook_enabled is True + assert c.source_hook == "default" + + +# ── Env disable ─────────────────────────────────────────────────────────── +@pytest.mark.parametrize("v", ["1", "true", "yes", "on", "TRUE", " On "]) +def test_env_disables_whole_hook(v): + c = resolve_hook_control(config=_cfg(None), env={"RAFTER_DISABLE_HOOKS": v}) + assert (c.hook_enabled, c.secret_scan_enabled, c.command_policy_enabled) == (False, False, False) + assert c.source_hook == "env" + + +def test_env_disables_secret_scan_only(): + c = resolve_hook_control(config=_cfg(None), env={"RAFTER_DISABLE_SECRET_SCAN": "1"}) + assert (c.hook_enabled, c.secret_scan_enabled, c.command_policy_enabled) == (True, False, True) + assert c.source_secret_scan == "env" + + +def test_env_disables_command_policy_only(): + c = resolve_hook_control(config=_cfg(None), env={"RAFTER_DISABLE_COMMAND_POLICY": "1"}) + assert (c.hook_enabled, c.secret_scan_enabled, c.command_policy_enabled) == (True, True, False) + + +# ── Global config disable ───────────────────────────────────────────────── +def test_global_disables_whole_hook(): + c = resolve_hook_control(config=_cfg(HooksConfig(enabled=False)), env={}) + assert c.hook_enabled is False + assert c.source_hook == "global-config" + + +def test_global_disables_secret_scan_only(): + c = resolve_hook_control(config=_cfg(HooksConfig(secret_scan=False)), env={}) + assert (c.hook_enabled, c.secret_scan_enabled, c.command_policy_enabled) == (True, False, True) + assert c.source_secret_scan == "global-config" + + +def test_explicit_enabled_true_is_on(): + c = resolve_hook_control(config=_cfg(HooksConfig(enabled=True)), env={}) + assert c.hook_enabled is True + + +# ── Precedence (env wins over global) ───────────────────────────────────── +def test_env_force_enable_overrides_global_disable(): + c = resolve_hook_control(config=_cfg(HooksConfig(enabled=False)), env={"RAFTER_DISABLE_HOOKS": "0"}) + assert c.hook_enabled is True + assert c.source_hook == "env" + + +def test_env_disable_overrides_global_enable(): + c = resolve_hook_control(config=_cfg(HooksConfig(enabled=True)), env={"RAFTER_DISABLE_HOOKS": "1"}) + assert c.hook_enabled is False + assert c.source_hook == "env" + + +# ── End-to-end via the real CLI (security property) ─────────────────────── +SECRET_WRITE = json.dumps({"tool_name": "Write", "tool_input": {"content": "aws_key = AKIAIOSFODNN7EXAMPLE"}}) + + +_PKG_ROOT = str(Path(__file__).resolve().parent.parent) + + +def _run_hook(cwd: str, extra_env: dict | None = None) -> str: + # We override HOME so rafter reads its global config from the temp dir (and + # not the developer's real ~/.rafter). That also moves Python's user + # site-packages, so pass the parent's full sys.path to the child to keep + # imports (typer, rafter_cli) resolvable regardless of HOME. + pythonpath = os.pathsep.join([_PKG_ROOT, *(p for p in sys.path if p)]) + env = { + **os.environ, + "HOME": cwd, + "XDG_CONFIG_HOME": os.path.join(cwd, ".config"), + "PYTHONPATH": pythonpath, + } + if extra_env: + env.update(extra_env) + r = subprocess.run( + [sys.executable, "-m", "rafter_cli", "hook", "pretool"], + input=SECRET_WRITE, capture_output=True, text=True, cwd=cwd, env=env, timeout=30, + ) + return json.loads(r.stdout or "{}").get("hookSpecificOutput", {}).get("permissionDecision", "unknown") + + +def test_e2e_default_denies(): + with tempfile.TemporaryDirectory() as d: + assert _run_hook(d) == "deny" + + +def test_e2e_env_disable_allows(): + with tempfile.TemporaryDirectory() as d: + assert _run_hook(d, {"RAFTER_DISABLE_HOOKS": "1"}) == "allow" + + +def test_e2e_security_project_local_cannot_disable(): + with tempfile.TemporaryDirectory() as d: + Path(d, ".rafter.yml").write_text( + "hooks:\n enabled: false\n secretScan: false\n commandPolicy: false\n" + ) + os.makedirs(os.path.join(d, ".rafter"), exist_ok=True) + Path(d, ".rafter", "config.yml").write_text("hooks:\n enabled: false\n") + assert _run_hook(d) == "deny" + + +def test_e2e_global_config_can_disable(): + with tempfile.TemporaryDirectory() as d: + os.makedirs(os.path.join(d, ".rafter"), exist_ok=True) + Path(d, ".rafter", "config.json").write_text( + json.dumps({"version": "1", "agent": {"hooks": {"enabled": False}}}) + ) + assert _run_hook(d) == "allow" diff --git a/shared-docs/CLI_SPEC.md b/shared-docs/CLI_SPEC.md index f3cb2691..34d2858a 100644 --- a/shared-docs/CLI_SPEC.md +++ b/shared-docs/CLI_SPEC.md @@ -824,10 +824,19 @@ Show agent security status dashboard. Displays config summary, installed integra "hooks_installed": ["pre-commit"], "betterleaks_available": true, "config_path": "~/.rafter/config.json", - "audit_log_path": "~/.rafter/audit.jsonl" + "audit_log_path": "~/.rafter/audit.jsonl", + "hook_control": { + "hook_enabled": true, + "secret_scan_enabled": true, + "command_policy_enabled": true, + "source": { "hook": "default", "secret_scan": "default", "command_policy": "default" } + } } ``` +`hook_control` reports the runtime hook off-switch (see the *hook off-switch* note +under `rafter hook pretool`). Each `source` is `default`, `global-config`, or `env`. + ### rafter agent update-betterleaks [OPTIONS] Update (or reinstall) the managed betterleaks binary. @@ -875,6 +884,8 @@ PreToolUse hook handler. Reads tool call JSON from stdin, evaluates risk, and wr On a `git commit` / `git push` (and on `Write`/`Edit`), the hook scans for secrets through the **same `.rafter.yml` policy as `rafter secrets`** — custom patterns, `scan.exclude_paths`, and `ignore` rules all apply — so the hook and the CLI agree on what is a finding (sable-55u). The hook is patterns-only (it never invokes betterleaks), so a betterleaks version mismatch cannot affect its decision. When it blocks, the deny `reason` names each offending `file:line — Pattern` rather than a bare count. +**Hook off-switch.** The hook can be disabled at runtime from **trusted sources only** — the `RAFTER_DISABLE_HOOKS` / `RAFTER_DISABLE_SECRET_SCAN` / `RAFTER_DISABLE_COMMAND_POLICY` env vars (`1`/`true`/`yes`/`on` = off; `0`/`false` = force-on) and the global `~/.rafter/config.json` `agent.hooks.{enabled,secretScan,commandPolicy}` keys. Env overrides global; default is enabled; a corrupt config or unrecognized value fails safe to enabled. By design this is **never** read from project-local `.rafter.yml`, so a hostile repo cannot ship a config that silently disables a victim's hook. `rafter agent status` reports the effective state and its source. See `shared-docs/CONFIG.md` for the full configuration reference. + ### rafter hook posttool [OPTIONS] PostToolUse hook handler. Reads tool output from stdin, redacts any secrets found, and writes JSON to stdout. diff --git a/shared-docs/CONFIG.md b/shared-docs/CONFIG.md new file mode 100644 index 00000000..699bf395 --- /dev/null +++ b/shared-docs/CONFIG.md @@ -0,0 +1,156 @@ +# Rafter Configuration Reference + +Canonical, code-verified reference for Rafter's two configuration files and every +toggle they expose. Both the Node and Python implementations follow this spec. + +Sources of truth in code: +- Global config schema — `node/src/core/config-schema.ts` (`RafterConfig`) / + `python/rafter_cli/core/config_schema.py`. +- Project policy schema — `node/src/core/policy-loader.ts` (`PolicyFile`) / + `python/rafter_cli/core/policy_loader.py`. +- Hook off-switch resolver — `node/src/core/hook-control.ts` / `python/rafter_cli/core/hook_control.py`. + +--- + +## Two config layers + +| | Global config | Project policy | +|---|---|---| +| **Path** | `~/.rafter/config.json` | `.rafter.yml` (also `.rafter.yaml`, `.rafter/config.yml`, `.rafter/config.yaml`) | +| **Format** | JSON | YAML | +| **Scope** | Whole machine / user | One repository | +| **Written by** | `rafter agent init`, `rafter agent config set` | You (commit it to the repo) | +| **Discovery** | Fixed path under `$HOME` | Walk from `cwd` up to the git root; first hit wins | +| **Trust** | **Trusted** — only the machine owner writes it | **Untrusted** — it ships inside the repo, so a repo author controls it | + +**Precedence:** when both files set the same key, the project policy overrides the +global config — *for the keys the policy schema supports* (see the matrix). The +project policy is a deliberately **narrow subset**: it can tune scanning and command +strictness, but it **cannot** disable a security control (see *Trust boundary* below). + +--- + +## What each layer can set + +### `.rafter.yml` (project policy) — the complete key set + +```yaml +version: "1" # optional, informational +riskLevel: moderate # minimal | moderate | aggressive +commandPolicy: + mode: approve-dangerous # allow-all | approve-dangerous | deny-list + blockedPatterns: ["rm -rf /"] # replaces (not appends) the defaults + requireApproval: ["git push --force"] +scan: + excludePaths: ["dist/**", "*.lock"] + customPatterns: + - name: Internal Token + regex: "intl_[a-z0-9]{32}" + severity: high + autoUpdateBetterleaks: true # false to opt out (e.g. CI provisions its own binary) +ignore: # suppress findings (top-level, NOT under scan:) + - paths: ["tests/fixtures/**"] + rules: ["AWS Access Key ID"] # omit to suppress all rules for those paths + reason: "test fixtures, not real keys" +audit: + retentionDays: 30 + logLevel: info # debug | info | warn | error + logPath: ".rafter/audit.jsonl" # repo-local audit log +docs: [ ... ] # repo security docs (see CLI_SPEC) +``` + +Backend-compatibility: top-level `exclude_paths:` / `custom_patterns:` (the flat +shape the cloud scanner reads from `.rafter/config.yml`) are also accepted; nested +`scan.*` wins on collision. Keys accept either `camelCase` or `snake_case`. + +> `.rafter.yml` does **not** contain `environments`, `components`, `outputFiltering`, +> `skills`, `notifications`, or `hooks` — those are global-only (by design for `hooks`). + +### `~/.rafter/config.json` (global) — additional keys + +Everything in the policy file, plus: `backend.{apiKey,endpoint}`, +`agent.environments..enabled`, `agent.components`, `agent.skills.*`, +`agent.outputFiltering.*`, `agent.notifications.*`, and **`agent.hooks.*`** (the +hook off-switch). Global JSON uses `camelCase`. + +--- + +## Toggle matrix — what you can turn on/off, and where it is honored + +Every row below was verified against the code that *reads* the setting. + +| Feature | How to toggle | Honored from | Enforced? | +|---|---|---|---| +| **Hook — whole** | `RAFTER_DISABLE_HOOKS=1` or `agent.hooks.enabled: false` | env, **global only** | ✅ `hook-control.ts` | +| **Hook — secret scan only** | `RAFTER_DISABLE_SECRET_SCAN=1` or `agent.hooks.secretScan: false` | env, **global only** | ✅ | +| **Hook — command policy only** | `RAFTER_DISABLE_COMMAND_POLICY=1` or `agent.hooks.commandPolicy: false` | env, **global only** | ✅ | +| Command-blocking strictness | `commandPolicy.mode` (`allow-all` = no blocking) | global + `.rafter.yml` | ✅ `command-interceptor` | +| Blocked / approval command lists | `commandPolicy.blockedPatterns` / `requireApproval` | global + `.rafter.yml` | ✅ | +| Risk threshold | `riskLevel` | global + `.rafter.yml` | ✅ | +| Secret-scan suppression | `ignore:` rules / `.rafterignore` | global + `.rafter.yml` | ✅ | +| Secret-scan path exclusion | `scan.excludePaths` | global + `.rafter.yml` | ✅ | +| Custom secret patterns | `scan.customPatterns` | global + `.rafter.yml` | ✅ | +| Betterleaks auto-update | `scan.autoUpdateBetterleaks` / `--no-auto-update` | global + `.rafter.yml` + flag | ✅ | +| Audit logging on/off | `audit.logAllActions` | global | ✅ `audit-logger` | +| Audit retention | `audit.retentionDays` | global + `.rafter.yml` | ✅ | +| Skill auto-update / backup | `skills.autoUpdate` / `skills.backupBeforeUpdate` | global | ✅ `skill-manager` | +| Webhook notifications | `notifications.webhook` / `RAFTER_NOTIFY_WEBHOOK` | global + env | ✅ | +| **Output redaction** | `outputFiltering.redactSecrets` / `blockPatterns` | global | ⚠️ **not enforced — see sable-y2z** | + +> ⚠️ `agent.outputFiltering.redactSecrets` / `blockPatterns` are accepted and +> validated but currently **have no runtime effect** — the PostToolUse hook always +> redacts. Setting them to `false` is a silent no-op until that gap is closed. + +--- + +## The hook off-switch (in depth) + +Two precedence-ordered, **trusted** sources. **Env wins over global config.** Default +is enabled; a missing/corrupt config or an unrecognized value fails safe to enabled. + +```bash +# Turn the whole hook off for this shell session +export RAFTER_DISABLE_HOOKS=1 # 1|true|yes|on = off; 0|false|no|off = force-on + +# Or persist in the global config (machine-owner-owned) +rafter agent config set agent.hooks.enabled false +``` + +Granular: `RAFTER_DISABLE_SECRET_SCAN` / `agent.hooks.secretScan` (keep command +policy, drop secret scanning) and `RAFTER_DISABLE_COMMAND_POLICY` / +`agent.hooks.commandPolicy` (keep secret scanning, drop command prompts). + +Check current state — including *which source* disabled it: + +```bash +rafter agent status # "Hooks: active" | "DISABLED (via …)" | "active (partial)" +rafter agent status --json # .hook_control.{hook_enabled,…,source} +``` + +### Trust boundary — why `.rafter.yml` can't disable hooks + +The off-switch is honored **only** from the env vars and the global +`~/.rafter/config.json` — never from project-local `.rafter.yml`. The resolver reads +`ConfigManager.load()` (global only), not `loadWithPolicy()`, and `hooks` is +deliberately absent from the policy schema. This closes a supply-chain footgun: if a +project file could disable the hook, cloning a hostile repo that ships +`hooks: { enabled: false }` would silently turn off the victim's secret scanning and +command interception. Disabling a security control is reserved to the machine owner. + +--- + +## Turning hooks on/off entirely (install vs. runtime) + +The off-switch above gates whether an *installed* hook acts. To install or remove the +hook itself: + +| Action | Command | +|---|---| +| Install agent hook for a platform | `rafter agent init --with-claude-code` (or `enable claude-code.hooks`) | +| Remove agent hook for a platform | `rafter agent disable claude-code.hooks` (uninstalls from the platform's settings) | +| Install git pre-commit / pre-push hook | `rafter agent install-hook [--push] [--global]` | +| Remove global git hook | `git config --global --unset core.hooksPath` | +| Bypass a git hook once | `git commit --no-verify` (does **not** bypass the agent PreToolUse hook) | + +`rafter agent list` shows per-component install state (`components` in the global +config), distinct from the runtime off-switch. From 4a8641d8da63e52bdcde9fb9a642c535bf705653 Mon Sep 17 00:00:00 2001 From: Rome-1 Date: Sat, 13 Jun 2026 06:30:44 +0000 Subject: [PATCH 4/5] chore(release): 0.8.6 Hook off-switch (sable-bnl) + betterleaks TOCTOU fix (sable-t0q) + config audit & reference (sable-59s/29w). Versions bumped in node/package.json, python/pyproject.toml, and both rafter-security-skill.md frontmatter; CHANGELOG [0.8.6] dated. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 ++ node/package.json | 2 +- node/resources/rafter-security-skill.md | 2 +- python/pyproject.toml | 2 +- python/rafter_cli/resources/rafter-security-skill.md | 2 +- 5 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f124151c..56c661c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ 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. diff --git a/node/package.json b/node/package.json index bab39ef5..756768e3 100644 --- a/node/package.json +++ b/node/package.json @@ -1,6 +1,6 @@ { "name": "@rafter-security/cli", - "version": "0.8.5", + "version": "0.8.6", "type": "module", "repository": { "type": "git", diff --git a/node/resources/rafter-security-skill.md b/node/resources/rafter-security-skill.md index a95f972c..8120e318 100644 --- a/node/resources/rafter-security-skill.md +++ b/node/resources/rafter-security-skill.md @@ -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: diff --git a/python/pyproject.toml b/python/pyproject.toml index 2ea8cc84..d691955e 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "rafter-cli" -version = "0.8.5" +version = "0.8.6" description = "Rafter CLI — the default security agent for AI workflows. Free for individuals and open source." authors = ["Rafter Team "] license = "MIT" diff --git a/python/rafter_cli/resources/rafter-security-skill.md b/python/rafter_cli/resources/rafter-security-skill.md index a95f972c..8120e318 100644 --- a/python/rafter_cli/resources/rafter-security-skill.md +++ b/python/rafter_cli/resources/rafter-security-skill.md @@ -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: From b36f321ad429a602e4ed8553f042f1e8d7c54dbf Mon Sep 17 00:00:00 2001 From: Rome-1 Date: Sat, 13 Jun 2026 06:38:21 +0000 Subject: [PATCH 5/5] fix(hook): make _evaluate_bash control arg optional (fixes test regression) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The off-switch change gave _evaluate_bash a required `control` param but only updated one of three test files that call it directly — test_hook.py and test_hook_integration.py (~20 call sites) broke with a TypeError, caught by the full Python CI suite. Default `control=None` to a fully-enabled HookControl: the production dispatch always passes a resolved control explicitly, so the default only affects direct test callers and is fail-safe (enabled). 101 hook tests green. Co-Authored-By: Claude Opus 4.8 --- python/rafter_cli/commands/hook.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/python/rafter_cli/commands/hook.py b/python/rafter_cli/commands/hook.py index 9007b173..e5c81466 100644 --- a/python/rafter_cli/commands/hook.py +++ b/python/rafter_cli/commands/hook.py @@ -294,7 +294,22 @@ def _scan_staged_files() -> dict: return empty -def _evaluate_bash(command: str, control) -> dict: +def _evaluate_bash(command: str, control=None) -> dict: + # The production caller (the pretool dispatch) always passes a resolved + # control. `control=None` defaults to fully-enabled — fail-safe, and keeps + # the function callable from focused tests that exercise interception/scan + # without constructing a control object. + if control is None: + from ..core.hook_control import HookControl + + control = HookControl( + hook_enabled=True, + secret_scan_enabled=True, + command_policy_enabled=True, + source_hook="default", + source_secret_scan="default", + source_command_policy="default", + ) audit = AuditLogger() # Command-risk interception — gated by command_policy. When disabled, skip the