@rafter-security/cli 0.4.2 → 0.5.1

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.
@@ -0,0 +1,205 @@
1
+ import { Command } from "commander";
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
+ import { RegexScanner } from "../../scanners/regex-scanner.js";
6
+ import { GitleaksScanner } from "../../scanners/gitleaks.js";
7
+ import { CommandInterceptor } from "../../core/command-interceptor.js";
8
+ import { AuditLogger } from "../../core/audit-logger.js";
9
+ import { ConfigManager } from "../../core/config-manager.js";
10
+ function formatScanResults(results) {
11
+ return results.map(r => ({
12
+ file: r.file,
13
+ matches: r.matches.map(m => ({
14
+ pattern: m.pattern.name,
15
+ severity: m.pattern.severity,
16
+ line: m.line,
17
+ redacted: m.redacted || m.match.slice(0, 4) + "****",
18
+ })),
19
+ }));
20
+ }
21
+ function textResult(data) {
22
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
23
+ }
24
+ function errorResult(message) {
25
+ return { content: [{ type: "text", text: JSON.stringify({ error: message }) }], isError: true };
26
+ }
27
+ function createServer() {
28
+ const server = new Server({ name: "rafter", version: "0.5.0" }, { capabilities: { tools: {}, resources: {} } });
29
+ // ── Tools ───────────────────────────────────────────────────────────
30
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
31
+ tools: [
32
+ {
33
+ name: "scan_secrets",
34
+ description: "Scan files or directories for hardcoded secrets and credentials",
35
+ inputSchema: {
36
+ type: "object",
37
+ properties: {
38
+ path: { type: "string", description: "File or directory path to scan" },
39
+ engine: {
40
+ type: "string",
41
+ enum: ["auto", "gitleaks", "patterns"],
42
+ description: "Scan engine: auto (default), gitleaks, or patterns",
43
+ },
44
+ },
45
+ required: ["path"],
46
+ },
47
+ },
48
+ {
49
+ name: "evaluate_command",
50
+ description: "Evaluate whether a shell command is allowed by Rafter security policy",
51
+ inputSchema: {
52
+ type: "object",
53
+ properties: {
54
+ command: { type: "string", description: "Shell command to evaluate" },
55
+ },
56
+ required: ["command"],
57
+ },
58
+ },
59
+ {
60
+ name: "read_audit_log",
61
+ description: "Read Rafter audit log entries with optional filtering",
62
+ inputSchema: {
63
+ type: "object",
64
+ properties: {
65
+ limit: { type: "number", description: "Maximum entries to return (default: 20)" },
66
+ event_type: {
67
+ type: "string",
68
+ description: "Filter by event type (e.g. command_intercepted, secret_detected)",
69
+ },
70
+ since: { type: "string", description: "ISO 8601 timestamp — only return entries after this time" },
71
+ },
72
+ },
73
+ },
74
+ {
75
+ name: "get_config",
76
+ description: "Read Rafter configuration (full config or a specific key)",
77
+ inputSchema: {
78
+ type: "object",
79
+ properties: {
80
+ key: {
81
+ type: "string",
82
+ description: "Dot-path config key (e.g. agent.commandPolicy). Omit for full config.",
83
+ },
84
+ },
85
+ },
86
+ },
87
+ ],
88
+ }));
89
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
90
+ const { name, arguments: args } = request.params;
91
+ switch (name) {
92
+ case "scan_secrets": {
93
+ const scanPath = args?.path;
94
+ const engine = args?.engine || "auto";
95
+ if (engine === "gitleaks" || engine === "auto") {
96
+ const gitleaks = new GitleaksScanner();
97
+ if (await gitleaks.isAvailable()) {
98
+ try {
99
+ const results = await gitleaks.scanDirectory(scanPath);
100
+ return textResult(formatScanResults(results));
101
+ }
102
+ catch {
103
+ if (engine === "gitleaks")
104
+ return errorResult("Gitleaks scan failed");
105
+ }
106
+ }
107
+ else if (engine === "gitleaks") {
108
+ return errorResult("Gitleaks not installed");
109
+ }
110
+ }
111
+ const scanner = new RegexScanner();
112
+ let results;
113
+ try {
114
+ results = scanner.scanDirectory(scanPath);
115
+ }
116
+ catch {
117
+ results = [scanner.scanFile(scanPath)];
118
+ }
119
+ return textResult(formatScanResults(results));
120
+ }
121
+ case "evaluate_command": {
122
+ const command = args?.command;
123
+ const interceptor = new CommandInterceptor();
124
+ const result = interceptor.evaluate(command);
125
+ const out = {
126
+ allowed: result.allowed,
127
+ risk_level: result.riskLevel,
128
+ requires_approval: result.requiresApproval,
129
+ };
130
+ if (result.reason)
131
+ out.reason = result.reason;
132
+ return textResult(out);
133
+ }
134
+ case "read_audit_log": {
135
+ const logger = new AuditLogger();
136
+ const entries = logger.read({
137
+ limit: args?.limit ?? 20,
138
+ eventType: args?.event_type,
139
+ since: args?.since ? new Date(args.since) : undefined,
140
+ });
141
+ return textResult(entries);
142
+ }
143
+ case "get_config": {
144
+ const manager = new ConfigManager();
145
+ const key = args?.key;
146
+ const value = key ? manager.get(key) : manager.load();
147
+ return textResult(value);
148
+ }
149
+ default:
150
+ return errorResult(`Unknown tool: ${name}`);
151
+ }
152
+ });
153
+ // ── Resources ───────────────────────────────────────────────────────
154
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({
155
+ resources: [
156
+ {
157
+ uri: "rafter://config",
158
+ name: "Rafter Configuration",
159
+ description: "Current Rafter configuration",
160
+ mimeType: "application/json",
161
+ },
162
+ {
163
+ uri: "rafter://policy",
164
+ name: "Rafter Policy",
165
+ description: "Active security policy (merged .rafter.yml + config)",
166
+ mimeType: "application/json",
167
+ },
168
+ ],
169
+ }));
170
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
171
+ const { uri } = request.params;
172
+ const manager = new ConfigManager();
173
+ switch (uri) {
174
+ case "rafter://config":
175
+ return {
176
+ contents: [{
177
+ uri: "rafter://config",
178
+ mimeType: "application/json",
179
+ text: JSON.stringify(manager.load(), null, 2),
180
+ }],
181
+ };
182
+ case "rafter://policy":
183
+ return {
184
+ contents: [{
185
+ uri: "rafter://policy",
186
+ mimeType: "application/json",
187
+ text: JSON.stringify(manager.loadWithPolicy(), null, 2),
188
+ }],
189
+ };
190
+ default:
191
+ throw new Error(`Unknown resource: ${uri}`);
192
+ }
193
+ });
194
+ return server;
195
+ }
196
+ export function createMcpServeCommand() {
197
+ return new Command("serve")
198
+ .description("Start MCP server over stdio transport")
199
+ .option("--transport <type>", "Transport type (currently only stdio)", "stdio")
200
+ .action(async () => {
201
+ const server = createServer();
202
+ const transport = new StdioServerTransport();
203
+ await server.connect(transport);
204
+ });
205
+ }
@@ -0,0 +1,81 @@
1
+ import { Command } from "commander";
2
+ import { ConfigManager } from "../../core/config-manager.js";
3
+ import { fmt } from "../../utils/formatter.js";
4
+ export function createPolicyExportCommand() {
5
+ return new Command("export")
6
+ .description("Export Rafter policy for agent platforms")
7
+ .requiredOption("--format <format>", "Target format: claude or codex")
8
+ .option("--output <path>", "Write to file instead of stdout")
9
+ .action((opts) => {
10
+ const format = opts.format;
11
+ if (format !== "claude" && format !== "codex") {
12
+ console.error(fmt.error(`Unknown format: ${format}`));
13
+ console.error("Valid options: claude, codex");
14
+ process.exit(1);
15
+ }
16
+ const content = format === "claude"
17
+ ? generateClaudeConfig()
18
+ : generateCodexConfig();
19
+ if (opts.output) {
20
+ const fs = require("fs");
21
+ const path = require("path");
22
+ const dir = path.dirname(opts.output);
23
+ if (!fs.existsSync(dir)) {
24
+ fs.mkdirSync(dir, { recursive: true });
25
+ }
26
+ fs.writeFileSync(opts.output, content, "utf-8");
27
+ console.log(fmt.success(`Exported ${format} policy to ${opts.output}`));
28
+ }
29
+ else {
30
+ process.stdout.write(content);
31
+ }
32
+ });
33
+ }
34
+ function generateClaudeConfig() {
35
+ const config = {
36
+ hooks: {
37
+ PreToolUse: [
38
+ {
39
+ matcher: "Bash",
40
+ hooks: [{ type: "command", command: "rafter hook pretool" }],
41
+ },
42
+ {
43
+ matcher: "Write|Edit",
44
+ hooks: [{ type: "command", command: "rafter hook pretool" }],
45
+ },
46
+ ],
47
+ },
48
+ };
49
+ return JSON.stringify(config, null, 2) + "\n";
50
+ }
51
+ function generateCodexConfig() {
52
+ const manager = new ConfigManager();
53
+ const cfg = manager.loadWithPolicy();
54
+ const policy = cfg.agent?.commandPolicy;
55
+ const blocked = policy?.blockedPatterns || [];
56
+ const approval = policy?.requireApproval || [];
57
+ let toml = `# Rafter security policy for OpenAI Codex
58
+ # Generated by: rafter policy export --format codex
59
+ # Docs: https://docs.rafter.so/cli/pretool-hooks
60
+
61
+ `;
62
+ if (blocked.length > 0) {
63
+ toml += `[rules.blocked]\n`;
64
+ toml += `# Commands that are always blocked\n`;
65
+ toml += `patterns = [\n`;
66
+ for (const p of blocked) {
67
+ toml += ` ${JSON.stringify(p)},\n`;
68
+ }
69
+ toml += `]\n\n`;
70
+ }
71
+ if (approval.length > 0) {
72
+ toml += `[rules.prompt]\n`;
73
+ toml += `# Commands that require user approval\n`;
74
+ toml += `patterns = [\n`;
75
+ for (const p of approval) {
76
+ toml += ` ${JSON.stringify(p)},\n`;
77
+ }
78
+ toml += `]\n`;
79
+ }
80
+ return toml;
81
+ }
@@ -0,0 +1,8 @@
1
+ import { Command } from "commander";
2
+ import { createPolicyExportCommand } from "./export.js";
3
+ export function createPolicyCommand() {
4
+ const policy = new Command("policy")
5
+ .description("Security policy management");
6
+ policy.addCommand(createPolicyExportCommand());
7
+ return policy;
8
+ }
@@ -2,6 +2,7 @@ import fs from "fs";
2
2
  import path from "path";
3
3
  import { getAuditLogPath } from "./config-defaults.js";
4
4
  import { ConfigManager } from "./config-manager.js";
5
+ import { assessCommandRisk } from "./risk-rules.js";
5
6
  export class AuditLogger {
6
7
  constructor(logPath) {
7
8
  this.logPath = logPath || getAuditLogPath();
@@ -166,38 +167,6 @@ export class AuditLogger {
166
167
  * Assess risk level of a command
167
168
  */
168
169
  assessCommandRisk(command) {
169
- const critical = [
170
- /rm\s+-rf\s+\//,
171
- /:\(\)\{\s*:\|:&\s*\};:/, // fork bomb
172
- /dd\s+if=.*of=\/dev\/sd/,
173
- />\s*\/dev\/sd/
174
- ];
175
- const high = [
176
- /rm\s+-rf/,
177
- /sudo\s+rm/,
178
- /chmod\s+777/,
179
- /curl.*\|.*sh/,
180
- /wget.*\|.*sh/,
181
- /git\s+push\s+--force/
182
- ];
183
- const medium = [
184
- /sudo/,
185
- /chmod/,
186
- /chown/,
187
- /systemctl/
188
- ];
189
- for (const pattern of critical) {
190
- if (pattern.test(command))
191
- return "critical";
192
- }
193
- for (const pattern of high) {
194
- if (pattern.test(command))
195
- return "high";
196
- }
197
- for (const pattern of medium) {
198
- if (pattern.test(command))
199
- return "medium";
200
- }
201
- return "low";
170
+ return assessCommandRisk(command);
202
171
  }
203
172
  }
@@ -1,5 +1,6 @@
1
1
  import { ConfigManager } from "./config-manager.js";
2
2
  import { AuditLogger } from "./audit-logger.js";
3
+ import { assessCommandRisk } from "./risk-rules.js";
3
4
  export class CommandInterceptor {
4
5
  constructor() {
5
6
  this.config = new ConfigManager();
@@ -9,7 +10,7 @@ export class CommandInterceptor {
9
10
  * Evaluate if a command should be allowed
10
11
  */
11
12
  evaluate(command) {
12
- const cfg = this.config.load();
13
+ const cfg = this.config.loadWithPolicy();
13
14
  const policy = cfg.agent?.commandPolicy;
14
15
  if (!policy) {
15
16
  // No policy configured, allow by default
@@ -103,63 +104,18 @@ export class CommandInterceptor {
103
104
  */
104
105
  matchesPattern(command, pattern) {
105
106
  try {
106
- const regex = new RegExp(pattern);
107
+ const regex = new RegExp(pattern, "i");
107
108
  return regex.test(command);
108
109
  }
109
110
  catch {
110
- // If pattern is not valid regex, try exact match
111
- return command.includes(pattern);
111
+ // If pattern is not valid regex, try case-insensitive substring match
112
+ return command.toLowerCase().includes(pattern.toLowerCase());
112
113
  }
113
114
  }
114
115
  /**
115
116
  * Assess risk level of command
116
117
  */
117
118
  assessRisk(command) {
118
- // Critical patterns
119
- const critical = [
120
- /rm\s+-rf\s+\//,
121
- /:\(\)\{\s*:\|:&\s*\};:/, // fork bomb
122
- /dd\s+if=.*of=\/dev\/sd/,
123
- />\s*\/dev\/sd/,
124
- /mkfs/,
125
- /fdisk/,
126
- /parted/
127
- ];
128
- // High risk patterns
129
- const high = [
130
- /rm\s+-rf/,
131
- /sudo\s+rm/,
132
- /chmod\s+777/,
133
- /curl.*\|.*sh/,
134
- /wget.*\|.*sh/,
135
- /git\s+push\s+--force/,
136
- /docker\s+system\s+prune/,
137
- /npm\s+publish/,
138
- /pypi.*upload/
139
- ];
140
- // Medium risk patterns
141
- const medium = [
142
- /sudo/,
143
- /chmod/,
144
- /chown/,
145
- /systemctl/,
146
- /service/,
147
- /kill\s+-9/,
148
- /pkill/,
149
- /killall/
150
- ];
151
- for (const pattern of critical) {
152
- if (pattern.test(command))
153
- return "critical";
154
- }
155
- for (const pattern of high) {
156
- if (pattern.test(command))
157
- return "high";
158
- }
159
- for (const pattern of medium) {
160
- if (pattern.test(command))
161
- return "medium";
162
- }
163
- return "low";
119
+ return assessCommandRisk(command);
164
120
  }
165
121
  }
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_BLOCKED_PATTERNS, DEFAULT_REQUIRE_APPROVAL } from "./risk-rules.js";
1
2
  import os from "os";
2
3
  import path from "path";
3
4
  export const CONFIG_VERSION = "1.0.0";
@@ -27,20 +28,8 @@ export function getDefaultConfig() {
27
28
  },
28
29
  commandPolicy: {
29
30
  mode: "approve-dangerous",
30
- blockedPatterns: [
31
- "rm -rf /",
32
- ":(){ :|:& };:", // fork bomb
33
- "dd if=/dev/zero of=/dev/sda",
34
- "> /dev/sda"
35
- ],
36
- requireApproval: [
37
- "rm -rf",
38
- "sudo rm",
39
- "curl.*|.*sh",
40
- "wget.*|.*sh",
41
- "chmod 777",
42
- "git push --force"
43
- ]
31
+ blockedPatterns: [...DEFAULT_BLOCKED_PATTERNS],
32
+ requireApproval: [...DEFAULT_REQUIRE_APPROVAL],
44
33
  },
45
34
  outputFiltering: {
46
35
  redactSecrets: true,
@@ -61,7 +50,7 @@ export function getConfigPath() {
61
50
  return path.join(getRafterDir(), "config.json");
62
51
  }
63
52
  export function getAuditLogPath() {
64
- return path.join(getRafterDir(), "audit.log");
53
+ return path.join(getRafterDir(), "audit.jsonl");
65
54
  }
66
55
  export function getBinDir() {
67
56
  return path.join(getRafterDir(), "bin");
@@ -1,6 +1,7 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import { getDefaultConfig, getConfigPath, getRafterDir, CONFIG_VERSION } from "./config-defaults.js";
4
+ import { loadPolicy } from "./policy-loader.js";
4
5
  export class ConfigManager {
5
6
  constructor(configPath) {
6
7
  this.configPath = configPath || getConfigPath();
@@ -101,6 +102,57 @@ export class ConfigManager {
101
102
  this.save(config);
102
103
  }
103
104
  }
105
+ /**
106
+ * Load config merged with .rafter.yml policy (policy wins)
107
+ */
108
+ loadWithPolicy() {
109
+ const config = this.load();
110
+ const policy = loadPolicy();
111
+ if (!policy)
112
+ return config;
113
+ // Ensure agent block exists
114
+ if (!config.agent) {
115
+ const defaults = getDefaultConfig();
116
+ config.agent = defaults.agent;
117
+ }
118
+ // Risk level
119
+ if (policy.riskLevel && config.agent) {
120
+ config.agent.riskLevel = policy.riskLevel;
121
+ }
122
+ // Command policy — arrays replace, not append
123
+ if (policy.commandPolicy && config.agent) {
124
+ if (policy.commandPolicy.mode) {
125
+ config.agent.commandPolicy.mode = policy.commandPolicy.mode;
126
+ }
127
+ if (policy.commandPolicy.blockedPatterns) {
128
+ config.agent.commandPolicy.blockedPatterns = policy.commandPolicy.blockedPatterns;
129
+ }
130
+ if (policy.commandPolicy.requireApproval) {
131
+ config.agent.commandPolicy.requireApproval = policy.commandPolicy.requireApproval;
132
+ }
133
+ }
134
+ // Scan settings
135
+ if (policy.scan && config.agent) {
136
+ if (!config.agent.scan)
137
+ config.agent.scan = {};
138
+ if (policy.scan.excludePaths) {
139
+ config.agent.scan.excludePaths = policy.scan.excludePaths;
140
+ }
141
+ if (policy.scan.customPatterns) {
142
+ config.agent.scan.customPatterns = policy.scan.customPatterns;
143
+ }
144
+ }
145
+ // Audit settings
146
+ if (policy.audit && config.agent) {
147
+ if (policy.audit.retentionDays != null) {
148
+ config.agent.audit.retentionDays = policy.audit.retentionDays;
149
+ }
150
+ if (policy.audit.logLevel) {
151
+ config.agent.audit.logLevel = policy.audit.logLevel;
152
+ }
153
+ }
154
+ return config;
155
+ }
104
156
  /**
105
157
  * Check if config exists
106
158
  */