shell-aliasforge 0.0.0-stage → 0.1.0

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/dist/cli.js ADDED
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, realpathSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename } from "node:path";
5
+ import { parseArgs } from "node:util";
6
+ import { fileURLToPath } from "node:url";
7
+ import { applyToRc, rcPathFor } from "./apply/apply.js";
8
+ import { selectSuggestions } from "./apply/select.js";
9
+ import { UsageError } from "./errors.js";
10
+ import { createEnvironment } from "./analyze/environment.js";
11
+ import { renderJson, renderReport } from "./analyze/report.js";
12
+ import { computeStats, renderStats } from "./analyze/stats.js";
13
+ import { suggest } from "./analyze/suggest.js";
14
+ import { loadHistory } from "./history/index.js";
15
+ import { scanHistory } from "./risks/engine.js";
16
+ import { renderRisks, renderRisksJson } from "./risks/report.js";
17
+ const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
18
+ const SHELLS = ["bash", "zsh", "fish"];
19
+ const HELP = `aliasforge ${VERSION}
20
+
21
+ Usage: aliasforge <command> [options]
22
+
23
+ Commands:
24
+ suggest Suggest aliases from shell history
25
+ risks Flag dangerous commands and leaked secrets
26
+ apply Write chosen aliases to a managed block in your rc file
27
+ stats Show history size, top commands and time span
28
+
29
+ suggest options:
30
+ --shell bash|zsh|fish Shell dialect (default: from --file or $SHELL)
31
+ --file <path> Read this history file instead of the default
32
+ --min-count N Minimum occurrences (default 5)
33
+ --min-length N Minimum command length in characters (default 8)
34
+ --top N Number of suggestions (default 10)
35
+ --json Machine-readable output
36
+
37
+ risks options:
38
+ --shell, --file, --json as above
39
+
40
+ stats options:
41
+ --shell, --file, --json as above, plus --top N (default 10)
42
+
43
+ apply options:
44
+ --shell bash|zsh|fish Required. Which rc file to update
45
+ --select 1,3,gs Ranks or names from suggest (default: all suggestions)
46
+ --dry-run Print a diff and write nothing
47
+ --file, --min-count, --min-length, --top as for suggest
48
+
49
+ apply writes only between "# >>> aliasforge >>>" and "# <<< aliasforge <<<",
50
+ keeps definitions already in the block, and makes a timestamped backup first.
51
+ `;
52
+ function defaultIo() {
53
+ return {
54
+ out: (text) => console.log(text),
55
+ err: (text) => console.error(text),
56
+ home: homedir(),
57
+ env: process.env,
58
+ };
59
+ }
60
+ function positiveInt(name, value, fallback) {
61
+ if (value === undefined)
62
+ return fallback;
63
+ const parsed = Number(value);
64
+ if (!Number.isInteger(parsed) || parsed < 1)
65
+ throw new UsageError(`${name} must be a positive integer`);
66
+ return parsed;
67
+ }
68
+ function parseShell(value) {
69
+ if (value === undefined)
70
+ return undefined;
71
+ if (!SHELLS.includes(value))
72
+ throw new UsageError("--shell must be bash, zsh or fish");
73
+ return value;
74
+ }
75
+ function shellFromEnv(env) {
76
+ const name = basename(env.SHELL ?? "");
77
+ return SHELLS.find((shell) => shell === name) ?? "bash";
78
+ }
79
+ function loadFromOptions(requested, file, io) {
80
+ const shell = parseShell(requested);
81
+ const shellHint = shell ?? (file === undefined ? shellFromEnv(io.env) : undefined);
82
+ return loadHistory({ file, shell: shellHint, home: io.home });
83
+ }
84
+ function runSuggest(args, io) {
85
+ const { values } = parseArgs({
86
+ args,
87
+ options: {
88
+ shell: { type: "string" },
89
+ file: { type: "string" },
90
+ "min-count": { type: "string" },
91
+ "min-length": { type: "string" },
92
+ top: { type: "string" },
93
+ json: { type: "boolean" },
94
+ },
95
+ strict: true,
96
+ });
97
+ const options = {
98
+ minCount: positiveInt("--min-count", values["min-count"], 5),
99
+ minLength: positiveInt("--min-length", values["min-length"], 8),
100
+ top: positiveInt("--top", values.top, 10),
101
+ };
102
+ const history = loadFromOptions(values.shell, values.file, io);
103
+ const isTaken = createEnvironment({ shell: history.shell, home: io.home, path: io.env.PATH ?? "" });
104
+ const suggestions = suggest(history.entries, isTaken, { ...options, shell: history.shell });
105
+ io.out(values.json
106
+ ? renderJson({ shell: history.shell, historyEntries: history.entries.length, suggestions })
107
+ : renderReport(history.shell, suggestions));
108
+ return 0;
109
+ }
110
+ function runRisks(args, io) {
111
+ const { values } = parseArgs({
112
+ args,
113
+ options: { shell: { type: "string" }, file: { type: "string" }, json: { type: "boolean" } },
114
+ strict: true,
115
+ });
116
+ const history = loadFromOptions(values.shell, values.file, io);
117
+ const findings = scanHistory(history.entries);
118
+ io.out(values.json
119
+ ? renderRisksJson({ shell: history.shell, historyEntries: history.entries.length, findings })
120
+ : renderRisks(findings, history.entries.length));
121
+ return 0;
122
+ }
123
+ function describeApply(result, dryRun) {
124
+ const names = (status) => result.changes.filter((c) => c.status === status).map((c) => c.name).join(", ");
125
+ const lines = [`Target: ${result.rcPath}`];
126
+ if (names("added"))
127
+ lines.push(`Added: ${names("added")}`);
128
+ if (names("unchanged"))
129
+ lines.push(`Already present: ${names("unchanged")}`);
130
+ if (result.skipped.length > 0)
131
+ lines.push(`Skipped (every proposed name clashes): ${result.skipped.join("; ")}`);
132
+ if (result.diff === "") {
133
+ lines.push("Nothing to change.");
134
+ }
135
+ else if (dryRun) {
136
+ lines.push("", result.diff, "", "Dry run: nothing was written.");
137
+ }
138
+ else {
139
+ lines.push(result.backupPath ? `Backup: ${result.backupPath}` : "No backup needed: the file did not exist.");
140
+ lines.push("Open a new shell or source the file to use the new definitions.");
141
+ }
142
+ return lines.join("\n");
143
+ }
144
+ function runApply(args, io) {
145
+ const { values } = parseArgs({
146
+ args,
147
+ options: {
148
+ shell: { type: "string" },
149
+ file: { type: "string" },
150
+ select: { type: "string" },
151
+ "dry-run": { type: "boolean" },
152
+ "min-count": { type: "string" },
153
+ "min-length": { type: "string" },
154
+ top: { type: "string" },
155
+ },
156
+ strict: true,
157
+ });
158
+ if (values.shell === undefined)
159
+ throw new UsageError("apply needs --shell bash|zsh|fish (it decides which rc file is edited)");
160
+ const options = {
161
+ minCount: positiveInt("--min-count", values["min-count"], 5),
162
+ minLength: positiveInt("--min-length", values["min-length"], 8),
163
+ top: positiveInt("--top", values.top, 10),
164
+ };
165
+ const history = loadFromOptions(values.shell, values.file, io);
166
+ const isTaken = createEnvironment({ shell: history.shell, home: io.home, path: io.env.PATH ?? "" });
167
+ const suggestions = suggest(history.entries, isTaken, { ...options, shell: history.shell });
168
+ const selected = selectSuggestions(suggestions, values.select);
169
+ const dryRun = values["dry-run"] === true;
170
+ const result = applyToRc({
171
+ shell: history.shell,
172
+ rcPath: rcPathFor(history.shell, io.home, io.env),
173
+ selected,
174
+ explicitNames: values.select !== undefined,
175
+ dryRun,
176
+ now: new Date(),
177
+ });
178
+ io.out(selected.length === 0 ? "No alias suggestions to apply." : describeApply(result, dryRun));
179
+ return 0;
180
+ }
181
+ function runStats(args, io) {
182
+ const { values } = parseArgs({
183
+ args,
184
+ options: { shell: { type: "string" }, file: { type: "string" }, top: { type: "string" }, json: { type: "boolean" } },
185
+ strict: true,
186
+ });
187
+ const top = positiveInt("--top", values.top, 10);
188
+ const history = loadFromOptions(values.shell, values.file, io);
189
+ const stats = computeStats(history.entries, top);
190
+ io.out(values.json ? JSON.stringify({ shell: history.shell, ...stats }, null, 2) : renderStats(history.shell, stats));
191
+ return 0;
192
+ }
193
+ function isUsageError(error) {
194
+ const code = error.code ?? "";
195
+ return error instanceof UsageError || code.startsWith("ERR_PARSE_ARGS");
196
+ }
197
+ function dispatch(command, rest, io) {
198
+ if (command === "suggest")
199
+ return runSuggest(rest, io);
200
+ if (command === "risks")
201
+ return runRisks(rest, io);
202
+ if (command === "apply")
203
+ return runApply(rest, io);
204
+ if (command === "stats")
205
+ return runStats(rest, io);
206
+ io.err(`Unknown command: ${command}\n\n${HELP}`);
207
+ return 2;
208
+ }
209
+ export function run(args, io = defaultIo()) {
210
+ const [command, ...rest] = args;
211
+ if (command === "--version" || command === "-v") {
212
+ io.out(VERSION);
213
+ return 0;
214
+ }
215
+ if (command === undefined || command === "--help" || command === "-h") {
216
+ io.out(HELP);
217
+ return 0;
218
+ }
219
+ try {
220
+ return dispatch(command, rest, io);
221
+ }
222
+ catch (error) {
223
+ io.err(error instanceof Error ? error.message : String(error));
224
+ return isUsageError(error) ? 2 : 1;
225
+ }
226
+ }
227
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
228
+ process.exitCode = run(process.argv.slice(2));
229
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,3 @@
1
+ /** Bad command-line input. The CLI reports it with exit code 2. */
2
+ export class UsageError extends Error {
3
+ }
@@ -0,0 +1,22 @@
1
+ import { splitLines, toText } from "./text.js";
2
+ const TIMESTAMP_LINE = /^#(\d{9,11})$/;
3
+ /**
4
+ * Parse ~/.bash_history. One command per line; with HISTTIMEFORMAT set,
5
+ * a `#<epoch>` line precedes each command.
6
+ */
7
+ export function parseBash(input) {
8
+ const entries = [];
9
+ let pending;
10
+ for (const line of splitLines(toText(input))) {
11
+ const stamp = TIMESTAMP_LINE.exec(line);
12
+ if (stamp) {
13
+ pending = Number(stamp[1]);
14
+ continue;
15
+ }
16
+ if (line.trim() === "")
17
+ continue;
18
+ entries.push(pending === undefined ? { command: line } : { command: line, timestamp: pending });
19
+ pending = undefined;
20
+ }
21
+ return entries;
22
+ }
@@ -0,0 +1,36 @@
1
+ import { basename, join } from "node:path";
2
+ import { splitLines, toText } from "./text.js";
3
+ const SNIFF_LINES = 50;
4
+ export function defaultHistoryPath(shell, home) {
5
+ switch (shell) {
6
+ case "bash":
7
+ return join(home, ".bash_history");
8
+ case "zsh":
9
+ return join(home, ".zsh_history");
10
+ case "fish":
11
+ return join(home, ".local", "share", "fish", "fish_history");
12
+ }
13
+ }
14
+ function shellFromName(path) {
15
+ const name = basename(path).toLowerCase();
16
+ if (name.includes("fish"))
17
+ return "fish";
18
+ if (name.includes("zsh"))
19
+ return "zsh";
20
+ if (name.includes("bash"))
21
+ return "bash";
22
+ return undefined;
23
+ }
24
+ function shellFromContent(input) {
25
+ const head = input instanceof Uint8Array ? input.subarray(0, 64 * 1024) : input.slice(0, 64 * 1024);
26
+ const lines = splitLines(toText(head)).slice(0, SNIFF_LINES);
27
+ if (lines.some((line) => line.startsWith("- cmd:")))
28
+ return "fish";
29
+ if (lines.some((line) => /^: \d+:\d+;/.test(line)))
30
+ return "zsh";
31
+ return "bash";
32
+ }
33
+ /** Guess the shell from the file name first, then from the content. */
34
+ export function detectShell(path, input) {
35
+ return shellFromName(path) ?? shellFromContent(input);
36
+ }
@@ -0,0 +1,29 @@
1
+ import { splitLines, toText } from "./text.js";
2
+ const CMD_LINE = /^- cmd: ?(.*)$/;
3
+ const WHEN_LINE = /^ {2}when: *(\d+)\s*$/;
4
+ /** Fish escapes backslash and newline inside `cmd:` values. */
5
+ function unescapeFish(value) {
6
+ return value.replace(/\\(.)/g, (_match, char) => (char === "n" ? "\n" : char));
7
+ }
8
+ /**
9
+ * Parse fish_history: YAML-like entries (`- cmd: ...` then ` when: ...`).
10
+ * Other keys such as `paths:` are ignored.
11
+ */
12
+ export function parseFish(input) {
13
+ const entries = [];
14
+ let current;
15
+ for (const line of splitLines(toText(input))) {
16
+ const cmd = CMD_LINE.exec(line);
17
+ if (cmd) {
18
+ const command = unescapeFish(cmd[1]);
19
+ current = { command };
20
+ if (command.trim() !== "")
21
+ entries.push(current);
22
+ continue;
23
+ }
24
+ const when = WHEN_LINE.exec(line);
25
+ if (when && current)
26
+ current.timestamp = Number(when[1]);
27
+ }
28
+ return entries;
29
+ }
@@ -0,0 +1,24 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { parseBash } from "./bash.js";
3
+ import { defaultHistoryPath, detectShell } from "./detect.js";
4
+ import { parseFish } from "./fish.js";
5
+ import { parseZsh } from "./zsh.js";
6
+ export { defaultHistoryPath, detectShell } from "./detect.js";
7
+ export { parseBash, parseFish, parseZsh };
8
+ export function parseHistory(shell, input) {
9
+ switch (shell) {
10
+ case "bash":
11
+ return parseBash(input);
12
+ case "zsh":
13
+ return parseZsh(input);
14
+ case "fish":
15
+ return parseFish(input);
16
+ }
17
+ }
18
+ /** Read and parse a history file. Never executes anything from it. */
19
+ export function loadHistory(options) {
20
+ const file = options.file ?? defaultHistoryPath(options.shell ?? "bash", options.home);
21
+ const bytes = readFileSync(file);
22
+ const shell = options.shell ?? detectShell(file, bytes);
23
+ return { shell, file, entries: parseHistory(shell, bytes) };
24
+ }
@@ -0,0 +1,13 @@
1
+ const decoder = new TextDecoder("utf-8");
2
+ /** Decode bytes as UTF-8 (invalid bytes become U+FFFD) and drop a leading BOM. */
3
+ export function toText(input) {
4
+ const text = typeof input === "string" ? input : decoder.decode(input);
5
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
6
+ }
7
+ /** Split on LF or CRLF. A trailing newline does not produce an empty last line. */
8
+ export function splitLines(text) {
9
+ const lines = text.split(/\r?\n/);
10
+ if (lines[lines.length - 1] === "")
11
+ lines.pop();
12
+ return lines;
13
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,62 @@
1
+ import { splitLines, toText } from "./text.js";
2
+ const META = 0x83;
3
+ const EXTENDED_LINE = /^: (\d+):\d+;(.*)$/;
4
+ /**
5
+ * Zsh stores bytes 0x00 and 0x83-0x9f as a Meta byte (0x83) followed by
6
+ * the original byte XOR 0x20. Undo that before decoding as UTF-8.
7
+ */
8
+ export function unmetafy(bytes) {
9
+ const out = new Uint8Array(bytes.length);
10
+ let length = 0;
11
+ for (let i = 0; i < bytes.length; i++) {
12
+ const byte = bytes[i];
13
+ if (byte === META) {
14
+ i++;
15
+ if (i < bytes.length)
16
+ out[length++] = bytes[i] ^ 0x20;
17
+ }
18
+ else {
19
+ out[length++] = byte;
20
+ }
21
+ }
22
+ return out.subarray(0, length);
23
+ }
24
+ /**
25
+ * Parse ~/.zsh_history, plain or extended (`: 1700000000:0;command`).
26
+ * A line ending in a backslash continues on the next line.
27
+ */
28
+ export function parseZsh(input) {
29
+ const text = toText(typeof input === "string" ? input : unmetafy(input));
30
+ const entries = [];
31
+ let parts = [];
32
+ let timestamp;
33
+ const flush = () => {
34
+ const command = parts.join("\n");
35
+ if (command.trim() !== "") {
36
+ entries.push(timestamp === undefined ? { command } : { command, timestamp });
37
+ }
38
+ parts = [];
39
+ timestamp = undefined;
40
+ };
41
+ for (const line of splitLines(text)) {
42
+ if (parts.length === 0) {
43
+ const extended = EXTENDED_LINE.exec(line);
44
+ if (extended)
45
+ timestamp = Number(extended[1]);
46
+ parts.push(extended ? extended[2] : line);
47
+ }
48
+ else {
49
+ parts.push(line);
50
+ }
51
+ const last = parts[parts.length - 1];
52
+ if (last.endsWith("\\")) {
53
+ parts[parts.length - 1] = last.slice(0, -1);
54
+ }
55
+ else {
56
+ flush();
57
+ }
58
+ }
59
+ if (parts.length > 0)
60
+ flush();
61
+ return entries;
62
+ }
@@ -0,0 +1,76 @@
1
+ import { normalizeCommand } from "../analyze/normalize.js";
2
+ import { maskRanges, shorten } from "./mask.js";
3
+ import { packs as defaultPacks } from "./packs/index.js";
4
+ /** Longer command lines are scanned only up to this many characters. */
5
+ const MAX_SCAN_LENGTH = 10_000;
6
+ function compile(pattern) {
7
+ const flags = [...new Set(`${pattern.flags ?? ""}gd`)].join("");
8
+ return { pattern, regex: new RegExp(pattern.pattern, flags) };
9
+ }
10
+ function overlaps(a, b) {
11
+ return a.start < b.end && b.start < a.end;
12
+ }
13
+ function secretRange(match) {
14
+ const group = match.indices?.groups?.secret;
15
+ return group ? { start: group[0], end: group[1] } : { start: match.index, end: match.index + match[0].length };
16
+ }
17
+ /** Secret matches in rule order. A later match overlapping an earlier one is dropped. */
18
+ function findSecrets(text, rules) {
19
+ const hits = [];
20
+ for (const { pattern, regex } of rules) {
21
+ for (const match of text.matchAll(regex)) {
22
+ const range = secretRange(match);
23
+ if (range.end > range.start && !hits.some((hit) => overlaps(hit.range, range))) {
24
+ hits.push({ pattern, range });
25
+ }
26
+ }
27
+ }
28
+ return hits;
29
+ }
30
+ function isoDate(timestamp) {
31
+ if (timestamp === undefined)
32
+ return undefined;
33
+ const date = new Date(timestamp * 1000);
34
+ return Number.isNaN(date.getTime()) ? undefined : date.toISOString().slice(0, 10);
35
+ }
36
+ function toFinding(pattern, entry, timestamp, preview) {
37
+ const finding = {
38
+ id: pattern.id,
39
+ category: pattern.category,
40
+ severity: pattern.severity,
41
+ title: pattern.title,
42
+ advice: pattern.advice,
43
+ entry,
44
+ preview,
45
+ };
46
+ const date = isoDate(timestamp);
47
+ return date === undefined ? finding : { ...finding, date };
48
+ }
49
+ function hasMatch(text, rule) {
50
+ rule.regex.lastIndex = 0;
51
+ return rule.regex.test(text);
52
+ }
53
+ /**
54
+ * Scan history entries for dangerous commands and secrets. Pure text matching:
55
+ * nothing from history is ever executed. Every preview has secrets masked.
56
+ */
57
+ export function scanHistory(entries, packList = defaultPacks) {
58
+ const rules = packList.flatMap((pack) => pack.patterns).map(compile);
59
+ const secretRules = rules.filter((rule) => rule.pattern.category === "secret");
60
+ const dangerRules = rules.filter((rule) => rule.pattern.category === "danger");
61
+ const findings = [];
62
+ entries.forEach((entry, index) => {
63
+ const text = normalizeCommand(entry.command).slice(0, MAX_SCAN_LENGTH);
64
+ if (text === "")
65
+ return;
66
+ const secrets = findSecrets(text, secretRules);
67
+ const preview = shorten(maskRanges(text, secrets.map((hit) => hit.range)));
68
+ const matched = [
69
+ ...secrets.map((hit) => hit.pattern),
70
+ ...dangerRules.filter((rule) => hasMatch(text, rule)).map((rule) => rule.pattern),
71
+ ];
72
+ for (const pattern of matched)
73
+ findings.push(toFinding(pattern, index + 1, entry.timestamp, preview));
74
+ });
75
+ return findings;
76
+ }
@@ -0,0 +1,23 @@
1
+ const PREVIEW_LENGTH = 100;
2
+ /**
3
+ * Keep up to the first 4 characters and hide the rest. Short secrets show
4
+ * fewer characters (a quarter of their length) so they are never exposed.
5
+ */
6
+ export function maskSecret(secret) {
7
+ const visible = Math.min(4, Math.floor(secret.length / 4));
8
+ return `${secret.slice(0, visible)}****`;
9
+ }
10
+ /** Replace each range of `text` with its masked form. Ranges must not overlap. */
11
+ export function maskRanges(text, ranges) {
12
+ const ordered = [...ranges].sort((a, b) => a.start - b.start);
13
+ let result = "";
14
+ let cursor = 0;
15
+ for (const range of ordered) {
16
+ result += text.slice(cursor, range.start) + maskSecret(text.slice(range.start, range.end));
17
+ cursor = range.end;
18
+ }
19
+ return result + text.slice(cursor);
20
+ }
21
+ export function shorten(text, length = PREVIEW_LENGTH) {
22
+ return text.length <= length ? text : `${text.slice(0, length)}…`;
23
+ }
@@ -0,0 +1,60 @@
1
+ const R = String.raw;
2
+ export const cloud = [
3
+ {
4
+ id: "cloud/aws-access-key-id",
5
+ category: "secret",
6
+ severity: "high",
7
+ title: "AWS access key ID",
8
+ advice: "Deactivate the key in IAM, create a new one and remove this entry from your history.",
9
+ pattern: R `\b(?<secret>(?:AKIA|ASIA)[A-Z0-9]{16})\b`,
10
+ },
11
+ {
12
+ id: "cloud/aws-secret-assignment",
13
+ category: "secret",
14
+ severity: "high",
15
+ title: "AWS secret or session token",
16
+ advice: "Rotate the credential in IAM and remove this entry from your history.",
17
+ pattern: R `\bAWS_(?:SECRET_ACCESS_KEY|SESSION_TOKEN)=["']?(?<secret>[^\s"'$][^\s"']*)`,
18
+ },
19
+ {
20
+ id: "cloud/aws-configure-set",
21
+ category: "secret",
22
+ severity: "high",
23
+ title: "AWS credential set through aws configure",
24
+ advice: "Rotate the credential in IAM and remove this entry from your history.",
25
+ pattern: R `\baws\s+configure\s+set\s+\w*(?:secret_access_key|session_token)\s+["']?(?<secret>[^\s"']+)`,
26
+ flags: "i",
27
+ },
28
+ {
29
+ id: "cloud/google-api-key",
30
+ category: "secret",
31
+ severity: "high",
32
+ title: "Google API key",
33
+ advice: "Delete or restrict the key in the Cloud console and remove this entry from your history.",
34
+ pattern: R `\b(?<secret>AIza[0-9A-Za-z_-]{35})`,
35
+ },
36
+ {
37
+ id: "cloud/s3-recursive-delete",
38
+ category: "danger",
39
+ severity: "medium",
40
+ title: "aws s3 rm --recursive or rb --force",
41
+ advice: "Run with --dryrun first and check the bucket and prefix.",
42
+ pattern: R `\baws\s+s3\s+(?:rm\b[^;&|]*\s--recursive|rb\b[^;&|]*\s--force)(?=\s|$)`,
43
+ },
44
+ {
45
+ id: "cloud/terraform-auto-destroy",
46
+ category: "danger",
47
+ severity: "high",
48
+ title: "terraform destroy without confirmation",
49
+ advice: "Review the plan (terraform plan -destroy) and confirm the workspace first.",
50
+ pattern: R `\bterraform\s+destroy\b[^;&|]*\s-auto-approve(?=\s|$)`,
51
+ },
52
+ {
53
+ id: "cloud/gcloud-project-delete",
54
+ category: "danger",
55
+ severity: "high",
56
+ title: "gcloud projects delete",
57
+ advice: "Deleting a project removes all its resources. Confirm the project ID.",
58
+ pattern: R `\bgcloud\s+projects\s+delete\b`,
59
+ },
60
+ ];