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
96 changes: 96 additions & 0 deletions .rafter.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Rafter policy for the rafter-cli repo itself.
#
# Every `ignore:` entry below is a TRIAGED false positive — each was traced
# input -> sink in this codebase before being suppressed (bead sable-qsig).
# These rules are honored identically by the local engines and the remote
# `rafter run` backend, and suppressed findings remain recoverable in the
# `_suppressed` / `suppressed.json` output. Nothing here removes a finding from
# the audit trail; it only declassifies analyzed non-issues.
#
# NOT suppressed (fixed instead): dependency CVEs (bumped in package.json /
# pnpm-workspace overrides / pyproject) and the remote-tarball extraction path
# (zip-slip guarded in skill_remote.extract_npm_tarball).
version: "1"

scan:
exclude_paths:
- "node_modules/"
- ".git/"
- "**/dist/"
- "**/__pycache__/**" # build artifacts (.pyc), not source

ignore:
# ── Intentional demonstration / test inputs ───────────────────────────────
# The demo app and the vulnerable-repo fixture EXIST to demonstrate detection;
# their "secrets" are deliberately fake (canonical AWS-docs example keys such
# as AKIAIOSFODNN7EXAMPLE) and their "vulnerabilities" are on purpose.
- paths: ["demo/**"]
reason: "Demo app: intentional FAKE secrets (canonical AWS-docs example keys, sk_test Stripe, an example JWT) used to showcase rafter detection. Not real credentials."
- paths: ["README.md", "outreach-drafts.md", ".github/workflows/test-comprehensive.yml", ".rafter.yml"]
reason: "Documentation / CI examples and this policy file reference the canonical AWS-docs example access key (a well-known public non-secret) to illustrate detection."
- paths: ["**/fixtures/**", "fixtures/**"]
reason: "Test fixtures: intentionally-vulnerable / fake-secret inputs that exercise the scanner (e.g. requests-without-timeout in fixtures/vulnerable-repo)."
- paths: ["**/tests/**", "**/test_*.py", "**/*.test.ts", "**/fixtures/**", ".github/fixtures/**"]
reason: "Test suites and fixtures embed fake secrets / deliberately-unsafe snippets as detection fixtures (this repo IS a secret scanner). Covers both the secret-pattern engine and the remote SAST rule ids (R-D577E etc.)."

# ── Hardcoded-secret rule firing on non-credential string literals ─────────
- paths: ["python/rafter_cli/commands/agent.py", "python/rafter_cli/commands/hook.py"]
rules: ["R-D577E"]
reason: "Event-indicator labels and placeholders ('[SECRET]', '🔑', 'default'), not credentials. Verified: these are display strings in _EVENT_INDICATORS_AGENT."

# ── XXE rule misfire (no XML parsing exists) ──────────────────────────────
- paths: ["python/**"]
rules: ["R-6675E"]
reason: "False positive: the Python codebase imports no XML parser (grep for xml/lxml/ElementTree is empty). Flagged lines are mkdir/read_text/for-loops. No XXE surface."

# ── git/diff/path parsing mislabeled as URL or format validation ──────────
- paths:
- "python/rafter_cli/utils/git_diff.py"
- "python/rafter_cli/utils/git.py"
- "python/rafter_cli/commands/skill.py"
- "python/rafter_cli/commands/skill_remote.py"
- "python/rafter_cli/commands/brief.py"
- "python/rafter_cli/commands/agent.py"
- "python/rafter_cli/commands/agent_components.py"
- "python/rafter_cli/core/pattern_engine.py"
rules: ["R-3F913", "R-88B14"]
reason: "startswith('b/')/('diff --git')/('+') is git-diff parsing; endswith('.git') is git-URL handling. Neither is URL/path-traversal validation."

# ── Timing-attack rule on a non-credential comparison ─────────────────────
- paths: ["python/rafter_cli/commands/notify.py"]
rules: ["R-51C60"]
reason: "Flagged `==` compares scan status strings ('completed'/'unknown'), not secrets. No credential comparison; timing channel is irrelevant."

# ── Local-CLI filesystem operations (no external attacker in threat model) ─
# This is a developer CLI that reads/writes config, skills, hooks and binaries
# at paths derived from the user's OWN $HOME, cwd and CLI arguments. There is
# no untrusted remote actor supplying these paths. The single genuine
# external-input path — extracting a downloaded remote skill tarball — is
# independently zip-slip-validated in skill_remote.extract_npm_tarball.
- paths: ["node/src/**", "vscode/src/**"]
rules: ["R-61603", "R-374DB"]
reason: "fs.*Sync / path.join on non-literal paths derived from the user's own $HOME/cwd/CLI args; not externally-tainted. Remote-tarball extraction is separately zip-slip-guarded."

# ── Local audit-log / process internals (note-level rule misfires) ────────
- paths: ["python/rafter_cli/core/audit_logger.py", "node/src/core/audit-logger.ts"]
rules: ["R-C66D8", "R-5DF7B"]
reason: "random.randbytes builds a log SESSION ID (not a security token); O_WRONLY|O_APPEND is correct for an append-only audit log."
- paths: ["python/rafter_cli/commands/agent.py"]
rules: ["R-93905"]
reason: "Style: re.sub on a precompiled pattern. No untrusted-substitution security impact."

# ── Scanner regexes / RegExp constructor (self-inflicted ReDoS at most) ────
# The CLI compiles its own maintained detection patterns plus user-supplied
# custom patterns; any catastrophic-backtracking risk is borne by the user
# scanning their own repo with their own pattern, not an external attacker.
- paths: ["node/src/core/**", "node/src/scanners/**", "vscode/src/secret-scanner.ts", "node/src/commands/issues/from-text.ts", "node/src/utils/git-diff.ts"]
rules: ["R-EFEEF", "R-8CBA2"]
reason: "RegExp built from maintained detection patterns / user-supplied custom patterns. ReDoS risk is self-inflicted (user scans own repo), not externally reachable."

# ── Style/lint notes with no exploit path ─────────────────────────────────
- paths: ["python/**"]
rules: ["R-F8672", "R-0AB4A", "R-A01A0", "R-CA639", "R-5FEE9", "R-E617E"]
reason: "Style/robustness notes: urllib responses are read via .read(); broad except-pass on best-effort config loads; PATH-resolved trusted binaries (git/betterleaks) via binary-manager. No exploit path."
- paths: ["node/src/commands/skill/remote.ts"]
rules: ["R-BFD19"]
reason: "ESLint config note (@typescript-eslint/no-explicit-any plugin not resolved during the scan), not a security finding."
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.8.10] - 2026-06-28

### Changed

- **Claude Code `PostToolUse` hook matcher narrowed from `.*` to `Bash|Write|Edit|MultiEdit`** (Node + Python, sable-h0ah). `rafter agent init --with-claude-code` (and `rafter agent enable claude-code.hooks`) previously registered the `rafter hook posttool` redaction hook with a catch-all `.*` matcher, so it fired after **every** Claude Code tool call — including `Read` and MCP tools, which never produce secrets to redact — adding latency to every operation. The matcher now targets only the tools whose output is worth scanning: shell output (`Bash`) and file writes (`Write`/`Edit`/`MultiEdit`). PreToolUse matchers are unchanged. Codex (`.*` PostToolUse) and Gemini (`.*` AfterTool) have the same broad-matcher latency issue and are tracked separately for platform-correct narrow matchers.

### Fixed

- **Hooks tolerate harness-appended flags** (#180). `rafter hook pretool` / `posttool` read their input from **stdin**, so they now ignore unknown options and extra positional arguments that an agent platform appends to the hook command — e.g. Claude Code adds `--hook-json <data>`. Such extras are discarded rather than erroring; declared options like `--format` are still parsed normally.

### Security

- **Dependency CVE updates** (sable-qsig). Bumped vulnerable (mostly transitive) dependencies past their fixed versions via pnpm workspace overrides + a re-lock: axios 1.13.6→1.18.1, hono 4.12.8→4.12.27, tar→7.5.17, js-yaml→4.3.0, plus fast-uri, path-to-regexp, form-data, follow-redirects, ip-address, qs, and brace-expansion. Python direct-dep floors raised (`requests`, `urllib3`, `python-dotenv`); transitive Python bumps tracked separately. No CLI behavior change.
- **Hardened remote-skill tarball extraction** (sable-qsig). `rafter skill review` / install now extracts only regular files and directories from a fetched npm tarball, skipping symlink/hardlink/device members that a malicious archive could use to redirect a later write outside the destination — defense-in-depth atop the existing zip-slip path check.

## [0.8.9] - 2026-06-20

### Added
Expand Down
4 changes: 2 additions & 2 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ rafter agent exec "rm -rf /" # critical -- blocked
When installed via `rafter agent init --with-claude-code`, Rafter registers hooks in `~/.claude/settings.json`:

- **PreToolUse** hooks on `Bash` and `Write|Edit` tool calls. Every shell command is evaluated against the risk policy before execution. Dangerous commands are blocked or require approval transparently -- no manual invocation needed.
- **PostToolUse** hooks on all tool calls (`.*` matcher). Scans output for accidentally leaked secrets and redacts them.
- **PostToolUse** hooks on `Bash`, `Write`, `Edit`, and `MultiEdit` tool calls (`Bash|Write|Edit|MultiEdit` matcher). Scans command output and file writes for accidentally leaked secrets and redacts them. (Scoped to these tools so the hook doesn't fire on every `Read`/MCP call, which never produces secrets to redact and only added latency.)

The hooks read JSON from stdin and write a `{"decision": "allow"}` or `{"decision": "deny", "reason": "..."}` response to stdout. This is the Claude Code hook protocol.

Expand All @@ -142,7 +142,7 @@ Manual hook config (if not using `rafter agent init`):
{ "matcher": "Write|Edit", "hooks": [{ "type": "command", "command": "rafter hook pretool" }] }
],
"PostToolUse": [
{ "matcher": ".*", "hooks": [{ "type": "command", "command": "rafter hook posttool" }] }
{ "matcher": "Bash|Write|Edit|MultiEdit", "hooks": [{ "type": "command", "command": "rafter hook posttool" }] }
]
}
}
Expand Down
8 changes: 4 additions & 4 deletions node/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@rafter-security/cli",
"version": "0.8.9",
"version": "0.8.10",
"type": "module",
"repository": {
"type": "git",
Expand Down Expand Up @@ -31,15 +31,15 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.27.1",
"axios": "^1.13.5",
"axios": "^1.16.0",
"chalk": "^5.3.0",
"chokidar": "^5.0.0",
"commander": "^11.1.0",
"dotenv": "^16.4.5",
"js-yaml": "^4.1.0",
"js-yaml": "^4.2.0",
"minimatch": "^10.2.4",
"ora": "^7.0.1",
"tar": "^7.5.10"
"tar": "^7.5.16"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
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.9
version: 0.8.10
homepage: https://rafter.so
metadata:
openclaw:
Expand Down
4 changes: 3 additions & 1 deletion node/src/commands/agent/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,9 @@ function claudeCodeHooks(): ComponentSpec {
{ matcher: "Bash", hooks: [pre] },
{ matcher: "Write|Edit", hooks: [pre] },
);
settings.hooks.PostToolUse.push({ matcher: ".*", hooks: [post] });
// Narrow to tools that produce scannable output (shell output + file
// writes); avoids firing posttool on every Read/MCP call (latency).
settings.hooks.PostToolUse.push({ matcher: "Bash|Write|Edit|MultiEdit", hooks: [post] });

writeJson(settingsPath, settings);
},
Expand Down
5 changes: 4 additions & 1 deletion node/src/commands/agent/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,8 +322,11 @@ function installClaudeCodeHooks(root: string): void {
{ matcher: "Bash", hooks: [preHook] },
{ matcher: "Write|Edit", hooks: [preHook] },
);
// Narrow to tools that produce scannable output (shell output + file
// writes). Firing posttool on every tool — including Read and MCP calls —
// added latency to operations that never produce secrets to redact.
settings.hooks.PostToolUse.push(
{ matcher: ".*", hooks: [postHook] },
{ matcher: "Bash|Write|Edit|MultiEdit", hooks: [postHook] },
);

fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf-8");
Expand Down
5 changes: 5 additions & 0 deletions node/src/commands/hook/posttool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ interface PostToolOutput {
export function createHookPosttoolCommand(): Command {
return new Command("posttool")
.description("PostToolUse hook handler (reads stdin, redacts secrets in output, writes JSON to stdout)")
// Tolerate extra flags/args the host harness appends to the hook command
// (e.g. Claude Code adds `--hook-json <data>`). Hook input comes from stdin,
// so anything else is unused — discard it instead of erroring out.
.allowUnknownOption()
.allowExcessArguments()
.option("--format <format>", "Output format: claude (default, also Codex/Continue), cursor, gemini, windsurf", "claude")
.action(async (opts) => {
const format = (opts.format || "claude") as HookFormat;
Expand Down
5 changes: 5 additions & 0 deletions node/src/commands/hook/pretool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ function formatApprovalMessage(command: string, evaluation: CommandEvaluation):
export function createHookPretoolCommand(): Command {
return new Command("pretool")
.description("PreToolUse hook handler (reads stdin, writes JSON decision to stdout)")
// Tolerate extra flags/args the host harness appends to the hook command
// (e.g. Claude Code adds `--hook-json <data>`). Hook input comes from stdin,
// so anything else is unused — discard it instead of erroring out.
.allowUnknownOption()
.allowExcessArguments()
.option("--format <format>", "Output format: claude (default, also Codex/Continue), cursor, gemini, windsurf", "claude")
.action(async (opts) => {
const format = (opts.format || "claude") as HookFormat;
Expand Down
34 changes: 31 additions & 3 deletions node/src/core/command-interceptor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ConfigManager } from "./config-manager.js";
import { AuditLogger } from "./audit-logger.js";
import { assessCommandRisk, CommandRiskLevel } from "./risk-rules.js";
import { assessCommandRisk, matchedCriticalPattern, CommandRiskLevel } from "./risk-rules.js";

export type { CommandRiskLevel } from "./risk-rules.js";

Expand All @@ -26,14 +26,42 @@ export class CommandInterceptor {
* Evaluate if a command should be allowed
*/
evaluate(command: string): CommandEvaluation {
const riskLevel = this.assessRisk(command);

// Unconditional hard-block: catastrophic destructive commands (rm -rf /,
// fork bombs, disk wipes, mkfs, …) are NEVER allowed, regardless of the
// configured policy — or its absence. Security must not depend on a policy
// being present (the default config may be missing one) or on the chosen
// mode (even allow-all / a custom deny-list cannot opt out of these).
if (riskLevel === "critical") {
return {
command,
riskLevel,
allowed: false,
requiresApproval: false,
reason: "Matches built-in blocked pattern (critical destructive command)",
matchedPattern: matchedCriticalPattern(command) ?? "builtin:critical-destructive"
};
}

const cfg = this.config.loadWithPolicy();
const policy = cfg.agent?.commandPolicy;

if (!policy) {
// No policy configured, allow by default but still assess risk
// No policy configured — fall back to safe built-in defaults rather than
// allow-all: high-risk commands still require approval.
if (riskLevel === "high") {
return {
command,
riskLevel,
allowed: false,
requiresApproval: true,
reason: "High risk command requires approval"
};
}
return {
command,
riskLevel: this.assessRisk(command),
riskLevel,
allowed: true,
requiresApproval: false
};
Expand Down
14 changes: 14 additions & 0 deletions node/src/core/risk-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,17 @@ export function assessCommandRisk(command: string): CommandRiskLevel {
}
return "low";
}

/**
* Return the source of the first CRITICAL pattern matching the command, or null.
* Mirrors assessCommandRisk's lowercasing. Intended to be called only once a
* command is already classified "critical" (safe-prefix exclusion is handled by
* assessCommandRisk), to surface *which* built-in rule matched.
*/
export function matchedCriticalPattern(command: string): string | null {
const cmd = command.toLowerCase().trim();
for (const pattern of CRITICAL_PATTERNS) {
if (pattern.test(cmd)) return pattern.source;
}
return null;
}
12 changes: 6 additions & 6 deletions node/tests/agent-compatibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -727,7 +727,7 @@ describe("Agent Init: Environment Detection & Hook/Skill Installation", () => {
{ matcher: "Write|Edit", hooks: [{ type: "command", command: "rafter hook pretool" }] },
],
PostToolUse: [
{ matcher: ".*", hooks: [{ type: "command", command: "rafter hook posttool" }] },
{ matcher: "Bash|Write|Edit|MultiEdit", hooks: [{ type: "command", command: "rafter hook posttool" }] },
],
},
};
Expand All @@ -739,7 +739,7 @@ describe("Agent Init: Environment Detection & Hook/Skill Installation", () => {
expect(loaded.hooks.PostToolUse).toHaveLength(1);
expect(loaded.hooks.PreToolUse[0].matcher).toBe("Bash");
expect(loaded.hooks.PreToolUse[1].matcher).toBe("Write|Edit");
expect(loaded.hooks.PostToolUse[0].matcher).toBe(".*");
expect(loaded.hooks.PostToolUse[0].matcher).toBe("Bash|Write|Edit|MultiEdit");
});

it("preserves existing non-rafter hooks when installing", () => {
Expand Down Expand Up @@ -776,7 +776,7 @@ describe("Agent Init: Environment Detection & Hook/Skill Installation", () => {
{ matcher: "Write|Edit", hooks: [{ type: "command", command: "rafter hook pretool" }] },
);
settings.hooks.PostToolUse.push(
{ matcher: ".*", hooks: [{ type: "command", command: "rafter hook posttool" }] },
{ matcher: "Bash|Write|Edit|MultiEdit", hooks: [{ type: "command", command: "rafter hook posttool" }] },
);

fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
Expand All @@ -800,7 +800,7 @@ describe("Agent Init: Environment Detection & Hook/Skill Installation", () => {
{ matcher: "Bash", hooks: [{ type: "command", command: "rafter hook pretool" }] },
],
PostToolUse: [
{ matcher: ".*", hooks: [{ type: "command", command: "rafter hook posttool" }] },
{ matcher: "Bash|Write|Edit|MultiEdit", hooks: [{ type: "command", command: "rafter hook posttool" }] },
],
},
};
Expand All @@ -827,7 +827,7 @@ describe("Agent Init: Environment Detection & Hook/Skill Installation", () => {
{ matcher: "Write|Edit", hooks: [{ type: "command", command: "rafter hook pretool" }] },
);
settings.hooks.PostToolUse.push(
{ matcher: ".*", hooks: [{ type: "command", command: "rafter hook posttool" }] },
{ matcher: "Bash|Write|Edit|MultiEdit", hooks: [{ type: "command", command: "rafter hook posttool" }] },
);

fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
Expand Down Expand Up @@ -886,7 +886,7 @@ describe("Agent Init: Environment Detection & Hook/Skill Installation", () => {
{ matcher: "Bash", hooks: [{ type: "command", command: "rafter hook pretool" }] },
],
PostToolUse: [
{ matcher: ".*", hooks: [{ type: "command", command: "rafter hook posttool" }] },
{ matcher: "Bash|Write|Edit|MultiEdit", hooks: [{ type: "command", command: "rafter hook posttool" }] },
],
},
}, null, 2)
Expand Down
Loading
Loading