@rafter-security/cli 0.5.5 → 0.6.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.
Files changed (40) hide show
  1. package/README.md +15 -3
  2. package/dist/commands/agent/audit-skill.js +1 -1
  3. package/dist/commands/agent/audit.js +106 -6
  4. package/dist/commands/agent/baseline.js +10 -0
  5. package/dist/commands/agent/exec.js +1 -1
  6. package/dist/commands/agent/init.js +366 -26
  7. package/dist/commands/agent/scan.js +160 -16
  8. package/dist/commands/agent/status.js +65 -4
  9. package/dist/commands/agent/verify.js +18 -4
  10. package/dist/commands/backend/run.js +76 -62
  11. package/dist/commands/ci/init.js +10 -3
  12. package/dist/commands/completion.js +21 -9
  13. package/dist/commands/hook/posttool.js +21 -7
  14. package/dist/commands/hook/pretool.js +50 -13
  15. package/dist/commands/issues/dedup.js +39 -0
  16. package/dist/commands/issues/from-scan.js +143 -0
  17. package/dist/commands/issues/from-text.js +185 -0
  18. package/dist/commands/issues/github-client.js +85 -0
  19. package/dist/commands/issues/index.js +25 -0
  20. package/dist/commands/issues/issue-builder.js +101 -0
  21. package/dist/commands/mcp/server.js +4 -1
  22. package/dist/commands/policy/export.js +7 -2
  23. package/dist/commands/scan/index.js +45 -0
  24. package/dist/core/audit-logger.js +106 -7
  25. package/dist/core/config-defaults.js +24 -0
  26. package/dist/core/config-manager.js +116 -3
  27. package/dist/core/custom-patterns.js +20 -17
  28. package/dist/core/pattern-engine.js +26 -1
  29. package/dist/core/policy-loader.js +25 -2
  30. package/dist/index.js +11 -2
  31. package/dist/scanners/gitleaks.js +8 -7
  32. package/dist/scanners/regex-scanner.js +16 -1
  33. package/dist/scanners/secret-patterns.js +6 -6
  34. package/dist/utils/api.js +18 -0
  35. package/dist/utils/binary-manager.js +74 -7
  36. package/dist/utils/skill-manager.js +5 -3
  37. package/package.json +5 -3
  38. package/resources/pre-commit-hook.sh +2 -2
  39. package/resources/pre-push-hook.sh +2 -2
  40. package/resources/rafter-security-skill.md +7 -11
@@ -62,7 +62,7 @@ _rafter_completions() {
62
62
  ;;
63
63
  init)
64
64
  if [[ "\${COMP_WORDS[1]}" == "agent" ]]; then
65
- COMPREPLY=( $(compgen -W "--risk-level --skip-openclaw --skip-claude-code --claude-code --skip-gitleaks --help" -- "\${cur}") )
65
+ COMPREPLY=( $(compgen -W "--risk-level --with-openclaw --with-claude-code --with-codex --with-gemini --with-aider --with-cursor --with-windsurf --with-continue --with-gitleaks --all --help" -- "\${cur}") )
66
66
  elif [[ "\${COMP_WORDS[1]}" == "ci" ]]; then
67
67
  COMPREPLY=( $(compgen -W "--platform --output --with-backend --help" -- "\${cur}") )
68
68
  fi
@@ -147,10 +147,16 @@ _rafter() {
147
147
  init)
148
148
  _arguments \\
149
149
  '--risk-level[Risk level]:level:(minimal moderate aggressive)' \\
150
- '--skip-openclaw[Skip OpenClaw installation]' \\
151
- '--skip-claude-code[Skip Claude Code installation]' \\
152
- '--claude-code[Force Claude Code installation]' \\
153
- '--skip-gitleaks[Skip Gitleaks download]'
150
+ '--with-openclaw[Install OpenClaw integration]' \\
151
+ '--with-claude-code[Install Claude Code integration]' \\
152
+ '--with-codex[Install Codex CLI integration]' \\
153
+ '--with-gemini[Install Gemini CLI integration]' \\
154
+ '--with-aider[Install Aider integration]' \\
155
+ '--with-cursor[Install Cursor integration]' \\
156
+ '--with-windsurf[Install Windsurf integration]' \\
157
+ '--with-continue[Install Continue.dev integration]' \\
158
+ '--with-gitleaks[Download Gitleaks binary]' \\
159
+ '--all[Install all detected integrations]'
154
160
  ;;
155
161
  audit)
156
162
  _arguments \\
@@ -297,10 +303,16 @@ complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcom
297
303
 
298
304
  # agent init options
299
305
  complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l risk-level -d 'Risk level' -ra 'minimal moderate aggressive'
300
- complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l skip-openclaw -d 'Skip OpenClaw'
301
- complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l skip-claude-code -d 'Skip Claude Code'
302
- complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l claude-code -d 'Force Claude Code'
303
- complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l skip-gitleaks -d 'Skip Gitleaks'
306
+ complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l with-openclaw -d 'Install OpenClaw'
307
+ complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l with-claude-code -d 'Install Claude Code'
308
+ complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l with-codex -d 'Install Codex CLI'
309
+ complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l with-gemini -d 'Install Gemini CLI'
310
+ complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l with-aider -d 'Install Aider'
311
+ complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l with-cursor -d 'Install Cursor'
312
+ complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l with-windsurf -d 'Install Windsurf'
313
+ complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l with-continue -d 'Install Continue.dev'
314
+ complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l with-gitleaks -d 'Install Gitleaks'
315
+ complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l all -d 'Install all detected'
304
316
 
305
317
  # agent audit options
306
318
  complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from audit' -l last -d 'Show last N entries' -r
@@ -5,17 +5,28 @@ export function createHookPosttoolCommand() {
5
5
  return new Command("posttool")
6
6
  .description("PostToolUse hook handler (reads stdin, redacts secrets in output, writes JSON to stdout)")
7
7
  .action(async () => {
8
- const input = await readStdin();
9
- let payload;
10
8
  try {
11
- payload = JSON.parse(input);
9
+ const input = await readStdin();
10
+ let payload;
11
+ try {
12
+ payload = JSON.parse(input);
13
+ }
14
+ catch {
15
+ writeOutput({ action: "continue" });
16
+ return;
17
+ }
18
+ // Validate payload is an object with expected shape
19
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
20
+ writeOutput({ action: "continue" });
21
+ return;
22
+ }
23
+ const output = evaluateToolResponse(payload);
24
+ writeOutput(output);
12
25
  }
13
26
  catch {
27
+ // Any unexpected error → fail open
14
28
  writeOutput({ action: "continue" });
15
- return;
16
29
  }
17
- const output = evaluateToolResponse(payload);
18
- writeOutput(output);
19
30
  });
20
31
  }
21
32
  function evaluateToolResponse(payload) {
@@ -59,12 +70,15 @@ function countMatches(scanner, tool_response) {
59
70
  }
60
71
  return count;
61
72
  }
73
+ const STDIN_TIMEOUT_MS = 5000;
62
74
  function readStdin() {
63
75
  return new Promise((resolve) => {
64
76
  let data = "";
77
+ const timeout = setTimeout(() => { resolve(data); }, STDIN_TIMEOUT_MS);
65
78
  process.stdin.setEncoding("utf-8");
66
79
  process.stdin.on("data", (chunk) => { data += chunk; });
67
- process.stdin.on("end", () => { resolve(data); });
80
+ process.stdin.on("end", () => { clearTimeout(timeout); resolve(data); });
81
+ process.stdin.on("error", () => { clearTimeout(timeout); resolve(data); });
68
82
  process.stdin.resume();
69
83
  });
70
84
  }
@@ -3,31 +3,65 @@ import { CommandInterceptor } from "../../core/command-interceptor.js";
3
3
  import { RegexScanner } from "../../scanners/regex-scanner.js";
4
4
  import { AuditLogger } from "../../core/audit-logger.js";
5
5
  import { execSync } from "child_process";
6
+ const RISK_LABELS = {
7
+ critical: "CRITICAL", high: "HIGH", medium: "MEDIUM", low: "LOW",
8
+ };
9
+ const RISK_DESCRIPTIONS = {
10
+ critical: "irreversible system damage",
11
+ high: "significant system changes",
12
+ medium: "moderate risk operation",
13
+ low: "minimal risk",
14
+ };
15
+ function formatBlockedMessage(command, evaluation) {
16
+ const cmdDisplay = command.length > 60 ? command.slice(0, 60) + "..." : command;
17
+ const rule = evaluation.matchedPattern ?? "policy violation";
18
+ const label = RISK_LABELS[evaluation.riskLevel] ?? evaluation.riskLevel.toUpperCase();
19
+ const desc = RISK_DESCRIPTIONS[evaluation.riskLevel] ?? "";
20
+ return `\u2717 Rafter blocked: ${cmdDisplay}\n Rule: ${rule}\n Risk: ${label}\u2014${desc}`;
21
+ }
22
+ function formatApprovalMessage(command, evaluation) {
23
+ const cmdDisplay = command.length > 60 ? command.slice(0, 60) + "..." : command;
24
+ const rule = evaluation.matchedPattern ?? "policy match";
25
+ const label = RISK_LABELS[evaluation.riskLevel] ?? evaluation.riskLevel.toUpperCase();
26
+ const desc = RISK_DESCRIPTIONS[evaluation.riskLevel] ?? "";
27
+ return `\u26a0 Rafter: approval required\n Command: ${cmdDisplay}\n Rule: ${rule}\n Risk: ${label}\u2014${desc}\n\nTo approve: rafter agent exec --approve "${command}"\nTo configure: rafter agent config set agent.riskLevel minimal`;
28
+ }
6
29
  export function createHookPretoolCommand() {
7
30
  return new Command("pretool")
8
31
  .description("PreToolUse hook handler (reads stdin, writes JSON decision to stdout)")
9
32
  .action(async () => {
10
- const input = await readStdin();
11
- let payload;
12
33
  try {
13
- payload = JSON.parse(input);
34
+ const input = await readStdin();
35
+ let payload;
36
+ try {
37
+ payload = JSON.parse(input);
38
+ }
39
+ catch {
40
+ // Can't parse → fail open
41
+ writeDecision({ decision: "allow" });
42
+ return;
43
+ }
44
+ // Validate payload is an object with expected shape
45
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
46
+ writeDecision({ decision: "allow" });
47
+ return;
48
+ }
49
+ const decision = evaluateToolCall(payload);
50
+ writeDecision(decision);
14
51
  }
15
52
  catch {
16
- // Can't parse → fail open
53
+ // Any unexpected error → fail open
17
54
  writeDecision({ decision: "allow" });
18
- return;
19
55
  }
20
- const decision = evaluateToolCall(payload);
21
- writeDecision(decision);
22
56
  });
23
57
  }
24
58
  function evaluateToolCall(payload) {
25
59
  const { tool_name, tool_input } = payload;
26
60
  if (tool_name === "Bash") {
27
- return evaluateBash(tool_input.command || "");
61
+ return evaluateBash(tool_input?.command || "");
28
62
  }
29
63
  if (tool_name === "Write" || tool_name === "Edit") {
30
- return evaluateWrite(tool_input);
64
+ return evaluateWrite(tool_input || {});
31
65
  }
32
66
  return { decision: "allow" };
33
67
  }
@@ -40,7 +74,7 @@ function evaluateBash(command) {
40
74
  audit.logCommandIntercepted(command, false, "blocked", evaluation.reason);
41
75
  return {
42
76
  decision: "deny",
43
- reason: `Blocked by Rafter policy: ${evaluation.reason}`,
77
+ reason: formatBlockedMessage(command, evaluation),
44
78
  };
45
79
  }
46
80
  // Requires approval — deny (agent can't provide interactive approval)
@@ -48,7 +82,7 @@ function evaluateBash(command) {
48
82
  audit.logCommandIntercepted(command, false, "blocked", evaluation.reason);
49
83
  return {
50
84
  decision: "deny",
51
- reason: `Rafter policy requires approval: ${evaluation.reason}`,
85
+ reason: formatApprovalMessage(command, evaluation),
52
86
  };
53
87
  }
54
88
  // Git commit/push — scan staged files for secrets
@@ -59,7 +93,7 @@ function evaluateBash(command) {
59
93
  audit.logSecretDetected("staged files", `${scanResult.count} secret(s)`, "blocked");
60
94
  return {
61
95
  decision: "deny",
62
- reason: `${scanResult.count} secret(s) detected in ${scanResult.files} staged file(s). Run 'rafter agent scan --staged' for details.`,
96
+ reason: `${scanResult.count} secret(s) detected in ${scanResult.files} staged file(s). Run 'rafter scan local --staged' for details.`,
63
97
  };
64
98
  }
65
99
  }
@@ -108,12 +142,15 @@ function scanStagedFiles() {
108
142
  return { secretsFound: false, count: 0, files: 0 };
109
143
  }
110
144
  }
145
+ const STDIN_TIMEOUT_MS = 5000;
111
146
  function readStdin() {
112
147
  return new Promise((resolve) => {
113
148
  let data = "";
149
+ const timeout = setTimeout(() => { resolve(data); }, STDIN_TIMEOUT_MS);
114
150
  process.stdin.setEncoding("utf-8");
115
151
  process.stdin.on("data", (chunk) => { data += chunk; });
116
- process.stdin.on("end", () => { resolve(data); });
152
+ process.stdin.on("end", () => { clearTimeout(timeout); resolve(data); });
153
+ process.stdin.on("error", () => { clearTimeout(timeout); resolve(data); });
117
154
  process.stdin.resume();
118
155
  });
119
156
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Deduplication logic for GitHub issues created from scan findings.
3
+ *
4
+ * Uses a fingerprint hash (file + pattern/ruleId) embedded in the issue body
5
+ * to avoid creating duplicate issues for the same finding.
6
+ */
7
+ import crypto from "crypto";
8
+ const FINGERPRINT_PREFIX = "<!-- rafter-fingerprint:";
9
+ const FINGERPRINT_SUFFIX = " -->";
10
+ export function fingerprint(file, ruleId) {
11
+ const hash = crypto
12
+ .createHash("sha256")
13
+ .update(`${file}:${ruleId}`)
14
+ .digest("hex")
15
+ .slice(0, 12);
16
+ return hash;
17
+ }
18
+ export function embedFingerprint(body, fp) {
19
+ return `${body}\n\n${FINGERPRINT_PREFIX}${fp}${FINGERPRINT_SUFFIX}`;
20
+ }
21
+ export function extractFingerprint(body) {
22
+ const idx = body.indexOf(FINGERPRINT_PREFIX);
23
+ if (idx === -1)
24
+ return null;
25
+ const start = idx + FINGERPRINT_PREFIX.length;
26
+ const end = body.indexOf(FINGERPRINT_SUFFIX, start);
27
+ if (end === -1)
28
+ return null;
29
+ return body.slice(start, end);
30
+ }
31
+ export function findDuplicates(existingIssues, newFingerprints) {
32
+ const existingFps = new Set();
33
+ for (const issue of existingIssues) {
34
+ const fp = extractFingerprint(issue.body);
35
+ if (fp)
36
+ existingFps.add(fp);
37
+ }
38
+ return new Set(newFingerprints.filter((fp) => existingFps.has(fp)));
39
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * rafter issues create from-scan — create GitHub issues from scan results.
3
+ *
4
+ * Supports both:
5
+ * - Backend scans: --scan-id <id> (fetches from Rafter API)
6
+ * - Local scans: --from-local <path> (reads JSON file from `rafter scan local --format json`)
7
+ */
8
+ import { Command } from "commander";
9
+ import fs from "fs";
10
+ import axios from "axios";
11
+ import { API, resolveKey, EXIT_GENERAL_ERROR } from "../../utils/api.js";
12
+ import { detectRepo } from "../../utils/git.js";
13
+ import { fmt } from "../../utils/formatter.js";
14
+ import { createIssue, listOpenIssues } from "./github-client.js";
15
+ import { findDuplicates } from "./dedup.js";
16
+ import { buildFromBackendVulnerability, buildFromLocalMatch, } from "./issue-builder.js";
17
+ export function createFromScanCommand() {
18
+ return new Command("from-scan")
19
+ .description("Create GitHub issues from scan results")
20
+ .option("--scan-id <id>", "Backend scan ID to create issues from")
21
+ .option("--from-local <path>", "Path to local scan JSON (from rafter scan local --format json)")
22
+ .option("-r, --repo <repo>", "Target GitHub repo (org/repo)")
23
+ .option("-k, --api-key <key>", "Rafter API key (for --scan-id)")
24
+ .option("--no-dedup", "Skip deduplication check")
25
+ .option("--dry-run", "Show issues that would be created without creating them")
26
+ .option("--quiet", "Suppress status messages")
27
+ .action(async (opts) => {
28
+ try {
29
+ await runFromScan(opts);
30
+ }
31
+ catch (e) {
32
+ console.error(fmt.error(e.message || String(e)));
33
+ process.exit(EXIT_GENERAL_ERROR);
34
+ }
35
+ });
36
+ }
37
+ async function runFromScan(opts) {
38
+ if (!opts.scanId && !opts.fromLocal) {
39
+ console.error(fmt.error("Provide --scan-id or --from-local"));
40
+ process.exit(EXIT_GENERAL_ERROR);
41
+ }
42
+ // Resolve target repo
43
+ let repo;
44
+ if (opts.repo) {
45
+ repo = opts.repo;
46
+ }
47
+ else {
48
+ const detected = detectRepo({});
49
+ repo = detected.repo;
50
+ }
51
+ if (!opts.quiet) {
52
+ console.error(fmt.info(`Target repo: ${repo}`));
53
+ }
54
+ // Build issue drafts from scan results
55
+ let drafts;
56
+ if (opts.scanId) {
57
+ drafts = await draftsFromBackendScan(opts.scanId, opts.apiKey);
58
+ }
59
+ else {
60
+ drafts = draftsFromLocalScan(opts.fromLocal);
61
+ }
62
+ if (drafts.length === 0) {
63
+ if (!opts.quiet) {
64
+ console.error(fmt.success("No findings to create issues for"));
65
+ }
66
+ return;
67
+ }
68
+ if (!opts.quiet) {
69
+ console.error(fmt.info(`Found ${drafts.length} findings`));
70
+ }
71
+ // Dedup against existing open issues
72
+ if (opts.dedup !== false) {
73
+ const existing = listOpenIssues(repo);
74
+ const dupes = findDuplicates(existing, drafts.map((d) => d.fingerprint));
75
+ const before = drafts.length;
76
+ drafts = drafts.filter((d) => !dupes.has(d.fingerprint));
77
+ if (before !== drafts.length && !opts.quiet) {
78
+ console.error(fmt.info(`Skipped ${before - drafts.length} duplicate(s)`));
79
+ }
80
+ }
81
+ if (drafts.length === 0) {
82
+ if (!opts.quiet) {
83
+ console.error(fmt.success("All findings already have open issues"));
84
+ }
85
+ return;
86
+ }
87
+ // Dry run — print what would be created
88
+ if (opts.dryRun) {
89
+ console.error(fmt.info(`Would create ${drafts.length} issue(s):`));
90
+ for (const draft of drafts) {
91
+ console.error(` - ${draft.title}`);
92
+ }
93
+ // Output drafts as JSON to stdout for piping
94
+ process.stdout.write(JSON.stringify(drafts, null, 2));
95
+ return;
96
+ }
97
+ // Create issues
98
+ const created = [];
99
+ for (const draft of drafts) {
100
+ try {
101
+ const issue = createIssue({
102
+ repo,
103
+ title: draft.title,
104
+ body: draft.body,
105
+ labels: draft.labels,
106
+ });
107
+ created.push(issue.html_url);
108
+ if (!opts.quiet) {
109
+ console.error(fmt.success(`Created: ${issue.html_url}`));
110
+ }
111
+ }
112
+ catch (e) {
113
+ console.error(fmt.error(`Failed to create issue: ${e.message}`));
114
+ }
115
+ }
116
+ // Output created issue URLs to stdout
117
+ if (created.length > 0) {
118
+ process.stdout.write(created.join("\n") + "\n");
119
+ }
120
+ if (!opts.quiet) {
121
+ console.error(fmt.success(`Created ${created.length}/${drafts.length} issue(s)`));
122
+ }
123
+ }
124
+ async function draftsFromBackendScan(scanId, apiKey) {
125
+ const key = resolveKey(apiKey);
126
+ const { data } = await axios.get(`${API}/static/scan`, {
127
+ params: { scan_id: scanId, format: "json" },
128
+ headers: { "x-api-key": key },
129
+ });
130
+ const vulns = data.vulnerabilities || [];
131
+ return vulns.map(buildFromBackendVulnerability);
132
+ }
133
+ function draftsFromLocalScan(filePath) {
134
+ const raw = fs.readFileSync(filePath, "utf-8");
135
+ const results = JSON.parse(raw);
136
+ const drafts = [];
137
+ for (const result of results) {
138
+ for (const match of result.matches) {
139
+ drafts.push(buildFromLocalMatch(result.file, match));
140
+ }
141
+ }
142
+ return drafts;
143
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * rafter issues create from-text — create a GitHub issue from natural language.
3
+ *
4
+ * Input sources: stdin (pipe), --file, --text inline.
5
+ * Parses natural text to extract title, description, labels, severity, affected files.
6
+ * Works as a skill: /rafter-issue in Claude Code / OpenClaw.
7
+ */
8
+ import { Command } from "commander";
9
+ import fs from "fs";
10
+ import { detectRepo } from "../../utils/git.js";
11
+ import { fmt } from "../../utils/formatter.js";
12
+ import { EXIT_GENERAL_ERROR } from "../../utils/api.js";
13
+ import { createIssue } from "./github-client.js";
14
+ export function createFromTextCommand() {
15
+ return new Command("from-text")
16
+ .description("Create a GitHub issue from natural language text (stdin, file, or inline)")
17
+ .option("-r, --repo <repo>", "Target GitHub repo (org/repo)")
18
+ .option("-t, --text <text>", "Inline text to convert to an issue")
19
+ .option("-f, --file <path>", "Read text from file")
20
+ .option("--title <title>", "Override extracted title")
21
+ .option("--labels <labels>", "Comma-separated labels to add")
22
+ .option("--dry-run", "Show parsed issue without creating it")
23
+ .option("--quiet", "Suppress status messages")
24
+ .action(async (opts) => {
25
+ try {
26
+ await runFromText(opts);
27
+ }
28
+ catch (e) {
29
+ console.error(fmt.error(e.message || String(e)));
30
+ process.exit(EXIT_GENERAL_ERROR);
31
+ }
32
+ });
33
+ }
34
+ async function runFromText(opts) {
35
+ // Read input text
36
+ const input = await readInput(opts);
37
+ if (!input.trim()) {
38
+ console.error(fmt.error("No input text provided. Use --text, --file, or pipe via stdin"));
39
+ process.exit(EXIT_GENERAL_ERROR);
40
+ }
41
+ // Resolve target repo
42
+ let repo;
43
+ if (opts.repo) {
44
+ repo = opts.repo;
45
+ }
46
+ else {
47
+ const detected = detectRepo({});
48
+ repo = detected.repo;
49
+ }
50
+ // Parse the natural text into a structured issue
51
+ const parsed = parseNaturalText(input);
52
+ // Apply overrides
53
+ if (opts.title)
54
+ parsed.title = opts.title;
55
+ if (opts.labels) {
56
+ const extra = opts.labels.split(",").map((l) => l.trim()).filter(Boolean);
57
+ parsed.labels.push(...extra);
58
+ }
59
+ if (!opts.quiet) {
60
+ console.error(fmt.info(`Target repo: ${repo}`));
61
+ console.error(fmt.info(`Title: ${parsed.title}`));
62
+ if (parsed.labels.length) {
63
+ console.error(fmt.info(`Labels: ${parsed.labels.join(", ")}`));
64
+ }
65
+ }
66
+ if (opts.dryRun) {
67
+ process.stdout.write(JSON.stringify(parsed, null, 2));
68
+ return;
69
+ }
70
+ const issue = createIssue({
71
+ repo,
72
+ title: parsed.title,
73
+ body: parsed.body,
74
+ labels: parsed.labels,
75
+ });
76
+ if (!opts.quiet) {
77
+ console.error(fmt.success(`Created: ${issue.html_url}`));
78
+ }
79
+ process.stdout.write(issue.html_url + "\n");
80
+ }
81
+ async function readInput(opts) {
82
+ if (opts.text)
83
+ return opts.text;
84
+ if (opts.file)
85
+ return fs.readFileSync(opts.file, "utf-8");
86
+ // Read from stdin if available (piped input)
87
+ if (!process.stdin.isTTY) {
88
+ return new Promise((resolve) => {
89
+ let data = "";
90
+ process.stdin.setEncoding("utf-8");
91
+ process.stdin.on("data", (chunk) => (data += chunk));
92
+ process.stdin.on("end", () => resolve(data));
93
+ });
94
+ }
95
+ return "";
96
+ }
97
+ /**
98
+ * Parse natural language text into a structured GitHub issue.
99
+ *
100
+ * Heuristics-based extraction:
101
+ * - First line or sentence → title
102
+ * - Severity keywords → labels
103
+ * - File paths → mentioned in body
104
+ * - Security keywords → security label
105
+ */
106
+ function parseNaturalText(text) {
107
+ const lines = text.trim().split("\n");
108
+ const labels = [];
109
+ // Extract title: first non-empty line, cleaned up
110
+ let title = "";
111
+ let bodyStart = 0;
112
+ for (let i = 0; i < lines.length; i++) {
113
+ const line = lines[i].trim();
114
+ if (line) {
115
+ // Strip markdown headers
116
+ title = line.replace(/^#+\s*/, "").trim();
117
+ bodyStart = i + 1;
118
+ break;
119
+ }
120
+ }
121
+ if (!title) {
122
+ title = "Security issue reported via Rafter CLI";
123
+ }
124
+ // Truncate long titles
125
+ if (title.length > 120) {
126
+ title = title.slice(0, 117) + "...";
127
+ }
128
+ // Build body from remaining text
129
+ const bodyLines = lines.slice(bodyStart);
130
+ let body = bodyLines.join("\n").trim();
131
+ if (!body) {
132
+ body = text.trim();
133
+ }
134
+ // Extract severity from text
135
+ const textLower = text.toLowerCase();
136
+ if (textLower.includes("critical") || textLower.includes("p0")) {
137
+ labels.push("severity:critical");
138
+ }
139
+ else if (textLower.includes("high severity") ||
140
+ textLower.includes("high risk") ||
141
+ textLower.includes("p1")) {
142
+ labels.push("severity:high");
143
+ }
144
+ else if (textLower.includes("medium") || textLower.includes("p2")) {
145
+ labels.push("severity:medium");
146
+ }
147
+ else if (textLower.includes("low") || textLower.includes("p3")) {
148
+ labels.push("severity:low");
149
+ }
150
+ // Detect security-related content
151
+ const securityKeywords = [
152
+ "security",
153
+ "vulnerability",
154
+ "cve",
155
+ "cwe",
156
+ "owasp",
157
+ "secret",
158
+ "credential",
159
+ "token",
160
+ "password",
161
+ "injection",
162
+ "xss",
163
+ "csrf",
164
+ "ssrf",
165
+ "exploit",
166
+ ];
167
+ if (securityKeywords.some((kw) => textLower.includes(kw))) {
168
+ labels.push("security");
169
+ }
170
+ // Extract file paths mentioned in text
171
+ const fileRefs = text.match(/(?:^|\s)([a-zA-Z0-9_./-]+\.[a-zA-Z]{1,10})(?::(\d+))?/gm);
172
+ if (fileRefs && fileRefs.length > 0) {
173
+ const files = fileRefs
174
+ .map((f) => f.trim())
175
+ .filter((f) => f.includes("/") || f.includes("."));
176
+ if (files.length > 0) {
177
+ body += `\n\n### Referenced Files\n\n`;
178
+ for (const f of files.slice(0, 10)) {
179
+ body += `- \`${f}\`\n`;
180
+ }
181
+ }
182
+ }
183
+ body += `\n\n---\n*Created by [Rafter CLI](https://rafter.so) — security for AI builders*\n`;
184
+ return { title, body, labels: [...new Set(labels)] };
185
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * GitHub API client — wraps `gh` CLI for issue operations.
3
+ *
4
+ * Prefers `gh` CLI (handles auth, rate limits, pagination) over raw REST.
5
+ * Falls back to GITHUB_TOKEN + REST if gh is unavailable.
6
+ */
7
+ import { execFileSync } from "child_process";
8
+ function ghAvailable() {
9
+ try {
10
+ execFileSync("gh", ["--version"], { stdio: "ignore" });
11
+ return true;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ export function createIssue(params) {
18
+ const { repo, title, body, labels } = params;
19
+ if (!ghAvailable()) {
20
+ throw new Error("GitHub CLI (gh) is required. Install: https://cli.github.com");
21
+ }
22
+ const args = [
23
+ "issue",
24
+ "create",
25
+ "--repo",
26
+ repo,
27
+ "--title",
28
+ title,
29
+ "--body",
30
+ body,
31
+ ];
32
+ if (labels && labels.length > 0) {
33
+ for (const label of labels) {
34
+ args.push("--label", label);
35
+ }
36
+ }
37
+ const result = execFileSync("gh", args, {
38
+ encoding: "utf-8",
39
+ stdio: ["ignore", "pipe", "pipe"],
40
+ }).trim();
41
+ // gh issue create returns the issue URL
42
+ const urlMatch = result.match(/https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/(\d+)/);
43
+ const number = urlMatch ? parseInt(urlMatch[1], 10) : 0;
44
+ return {
45
+ number,
46
+ title,
47
+ body,
48
+ labels: labels || [],
49
+ html_url: result,
50
+ state: "open",
51
+ };
52
+ }
53
+ export function listOpenIssues(repo) {
54
+ if (!ghAvailable()) {
55
+ return [];
56
+ }
57
+ try {
58
+ const result = execFileSync("gh", [
59
+ "issue",
60
+ "list",
61
+ "--repo",
62
+ repo,
63
+ "--state",
64
+ "open",
65
+ "--json",
66
+ "number,title,body,labels,url",
67
+ "--limit",
68
+ "200",
69
+ ], { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
70
+ if (!result)
71
+ return [];
72
+ const issues = JSON.parse(result);
73
+ return issues.map((i) => ({
74
+ number: i.number,
75
+ title: i.title,
76
+ body: i.body || "",
77
+ labels: (i.labels || []).map((l) => l.name),
78
+ html_url: i.url,
79
+ state: "open",
80
+ }));
81
+ }
82
+ catch {
83
+ return [];
84
+ }
85
+ }