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

## [Unreleased]

### 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.



### Added
- **MCP `suppress_finding` tool** (sable-bjl). Agents and MCP clients can now triage a false positive directly through the MCP instead of hand-editing config. The tool persists an `ignore` rule (path glob, optional rule names, reason) into the project `.rafter.yml`, mirroring the loader's resolution precedence and creating a canonical dotfile at the git root when none exists. The merge is idempotent — re-suppressing the same path+rules scope updates the reason in place (order-insensitive) rather than appending a duplicate. Suppressed findings still surface under `_suppressed` in scan output, so the decision stays reviewable and version-controlled. This is the 7th MCP tool; Node + Python parity, with unit tests for the writer (create/append/update-in-place/dedup/empty-guard) and tool-registration assertions in both suites. Security-reviewed (CWE Top 25): the write target derives only from policy-file resolution, never from user input (no path traversal); YAML is read via safe loaders and written from structured objects (no injection); existing config is preserved.

### Changed
- **Finding-triage docs point to `.rafter.yml` + the new MCP tool** for suppression, and the previously documented-but-unimplemented inline `// rafter-ignore:` directive has been removed (product decision: not building it). See https://docs.rafter.so/suppression.

## [0.8.6] - 2026-06-13

### Added
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.6",
"version": "0.8.7",
"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.6
version: 0.8.7
homepage: https://rafter.so
metadata:
openclaw:
Expand Down
5 changes: 3 additions & 2 deletions node/resources/skills/rafter/docs/finding-triage.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ If the finding is a leaked secret that was committed:

## Suppression — When It's OK

Suppress only when the finding is a real false positive *for this context*, with a written reason. Two mechanisms:
Suppress only when the finding is a real false positive *for this context*, with a written reason. Three mechanisms:

- **`.rafter.yml`**: add an `ignore:` rule naming the path(s), the rule(s), and a `reason` (your evidence). Repo-tied; persists across scans:
```yaml
Expand All @@ -62,7 +62,8 @@ Suppress only when the finding is a real false positive *for this context*, with
rules: ["AWS Access Key ID"] # omit `rules` to suppress every finding on those paths
reason: "fake test keys — no live path"
```
Match `rules` on the finding's **rule name** (case-insensitive), not a hashed `R-…` id. On a local `rafter scan`, suppressed findings move into a `_suppressed[]` array and don't affect the exit code — you only fail on a *non-suppressed* finding. Docs: https://docs.rafter.so/suppression
Each `rules` entry matches (case-insensitively) the finding's **rule name** (e.g. `AWS Access Key ID`) **or** its **rule id** (e.g. `R-6D5E2`) — use the name for local pattern findings, the id for remote SAST/SCA findings. Path globs are gitignore-style: `*` stays within a path segment, `**` crosses segments, a bare name matches the basename. The same `.rafter.yml ignore` block is honored by **local** scans and **remote `rafter run`** alike. Suppressed findings move into a `_suppressed[]` array and don't affect the exit code — you only fail on a *non-suppressed* finding. Docs: https://docs.rafter.so/suppression
- **MCP `suppress_finding` tool**: agents can triage a false positive directly through the MCP — it writes the same `.rafter.yml` `ignore` rule above (path, optional rule names, reason). No hand-editing required.
- **Baseline**: `rafter agent baseline create` snapshots current findings; scan with `rafter scan --baseline` so only *new* findings surface. Good for adopting Rafter on a legacy codebase without a big bang.

Never suppress by:
Expand Down
39 changes: 39 additions & 0 deletions node/src/commands/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { CommandInterceptor } from "../../core/command-interceptor.js";
import { AuditLogger } from "../../core/audit-logger.js";
import { ConfigManager } from "../../core/config-manager.js";
import { listDocs, resolveDocSelector, fetchDoc } from "../../core/docs-loader.js";
import { writeSuppression } from "../../core/suppression-writer.js";
import { createRequire } from "module";

const _require = createRequire(import.meta.url);
Expand Down Expand Up @@ -139,6 +140,23 @@ export function createServer(): Server {
required: ["id_or_tag"],
},
},
{
name: "suppress_finding",
description: "Triage a false positive by persisting a suppression rule into the project's .rafter.yml. Use when a scan_secrets finding (or a remote scan finding) is a confirmed false positive — e.g. a test fixture or sample credential. Suppressed findings still surface under '_suppressed' in scan output, so the decision is reviewable and version-controlled. Always include a reason.",
inputSchema: {
type: "object" as const,
properties: {
path: { type: "string", description: "File path or glob to suppress findings in (e.g. 'test/fixtures/**')" },
rules: {
type: "array",
items: { type: "string" },
description: "Specific rules to suppress, matched case-insensitively against a finding's rule name OR rule id — e.g. 'AWS Access Key' (local pattern name) or 'R-6D5E2' (remote SAST/SCA rule id). Omit to suppress all rules for the path. Honored by both local scans and remote `rafter run`.",
},
reason: { type: "string", description: "Why this is a false positive — persisted with the rule. Strongly recommended." },
},
required: ["path"],
},
},
],
}));

Expand Down Expand Up @@ -258,6 +276,27 @@ export function createServer(): Server {
return textResult(results);
}

case "suppress_finding": {
const suppressPath = args?.path as string | undefined;
if (!suppressPath) return errorResult("path is required");
const rules = Array.isArray(args?.rules)
? (args!.rules as unknown[]).map((r) => String(r))
: undefined;
const reason = args?.reason as string | undefined;
try {
const result = writeSuppression({ paths: [suppressPath], rules, reason });
return textResult({
ok: true,
file: result.file,
action: result.action,
entry: result.entry,
suppression_count: result.suppressionCount,
});
} catch (err: any) {
return errorResult(`Failed to write suppression: ${err.message || err}`);
}
}

default:
return errorResult(`Unknown tool: ${name}`);
}
Expand Down
4 changes: 2 additions & 2 deletions node/src/core/policy-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ const POLICY_FILE_CANDIDATES: string[] = [
* For each directory, check candidates in precedence order; the first hit
* wins. The walk only ascends — siblings are not considered.
*/
export function findPolicyFile(): string | null {
let dir = process.cwd();
export function findPolicyFile(startDir: string = process.cwd()): string | null {
let dir = startDir;
const root = getGitRoot() || path.parse(dir).root;

while (true) {
Expand Down
121 changes: 121 additions & 0 deletions node/src/core/suppression-writer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import fs from "fs";
import path from "path";
import { execSync } from "child_process";
import yaml from "js-yaml";
import { findPolicyFile } from "./policy-loader.js";

export interface SuppressionInput {
/** File path or glob to suppress findings in. Required, non-empty. */
paths: string[];
/** Specific rule/pattern names to suppress. Omitted/empty = suppress all rules for those paths. */
rules?: string[];
/** Human-readable rationale, persisted alongside the rule. */
reason?: string;
/** Base directory for resolving the policy file. Defaults to process.cwd(). */
cwd?: string;
}

export interface SuppressionResult {
/** Absolute path of the policy file written. */
file: string;
/** What happened: a new file was created, a rule appended, or an existing rule's reason updated. */
action: "created" | "appended" | "updated";
/** The ignore rule as persisted. */
entry: { paths: string[]; rules?: string[]; reason?: string };
/** Total number of ignore rules in the file after the write. */
suppressionCount: number;
}

function getGitRoot(cwd: string): string | null {
try {
return execSync("git rev-parse --show-toplevel", {
cwd,
encoding: "utf-8",
stdio: ["pipe", "pipe", "ignore"],
}).trim();
} catch {
return null;
}
}

/**
* Normalize a rule for dedup comparison: an ignore rule is "the same" if it
* targets the same set of paths and the same set of rule names (order- and
* duplicate-insensitive). Reason is intentionally excluded — re-suppressing
* the same scope just updates the reason.
*/
function ruleKey(paths: string[], rules?: string[]): string {
const norm = (xs?: string[]) =>
Array.from(new Set((xs ?? []).map((x) => String(x)))).sort();
return JSON.stringify({ paths: norm(paths), rules: norm(rules) });
}

/**
* Persist a finding suppression into the project's `.rafter.yml` `ignore`
* list. Resolves the policy file via the same precedence the loader uses;
* if none exists, creates a canonical `.rafter.yml` at the git root (or cwd).
*
* Merge semantics: if an existing ignore rule targets the same paths + rules,
* its reason is updated in place rather than appending a duplicate.
*/
export function writeSuppression(input: SuppressionInput): SuppressionResult {
const paths = (input.paths ?? []).map((p) => String(p)).filter((p) => p.length > 0);
if (paths.length === 0) {
throw new Error('"paths" must be a non-empty array of file paths or globs.');
}
const rules = Array.isArray(input.rules)
? input.rules.map((r) => String(r)).filter((r) => r.length > 0)
: undefined;
const reason = typeof input.reason === "string" && input.reason.trim() ? input.reason.trim() : undefined;
const baseDir = input.cwd || process.cwd();

// Resolve target file: existing policy file wins; else canonical dotfile at git root / cwd.
let target = findPolicyFile(baseDir);
let action: SuppressionResult["action"];
let raw: Record<string, any> = {};

if (target && fs.existsSync(target)) {
const content = fs.readFileSync(target, "utf-8");
const parsed = yaml.load(content);
raw = parsed && typeof parsed === "object" ? (parsed as Record<string, any>) : {};
action = "appended";
} else {
const root = getGitRoot(baseDir) || baseDir;
target = path.join(root, ".rafter.yml");
action = "created";
}

const ignoreList: any[] = Array.isArray(raw.ignore) ? raw.ignore : [];

const newEntry: { paths: string[]; rules?: string[]; reason?: string } = { paths };
if (rules && rules.length > 0) newEntry.rules = rules;
if (reason) newEntry.reason = reason;

const key = ruleKey(paths, rules);
const existing = ignoreList.find(
(e) => e && typeof e === "object" && Array.isArray(e.paths) && ruleKey(e.paths, e.rules) === key,
);

if (existing) {
// Same scope already suppressed — update the reason in place.
if (reason) existing.reason = reason;
else delete existing.reason;
if (action !== "created") action = "updated";
} else {
ignoreList.push(newEntry);
}

raw.ignore = ignoreList;

const dumped = yaml.dump(raw, { lineWidth: 100, noRefs: true, sortKeys: false });
const dir = path.dirname(target);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(target, dumped, "utf-8");

return {
file: target,
action,
entry: existing ? { ...newEntry, ...(existing.reason ? { reason: existing.reason } : {}) } : newEntry,
suppressionCount: ignoreList.length,
};
}
17 changes: 14 additions & 3 deletions node/tests/mcp-server-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,19 @@ describe("MCP Server — tool registration and schema", () => {
beforeAll(setupClientServer);
afterAll(teardown);

it("should register exactly 6 tools", async () => {
it("should register exactly 7 tools", async () => {
const { tools } = await client.listTools();
expect(tools).toHaveLength(6);
expect(tools).toHaveLength(7);
});

it("should expose suppress_finding with correct schema", async () => {
const { tools } = await client.listTools();
const tool = tools.find(t => t.name === "suppress_finding");
expect(tool).toBeDefined();
expect(tool!.inputSchema.required).toContain("path");
expect(tool!.inputSchema.properties).toHaveProperty("path");
expect(tool!.inputSchema.properties).toHaveProperty("rules");
expect(tool!.inputSchema.properties).toHaveProperty("reason");
});

it("should expose scan_secrets with correct schema", async () => {
Expand Down Expand Up @@ -175,6 +185,7 @@ describe("MCP Server — tool registration and schema", () => {
"list_docs",
"read_audit_log",
"scan_secrets",
"suppress_finding",
]);
});

Expand Down Expand Up @@ -452,7 +463,7 @@ describe("MCP Server — lifecycle", () => {

// Quick sanity — tools are still listed
const { tools } = await c.listTools();
expect(tools).toHaveLength(6);
expect(tools).toHaveLength(7);

await c.close();
await s.close();
Expand Down
9 changes: 5 additions & 4 deletions node/tests/mcp-server-stdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ describe("MCP Server — real stdio transport: tool listing", () => {
await client.close();
});

it("should register exactly 6 tools over stdio", async () => {
it("should register exactly 7 tools over stdio", async () => {
const { tools } = await client.listTools();
expect(tools).toHaveLength(6);
expect(tools).toHaveLength(7);
});

it("tool names match expected set", async () => {
Expand All @@ -61,6 +61,7 @@ describe("MCP Server — real stdio transport: tool listing", () => {
"list_docs",
"read_audit_log",
"scan_secrets",
"suppress_finding",
]);
});

Expand Down Expand Up @@ -377,7 +378,7 @@ describe("MCP Server — real stdio transport: lifecycle", () => {
const { client } = await createConnectedClient();
// Verify we can list tools (connection works)
const { tools } = await client.listTools();
expect(tools).toHaveLength(6);
expect(tools).toHaveLength(7);
await client.close();
});

Expand All @@ -395,7 +396,7 @@ describe("MCP Server — real stdio transport: lifecycle", () => {
for (let i = 0; i < 3; i++) {
const { client } = await createConnectedClient();
const { tools } = await client.listTools();
expect(tools).toHaveLength(6);
expect(tools).toHaveLength(7);
await client.close();
// Let the subprocess's exit propagate before the next spawn.
if (i < 2) await new Promise((resolve) => setTimeout(resolve, 200));
Expand Down
75 changes: 75 additions & 0 deletions node/tests/suppression-writer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "fs";
import os from "os";
import path from "path";
import yaml from "js-yaml";
import { writeSuppression } from "../src/core/suppression-writer.js";

let tmp: string;

beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "rafter-suppress-"));
});

afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});

function readPolicy(): any {
return yaml.load(fs.readFileSync(path.join(tmp, ".rafter.yml"), "utf-8"));
}

describe("writeSuppression", () => {
it("creates .rafter.yml when none exists", () => {
const res = writeSuppression({ cwd: tmp, paths: ["test/fixtures/**"], rules: ["AWS Access Key"], reason: "fixtures" });
expect(res.action).toBe("created");
expect(res.suppressionCount).toBe(1);
expect(fs.existsSync(path.join(tmp, ".rafter.yml"))).toBe(true);
const policy = readPolicy();
expect(policy.ignore).toEqual([
{ paths: ["test/fixtures/**"], rules: ["AWS Access Key"], reason: "fixtures" },
]);
});

it("appends a new rule to an existing policy without clobbering other keys", () => {
fs.writeFileSync(
path.join(tmp, ".rafter.yml"),
yaml.dump({ risk_level: "moderate", ignore: [{ paths: ["a/**"], reason: "first" }] }),
);
const res = writeSuppression({ cwd: tmp, paths: ["b/**"], reason: "second" });
expect(res.action).toBe("appended");
expect(res.suppressionCount).toBe(2);
const policy = readPolicy();
expect(policy.risk_level).toBe("moderate");
expect(policy.ignore).toHaveLength(2);
expect(policy.ignore[1]).toEqual({ paths: ["b/**"], reason: "second" });
});

it("updates reason in place for the same path+rules scope (no duplicate)", () => {
writeSuppression({ cwd: tmp, paths: ["a/**"], rules: ["X"], reason: "old" });
const res = writeSuppression({ cwd: tmp, paths: ["a/**"], rules: ["X"], reason: "new reason" });
expect(res.action).toBe("updated");
expect(res.suppressionCount).toBe(1);
const policy = readPolicy();
expect(policy.ignore).toHaveLength(1);
expect(policy.ignore[0].reason).toBe("new reason");
});

it("treats path/rule order as identical scope (dedup is order-insensitive)", () => {
writeSuppression({ cwd: tmp, paths: ["a/**", "b/**"], rules: ["X", "Y"], reason: "first" });
const res = writeSuppression({ cwd: tmp, paths: ["b/**", "a/**"], rules: ["Y", "X"], reason: "second" });
expect(res.action).toBe("updated");
expect(res.suppressionCount).toBe(1);
});

it("omits rules key when no rule names given (suppress-all-for-path)", () => {
writeSuppression({ cwd: tmp, paths: ["docs/**"], reason: "docs" });
const policy = readPolicy();
expect(policy.ignore[0]).toEqual({ paths: ["docs/**"], reason: "docs" });
expect(policy.ignore[0]).not.toHaveProperty("rules");
});

it("throws on empty paths", () => {
expect(() => writeSuppression({ cwd: tmp, paths: [] })).toThrow();
});
});
Loading
Loading