@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.
- package/README.md +27 -681
- package/dist/commands/agent/components.js +282 -138
- package/dist/commands/agent/init.js +399 -150
- package/dist/commands/agent/scan.js +52 -23
- package/dist/commands/agent/verify.js +211 -21
- package/dist/commands/brief.js +13 -45
- package/dist/commands/issues/from-scan.js +4 -1
- package/dist/core/config-manager.js +6 -0
- package/dist/core/custom-patterns.js +86 -4
- package/dist/core/policy-loader.js +60 -1
- package/dist/scanners/regex-scanner.js +4 -5
- package/dist/utils/skill-manager.js +96 -16
- package/package.json +1 -1
- package/resources/agents/rafter.md +81 -0
- package/resources/continue-rules/rafter-code-review.md +15 -0
- package/resources/continue-rules/rafter-secure-design.md +15 -0
- package/resources/continue-rules/rafter-skill-review.md +15 -0
- package/resources/continue-rules/rafter.md +16 -0
- package/resources/cursor-rules/rafter-code-review.mdc +14 -0
- package/resources/cursor-rules/rafter-secure-design.mdc +14 -0
- package/resources/cursor-rules/rafter-skill-review.mdc +14 -0
- package/resources/cursor-rules/rafter.mdc +15 -0
- package/resources/rafter-security-skill.md +17 -9
- package/resources/windsurf-rules/rafter-code-review.md +14 -0
- package/resources/windsurf-rules/rafter-secure-design.md +14 -0
- package/resources/windsurf-rules/rafter-skill-review.md +14 -0
- package/resources/windsurf-rules/rafter.md +15 -0
|
@@ -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(
|
|
203
|
+
outputSarif(keptResults);
|
|
191
204
|
return;
|
|
192
205
|
}
|
|
193
206
|
if (format === "json" || opts.json) {
|
|
194
|
-
const
|
|
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(
|
|
230
|
+
process.exit(keptResults.length > 0 ? 1 : 0);
|
|
206
231
|
return;
|
|
207
232
|
}
|
|
208
|
-
|
|
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 ${
|
|
246
|
+
console.log(`\n${fmt.warning(`Found secrets in ${keptResults.length} file(s):`)}\n`);
|
|
218
247
|
let totalMatches = 0;
|
|
219
|
-
for (const result of
|
|
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 ${
|
|
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
|
|
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
|
-
.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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 (
|
|
192
|
-
const
|
|
193
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/brief.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
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
|
|
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) {
|