@rafter-security/cli 0.6.5 → 0.6.6

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.
@@ -29,32 +29,81 @@ function formatApprovalMessage(command, evaluation) {
29
29
  export function createHookPretoolCommand() {
30
30
  return new Command("pretool")
31
31
  .description("PreToolUse hook handler (reads stdin, writes JSON decision to stdout)")
32
- .action(async () => {
32
+ .option("--format <format>", "Output format: claude (default, also Codex/Continue), cursor, gemini, windsurf", "claude")
33
+ .action(async (opts) => {
34
+ const format = (opts.format || "claude");
33
35
  try {
34
36
  const input = await readStdin();
35
- let payload;
37
+ let raw;
36
38
  try {
37
- payload = JSON.parse(input);
39
+ raw = JSON.parse(input);
38
40
  }
39
41
  catch {
40
42
  // Can't parse → fail open
41
- writeDecision({ decision: "allow" });
43
+ writeDecision({ decision: "allow" }, format);
42
44
  return;
43
45
  }
44
46
  // Validate payload is an object with expected shape
45
- if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
46
- writeDecision({ decision: "allow" });
47
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
48
+ writeDecision({ decision: "allow" }, format);
47
49
  return;
48
50
  }
51
+ const payload = normalizeInput(raw, format);
49
52
  const decision = evaluateToolCall(payload);
50
- writeDecision(decision);
53
+ writeDecision(decision, format);
51
54
  }
52
55
  catch {
53
56
  // Any unexpected error → fail open
54
- writeDecision({ decision: "allow" });
57
+ writeDecision({ decision: "allow" }, format);
55
58
  }
56
59
  });
57
60
  }
61
+ /**
62
+ * Normalize platform-specific stdin JSON into a common HookInput shape.
63
+ *
64
+ * Claude/Codex/Continue: { tool_name, tool_input: { command } }
65
+ * Cursor: { hook_event_name, command, cwd }
66
+ * Gemini: { tool_name, tool_input: { command } } (same as Claude)
67
+ * Windsurf: { agent_action_name, tool_info: { command_line, cwd } }
68
+ */
69
+ function normalizeInput(raw, format) {
70
+ if (format === "cursor") {
71
+ // Cursor sends { command, cwd, hook_event_name, ... }
72
+ const command = raw.command || "";
73
+ const eventName = raw.hook_event_name || "";
74
+ // beforeShellExecution → Bash, beforeMCPExecution → tool name from payload
75
+ const toolName = eventName === "beforeShellExecution" ? "Bash"
76
+ : eventName === "beforeReadFile" ? "Read"
77
+ : eventName === "afterFileEdit" ? "Write"
78
+ : raw.tool_name || "unknown";
79
+ return {
80
+ session_id: raw.conversation_id,
81
+ tool_name: toolName,
82
+ tool_input: eventName === "beforeShellExecution" ? { command } : (raw.tool_input || {}),
83
+ };
84
+ }
85
+ if (format === "windsurf") {
86
+ // Windsurf sends { agent_action_name, tool_info: { command_line, cwd } }
87
+ const toolInfo = raw.tool_info || {};
88
+ const actionName = raw.agent_action_name || "";
89
+ const toolName = actionName.includes("run_command") ? "Bash"
90
+ : actionName.includes("write_code") ? "Write"
91
+ : actionName.includes("read_code") ? "Read"
92
+ : actionName.includes("mcp_tool_use") ? (toolInfo.mcp_tool_name || "unknown")
93
+ : "unknown";
94
+ return {
95
+ session_id: raw.trajectory_id,
96
+ tool_name: toolName,
97
+ tool_input: toolName === "Bash" ? { command: toolInfo.command_line || "" } : toolInfo,
98
+ };
99
+ }
100
+ // Claude, Codex, Continue, Gemini — all use { tool_name, tool_input }
101
+ return {
102
+ session_id: raw.session_id,
103
+ tool_name: raw.tool_name || "",
104
+ tool_input: raw.tool_input || {},
105
+ };
106
+ }
58
107
  function evaluateToolCall(payload) {
59
108
  const { tool_name, tool_input } = payload;
60
109
  if (tool_name === "Bash") {
@@ -154,6 +203,52 @@ function readStdin() {
154
203
  process.stdin.resume();
155
204
  });
156
205
  }
157
- function writeDecision(decision) {
158
- process.stdout.write(JSON.stringify(decision) + "\n");
206
+ function writeDecision(decision, format) {
207
+ const isDeny = decision.decision === "deny";
208
+ const reason = decision.reason ?? "";
209
+ switch (format) {
210
+ case "cursor": {
211
+ // Cursor: { permission: "allow"|"deny"|"ask", agentMessage?, userMessage? }
212
+ const output = {
213
+ permission: isDeny ? "deny" : "allow",
214
+ };
215
+ if (isDeny && reason) {
216
+ output.agentMessage = reason;
217
+ output.userMessage = reason;
218
+ }
219
+ process.stdout.write(JSON.stringify(output) + "\n");
220
+ break;
221
+ }
222
+ case "gemini": {
223
+ // Gemini: {} for allow, { decision: "deny", reason: "..." } for deny
224
+ if (isDeny) {
225
+ process.stdout.write(JSON.stringify({ decision: "deny", reason }) + "\n");
226
+ }
227
+ else {
228
+ process.stdout.write("{}\n");
229
+ }
230
+ break;
231
+ }
232
+ case "windsurf": {
233
+ // Windsurf: exit 0 for allow, exit 2 + stderr for deny
234
+ if (isDeny) {
235
+ process.stderr.write(reason + "\n");
236
+ process.exit(2);
237
+ }
238
+ // Allow: exit 0 (no output needed)
239
+ break;
240
+ }
241
+ default: {
242
+ // Claude Code / Codex / Continue.dev: hookSpecificOutput envelope
243
+ const output = {
244
+ hookSpecificOutput: {
245
+ hookEventName: "PreToolUse",
246
+ permissionDecision: isDeny ? "deny" : "allow",
247
+ permissionDecisionReason: reason,
248
+ },
249
+ };
250
+ process.stdout.write(JSON.stringify(output) + "\n");
251
+ break;
252
+ }
253
+ }
159
254
  }
@@ -27,14 +27,14 @@ function textResult(data) {
27
27
  function errorResult(message) {
28
28
  return { content: [{ type: "text", text: JSON.stringify({ error: message }) }], isError: true };
29
29
  }
30
- function createServer() {
30
+ export function createServer() {
31
31
  const server = new Server({ name: "rafter", version: CLI_VERSION }, { capabilities: { tools: {}, resources: {} } });
32
32
  // ── Tools ───────────────────────────────────────────────────────────
33
33
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
34
34
  tools: [
35
35
  {
36
36
  name: "scan_secrets",
37
- description: "Scan files or directories for hardcoded secrets and credentials",
37
+ description: "Scan files or directories for leaked secrets, API keys, tokens, passwords, and credentials. Use before pushing code, when handling config files, or when asked 'is this safe to commit?' or 'check for leaked keys'.",
38
38
  inputSchema: {
39
39
  type: "object",
40
40
  properties: {
@@ -50,7 +50,7 @@ function createServer() {
50
50
  },
51
51
  {
52
52
  name: "evaluate_command",
53
- description: "Evaluate whether a shell command is allowed by Rafter security policy",
53
+ description: "Check if a shell command is safe to run per security policy. Use when asked 'is this command safe?' or before running destructive or privileged operations.",
54
54
  inputSchema: {
55
55
  type: "object",
56
56
  properties: {
@@ -61,7 +61,7 @@ function createServer() {
61
61
  },
62
62
  {
63
63
  name: "read_audit_log",
64
- description: "Read Rafter audit log entries with optional filtering",
64
+ description: "Read security event history blocked commands, detected secrets, policy overrides. Use when asked 'what happened?' or 'show security events'.",
65
65
  inputSchema: {
66
66
  type: "object",
67
67
  properties: {
@@ -76,7 +76,7 @@ function createServer() {
76
76
  },
77
77
  {
78
78
  name: "get_config",
79
- description: "Read Rafter configuration (full config or a specific key)",
79
+ description: "Read Rafter security policy and configuration. Use to understand what protections are active and what risk level is configured.",
80
80
  inputSchema: {
81
81
  type: "object",
82
82
  properties: {
@@ -0,0 +1,278 @@
1
+ import { Command } from "commander";
2
+ import axios from "axios";
3
+ import { API, resolveKey, EXIT_GENERAL_ERROR, EXIT_SCAN_NOT_FOUND } from "../utils/api.js";
4
+ import { validateWebhookUrl } from "../core/audit-logger.js";
5
+ import { ConfigManager } from "../core/config-manager.js";
6
+ import { fmt } from "../utils/formatter.js";
7
+ function resolveWebhook(cliOpt) {
8
+ if (cliOpt)
9
+ return cliOpt;
10
+ if (process.env.RAFTER_NOTIFY_WEBHOOK)
11
+ return process.env.RAFTER_NOTIFY_WEBHOOK;
12
+ // Try config file
13
+ try {
14
+ const configManager = new ConfigManager();
15
+ const config = configManager.load();
16
+ if (config.agent?.notifications?.webhook) {
17
+ return config.agent.notifications.webhook;
18
+ }
19
+ }
20
+ catch {
21
+ // ignore
22
+ }
23
+ console.error("No webhook URL provided. Use --webhook or set RAFTER_NOTIFY_WEBHOOK");
24
+ process.exit(EXIT_GENERAL_ERROR);
25
+ }
26
+ function detectPlatform(url) {
27
+ if (url.includes("hooks.slack.com") || url.includes("slack.com/api"))
28
+ return "slack";
29
+ if (url.includes("discord.com/api/webhooks") || url.includes("discordapp.com/api/webhooks"))
30
+ return "discord";
31
+ return "generic";
32
+ }
33
+ function formatSlackPayload(scan) {
34
+ const status = scan.status ?? "unknown";
35
+ const repo = scan.repository_name ?? "unknown";
36
+ const scanId = scan.scan_id ?? "";
37
+ const findings = scan.findings ?? [];
38
+ const summary = scan.summary ?? {};
39
+ const critical = summary.critical ?? 0;
40
+ const high = summary.high ?? 0;
41
+ const medium = summary.medium ?? 0;
42
+ const low = summary.low ?? 0;
43
+ const total = critical + high + medium + low;
44
+ let statusIcon;
45
+ let statusText;
46
+ if (status === "completed" && total === 0) {
47
+ statusIcon = ":white_check_mark:";
48
+ statusText = "Clean — no issues found";
49
+ }
50
+ else if (status === "completed" && (critical > 0 || high > 0)) {
51
+ statusIcon = ":rotating_light:";
52
+ statusText = `${total} issue${total !== 1 ? "s" : ""} found`;
53
+ }
54
+ else if (status === "completed") {
55
+ statusIcon = ":warning:";
56
+ statusText = `${total} issue${total !== 1 ? "s" : ""} found`;
57
+ }
58
+ else if (status === "failed") {
59
+ statusIcon = ":x:";
60
+ statusText = "Scan failed";
61
+ }
62
+ else {
63
+ statusIcon = ":hourglass_flowing_sand:";
64
+ statusText = `Scan ${status}`;
65
+ }
66
+ const sectionFields = [
67
+ { type: "mrkdwn", text: `*Repository:*\n${repo}` },
68
+ { type: "mrkdwn", text: `*Status:*\n${statusText}` },
69
+ ];
70
+ if (scanId)
71
+ sectionFields.push({ type: "mrkdwn", text: `*Scan ID:*\n\`${scanId}\`` });
72
+ if (scan.branch_name)
73
+ sectionFields.push({ type: "mrkdwn", text: `*Branch:*\n\`${scan.branch_name}\`` });
74
+ const blocks = [
75
+ { type: "header", text: { type: "plain_text", text: `${statusIcon} Rafter Security Scan` } },
76
+ { type: "section", fields: sectionFields },
77
+ ];
78
+ if (total > 0) {
79
+ const parts = [];
80
+ if (critical)
81
+ parts.push(`:red_circle: Critical: *${critical}*`);
82
+ if (high)
83
+ parts.push(`:orange_circle: High: *${high}*`);
84
+ if (medium)
85
+ parts.push(`:large_yellow_circle: Medium: *${medium}*`);
86
+ if (low)
87
+ parts.push(`:white_circle: Low: *${low}*`);
88
+ blocks.push({ type: "section", text: { type: "mrkdwn", text: parts.join("\n") } });
89
+ }
90
+ if (findings.length > 0) {
91
+ const lines = findings.slice(0, 5).map((f) => {
92
+ const sev = (f.severity ?? "unknown").toUpperCase();
93
+ const title = f.title ?? f.rule_id ?? "Unknown";
94
+ const loc = f.location ?? f.file ?? "";
95
+ let line = `• \`[${sev}]\` ${title}`;
96
+ if (loc)
97
+ line += ` — ${loc}`;
98
+ return line;
99
+ });
100
+ if (findings.length > 5)
101
+ lines.push(`_... and ${findings.length - 5} more_`);
102
+ blocks.push({ type: "divider" });
103
+ blocks.push({ type: "section", text: { type: "mrkdwn", text: `*Top Findings:*\n${lines.join("\n")}` } });
104
+ }
105
+ blocks.push({
106
+ type: "context",
107
+ elements: [{ type: "mrkdwn", text: "Posted by *rafter-bot* | <https://rafter.so|rafter.so>" }],
108
+ });
109
+ return { text: `[rafter] ${repo}: ${statusText}`, blocks };
110
+ }
111
+ function formatDiscordPayload(scan) {
112
+ const status = scan.status ?? "unknown";
113
+ const repo = scan.repository_name ?? "unknown";
114
+ const scanId = scan.scan_id ?? "";
115
+ const findings = scan.findings ?? [];
116
+ const summary = scan.summary ?? {};
117
+ const critical = summary.critical ?? 0;
118
+ const high = summary.high ?? 0;
119
+ const medium = summary.medium ?? 0;
120
+ const low = summary.low ?? 0;
121
+ const total = critical + high + medium + low;
122
+ let color;
123
+ let statusText;
124
+ if (status === "completed" && total === 0) {
125
+ color = 0x2ecc71;
126
+ statusText = "Clean — no issues found";
127
+ }
128
+ else if (status === "completed" && (critical > 0 || high > 0)) {
129
+ color = 0xe74c3c;
130
+ statusText = `${total} issue${total !== 1 ? "s" : ""} found`;
131
+ }
132
+ else if (status === "completed") {
133
+ color = 0xf39c12;
134
+ statusText = `${total} issue${total !== 1 ? "s" : ""} found`;
135
+ }
136
+ else if (status === "failed") {
137
+ color = 0x95a5a6;
138
+ statusText = "Scan failed";
139
+ }
140
+ else {
141
+ color = 0x3498db;
142
+ statusText = `Scan ${status}`;
143
+ }
144
+ const fields = [
145
+ { name: "Repository", value: repo, inline: true },
146
+ { name: "Status", value: statusText, inline: true },
147
+ ];
148
+ if (scanId)
149
+ fields.push({ name: "Scan ID", value: `\`${scanId}\``, inline: true });
150
+ if (scan.branch_name)
151
+ fields.push({ name: "Branch", value: `\`${scan.branch_name}\``, inline: true });
152
+ if (total > 0) {
153
+ const parts = [];
154
+ if (critical)
155
+ parts.push(`\u{1f534} Critical: **${critical}**`);
156
+ if (high)
157
+ parts.push(`\u{1f7e0} High: **${high}**`);
158
+ if (medium)
159
+ parts.push(`\u{1f7e1} Medium: **${medium}**`);
160
+ if (low)
161
+ parts.push(`\u26aa Low: **${low}**`);
162
+ fields.push({ name: "Severity Breakdown", value: parts.join("\n"), inline: false });
163
+ }
164
+ if (findings.length > 0) {
165
+ const lines = findings.slice(0, 5).map((f) => {
166
+ const sev = (f.severity ?? "unknown").toUpperCase();
167
+ const title = f.title ?? f.rule_id ?? "Unknown";
168
+ const loc = f.location ?? f.file ?? "";
169
+ let line = `• \`[${sev}]\` ${title}`;
170
+ if (loc)
171
+ line += ` — ${loc}`;
172
+ return line;
173
+ });
174
+ if (findings.length > 5)
175
+ lines.push(`*... and ${findings.length - 5} more*`);
176
+ fields.push({ name: "Top Findings", value: lines.join("\n"), inline: false });
177
+ }
178
+ return {
179
+ content: `[rafter] ${repo}: ${statusText}`,
180
+ embeds: [{ title: "\u{1f6e1}\ufe0f Rafter Security Scan", color, fields, footer: { text: "rafter-bot | rafter.so" } }],
181
+ };
182
+ }
183
+ function formatGenericPayload(scan) {
184
+ const status = scan.status ?? "unknown";
185
+ const repo = scan.repository_name ?? "unknown";
186
+ const summary = scan.summary ?? {};
187
+ const total = (summary.critical ?? 0) + (summary.high ?? 0) + (summary.medium ?? 0) + (summary.low ?? 0);
188
+ let statusText;
189
+ if (status === "completed" && total === 0)
190
+ statusText = "Clean — no issues found";
191
+ else if (status === "completed")
192
+ statusText = `${total} issue${total !== 1 ? "s" : ""} found`;
193
+ else
194
+ statusText = `Scan ${status}`;
195
+ const msg = `[rafter] ${repo}: ${statusText}`;
196
+ return { text: msg, content: msg, ...scan };
197
+ }
198
+ export function createNotifyCommand() {
199
+ return new Command("notify")
200
+ .description("Post scan results to Slack or Discord channels via webhooks")
201
+ .argument("[scan_id]", "Scan ID to fetch and post results for")
202
+ .option("-w, --webhook <url>", "Webhook URL (Slack or Discord)")
203
+ .option("-k, --api-key <key>", "API key for fetching scan results")
204
+ .option("-p, --platform <platform>", "Force platform: slack, discord, or generic")
205
+ .option("--quiet", "Suppress status messages")
206
+ .option("--dry-run", "Print payload without posting")
207
+ .action(async (scanId, opts) => {
208
+ const webhookUrl = resolveWebhook(opts?.webhook);
209
+ let scanData;
210
+ if (scanId) {
211
+ const key = resolveKey(opts?.apiKey);
212
+ try {
213
+ const { data } = await axios.get(`${API}/static/scan`, {
214
+ params: { scan_id: scanId, format: "json" },
215
+ headers: { "x-api-key": key },
216
+ });
217
+ scanData = data;
218
+ }
219
+ catch (e) {
220
+ if (e.response?.status === 404) {
221
+ console.error(`Scan '${scanId}' not found`);
222
+ process.exit(EXIT_SCAN_NOT_FOUND);
223
+ }
224
+ console.error(`Error: ${e.response?.data ?? e.message}`);
225
+ process.exit(EXIT_GENERAL_ERROR);
226
+ }
227
+ }
228
+ else if (!process.stdin.isTTY) {
229
+ // Read from stdin
230
+ const chunks = [];
231
+ for await (const chunk of process.stdin) {
232
+ chunks.push(chunk);
233
+ }
234
+ const raw = Buffer.concat(chunks).toString("utf-8");
235
+ try {
236
+ scanData = JSON.parse(raw);
237
+ }
238
+ catch {
239
+ console.error("Error: stdin is not valid JSON");
240
+ process.exit(EXIT_GENERAL_ERROR);
241
+ }
242
+ }
243
+ else {
244
+ console.error("Error: provide a scan ID or pipe JSON scan data via stdin");
245
+ process.exit(EXIT_GENERAL_ERROR);
246
+ }
247
+ const detected = opts?.platform || detectPlatform(webhookUrl);
248
+ let payload;
249
+ if (detected === "slack")
250
+ payload = formatSlackPayload(scanData);
251
+ else if (detected === "discord")
252
+ payload = formatDiscordPayload(scanData);
253
+ else
254
+ payload = formatGenericPayload(scanData);
255
+ if (opts?.dryRun) {
256
+ console.log(JSON.stringify(payload, null, 2));
257
+ return;
258
+ }
259
+ if (!opts?.quiet) {
260
+ process.stderr.write(fmt.info(`Posting to ${detected} webhook...`) + "\n");
261
+ }
262
+ try {
263
+ await validateWebhookUrl(webhookUrl);
264
+ await fetch(webhookUrl, {
265
+ method: "POST",
266
+ headers: { "Content-Type": "application/json" },
267
+ body: JSON.stringify(payload),
268
+ });
269
+ }
270
+ catch (e) {
271
+ console.error(`Error posting to webhook: ${e.message}`);
272
+ process.exit(EXIT_GENERAL_ERROR);
273
+ }
274
+ if (!opts?.quiet) {
275
+ process.stderr.write(fmt.success(`Scan results posted to ${detected} channel`) + "\n");
276
+ }
277
+ });
278
+ }