@rafter-security/cli 0.7.7 → 0.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ import { RegexScanner } from "../../scanners/regex-scanner.js";
3
3
  import { GitleaksScanner } from "../../scanners/gitleaks.js";
4
4
  import { ConfigManager } from "../../core/config-manager.js";
5
5
  import { AuditLogger } from "../../core/audit-logger.js";
6
+ import { applySuppressions, loadSuppressions, policyIgnoreToSuppressions, } from "../../core/custom-patterns.js";
6
7
  import { execFileSync } from "child_process";
7
8
  import fs from "fs";
8
9
  import os from "os";
@@ -69,19 +70,20 @@ export function createScanCommand() {
69
70
  if (isAgentScan) {
70
71
  process.stderr.write("Warning: rafter agent scan is deprecated and will be removed in a future major version. Use rafter secrets instead.\n");
71
72
  }
72
- // Load policy-merged config for excludePaths/customPatterns
73
+ // Load policy-merged config for excludePaths/customPatterns/ignore
73
74
  const manager = new ConfigManager();
74
75
  const cfg = manager.loadWithPolicy();
75
76
  const scanCfg = cfg.agent?.scan;
77
+ const suppressions = collectSuppressions(scanCfg?.ignore);
76
78
  const baselineEntries = opts.baseline ? loadBaselineEntries() : [];
77
79
  // Handle --diff flag
78
80
  if (opts.diff) {
79
- await scanDiffFiles(opts.diff, opts, scanCfg, baselineEntries, path.resolve(scanPath));
81
+ await scanDiffFiles(opts.diff, opts, scanCfg, baselineEntries, path.resolve(scanPath), suppressions);
80
82
  return;
81
83
  }
82
84
  // Handle --staged flag
83
85
  if (opts.staged) {
84
- await scanStagedFiles(opts, scanCfg, baselineEntries, path.resolve(scanPath));
86
+ await scanStagedFiles(opts, scanCfg, baselineEntries, path.resolve(scanPath), suppressions);
85
87
  return;
86
88
  }
87
89
  const resolvedPath = path.resolve(scanPath);
@@ -92,7 +94,7 @@ export function createScanCommand() {
92
94
  }
93
95
  // Handle --watch flag
94
96
  if (opts.watch) {
95
- await watchAndScan(resolvedPath, opts, scanCfg);
97
+ await watchAndScan(resolvedPath, opts, scanCfg, suppressions);
96
98
  return;
97
99
  }
98
100
  // Determine scan engine
@@ -112,7 +114,7 @@ export function createScanCommand() {
112
114
  }
113
115
  results = await scanFile(resolvedPath, engine, scanCfg);
114
116
  }
115
- outputScanResults(applyBaseline(results, baselineEntries), opts);
117
+ outputScanResults(applyBaseline(results, baselineEntries), opts, undefined, true, suppressions);
116
118
  });
117
119
  }
118
120
  /**
@@ -126,6 +128,14 @@ export function createSecretsCommand() {
126
128
  cmd.description("Scan files/directories for hardcoded secrets (regex + gitleaks). Secrets only — not a code analysis. For full SAST/SCA, use 'rafter run'.");
127
129
  return cmd;
128
130
  }
131
+ /**
132
+ * Combine .rafterignore + policy ignore rules into a single Suppression list.
133
+ * Order matters — first match wins, and policy rules are checked first so an
134
+ * explicit reason wins over a bare .rafterignore line covering the same finding.
135
+ */
136
+ function collectSuppressions(policyIgnore) {
137
+ return [...policyIgnoreToSuppressions(policyIgnore), ...loadSuppressions()];
138
+ }
129
139
  /**
130
140
  * Emit SARIF 2.1.0 JSON for GitHub/GitLab security tab integration
131
141
  */
@@ -180,18 +190,21 @@ function outputSarif(results) {
180
190
  /**
181
191
  * Shared output logic for scan results
182
192
  */
183
- function outputScanResults(results, opts, context, exitOnFindings = true) {
193
+ function outputScanResults(results, opts, context, exitOnFindings = true, suppressions = []) {
184
194
  const format = opts.format ?? (opts.json ? "json" : "text");
185
195
  if (!["text", "json", "sarif"].includes(format)) {
186
196
  console.error(`Invalid format: ${format}. Valid values: text, json, sarif`);
187
197
  process.exit(2);
188
198
  }
199
+ // Split suppressed findings off the main result list. Both engines feed
200
+ // through here, so policy-driven suppression applies regardless of source.
201
+ const { results: keptResults, suppressed } = applySuppressions(results, suppressions);
189
202
  if (format === "sarif") {
190
- outputSarif(results);
203
+ outputSarif(keptResults);
191
204
  return;
192
205
  }
193
206
  if (format === "json" || opts.json) {
194
- const out = results.map((r) => ({
207
+ const filesOut = keptResults.map((r) => ({
195
208
  file: r.file,
196
209
  matches: r.matches.map((m) => ({
197
210
  pattern: { name: m.pattern.name, severity: m.pattern.severity, description: m.pattern.description || "" },
@@ -200,12 +213,28 @@ function outputScanResults(results, opts, context, exitOnFindings = true) {
200
213
  redacted: m.redacted || "",
201
214
  })),
202
215
  }));
216
+ const out = {
217
+ _note: "Local-only scan: pattern-based detection without agentic-intelligence triage. " +
218
+ "Findings have not been evaluated for context (public exposure, key validity, " +
219
+ "deployment environment). Investigate each before acting; do not dismiss. " +
220
+ "Run 'rafter run' for backend agentic analysis.",
221
+ scan_mode: "local",
222
+ triage_applied: false,
223
+ results: filesOut,
224
+ };
225
+ if (suppressed.length > 0) {
226
+ out._suppressed = suppressed;
227
+ }
203
228
  console.log(JSON.stringify(out, null, 2));
204
229
  if (exitOnFindings)
205
- process.exit(results.length > 0 ? 1 : 0);
230
+ process.exit(keptResults.length > 0 ? 1 : 0);
206
231
  return;
207
232
  }
208
- if (results.length === 0) {
233
+ // Text output note suppression on stderr so stdout remains parseable.
234
+ if (suppressed.length > 0 && !opts.quiet) {
235
+ console.error(fmt.info(`(${suppressed.length} finding(s) hidden by .rafter.yml)`));
236
+ }
237
+ if (keptResults.length === 0) {
209
238
  if (!opts.quiet) {
210
239
  const msg = context ? `No secrets detected in ${context}` : "No secrets detected";
211
240
  console.log(`\n${fmt.success(msg)}\n`);
@@ -214,9 +243,9 @@ function outputScanResults(results, opts, context, exitOnFindings = true) {
214
243
  process.exit(0);
215
244
  return;
216
245
  }
217
- console.log(`\n${fmt.warning(`Found secrets in ${results.length} file(s):`)}\n`);
246
+ console.log(`\n${fmt.warning(`Found secrets in ${keptResults.length} file(s):`)}\n`);
218
247
  let totalMatches = 0;
219
- for (const result of results) {
248
+ for (const result of keptResults) {
220
249
  console.log(`\n${fmt.info(result.file)}`);
221
250
  for (const match of result.matches) {
222
251
  totalMatches++;
@@ -229,7 +258,7 @@ function outputScanResults(results, opts, context, exitOnFindings = true) {
229
258
  console.log();
230
259
  }
231
260
  }
232
- console.log(`\n${fmt.warning(`Total: ${totalMatches} secret(s) detected in ${results.length} file(s)`)}\n`);
261
+ console.log(`\n${fmt.warning(`Total: ${totalMatches} secret(s) detected in ${keptResults.length} file(s)`)}\n`);
233
262
  if (context === "staged files") {
234
263
  console.log(`${fmt.error("Commit blocked. Remove secrets before committing.")}\n`);
235
264
  }
@@ -242,7 +271,7 @@ function outputScanResults(results, opts, context, exitOnFindings = true) {
242
271
  /**
243
272
  * Scan files changed since a git ref
244
273
  */
245
- async function scanDiffFiles(ref, opts, scanCfg, baselineEntries = [], scanPath) {
274
+ async function scanDiffFiles(ref, opts, scanCfg, baselineEntries = [], scanPath, suppressions = []) {
246
275
  const cwd = scanPath && fs.existsSync(scanPath) && fs.statSync(scanPath).isDirectory() ? scanPath : undefined;
247
276
  try {
248
277
  const diffOutput = execFileSync("git", ["diff", "--name-only", "--diff-filter=ACM", ref], {
@@ -251,7 +280,7 @@ async function scanDiffFiles(ref, opts, scanCfg, baselineEntries = [], scanPath)
251
280
  stdio: ["pipe", "pipe", "ignore"],
252
281
  }).trim();
253
282
  if (!diffOutput) {
254
- outputScanResults([], opts, `files changed since ${ref}`);
283
+ outputScanResults([], opts, `files changed since ${ref}`, true, suppressions);
255
284
  return;
256
285
  }
257
286
  const changedFiles = diffOutput.split("\n").map(f => f.trim()).filter(f => f);
@@ -275,7 +304,7 @@ async function scanDiffFiles(ref, opts, scanCfg, baselineEntries = [], scanPath)
275
304
  const results = await scanFile(filePath, engine, scanCfg);
276
305
  allResults.push(...results);
277
306
  }
278
- outputScanResults(applyBaseline(allResults, baselineEntries), opts, `files changed since ${ref}`);
307
+ outputScanResults(applyBaseline(allResults, baselineEntries), opts, `files changed since ${ref}`, true, suppressions);
279
308
  }
280
309
  catch (error) {
281
310
  if (error.status === 128) {
@@ -288,7 +317,7 @@ async function scanDiffFiles(ref, opts, scanCfg, baselineEntries = [], scanPath)
288
317
  /**
289
318
  * Scan git staged files for secrets
290
319
  */
291
- async function scanStagedFiles(opts, scanCfg, baselineEntries = [], scanPath) {
320
+ async function scanStagedFiles(opts, scanCfg, baselineEntries = [], scanPath, suppressions = []) {
292
321
  const cwd = scanPath && fs.existsSync(scanPath) && fs.statSync(scanPath).isDirectory() ? scanPath : undefined;
293
322
  try {
294
323
  const stagedFilesOutput = execFileSync("git", ["diff", "--cached", "--name-only", "--diff-filter=ACM"], {
@@ -297,7 +326,7 @@ async function scanStagedFiles(opts, scanCfg, baselineEntries = [], scanPath) {
297
326
  stdio: ["pipe", "pipe", "ignore"]
298
327
  }).trim();
299
328
  if (!stagedFilesOutput) {
300
- outputScanResults([], opts, "staged files");
329
+ outputScanResults([], opts, "staged files", true, suppressions);
301
330
  return;
302
331
  }
303
332
  const stagedFiles = stagedFilesOutput.split("\n").map(f => f.trim()).filter(f => f);
@@ -321,7 +350,7 @@ async function scanStagedFiles(opts, scanCfg, baselineEntries = [], scanPath) {
321
350
  const results = await scanFile(filePath, engine, scanCfg);
322
351
  allResults.push(...results);
323
352
  }
324
- outputScanResults(applyBaseline(allResults, baselineEntries), opts, "staged files");
353
+ outputScanResults(applyBaseline(allResults, baselineEntries), opts, "staged files", true, suppressions);
325
354
  }
326
355
  catch (error) {
327
356
  if (error.status === 128) {
@@ -404,7 +433,7 @@ async function scanDirectory(dirPath, engine, scanCfg, history) {
404
433
  /**
405
434
  * Watch a path for changes and re-scan on each change
406
435
  */
407
- async function watchAndScan(watchPath, opts, scanCfg) {
436
+ async function watchAndScan(watchPath, opts, scanCfg, suppressions = []) {
408
437
  const { watch } = await import("chokidar");
409
438
  const logger = new AuditLogger();
410
439
  const engine = await selectEngine(opts.engine || "auto", opts.quiet || false);
@@ -418,7 +447,7 @@ async function watchAndScan(watchPath, opts, scanCfg) {
418
447
  : await scanFile(watchPath, engine, scanCfg);
419
448
  if (initialResults.length > 0) {
420
449
  console.log(fmt.warning(`\n[Initial scan] Found secrets:`));
421
- outputScanResults(initialResults, { ...opts, quiet: false }, undefined, /* exitOnFindings= */ false);
450
+ outputScanResults(initialResults, { ...opts, quiet: false }, undefined, /* exitOnFindings= */ false, suppressions);
422
451
  logWatchFindings(logger, initialResults);
423
452
  }
424
453
  else if (!opts.quiet) {
@@ -442,7 +471,7 @@ async function watchAndScan(watchPath, opts, scanCfg) {
442
471
  return;
443
472
  const results = await scanFile(filePath, engine, scanCfg);
444
473
  if (results.length > 0) {
445
- outputScanResults(results, { ...opts, quiet: false }, undefined, /* exitOnFindings= */ false);
474
+ outputScanResults(results, { ...opts, quiet: false }, undefined, /* exitOnFindings= */ false, suppressions);
446
475
  logWatchFindings(logger, results);
447
476
  }
448
477
  else if (!opts.quiet) {
@@ -459,7 +488,7 @@ async function watchAndScan(watchPath, opts, scanCfg) {
459
488
  return;
460
489
  const results = await scanFile(filePath, engine, scanCfg);
461
490
  if (results.length > 0) {
462
- outputScanResults(results, { ...opts, quiet: false }, undefined, /* exitOnFindings= */ false);
491
+ outputScanResults(results, { ...opts, quiet: false }, undefined, /* exitOnFindings= */ false, suppressions);
463
492
  logWatchFindings(logger, results);
464
493
  }
465
494
  else if (!opts.quiet) {
@@ -1,9 +1,11 @@
1
1
  import { Command } from "commander";
2
2
  import { BinaryManager } from "../../utils/binary-manager.js";
3
3
  import { SkillManager } from "../../utils/skill-manager.js";
4
+ import { spawnSync } from "child_process";
4
5
  import fs from "fs";
5
6
  import path from "path";
6
7
  import os from "os";
8
+ import yaml from "js-yaml";
7
9
  import { fmt } from "../../utils/formatter.js";
8
10
  async function checkGitleaks() {
9
11
  const binaryManager = new BinaryManager();
@@ -51,8 +53,10 @@ function checkClaudeCode() {
51
53
  }
52
54
  try {
53
55
  const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
56
+ // Substring match — Python install writes an absolute path
57
+ // (/home/foo/bin/rafter hook pretool), Node writes the bare command.
54
58
  const hooks = settings?.hooks?.PreToolUse || [];
55
- const hasRafterHook = hooks.some((entry) => (entry.hooks || []).some((h) => h.command === "rafter hook pretool"));
59
+ const hasRafterHook = hooks.some((entry) => (entry.hooks || []).some((h) => String(h?.command ?? "").includes("rafter hook pretool")));
56
60
  if (!hasRafterHook) {
57
61
  return { name, passed: false, optional: true, detail: "Rafter hooks not installed — run 'rafter agent init --with-claude-code'" };
58
62
  }
@@ -69,6 +73,16 @@ function checkOpenClaw() {
69
73
  return { name, passed: false, optional: true, detail: `Not detected — run 'rafter agent init --with-openclaw' to enable` };
70
74
  }
71
75
  if (!skillManager.isRafterSkillInstalled()) {
76
+ // rf-zgwj: surface the legacy install path so users on rafter ≤ 0.7.7
77
+ // know they need to re-run to migrate.
78
+ if (skillManager.hasLegacyRafterSkill()) {
79
+ return {
80
+ name,
81
+ passed: false,
82
+ optional: true,
83
+ detail: `Legacy skill at ${skillManager.getLegacyRafterSkillPath()} (not loaded by OpenClaw) — re-run 'rafter agent init --with-openclaw' to migrate to ${skillManager.getRafterSkillPath()}`,
84
+ };
85
+ }
72
86
  return { name, passed: false, optional: true, detail: `Rafter skill not installed — run 'rafter agent init --with-openclaw'` };
73
87
  }
74
88
  const version = skillManager.getInstalledVersion();
@@ -156,13 +170,163 @@ function checkWindsurf() {
156
170
  return { name, passed: false, optional: true, detail: `Cannot read config: ${e}` };
157
171
  }
158
172
  }
173
+ function checkContinueDev() {
174
+ const name = "Continue.dev";
175
+ const homeDir = os.homedir();
176
+ const continueDir = path.join(homeDir, ".continue");
177
+ if (!fs.existsSync(continueDir)) {
178
+ return { name, passed: false, optional: true, detail: `Not detected — run 'rafter agent init --with-continue' to enable` };
179
+ }
180
+ const configPath = path.join(continueDir, "config.json");
181
+ if (!fs.existsSync(configPath)) {
182
+ return { name, passed: false, optional: true, detail: `MCP config not found: ${configPath} — run 'rafter agent init --with-continue'` };
183
+ }
184
+ try {
185
+ const cfg = JSON.parse(fs.readFileSync(configPath, "utf-8"));
186
+ const servers = cfg?.mcpServers;
187
+ let hasRafter = false;
188
+ if (Array.isArray(servers))
189
+ hasRafter = servers.some((s) => s?.name === "rafter");
190
+ else if (servers && typeof servers === "object")
191
+ hasRafter = !!servers.rafter;
192
+ if (!hasRafter) {
193
+ return { name, passed: false, optional: true, detail: "Rafter MCP server not configured — run 'rafter agent init --with-continue'" };
194
+ }
195
+ return { name, passed: true, detail: "MCP server configured" };
196
+ }
197
+ catch (e) {
198
+ return { name, passed: false, optional: true, detail: `Cannot read config: ${e}` };
199
+ }
200
+ }
201
+ function checkAider() {
202
+ // Aider has no platform dir of its own; presence of ~/.aider.conf.yml or
203
+ // a project-local .aider.conf.yml is the install signal. We check the
204
+ // user-scope file plus the cwd file (rf-du2o ships at --local scope too).
205
+ const name = "Aider";
206
+ const home = os.homedir();
207
+ const userConf = path.join(home, ".aider.conf.yml");
208
+ const projectConf = path.join(process.cwd(), ".aider.conf.yml");
209
+ const userRafterMd = path.join(home, "RAFTER.md");
210
+ const projectRafterMd = path.join(process.cwd(), "RAFTER.md");
211
+ // Pick whichever scope has a config file; prefer cwd.
212
+ const conf = fs.existsSync(projectConf) ? projectConf
213
+ : fs.existsSync(userConf) ? userConf
214
+ : null;
215
+ if (!conf) {
216
+ return { name, passed: false, optional: true, detail: `Not detected — run 'rafter agent init --with-aider' to enable` };
217
+ }
218
+ let raw = "";
219
+ try {
220
+ raw = fs.readFileSync(conf, "utf-8");
221
+ }
222
+ catch (e) {
223
+ return { name, passed: false, optional: true, detail: `Cannot read config: ${e}` };
224
+ }
225
+ let parsed = {};
226
+ try {
227
+ const loaded = yaml.load(raw);
228
+ if (loaded && typeof loaded === "object" && !Array.isArray(loaded)) {
229
+ parsed = loaded;
230
+ }
231
+ }
232
+ catch {
233
+ // Unparseable — fall back to substring check
234
+ const hasReadEntry = /\bRAFTER\.md\b/.test(raw);
235
+ if (!hasReadEntry) {
236
+ return { name, passed: false, optional: true, detail: "RAFTER.md not in read: list — run 'rafter agent init --with-aider'" };
237
+ }
238
+ return { name, passed: true, detail: "RAFTER.md in read: list (config not strict-YAML)" };
239
+ }
240
+ const reads = Array.isArray(parsed.read) ? parsed.read.map(String)
241
+ : typeof parsed.read === "string" ? [parsed.read] : [];
242
+ if (!reads.includes("RAFTER.md")) {
243
+ return { name, passed: false, optional: true, detail: `RAFTER.md not in read: list (${conf}) — run 'rafter agent init --with-aider'` };
244
+ }
245
+ const rafterMd = conf === projectConf ? projectRafterMd : userRafterMd;
246
+ if (!fs.existsSync(rafterMd)) {
247
+ return { name, passed: false, optional: true, detail: `RAFTER.md missing at ${rafterMd} — run 'rafter agent init --with-aider'` };
248
+ }
249
+ return { name, passed: true, detail: `RAFTER.md + read: entry in ${conf}` };
250
+ }
251
+ /**
252
+ * Probe the Claude Code hook integration end-to-end (rf-65zg).
253
+ *
254
+ * Synthesizes a stdin payload that mimics Claude's PreToolUse hook contract
255
+ * with a known-dangerous test command, invokes `rafter hook pretool` (the
256
+ * command Claude would invoke), and asserts ~/.rafter/audit.jsonl received
257
+ * a `command_intercepted` entry for the probe command.
258
+ *
259
+ * Catches the rf-luk-style "wrote file but the command never fires the
260
+ * audit log" failure without needing to drive Claude Code itself.
261
+ */
262
+ function probeClaudeCode() {
263
+ const name = "Claude Code (probe)";
264
+ const home = os.homedir();
265
+ const settingsPath = path.join(home, ".claude", "settings.json");
266
+ if (!fs.existsSync(settingsPath)) {
267
+ return { name, passed: false, optional: true, detail: "Not installed — skip" };
268
+ }
269
+ // Use a unique sentinel command per probe run so we don't collide with
270
+ // real-world audit entries.
271
+ const sentinel = `rafter-probe-${process.pid}-${Date.now()}`;
272
+ const probeCommand = `rm -rf /tmp/${sentinel}`;
273
+ const stdinPayload = JSON.stringify({
274
+ session_id: sentinel,
275
+ transcript_path: "",
276
+ cwd: process.cwd(),
277
+ permission_mode: "default",
278
+ hook_event_name: "PreToolUse",
279
+ tool_name: "Bash",
280
+ tool_input: { command: probeCommand },
281
+ });
282
+ const auditPath = path.join(home, ".rafter", "audit.jsonl");
283
+ const sizeBefore = fs.existsSync(auditPath) ? fs.statSync(auditPath).size : 0;
284
+ // Resolve the rafter binary the same way Claude Code would: `rafter hook
285
+ // pretool` on PATH. Fall back to argv[0] if PATH lookup fails.
286
+ const result = spawnSync(process.execPath, [process.argv[1], "hook", "pretool"], {
287
+ input: stdinPayload,
288
+ encoding: "utf-8",
289
+ timeout: 10000,
290
+ });
291
+ if (result.error) {
292
+ return { name, passed: false, detail: `rafter hook pretool failed to spawn: ${result.error.message}` };
293
+ }
294
+ if (!fs.existsSync(auditPath)) {
295
+ return { name, passed: false, detail: `Hook ran but ${auditPath} was not created (exit=${result.status})` };
296
+ }
297
+ const newContent = fs.readFileSync(auditPath, "utf-8").slice(sizeBefore);
298
+ const lines = newContent.split("\n").filter((l) => l.trim().length > 0);
299
+ const hit = lines.some((line) => {
300
+ try {
301
+ const entry = JSON.parse(line);
302
+ const cmd = String(entry?.action?.command ?? entry?.command ?? "");
303
+ return entry?.eventType === "command_intercepted" && cmd.includes(sentinel);
304
+ }
305
+ catch {
306
+ return false;
307
+ }
308
+ });
309
+ if (!hit) {
310
+ return {
311
+ name,
312
+ passed: false,
313
+ detail: `Probe ran (exit=${result.status}) but no command_intercepted entry for sentinel "${sentinel}" landed in ${auditPath}`,
314
+ };
315
+ }
316
+ return { name, passed: true, detail: `Probe fired → command_intercepted recorded in ${auditPath}` };
317
+ }
159
318
  export function createVerifyCommand() {
160
319
  return new Command("verify")
161
320
  .description("Check agent security integration status")
162
- .action(async () => {
163
- console.log(fmt.header("Rafter Agent Verify"));
164
- console.log(fmt.divider());
165
- console.log();
321
+ .option("--json", "Emit results as JSON (one object per check + summary)")
322
+ .option("--probe", "Runtime probe: invoke rafter hook commands with synthetic platform-format payloads and assert ~/.rafter/audit.jsonl recorded the interception. Catches the 'wrote file but never fires' failure mode (rf-65zg).")
323
+ .action(async (opts) => {
324
+ const json = !!opts.json;
325
+ if (!json) {
326
+ console.log(fmt.header("Rafter Agent Verify"));
327
+ console.log(fmt.divider());
328
+ console.log();
329
+ }
166
330
  const results = [
167
331
  checkConfig(),
168
332
  await checkGitleaks(),
@@ -172,30 +336,56 @@ export function createVerifyCommand() {
172
336
  checkGemini(),
173
337
  checkCursor(),
174
338
  checkWindsurf(),
339
+ checkContinueDev(),
340
+ checkAider(),
175
341
  ];
176
- for (const r of results) {
177
- if (r.passed) {
178
- console.log(fmt.success(`${r.name}: ${r.detail}`));
179
- }
180
- else if (r.optional) {
181
- console.log(fmt.warning(`${r.name}: ${r.detail}`));
182
- }
183
- else {
184
- console.log(fmt.error(`${r.name}: FAIL — ${r.detail}`));
185
- }
342
+ if (opts.probe) {
343
+ // Only Claude Code has a probe today (rf-65zg). Codex/Cursor/Gemini
344
+ // hook payloads can be added in follow-ups.
345
+ results.push(probeClaudeCode());
186
346
  }
187
- console.log();
188
347
  const hardFailed = results.filter((r) => !r.passed && !r.optional);
189
348
  const warned = results.filter((r) => !r.passed && r.optional);
190
349
  const passed = results.filter((r) => r.passed);
191
- if (hardFailed.length === 0) {
192
- const warnNote = warned.length > 0 ? ` (${warned.length} optional check${warned.length > 1 ? "s" : ""} not configured)` : "";
193
- console.log(fmt.success(`${passed.length}/${results.length} core checks passed${warnNote}`));
350
+ if (json) {
351
+ const payload = {
352
+ checks: results.map((r) => ({
353
+ name: r.name,
354
+ status: r.passed ? "pass" : r.optional ? "warn" : "fail",
355
+ detail: r.detail,
356
+ })),
357
+ summary: {
358
+ passed: passed.length,
359
+ warned: warned.length,
360
+ failed: hardFailed.length,
361
+ total: results.length,
362
+ probe: !!opts.probe,
363
+ },
364
+ };
365
+ process.stdout.write(JSON.stringify(payload) + "\n");
194
366
  }
195
367
  else {
196
- console.log(fmt.error(`${passed.length}/${results.length} checks passed ${hardFailed.length} failed`));
368
+ for (const r of results) {
369
+ if (r.passed) {
370
+ console.log(fmt.success(`${r.name}: ${r.detail}`));
371
+ }
372
+ else if (r.optional) {
373
+ console.log(fmt.warning(`${r.name}: ${r.detail}`));
374
+ }
375
+ else {
376
+ console.log(fmt.error(`${r.name}: FAIL — ${r.detail}`));
377
+ }
378
+ }
379
+ console.log();
380
+ if (hardFailed.length === 0) {
381
+ const warnNote = warned.length > 0 ? ` (${warned.length} optional check${warned.length > 1 ? "s" : ""} not configured)` : "";
382
+ console.log(fmt.success(`${passed.length}/${results.length} core checks passed${warnNote}`));
383
+ }
384
+ else {
385
+ console.log(fmt.error(`${passed.length}/${results.length} checks passed — ${hardFailed.length} failed`));
386
+ }
387
+ console.log();
197
388
  }
198
- console.log();
199
389
  if (hardFailed.length > 0) {
200
390
  process.exit(1);
201
391
  }
@@ -125,43 +125,6 @@ function buildTopics() {
125
125
  description: "Setup instructions for unsupported / generic agents",
126
126
  render: () => renderPlatformSetup("generic"),
127
127
  },
128
- pricing: {
129
- description: "What's free, what's paid, and the philosophy behind it",
130
- render: () => [
131
- "# Rafter Pricing",
132
- "",
133
- "**Free forever for individuals and open source. No account required. No telemetry.**",
134
- "",
135
- "## What's Free",
136
- "",
137
- "All local agent security features are free with no limits:",
138
- "",
139
- "- Secret scanning (21+ patterns, Gitleaks integration)",
140
- "- Pre-commit hooks (local and global)",
141
- "- Command interception with risk-tiered approval",
142
- "- Skill/extension auditing",
143
- "- Audit logging",
144
- "- MCP server for tool integration",
145
- "- CI/CD pipeline generation",
146
- "- All supported agent integrations (Claude Code, Codex, Gemini, Cursor, Windsurf, Aider, OpenClaw, Continue.dev)",
147
- "",
148
- "No API key. No sign-up. No telemetry. No data collection. No network access required.",
149
- "Everything runs locally on your machine. MIT licensed.",
150
- "",
151
- "## Remote Code Analysis (API)",
152
- "",
153
- "Remote SAST/SCA scanning via the Rafter API has a free tier.",
154
- "Sign up at rafter.so for an API key. Enterprise plans offer higher",
155
- "limits, dashboards, policy management, and compliance reporting.",
156
- "",
157
- "## Philosophy",
158
- "",
159
- "Security tooling should be free for the people writing code.",
160
- "Generous free tiers drive bottom-up adoption. Enterprise value",
161
- "comes from dashboards, policy, and compliance — not from gating",
162
- "the tools developers use every day.",
163
- ].join("\n"),
164
- },
165
128
  ...Object.fromEntries(RAFTER_SUBDOCS.map(({ slug, desc }) => [
166
129
  slug,
167
130
  {
@@ -323,7 +286,9 @@ Add to Windsurf's MCP config (\`~/.codeium/windsurf/mcp_config.json\`):
323
286
  \`\`\``,
324
287
  aider: `# Rafter Setup — Aider
325
288
 
326
- Aider uses MCP for tool integration.
289
+ Aider has no plugin/hook system and no native MCP support. Its only intercept
290
+ for persistent context is the \`read:\` flag in \`.aider.conf.yml\`, which
291
+ injects read-only files into every session.
327
292
 
328
293
  ## Automated Setup
329
294
 
@@ -331,18 +296,21 @@ Aider uses MCP for tool integration.
331
296
  rafter agent init --with-aider
332
297
  \`\`\`
333
298
 
299
+ This writes \`RAFTER.md\` at the workspace root and adds it to \`read:\` in
300
+ \`.aider.conf.yml\`.
301
+
334
302
  ## Manual Setup
335
303
 
336
- Add to \`~/.aider.conf.yml\`:
337
- \`\`\`yaml
338
- mcp-servers:
339
- - name: rafter
340
- command: rafter mcp serve
341
- \`\`\`
304
+ 1. Create \`RAFTER.md\` at the workspace root with rafter's security context.
305
+ 2. Add to \`.aider.conf.yml\`:
306
+ \`\`\`yaml
307
+ read:
308
+ - RAFTER.md
309
+ \`\`\`
342
310
 
343
311
  ## Supplementing with Brief
344
312
 
345
- Aider doesn't have persistent memory, so run before each session:
313
+ Aider doesn't have persistent memory beyond \`read:\`, so run before each session:
346
314
  \`\`\`bash
347
315
  rafter brief commands # quick command reference
348
316
  \`\`\``,
@@ -132,7 +132,10 @@ async function draftsFromBackendScan(scanId, apiKey) {
132
132
  }
133
133
  function draftsFromLocalScan(filePath) {
134
134
  const raw = fs.readFileSync(filePath, "utf-8");
135
- const results = JSON.parse(raw);
135
+ const parsed = JSON.parse(raw);
136
+ // New shape: { _note, scan_mode, triage_applied, results: [...] }
137
+ // Legacy shape (pre-0.7.8): bare array. Accept both for forward-compat reading.
138
+ const results = Array.isArray(parsed) ? parsed : (parsed?.results ?? []);
136
139
  const drafts = [];
137
140
  for (const result of results) {
138
141
  for (const match of result.matches) {
@@ -254,6 +254,12 @@ export class ConfigManager {
254
254
  config.agent.scan.customPatterns = policy.scan.customPatterns;
255
255
  }
256
256
  }
257
+ // Ignore rules — top-level policy key, applied per finding at scan time
258
+ if (policy.ignore && config.agent) {
259
+ if (!config.agent.scan)
260
+ config.agent.scan = {};
261
+ config.agent.scan.ignore = policy.ignore;
262
+ }
257
263
  // Audit settings
258
264
  if (policy.audit && config.agent) {
259
265
  if (policy.audit.retentionDays != null) {