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

## [Unreleased]

## [0.8.9] - 2026-06-20

### Added
- **Opt-in `--deep` skill-review engine** (sable-7g7). `rafter skill review <path|dir|github:/gitlab:/npm:|--installed> --deep` (alias `--engine skill-scanner`) couples Cisco AI Defense's `skill-scanner` as a deeper pass — prompt injection, taint/dataflow exfiltration, YARA, and `.pyc` integrity — the blind spots the deterministic quick scan structurally cannot see. **Couple, not swap:** the zero-dependency quick scan stays the default; deep results attach a `deepScan` block (top-level for a single skill, per-skill for multi-skill / `--installed`), and only `critical`/`high`/`medium` deep findings are actionable (escalate severity + flip the exit code). **Offline analyzers only** — the argv never enables `--use-llm`/`--use-virustotal`/`--use-aidefense`/`--use-behavioral`, enforced by a `FORBIDDEN_FLAGS` test in both runtimes, so a regression that turns on a network analyzer fails CI. The engine is a heavy third-party package and is **not bundled**: the first `--deep` run offers to install it interactively (isolated, version-pinned `uv tool install`, pip `--user` fallback), or set it up ahead of time with `rafter agent update-skill-scanner` / `rafter agent init --with-skill-scanner`, and remove it with `rafter agent remove-skill-scanner`. Node + Python parity (both shell out to the same external CLI and parse identical JSON, mirroring the betterleaks pattern). `rafter agent audit-skill --deep` remains as a deprecated back-compat alias. Security-reviewed: list-form subprocess (no shell), version-pinned installer, untrusted skill paths passed as single argv elements.

### Fixed
- **Hardened `RAFTER_API_KEY` handling** (sable-q9to). Three credential gaps closed across Node + Python: (1) `~/.rafter/config.json` is now written `0600` (dir `0700`) and an existing looser-perm file is tightened on the next write; (2) `config show`/`get`/`set`, the MCP `get_config` tool, and the `rafter://config` / `rafter://policy` resources now **redact** values under credential-named keys (`api_?key|token|secret|password|credential` → `abcd****`) at every render path — the stored config is never mutated; (3) a key persisted via `rafter agent config set backend.apiKey` is now read as the lowest-precedence source (`--api-key` flag > `RAFTER_API_KEY` env > global config). Trust boundary verified: the config fallback is read only from the **global** config (`get` → `load`, never `loadWithPolicy`), so a hostile project-local `.rafter.yml` cannot inject a key that redirects scans to another account. `rafter-secure-design` run before coding.

### Changed
- **Pinned the `.rafter.yml` `ignore` matching contract in `CLI_SPEC.md`** (sable-eltr). The glob semantics are now specified exactly — `*` stays within a path segment, `**` crosses segments, bare patterns match the basename, relative globs auto-anchor anywhere — replacing the vague (and slightly inaccurate) "minimatch (Node) / fnmatch (Python)" wording. `rules` selectors now documented as matching a finding's **rule name or rule id** (case-insensitively). These semantics are honored identically by the local CLI engines and the remote `rafter run` backend, which adopted the CLI's matcher (rafter-backend ra-a8j, in response to rafter-cli#166) — so a suppression that works locally now works remotely. `suppress_finding` tool descriptions updated to match.

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,8 @@ rafter skill review --installed --summary # terse table across all agents

**Deep analysis** (via OpenClaw, if installed): 12-dimension security review covering trust/attribution, network security, command execution, file system access, credential handling, input validation, data exfiltration, obfuscation, scope alignment, error handling, dependencies, and environment manipulation. Without OpenClaw, generates an LLM-ready review prompt you can paste into any model.

**Optional `--deep` engine** (`rafter skill review <path> --deep`): couples Cisco AI Defense's `skill-scanner` as an opt-in deeper pass for prompt injection, taint/dataflow, YARA and `.pyc` integrity — the blind spots the deterministic scan misses. Works across every input mode (path / directory / `github:`/`gitlab:`/`npm:` shorthand / `--installed`). It runs **offline analyzers only** (no LLM/cloud/network) and is **not bundled**: the first `--deep` run offers to install the engine for you, or set it up ahead of time with `rafter agent update-skill-scanner` (or `rafter agent init --with-skill-scanner`); remove it any time with `rafter agent remove-skill-scanner`. The zero-dependency quick scan stays the default — this couples, it does not replace. (`rafter agent audit-skill --deep` is a deprecated back-compat alias.)

### Audit Log

Every security-relevant event is logged to `~/.rafter/audit.jsonl` in JSON-lines format. Each entry carries a `prevHash` forming a SHA-256 chain, plus the `cwd` and enclosing `gitRepo` where the event was recorded — so tampering, truncation, and out-of-context replays are all detectable.
Expand Down
2 changes: 1 addition & 1 deletion node/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@rafter-security/cli",
"version": "0.8.7",
"version": "0.8.9",
"type": "module",
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion node/resources/rafter-security-skill.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
name: rafter-security
description: Security toolkit for AI workflows. Use when scanning code or repos for vulnerabilities, auditing third-party skills/MCPs/agent configs before installing, evaluating shell commands before running them, or generating secure design questions for new features. Provides `rafter run` (remote SAST + SCA, needs RAFTER_API_KEY), `rafter secrets` (offline secrets-only), `rafter agent exec --dry-run` (command-risk classification), and `rafter skill review`.
version: 0.8.7
version: 0.8.9
homepage: https://rafter.so
metadata:
openclaw:
Expand Down
115 changes: 106 additions & 9 deletions node/src/commands/agent/audit-skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import fs from "fs";
import path from "path";
import { PatternEngine } from "../../core/pattern-engine.js";
import { DEFAULT_SECRET_PATTERNS } from "../../scanners/secret-patterns.js";
import {
SkillScanner,
hasFindings as deepHasFindings,
INSTALL_HINT,
type DeepScanResult,
} from "../../scanners/skill-scanner.js";
import { SkillManager } from "../../utils/skill-manager.js";
import { fmt, isAgentMode } from "../../utils/formatter.js";

Expand All @@ -12,13 +18,25 @@ interface QuickScanResults {
highRiskCommands: Array<{ command: string; line: number }>;
}

interface AuditSkillOpts {
skipOpenclaw?: boolean;
json?: boolean;
deep?: boolean;
engine?: string;
}

export function createAuditSkillCommand(): Command {
return new Command("audit-skill")
.description("[deprecated] Security audit of a Claude Code skill file — use `rafter skill review` instead")
.argument("<skill-path>", "Path to skill file to audit")
.argument("<skill-path>", "Path to skill file or directory to audit")
.option("--skip-openclaw", "Skip OpenClaw integration, show manual review prompt")
.option("--json", "Output results as JSON")
.action(async (skillPath: string, opts: { skipOpenclaw?: boolean; json?: boolean }) => {
.option(
"--deep",
"Run the optional DEEP engine (Cisco AI Defense skill-scanner) in addition to the quick scan. Offline analyzers only — no LLM/cloud/network. Requires cisco-ai-skill-scanner.",
)
.option("--engine <engine>", "Deep engine selector. 'skill-scanner' is equivalent to --deep.")
.action(async (skillPath: string, opts: AuditSkillOpts) => {
process.stderr.write(
"[deprecated] `rafter agent audit-skill` is deprecated; use `rafter skill review <path-or-url>` instead.\n",
);
Expand All @@ -28,18 +46,35 @@ export function createAuditSkillCommand(): Command {

async function auditSkill(
skillPath: string,
opts: { skipOpenclaw?: boolean; json?: boolean }
opts: AuditSkillOpts
): Promise<void> {
// Validate skill file exists
// Validate target exists. Accept either a skill *file* (.md) or a skill
// *directory*. The deep engine (--deep) is most thorough on a directory,
// where it can also see bundled scripts / .pyc; the quick scan reads the
// directory's SKILL.md (or the file itself).
if (!fs.existsSync(skillPath)) {
console.error(fmt.error(`Skill file not found: ${skillPath}`));
console.error(fmt.error(`Skill path not found: ${skillPath}`));
process.exit(2);
}

const absolutePath = path.resolve(skillPath);
const skillContent = fs.readFileSync(absolutePath, "utf-8");
const isDir = fs.statSync(absolutePath).isDirectory();
let skillContent: string;
if (isDir) {
const skillMd = path.join(absolutePath, "SKILL.md");
skillContent = fs.existsSync(skillMd) ? fs.readFileSync(skillMd, "utf-8") : "";
} else {
skillContent = fs.readFileSync(absolutePath, "utf-8");
}
const skillName = path.basename(absolutePath);

// Validate --engine early (mirrors the Python contract).
const wantDeep = !!opts.deep || opts.engine === "skill-scanner";
if (opts.engine != null && opts.engine !== "skill-scanner") {
console.error(fmt.error(`unknown --engine '${opts.engine}' (supported: skill-scanner)`));
process.exit(2);
}

// Run deterministic analysis
if (!opts.json) {
console.log(fmt.header(`Auditing skill: ${skillName}`));
Expand All @@ -54,22 +89,54 @@ async function auditSkill(
displayQuickScan(quickScan, skillName);
}

// Optional DEEP engine (skill-scanner) — opt-in via --deep or
// --engine skill-scanner. Offline analyzers only; preserves our
// no-telemetry default (sable-7g7).
let deepResult: DeepScanResult | null = null;
if (wantDeep) {
const scanner = new SkillScanner();
if (!scanner.isAvailable()) {
// --deep requested but tool missing: clear hint, non-zero exit, no crash.
console.error(INSTALL_HINT);
process.exit(2);
}
deepResult = await scanner.scanPath(absolutePath);
if (deepResult.error) {
console.error(fmt.error(`deep scan failed: ${deepResult.error}`));
process.exit(2);
}
if (!opts.json) {
displayDeepScan(deepResult);
}
}

const quickHasFindings = quickScan.secrets > 0 || quickScan.highRiskCommands.length > 0;
const deepFound = deepResult ? deepHasFindings(deepResult) : false;

// Check OpenClaw availability
const skillManager = new SkillManager();
const openClawAvailable = skillManager.isOpenClawInstalled();
const rafterSkillInstalled = skillManager.isRafterSkillInstalled();

if (opts.json) {
// JSON output
const result = {
const result: Record<string, unknown> = {
skill: skillName,
path: absolutePath,
quickScan,
openClawAvailable,
rafterSkillInstalled
};
if (deepResult) {
result.deepScan = {
engine: "skill-scanner",
maxSeverity: deepResult.maxSeverity,
analyzersUsed: deepResult.analyzersUsed,
findings: deepResult.findings,
};
}
console.log(JSON.stringify(result, null, 2));
if (quickScan.secrets > 0 || quickScan.highRiskCommands.length > 0) {
if (quickHasFindings || deepFound) {
process.exit(1);
}
return;
Expand Down Expand Up @@ -110,11 +177,41 @@ async function auditSkill(

console.log();

if (quickScan.secrets > 0 || quickScan.highRiskCommands.length > 0) {
if (quickHasFindings || deepFound) {
process.exit(1);
}
}

function displayDeepScan(deep: DeepScanResult): void {
console.log(`\n🔎 Deep Scan Results (skill-scanner)`);
console.log(fmt.divider());
if (!deep.available) {
console.log(fmt.warning("skill-scanner not available"));
return;
}
const actionable = deep.findings.filter((f) =>
["critical", "high", "medium"].includes(f.severity),
);
if (actionable.length === 0) {
console.log(fmt.success("No critical/high/medium findings"));
} else {
console.log(
fmt.warning(`${actionable.length} finding(s) (max severity: ${deep.maxSeverity})`),
);
actionable.slice(0, 10).forEach((f) => {
const loc = f.line ? ` (line ${f.line})` : "";
console.log(` • [${f.severity.toUpperCase()}] ${f.category}: ${f.title}${loc}`);
});
if (actionable.length > 10) {
console.log(` ... and ${actionable.length - 10} more`);
}
}
if (deep.analyzersUsed.length > 0) {
console.log(` analyzers: ${deep.analyzersUsed.join(", ")} (offline only)`);
}
console.log();
}

async function runQuickScan(content: string): Promise<QuickScanResults> {
// 1. Scan for secrets
const patternEngine = new PatternEngine(DEFAULT_SECRET_PATTERNS);
Expand Down
15 changes: 11 additions & 4 deletions node/src/commands/agent/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Command } from "commander";
import { ConfigManager } from "../../core/config-manager.js";
import { ConfigManager, redactConfigSecrets, isSecretConfigKey, maskSecretValue } from "../../core/config-manager.js";
import { fmt } from "../../utils/formatter.js";

export function createConfigCommand(): Command {
Expand All @@ -13,7 +13,7 @@ export function createConfigCommand(): Command {
.action(() => {
const manager = new ConfigManager();
const cfg = manager.load();
console.log(JSON.stringify(cfg, null, 2));
console.log(JSON.stringify(redactConfigSecrets(cfg), null, 2));
});

// Get specific value
Expand All @@ -30,8 +30,11 @@ export function createConfigCommand(): Command {
process.exit(1);
}

const leaf = key.split(".").pop() ?? key;
if (typeof value === "object") {
console.log(JSON.stringify(value, null, 2));
console.log(JSON.stringify(redactConfigSecrets(value), null, 2));
} else if (isSecretConfigKey(leaf) && typeof value === "string") {
console.log(maskSecretValue(value));
} else {
console.log(value);
}
Expand All @@ -55,7 +58,11 @@ export function createConfigCommand(): Command {
}

manager.set(key, parsedValue);
console.log(fmt.success(`Set ${key} = ${JSON.stringify(parsedValue)}`));
const leaf = key.split(".").pop() ?? key;
const echo = isSecretConfigKey(leaf) && typeof parsedValue === "string"
? JSON.stringify(maskSecretValue(parsedValue))
: JSON.stringify(parsedValue);
console.log(fmt.success(`Set ${key} = ${echo}`));
});

return config;
Expand Down
30 changes: 17 additions & 13 deletions node/src/commands/agent/exec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Command } from "commander";
import { CommandInterceptor } from "../../core/command-interceptor.js";
import { RegexScanner } from "../../scanners/regex-scanner.js";
import { scanAddedDiffLines } from "../../scanners/git-diff-scan.js";
import { parseUnifiedDiffAddedLines } from "../../utils/git-diff.js";
import { execSync } from "child_process";
import readline from "readline";
import { fmt } from "../../utils/formatter.js";
Expand Down Expand Up @@ -91,29 +92,32 @@ function isGitCommand(command: string): boolean {

async function scanStagedFiles(): Promise<{ secretsFound: boolean; count: number; files: number }> {
try {
// Get staged files
const stagedFiles = execSync("git diff --cached --name-only", {
const patch = execSync("git diff -U0 --no-color --cached --diff-filter=ACM", {
encoding: "utf-8",
stdio: ["pipe", "pipe", "ignore"]
})
.trim()
.split("\n")
.filter(f => f);
stdio: ["pipe", "pipe", "ignore"],
}).trim();

if (stagedFiles.length === 0) {
if (!patch) {
return { secretsFound: false, count: 0, files: 0 };
}

// Scan staged files
const scanner = new RegexScanner();
const results = scanner.scanFiles(stagedFiles);
const repoRoot = execSync("git rev-parse --show-toplevel", {
encoding: "utf-8",
stdio: ["pipe", "pipe", "ignore"],
}).trim();

const addedLines = parseUnifiedDiffAddedLines(patch);
if (addedLines.length === 0) {
return { secretsFound: false, count: 0, files: 0 };
}

const results = scanAddedDiffLines(addedLines, repoRoot);
const totalMatches = results.reduce((sum, r) => sum + r.matches.length, 0);

return {
secretsFound: results.length > 0,
count: totalMatches,
files: results.length
files: results.length,
};
} catch {
// If git command fails (not in repo, etc.), skip scanning
Expand Down
4 changes: 4 additions & 0 deletions node/src/commands/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { createInstallHookCommand } from "./install-hook.js";
import { createVerifyCommand } from "./verify.js";
import { createStatusCommand } from "./status.js";
import { createUpdateBetterleaksCommand } from "./update-betterleaks.js";
import { createUpdateSkillScannerCommand } from "./update-skill-scanner.js";
import { createRemoveSkillScannerCommand } from "./remove-skill-scanner.js";
import { createBaselineCommand } from "./baseline.js";
import { createListCommand } from "./list.js";
import { createEnableCommand } from "./enable.js";
Expand All @@ -31,6 +33,8 @@ export function createAgentCommand(): Command {
agent.addCommand(createVerifyCommand());
agent.addCommand(createStatusCommand());
agent.addCommand(createUpdateBetterleaksCommand());
agent.addCommand(createUpdateSkillScannerCommand());
agent.addCommand(createRemoveSkillScannerCommand());
agent.addCommand(createBaselineCommand());
agent.addCommand(createListCommand());
agent.addCommand(createEnableCommand());
Expand Down
Loading
Loading