From 49b61f67cd7f342ce88743f82b51b609f81c802c Mon Sep 17 00:00:00 2001 From: sable Date: Tue, 16 Jun 2026 07:13:25 +0000 Subject: [PATCH 1/4] feat(mcp): add suppress_finding tool for triaging false positives (sable-bjl) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 7th MCP tool, `suppress_finding`, so agents/clients can triage a false positive directly through the MCP instead of hand-editing config. It 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. Merge is idempotent: re-suppressing the same path+rules scope updates the reason in place rather than appending a duplicate (order-insensitive). Suppressed findings still surface under `_suppressed` in scan output, so the decision stays reviewable and version-controlled. Also removes the documented-but-unimplemented inline `// rafter-ignore:` directive from finding-triage docs (product decision: not building it) and points to `.rafter.yml` + the new MCP tool + docs.rafter.so/suppression. Node + Python parity. CLI_SPEC updated (6→7 tools). New unit tests for the writer (create/append/update-in-place/dedup/empty-guard) plus MCP tool registration assertions in both suites. Security (rafter-code-review, CWE Top 25): write target derives only from policy-file resolution, never user input (no CWE-22); YAML read via safe_load / js-yaml safe schema (no CWE-502); output via dump on structured objects (no YAML injection); existing entries + top-level keys preserved. Co-Authored-By: Claude Opus 4.8 --- .../skills/rafter/docs/finding-triage.md | 3 +- node/src/commands/mcp/server.ts | 39 ++++++ node/src/core/policy-loader.ts | 4 +- node/src/core/suppression-writer.ts | 121 ++++++++++++++++++ node/tests/mcp-server-integration.test.ts | 17 ++- node/tests/mcp-server-stdio.test.ts | 9 +- node/tests/suppression-writer.test.ts | 75 +++++++++++ python/rafter_cli/commands/mcp_server.py | 33 +++++ python/rafter_cli/core/policy_loader.py | 6 +- python/rafter_cli/core/suppression_writer.py | 111 ++++++++++++++++ .../skills/rafter/docs/finding-triage.md | 3 +- python/tests/test_mcp_server.py | 2 +- python/tests/test_mcp_server_stdio.py | 9 +- python/tests/test_suppression_writer.py | 81 ++++++++++++ shared-docs/CLI_SPEC.md | 10 +- 15 files changed, 503 insertions(+), 20 deletions(-) create mode 100644 node/src/core/suppression-writer.ts create mode 100644 node/tests/suppression-writer.test.ts create mode 100644 python/rafter_cli/core/suppression_writer.py create mode 100644 python/tests/test_suppression_writer.py diff --git a/node/resources/skills/rafter/docs/finding-triage.md b/node/resources/skills/rafter/docs/finding-triage.md index 05929016..40af0005 100644 --- a/node/resources/skills/rafter/docs/finding-triage.md +++ b/node/resources/skills/rafter/docs/finding-triage.md @@ -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 @@ -63,6 +63,7 @@ Suppress only when the finding is a real false positive *for this context*, with 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 +- **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: diff --git a/node/src/commands/mcp/server.ts b/node/src/commands/mcp/server.ts index fb7cd3c8..a2050fb4 100644 --- a/node/src/commands/mcp/server.ts +++ b/node/src/commands/mcp/server.ts @@ -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); @@ -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 rule/pattern names to suppress (e.g. ['AWS Access Key']). Omit to suppress all rules for the path.", + }, + reason: { type: "string", description: "Why this is a false positive — persisted with the rule. Strongly recommended." }, + }, + required: ["path"], + }, + }, ], })); @@ -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}`); } diff --git a/node/src/core/policy-loader.ts b/node/src/core/policy-loader.ts index f18da519..3297df3f 100644 --- a/node/src/core/policy-loader.ts +++ b/node/src/core/policy-loader.ts @@ -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) { diff --git a/node/src/core/suppression-writer.ts b/node/src/core/suppression-writer.ts new file mode 100644 index 00000000..56f083ac --- /dev/null +++ b/node/src/core/suppression-writer.ts @@ -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 = {}; + + 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) : {}; + 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, + }; +} diff --git a/node/tests/mcp-server-integration.test.ts b/node/tests/mcp-server-integration.test.ts index 5c96b9b7..1b29c09e 100644 --- a/node/tests/mcp-server-integration.test.ts +++ b/node/tests/mcp-server-integration.test.ts @@ -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 () => { @@ -175,6 +185,7 @@ describe("MCP Server — tool registration and schema", () => { "list_docs", "read_audit_log", "scan_secrets", + "suppress_finding", ]); }); @@ -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(); diff --git a/node/tests/mcp-server-stdio.test.ts b/node/tests/mcp-server-stdio.test.ts index 9d248956..9f3f0385 100644 --- a/node/tests/mcp-server-stdio.test.ts +++ b/node/tests/mcp-server-stdio.test.ts @@ -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 () => { @@ -61,6 +61,7 @@ describe("MCP Server — real stdio transport: tool listing", () => { "list_docs", "read_audit_log", "scan_secrets", + "suppress_finding", ]); }); @@ -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(); }); @@ -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)); diff --git a/node/tests/suppression-writer.test.ts b/node/tests/suppression-writer.test.ts new file mode 100644 index 00000000..3d1cd68e --- /dev/null +++ b/node/tests/suppression-writer.test.ts @@ -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(); + }); +}); diff --git a/python/rafter_cli/commands/mcp_server.py b/python/rafter_cli/commands/mcp_server.py index 37e7795b..d8c1144f 100644 --- a/python/rafter_cli/commands/mcp_server.py +++ b/python/rafter_cli/commands/mcp_server.py @@ -175,6 +175,18 @@ def handle_get_docs_resource() -> str: return json.dumps(handle_list_docs(), indent=2) +def handle_suppress_finding( + path: str, + rules: list[str] | None = None, + reason: str | None = None, +) -> dict: + """Persist a false-positive suppression into the project's .rafter.yml.""" + from ..core.suppression_writer import write_suppression + + result = write_suppression([path], rules=rules, reason=reason) + return {"ok": True, **result} + + # ── MCP server factory ──────────────────────────────────────────────── @@ -249,6 +261,27 @@ def get_doc(id_or_tag: str, refresh: bool = False) -> str: """ return json.dumps(handle_get_doc(id_or_tag, refresh)) + @mcp.tool() + def suppress_finding( + path: str, + rules: list[str] | None = None, + reason: str | None = None, + ) -> str: + """Triage a false positive by persisting a suppression rule into .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. + + Args: + path: File path or glob to suppress findings in (e.g. 'test/fixtures/**'). + rules: Specific rule/pattern names to suppress (e.g. ['AWS Access Key']). + Omit to suppress all rules for the path. + reason: Why this is a false positive — persisted with the rule. Strongly recommended. + """ + return json.dumps(handle_suppress_finding(path, rules, reason)) + @mcp.resource("rafter://config") def config_resource() -> str: """Current Rafter configuration.""" diff --git a/python/rafter_cli/core/policy_loader.py b/python/rafter_cli/core/policy_loader.py index 697a6e28..12d42b10 100644 --- a/python/rafter_cli/core/policy_loader.py +++ b/python/rafter_cli/core/policy_loader.py @@ -30,13 +30,13 @@ _KNOWN_DOC_KEYS = {"id", "path", "url", "description", "tags", "cache"} -def find_policy_file() -> Path | None: - """Walk from cwd up to git root looking for a policy file. +def find_policy_file(start_dir: str | Path | None = None) -> Path | None: + """Walk from ``start_dir`` (default cwd) up to git root looking for a policy file. Returns the first candidate that exists, in the precedence order declared by ``POLICY_FILE_CANDIDATES``. """ - cwd = Path.cwd() + cwd = Path(start_dir) if start_dir else Path.cwd() root = get_git_root() stop = Path(root) if root else cwd.anchor and Path(cwd.anchor) diff --git a/python/rafter_cli/core/suppression_writer.py b/python/rafter_cli/core/suppression_writer.py new file mode 100644 index 00000000..6e41dd74 --- /dev/null +++ b/python/rafter_cli/core/suppression_writer.py @@ -0,0 +1,111 @@ +"""Persist finding suppressions into the project's .rafter.yml ignore list. + +Mirrors node/src/core/suppression-writer.ts — keep both in sync. +""" +from __future__ import annotations + +import json +from pathlib import Path + +from ..utils.git import get_git_root +from .policy_loader import find_policy_file + + +def _rule_key(paths: list[str], rules: list[str] | None) -> str: + """Order- and duplicate-insensitive identity for an ignore rule. + + Two rules are "the same" if they target the same set of paths and the + same set of rule names. Reason is excluded — re-suppressing the same + scope just updates the reason. + """ + def norm(xs: list[str] | None) -> list[str]: + return sorted({str(x) for x in (xs or [])}) + + return json.dumps({"paths": norm(paths), "rules": norm(rules)}, sort_keys=True) + + +def write_suppression( + paths: list[str], + rules: list[str] | None = None, + reason: str | None = None, + cwd: str | Path | None = None, +) -> dict: + """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. + + ``cwd`` overrides the base directory for resolution (defaults to the process cwd). + + Returns a dict: {file, action, entry, suppression_count}. + """ + import yaml + + norm_paths = [str(p) for p in (paths or []) if str(p)] + if not norm_paths: + raise ValueError('"paths" must be a non-empty list of file paths or globs.') + norm_rules = [str(r) for r in rules if str(r)] if isinstance(rules, list) else None + norm_reason = reason.strip() if isinstance(reason, str) and reason.strip() else None + base_dir = str(cwd) if cwd else str(Path.cwd()) + + target = find_policy_file(base_dir) + raw: dict = {} + + if target and Path(target).exists(): + parsed = yaml.safe_load(Path(target).read_text()) + raw = parsed if isinstance(parsed, dict) else {} + action = "appended" + else: + root = get_git_root() or base_dir + target = Path(root) / ".rafter.yml" + action = "created" + + target = Path(target) + ignore_list = raw.get("ignore") if isinstance(raw.get("ignore"), list) else [] + + new_entry: dict = {"paths": norm_paths} + if norm_rules: + new_entry["rules"] = norm_rules + if norm_reason: + new_entry["reason"] = norm_reason + + key = _rule_key(norm_paths, norm_rules) + existing = next( + ( + e + for e in ignore_list + if isinstance(e, dict) + and isinstance(e.get("paths"), list) + and _rule_key(e["paths"], e.get("rules")) == key + ), + None, + ) + + if existing is not None: + if norm_reason: + existing["reason"] = norm_reason + else: + existing.pop("reason", None) + if action != "created": + action = "updated" + entry = dict(new_entry) + if existing.get("reason"): + entry["reason"] = existing["reason"] + else: + ignore_list.append(new_entry) + entry = new_entry + + raw["ignore"] = ignore_list + + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(yaml.safe_dump(raw, sort_keys=False, default_flow_style=False)) + + return { + "file": str(target), + "action": action, + "entry": entry, + "suppression_count": len(ignore_list), + } diff --git a/python/rafter_cli/resources/skills/rafter/docs/finding-triage.md b/python/rafter_cli/resources/skills/rafter/docs/finding-triage.md index 05929016..40af0005 100644 --- a/python/rafter_cli/resources/skills/rafter/docs/finding-triage.md +++ b/python/rafter_cli/resources/skills/rafter/docs/finding-triage.md @@ -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 @@ -63,6 +63,7 @@ Suppress only when the finding is a real false positive *for this context*, with 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 +- **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: diff --git a/python/tests/test_mcp_server.py b/python/tests/test_mcp_server.py index 4e88f0d1..b26c77d8 100644 --- a/python/tests/test_mcp_server.py +++ b/python/tests/test_mcp_server.py @@ -235,7 +235,7 @@ def test_registers_expected_tools(self): if tool_names: expected = { "scan_secrets", "evaluate_command", "read_audit_log", "get_config", - "list_docs", "get_doc", + "list_docs", "get_doc", "suppress_finding", } assert expected == tool_names diff --git a/python/tests/test_mcp_server_stdio.py b/python/tests/test_mcp_server_stdio.py index 8c4129c5..670f11a7 100644 --- a/python/tests/test_mcp_server_stdio.py +++ b/python/tests/test_mcp_server_stdio.py @@ -127,9 +127,9 @@ async def _connect(self): await cleanup(self.session, self.cm) @pytest.mark.asyncio - async def test_registers_exactly_6_tools(self): + async def test_registers_exactly_7_tools(self): result = await self.session.list_tools() - assert len(result.tools) == 6 + assert len(result.tools) == 7 @pytest.mark.asyncio async def test_tool_names_match_expected_set(self): @@ -142,6 +142,7 @@ async def test_tool_names_match_expected_set(self): "list_docs", "read_audit_log", "scan_secrets", + "suppress_finding", ] @pytest.mark.asyncio @@ -413,7 +414,7 @@ class TestLifecycle: async def test_connect_disconnect_cleanly(self): session, cm = await create_connected_session() result = await session.list_tools() - assert len(result.tools) == 6 + assert len(result.tools) == 7 await cleanup(session, cm) @pytest.mark.asyncio @@ -421,5 +422,5 @@ async def test_sequential_sessions(self): for _ in range(3): session, cm = await create_connected_session() result = await session.list_tools() - assert len(result.tools) == 6 + assert len(result.tools) == 7 await cleanup(session, cm) diff --git a/python/tests/test_suppression_writer.py b/python/tests/test_suppression_writer.py new file mode 100644 index 00000000..f140d5a2 --- /dev/null +++ b/python/tests/test_suppression_writer.py @@ -0,0 +1,81 @@ +"""Tests for the .rafter.yml suppression writer + MCP suppress_finding handler.""" +from __future__ import annotations + +import os +from pathlib import Path + +import pytest +import yaml + +from rafter_cli.commands.mcp_server import handle_suppress_finding +from rafter_cli.core.suppression_writer import write_suppression + + +@pytest.fixture +def in_tmp(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + return tmp_path + + +def read_policy(tmp_path: Path) -> dict: + return yaml.safe_load((tmp_path / ".rafter.yml").read_text()) + + +def test_creates_rafter_yml_when_absent(in_tmp): + res = write_suppression(["test/fixtures/**"], rules=["AWS Access Key"], reason="fixtures") + assert res["action"] == "created" + assert res["suppression_count"] == 1 + assert (in_tmp / ".rafter.yml").exists() + policy = read_policy(in_tmp) + assert policy["ignore"] == [ + {"paths": ["test/fixtures/**"], "rules": ["AWS Access Key"], "reason": "fixtures"} + ] + + +def test_appends_without_clobbering_other_keys(in_tmp): + (in_tmp / ".rafter.yml").write_text( + yaml.safe_dump({"risk_level": "moderate", "ignore": [{"paths": ["a/**"], "reason": "first"}]}) + ) + res = write_suppression(["b/**"], reason="second") + assert res["action"] == "appended" + assert res["suppression_count"] == 2 + policy = read_policy(in_tmp) + assert policy["risk_level"] == "moderate" + assert policy["ignore"][1] == {"paths": ["b/**"], "reason": "second"} + + +def test_updates_reason_in_place_for_same_scope(in_tmp): + write_suppression(["a/**"], rules=["X"], reason="old") + res = write_suppression(["a/**"], rules=["X"], reason="new reason") + assert res["action"] == "updated" + assert res["suppression_count"] == 1 + policy = read_policy(in_tmp) + assert len(policy["ignore"]) == 1 + assert policy["ignore"][0]["reason"] == "new reason" + + +def test_dedup_is_order_insensitive(in_tmp): + write_suppression(["a/**", "b/**"], rules=["X", "Y"], reason="first") + res = write_suppression(["b/**", "a/**"], rules=["Y", "X"], reason="second") + assert res["action"] == "updated" + assert res["suppression_count"] == 1 + + +def test_omits_rules_when_none_given(in_tmp): + write_suppression(["docs/**"], reason="docs") + policy = read_policy(in_tmp) + assert policy["ignore"][0] == {"paths": ["docs/**"], "reason": "docs"} + assert "rules" not in policy["ignore"][0] + + +def test_empty_paths_raises(in_tmp): + with pytest.raises(ValueError): + write_suppression([]) + + +def test_mcp_handler_returns_ok(in_tmp): + out = handle_suppress_finding("test/fixtures/**", rules=["AWS Access Key"], reason="fixtures") + assert out["ok"] is True + assert out["action"] == "created" + assert out["entry"]["paths"] == ["test/fixtures/**"] + assert out["suppression_count"] == 1 diff --git a/shared-docs/CLI_SPEC.md b/shared-docs/CLI_SPEC.md index 34d2858a..d4fda3cb 100644 --- a/shared-docs/CLI_SPEC.md +++ b/shared-docs/CLI_SPEC.md @@ -894,7 +894,7 @@ PostToolUse hook handler. Reads tool output from stdin, redacts any secrets foun ### rafter mcp serve [OPTIONS] -Start MCP server over stdio transport. Exposes 6 tools and 3 resources. +Start MCP server over stdio transport. Exposes 7 tools and 3 resources. - `--transport ` — transport type (currently only `stdio`, default: `stdio`) @@ -908,6 +908,7 @@ Start MCP server over stdio transport. Exposes 6 tools and 3 resources. | `get_config` | Read active Rafter configuration and policy | none (optional: `key` dot-path) | | `list_docs` | List repo-specific security docs declared in `.rafter.yml` (metadata only, no content) | none (optional: `tag`) | | `get_doc` | Return the content of a repo-specific security doc by id or tag | `id_or_tag` (string); optional: `refresh` (bool) | +| `suppress_finding` | Triage a false positive by writing an `ignore` rule into the project `.rafter.yml` | `path` (string); optional: `rules` (string[]), `reason` (string) | **`scan_secrets` inputs:** - `path` (required) — file or directory path to scan @@ -935,6 +936,13 @@ Start MCP server over stdio transport. Exposes 6 tools and 3 resources. **`get_doc` output schema:** array of `{ id, source, source_kind, stale, content }`. Returns multiple entries when `id_or_tag` matches a tag shared by several docs; returns a single entry when it matches an `id` exactly. +**`suppress_finding` inputs:** +- `path` (required, string) — file path or glob to suppress findings in (e.g. `test/fixtures/**`) +- `rules` (optional, string[]) — specific rule/pattern names to suppress (e.g. `["AWS Access Key"]`); omit to suppress all rules for the path +- `reason` (optional, string) — why this is a false positive; persisted with the rule and surfaced in `_suppressed` output + +**`suppress_finding` output schema:** `{ ok, file, action, entry, suppression_count }` where `action` is `"created"` (new `.rafter.yml` written), `"appended"` (rule added to an existing file), or `"updated"` (an existing rule with the same path+rules scope had its reason refreshed). `entry` is the persisted ignore rule `{ paths, rules?, reason? }`. The tool resolves the existing policy file via the loader's precedence; if none exists it creates a canonical `.rafter.yml` at the git root. It never appends a duplicate rule for the same path+rules scope. + #### MCP Resources | URI | MIME type | Description | From 645f7dd614217758dcf72be76e2d19f0d7c43776 Mon Sep 17 00:00:00 2001 From: sable Date: Tue, 16 Jun 2026 19:58:23 +0000 Subject: [PATCH 2/4] chore(release): 0.8.7 Bump Node + Python packages and both rafter-security-skill.md frontmatter versions to 0.8.7 (validate-release parity). Headline change since 0.8.6: the MCP `suppress_finding` tool (sable-bjl) for triaging false positives. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 8 ++++++++ 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, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56c661c4..dfe9edc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.7] - 2026-06-16 + +### 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 diff --git a/node/package.json b/node/package.json index 756768e3..de553274 100644 --- a/node/package.json +++ b/node/package.json @@ -1,6 +1,6 @@ { "name": "@rafter-security/cli", - "version": "0.8.6", + "version": "0.8.7", "type": "module", "repository": { "type": "git", diff --git a/node/resources/rafter-security-skill.md b/node/resources/rafter-security-skill.md index 8120e318..bb7ad277 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.6 +version: 0.8.7 homepage: https://rafter.so metadata: openclaw: diff --git a/python/pyproject.toml b/python/pyproject.toml index d691955e..60f6579e 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "rafter-cli" -version = "0.8.6" +version = "0.8.7" 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 8120e318..bb7ad277 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.6 +version: 0.8.7 homepage: https://rafter.so metadata: openclaw: From dc1411655d02424f9cc43cf35f620f3509a1ce74 Mon Sep 17 00:00:00 2001 From: sable Date: Tue, 16 Jun 2026 22:22:18 +0000 Subject: [PATCH 3/4] docs(suppression): pin .rafter.yml ignore glob+rule contract; note remote parity (sable-eltr) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend (rafter-backend ra-a8j, responding to rafter-cli#166) now honors .rafter.yml ignore rules and adopted the CLI's exact matcher, so local and remote rafter run suppress identically. Pin that shared contract: - CLI_SPEC.md: specify glob semantics exactly (* within a path segment, ** crosses segments, bare = basename, relative globs auto-anchored) — replacing the vague, slightly-wrong "minimatch (Node)/fnmatch (Python)" wording (Python uses _glob_in_path, not raw fnmatch). Document `rules` as matching a finding's rule name OR rule id (case-insensitive), honored locally and remotely. - suppress_finding tool descriptions (Node + Python): `rules` accepts a rule name or rule id; honored by local scans and remote `rafter run`. - finding-triage.md (both copies): corrected the now-false "match rule name, not a hashed R-… id" line — it's name OR id now. - CHANGELOG [Unreleased] entry. Docs/description only; no behavior change (the CLI matcher was already the agreed standard — the backend aligned to it). Affected Node + Python suites green. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 5 ++++- .../resources/skills/rafter/docs/finding-triage.md | 2 +- node/src/commands/mcp/server.ts | 2 +- python/rafter_cli/commands/mcp_server.py | 6 ++++-- .../resources/skills/rafter/docs/finding-triage.md | 2 +- shared-docs/CLI_SPEC.md | 14 +++++++++++++- 6 files changed, 24 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfe9edc0..51882407 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.8.7] - 2026-06-16 +### 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. diff --git a/node/resources/skills/rafter/docs/finding-triage.md b/node/resources/skills/rafter/docs/finding-triage.md index 40af0005..04411c22 100644 --- a/node/resources/skills/rafter/docs/finding-triage.md +++ b/node/resources/skills/rafter/docs/finding-triage.md @@ -62,7 +62,7 @@ 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. diff --git a/node/src/commands/mcp/server.ts b/node/src/commands/mcp/server.ts index a2050fb4..5fe26df8 100644 --- a/node/src/commands/mcp/server.ts +++ b/node/src/commands/mcp/server.ts @@ -150,7 +150,7 @@ export function createServer(): Server { rules: { type: "array", items: { type: "string" }, - description: "Specific rule/pattern names to suppress (e.g. ['AWS Access Key']). Omit to suppress all rules for the path.", + 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." }, }, diff --git a/python/rafter_cli/commands/mcp_server.py b/python/rafter_cli/commands/mcp_server.py index d8c1144f..74049f14 100644 --- a/python/rafter_cli/commands/mcp_server.py +++ b/python/rafter_cli/commands/mcp_server.py @@ -276,8 +276,10 @@ def suppress_finding( Args: path: File path or glob to suppress findings in (e.g. 'test/fixtures/**'). - rules: Specific rule/pattern names to suppress (e.g. ['AWS Access Key']). - Omit to suppress all rules for the path. + rules: 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: Why this is a false positive — persisted with the rule. Strongly recommended. """ return json.dumps(handle_suppress_finding(path, rules, reason)) diff --git a/python/rafter_cli/resources/skills/rafter/docs/finding-triage.md b/python/rafter_cli/resources/skills/rafter/docs/finding-triage.md index 40af0005..04411c22 100644 --- a/python/rafter_cli/resources/skills/rafter/docs/finding-triage.md +++ b/python/rafter_cli/resources/skills/rafter/docs/finding-triage.md @@ -62,7 +62,7 @@ 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. diff --git a/shared-docs/CLI_SPEC.md b/shared-docs/CLI_SPEC.md index d4fda3cb..a947d597 100644 --- a/shared-docs/CLI_SPEC.md +++ b/shared-docs/CLI_SPEC.md @@ -1130,7 +1130,19 @@ Precedence: policy file overrides `~/.rafter/config.json`. Arrays replace, not a **URL caching:** URL-backed docs are cached at `~/.rafter/docs-cache/` keyed by `sha256(url)[:32]`. Default TTL is 86400 seconds. On network failure, a stale cached copy is served and a warning is printed. `docs list` never fetches; `docs show` fetches on miss/expired or when `--refresh` is set. -**Ignore rules (`ignore:`):** suppress findings without removing them from the audit trail. Each entry needs `paths:` (a non-empty list of globs); `rules:` is optional (omitting it suppresses every rule on the matched paths) and `reason:` is surfaced verbatim in the JSON `_suppressed` output. Path globs are matched anywhere along absolute scan paths — `tests/fixtures/**` matches `/abs/project/tests/fixtures/foo`. Rule-name matching is case-insensitive; non-existent rule names are harmless (they just never match). First entry that matches wins, so put more specific entries earlier. +**Ignore rules (`ignore:`):** suppress findings without removing them from the audit trail. Each entry needs `paths:` (a non-empty list of globs); `rules:` is optional (omitting it suppresses every rule on the matched paths) and `reason:` is surfaced verbatim in the JSON `_suppressed` output. First entry that matches wins, so put more specific entries earlier. + +These rules are honored identically by the **local** CLI engines (Node and Python) and by the **remote `rafter run`** backend — they read the same `.rafter.yml` (and `.rafter/config.yml`) `ignore:` block. The matching contract is fixed and the same on every engine: + +*Path globs (`paths:`)* — gitignore/minimatch semantics: +- A bare pattern with no `/` (e.g. `*.env`) matches against the file **basename** anywhere in the tree (so it matches `config/.env`). +- `*` matches any run of characters **within a single path segment** — it does **not** cross `/`. So `src/*.json` matches `src/a.json` but **not** `src/sub/a.json`. +- `**` matches across segments (it **does** cross `/`). Use it for recursive matches: `tests/fixtures/**`. +- `?` matches exactly one non-`/` character. +- A relative glob (no leading `/`, not starting with `**`) is auto-anchored to match **anywhere** along the absolute scan path, so `tests/fixtures/**` matches `/abs/project/tests/fixtures/foo`. +- Path matching is case-sensitive. + +*Rule selectors (`rules:`)* — each entry matches a finding when it equals (case-insensitively) **either** the finding's rule **name/title** (e.g. `AWS Access Key`) **or** its **rule id** (e.g. `R-6D5E2` / `rules.autogrep.json.vuln-…`). Use the name for local pattern findings and the id for remote SAST/SCA findings. Non-existent selectors are harmless (they just never match). --- From 50be908f54a9807bd75d7c6a98d197c7a14c1f85 Mon Sep 17 00:00:00 2001 From: sable Date: Tue, 16 Jun 2026 22:29:46 +0000 Subject: [PATCH 4/4] docs(spec): note remote suppressed.json artifact mirrors _suppressed shape (ra-nrr) Backend (rafter-backend ra-a8j/ra-nrr) now emits a suppressed.json artifact in the CLI's exact _suppressed per-entry shape, so hidden findings are recoverable on remote rafter run too. Cross-reference it from the suppression section. Co-Authored-By: Claude Opus 4.8 --- shared-docs/CLI_SPEC.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/shared-docs/CLI_SPEC.md b/shared-docs/CLI_SPEC.md index a947d597..60f83ec3 100644 --- a/shared-docs/CLI_SPEC.md +++ b/shared-docs/CLI_SPEC.md @@ -399,6 +399,8 @@ When `.rafter.yml` `ignore:` rules (or `.rafterignore`) hide one or more finding Exit code is unaffected by suppression — exit `1` is returned only when at least one *non-suppressed* finding remains. +Remote `rafter run` emits the same suppression data as a separate `suppressed.json` artifact (alongside `findings.json`, which is unaffected), using this identical per-entry shape; its `source` is `".rafter/config.yml"` (the backend's config filename). So a finding hidden by an `ignore` rule is recoverable whether the scan ran locally or remotely. + ### rafter agent exec COMMAND [OPTIONS] Execute shell command with risk assessment and approval workflow.