- Created skills/ directory - Moved 272 skills to skills/ subfolder - Kept agents/ at root level - Kept installation scripts and docs at root level Repository structure: - skills/ - All 272 skills from skills.sh - agents/ - Agent definitions - *.sh, *.ps1 - Installation scripts - README.md, etc. - Documentation Co-Authored-By: Claude <noreply@anthropic.com>
2797 lines
76 KiB
JavaScript
Executable File
2797 lines
76 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
||
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
||
|
||
// node_modules/shell-quote/quote.js
|
||
var require_quote = __commonJS((exports, module) => {
|
||
module.exports = function quote(xs) {
|
||
return xs.map(function(s) {
|
||
if (s === "") {
|
||
return "''";
|
||
}
|
||
if (s && typeof s === "object") {
|
||
return s.op.replace(/(.)/g, "\\$1");
|
||
}
|
||
if (/["\s\\]/.test(s) && !/'/.test(s)) {
|
||
return "'" + s.replace(/(['])/g, "\\$1") + "'";
|
||
}
|
||
if (/["'\s]/.test(s)) {
|
||
return '"' + s.replace(/(["\\$`!])/g, "\\$1") + '"';
|
||
}
|
||
return String(s).replace(/([A-Za-z]:)?([#!"$&'()*,:;<=>?@[\\\]^`{|}])/g, "$1\\$2");
|
||
}).join(" ");
|
||
};
|
||
});
|
||
|
||
// node_modules/shell-quote/parse.js
|
||
var require_parse = __commonJS((exports, module) => {
|
||
var CONTROL = "(?:" + [
|
||
"\\|\\|",
|
||
"\\&\\&",
|
||
";;",
|
||
"\\|\\&",
|
||
"\\<\\(",
|
||
"\\<\\<\\<",
|
||
">>",
|
||
">\\&",
|
||
"<\\&",
|
||
"[&;()|<>]"
|
||
].join("|") + ")";
|
||
var controlRE = new RegExp("^" + CONTROL + "$");
|
||
var META = "|&;()<> \\t";
|
||
var SINGLE_QUOTE = '"((\\\\"|[^"])*?)"';
|
||
var DOUBLE_QUOTE = "'((\\\\'|[^'])*?)'";
|
||
var hash = /^#$/;
|
||
var SQ = "'";
|
||
var DQ = '"';
|
||
var DS = "$";
|
||
var TOKEN = "";
|
||
var mult = 4294967296;
|
||
for (i = 0;i < 4; i++) {
|
||
TOKEN += (mult * Math.random()).toString(16);
|
||
}
|
||
var i;
|
||
var startsWithToken = new RegExp("^" + TOKEN);
|
||
function matchAll(s, r) {
|
||
var origIndex = r.lastIndex;
|
||
var matches = [];
|
||
var matchObj;
|
||
while (matchObj = r.exec(s)) {
|
||
matches.push(matchObj);
|
||
if (r.lastIndex === matchObj.index) {
|
||
r.lastIndex += 1;
|
||
}
|
||
}
|
||
r.lastIndex = origIndex;
|
||
return matches;
|
||
}
|
||
function getVar(env, pre, key) {
|
||
var r = typeof env === "function" ? env(key) : env[key];
|
||
if (typeof r === "undefined" && key != "") {
|
||
r = "";
|
||
} else if (typeof r === "undefined") {
|
||
r = "$";
|
||
}
|
||
if (typeof r === "object") {
|
||
return pre + TOKEN + JSON.stringify(r) + TOKEN;
|
||
}
|
||
return pre + r;
|
||
}
|
||
function parseInternal(string, env, opts) {
|
||
if (!opts) {
|
||
opts = {};
|
||
}
|
||
var BS = opts.escape || "\\";
|
||
var BAREWORD = "(\\" + BS + `['"` + META + `]|[^\\s'"` + META + "])+";
|
||
var chunker = new RegExp([
|
||
"(" + CONTROL + ")",
|
||
"(" + BAREWORD + "|" + SINGLE_QUOTE + "|" + DOUBLE_QUOTE + ")+"
|
||
].join("|"), "g");
|
||
var matches = matchAll(string, chunker);
|
||
if (matches.length === 0) {
|
||
return [];
|
||
}
|
||
if (!env) {
|
||
env = {};
|
||
}
|
||
var commented = false;
|
||
return matches.map(function(match) {
|
||
var s = match[0];
|
||
if (!s || commented) {
|
||
return;
|
||
}
|
||
if (controlRE.test(s)) {
|
||
return { op: s };
|
||
}
|
||
var quote = false;
|
||
var esc = false;
|
||
var out = "";
|
||
var isGlob = false;
|
||
var i2;
|
||
function parseEnvVar() {
|
||
i2 += 1;
|
||
var varend;
|
||
var varname;
|
||
var char = s.charAt(i2);
|
||
if (char === "{") {
|
||
i2 += 1;
|
||
if (s.charAt(i2) === "}") {
|
||
throw new Error("Bad substitution: " + s.slice(i2 - 2, i2 + 1));
|
||
}
|
||
varend = s.indexOf("}", i2);
|
||
if (varend < 0) {
|
||
throw new Error("Bad substitution: " + s.slice(i2));
|
||
}
|
||
varname = s.slice(i2, varend);
|
||
i2 = varend;
|
||
} else if (/[*@#?$!_-]/.test(char)) {
|
||
varname = char;
|
||
i2 += 1;
|
||
} else {
|
||
var slicedFromI = s.slice(i2);
|
||
varend = slicedFromI.match(/[^\w\d_]/);
|
||
if (!varend) {
|
||
varname = slicedFromI;
|
||
i2 = s.length;
|
||
} else {
|
||
varname = slicedFromI.slice(0, varend.index);
|
||
i2 += varend.index - 1;
|
||
}
|
||
}
|
||
return getVar(env, "", varname);
|
||
}
|
||
for (i2 = 0;i2 < s.length; i2++) {
|
||
var c = s.charAt(i2);
|
||
isGlob = isGlob || !quote && (c === "*" || c === "?");
|
||
if (esc) {
|
||
out += c;
|
||
esc = false;
|
||
} else if (quote) {
|
||
if (c === quote) {
|
||
quote = false;
|
||
} else if (quote == SQ) {
|
||
out += c;
|
||
} else {
|
||
if (c === BS) {
|
||
i2 += 1;
|
||
c = s.charAt(i2);
|
||
if (c === DQ || c === BS || c === DS) {
|
||
out += c;
|
||
} else {
|
||
out += BS + c;
|
||
}
|
||
} else if (c === DS) {
|
||
out += parseEnvVar();
|
||
} else {
|
||
out += c;
|
||
}
|
||
}
|
||
} else if (c === DQ || c === SQ) {
|
||
quote = c;
|
||
} else if (controlRE.test(c)) {
|
||
return { op: s };
|
||
} else if (hash.test(c)) {
|
||
commented = true;
|
||
var commentObj = { comment: string.slice(match.index + i2 + 1) };
|
||
if (out.length) {
|
||
return [out, commentObj];
|
||
}
|
||
return [commentObj];
|
||
} else if (c === BS) {
|
||
esc = true;
|
||
} else if (c === DS) {
|
||
out += parseEnvVar();
|
||
} else {
|
||
out += c;
|
||
}
|
||
}
|
||
if (isGlob) {
|
||
return { op: "glob", pattern: out };
|
||
}
|
||
return out;
|
||
}).reduce(function(prev, arg) {
|
||
return typeof arg === "undefined" ? prev : prev.concat(arg);
|
||
}, []);
|
||
}
|
||
module.exports = function parse(s, env, opts) {
|
||
var mapped = parseInternal(s, env, opts);
|
||
if (typeof env !== "function") {
|
||
return mapped;
|
||
}
|
||
return mapped.reduce(function(acc, s2) {
|
||
if (typeof s2 === "object") {
|
||
return acc.concat(s2);
|
||
}
|
||
var xs = s2.split(RegExp("(" + TOKEN + ".*?" + TOKEN + ")", "g"));
|
||
if (xs.length === 1) {
|
||
return acc.concat(xs[0]);
|
||
}
|
||
return acc.concat(xs.filter(Boolean).map(function(x) {
|
||
if (startsWithToken.test(x)) {
|
||
return JSON.parse(x.split(TOKEN)[1]);
|
||
}
|
||
return x;
|
||
}));
|
||
}, []);
|
||
};
|
||
});
|
||
|
||
// src/types.ts
|
||
var MAX_RECURSION_DEPTH = 10;
|
||
var MAX_STRIP_ITERATIONS = 20;
|
||
var NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
|
||
var COMMAND_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
||
var MAX_REASON_LENGTH = 256;
|
||
var SHELL_OPERATORS = new Set(["&&", "||", "|&", "|", "&", ";", `
|
||
`]);
|
||
var SHELL_WRAPPERS = new Set(["bash", "sh", "zsh", "ksh", "dash", "fish", "csh", "tcsh"]);
|
||
var INTERPRETERS = new Set(["python", "python3", "python2", "node", "ruby", "perl"]);
|
||
var DANGEROUS_PATTERNS = [
|
||
/\brm\s+.*-[rR].*-f\b/,
|
||
/\brm\s+.*-f.*-[rR]\b/,
|
||
/\brm\s+-rf\b/,
|
||
/\brm\s+-fr\b/,
|
||
/\bgit\s+reset\s+--hard\b/,
|
||
/\bgit\s+checkout\s+--\b/,
|
||
/\bgit\s+clean\s+-f\b/,
|
||
/\bfind\b.*\s-delete\b/
|
||
];
|
||
var PARANOID_INTERPRETERS_SUFFIX = `
|
||
|
||
(Paranoid mode: interpreter one-liners are blocked.)`;
|
||
|
||
// node_modules/shell-quote/index.js
|
||
var $quote = require_quote();
|
||
var $parse = require_parse();
|
||
|
||
// src/core/shell.ts
|
||
var ENV_PROXY = new Proxy({}, {
|
||
get: (_, name) => `$${String(name)}`
|
||
});
|
||
function splitShellCommands(command) {
|
||
if (hasUnclosedQuotes(command)) {
|
||
return [[command]];
|
||
}
|
||
const normalizedCommand = command.replace(/\n/g, " ; ");
|
||
const tokens = $parse(normalizedCommand, ENV_PROXY);
|
||
const segments = [];
|
||
let current = [];
|
||
let i = 0;
|
||
while (i < tokens.length) {
|
||
const token = tokens[i];
|
||
if (token === undefined) {
|
||
i++;
|
||
continue;
|
||
}
|
||
if (isOperator(token)) {
|
||
if (current.length > 0) {
|
||
segments.push(current);
|
||
current = [];
|
||
}
|
||
i++;
|
||
continue;
|
||
}
|
||
if (typeof token !== "string") {
|
||
i++;
|
||
continue;
|
||
}
|
||
const nextToken = tokens[i + 1];
|
||
if (token === "$" && nextToken && isParenOpen(nextToken)) {
|
||
if (current.length > 0) {
|
||
segments.push(current);
|
||
current = [];
|
||
}
|
||
const { innerSegments, endIndex } = extractCommandSubstitution(tokens, i + 2);
|
||
for (const seg of innerSegments) {
|
||
segments.push(seg);
|
||
}
|
||
i = endIndex + 1;
|
||
continue;
|
||
}
|
||
const backtickSegments = extractBacktickSubstitutions(token);
|
||
if (backtickSegments.length > 0) {
|
||
for (const seg of backtickSegments) {
|
||
segments.push(seg);
|
||
}
|
||
}
|
||
current.push(token);
|
||
i++;
|
||
}
|
||
if (current.length > 0) {
|
||
segments.push(current);
|
||
}
|
||
return segments;
|
||
}
|
||
function extractBacktickSubstitutions(token) {
|
||
const segments = [];
|
||
let i = 0;
|
||
while (i < token.length) {
|
||
const backtickStart = token.indexOf("`", i);
|
||
if (backtickStart === -1)
|
||
break;
|
||
const backtickEnd = token.indexOf("`", backtickStart + 1);
|
||
if (backtickEnd === -1)
|
||
break;
|
||
const innerCommand = token.slice(backtickStart + 1, backtickEnd);
|
||
if (innerCommand.trim()) {
|
||
const innerSegments = splitShellCommands(innerCommand);
|
||
for (const seg of innerSegments) {
|
||
segments.push(seg);
|
||
}
|
||
}
|
||
i = backtickEnd + 1;
|
||
}
|
||
return segments;
|
||
}
|
||
function isParenOpen(token) {
|
||
return typeof token === "object" && token !== null && "op" in token && token.op === "(";
|
||
}
|
||
function isParenClose(token) {
|
||
return typeof token === "object" && token !== null && "op" in token && token.op === ")";
|
||
}
|
||
function extractCommandSubstitution(tokens, startIndex) {
|
||
const innerSegments = [];
|
||
let currentSegment = [];
|
||
let depth = 1;
|
||
let i = startIndex;
|
||
while (i < tokens.length && depth > 0) {
|
||
const token = tokens[i];
|
||
if (isParenOpen(token)) {
|
||
depth++;
|
||
i++;
|
||
continue;
|
||
}
|
||
if (isParenClose(token)) {
|
||
depth--;
|
||
if (depth === 0)
|
||
break;
|
||
i++;
|
||
continue;
|
||
}
|
||
if (depth === 1 && token && isOperator(token)) {
|
||
if (currentSegment.length > 0) {
|
||
innerSegments.push(currentSegment);
|
||
currentSegment = [];
|
||
}
|
||
i++;
|
||
continue;
|
||
}
|
||
if (typeof token === "string") {
|
||
currentSegment.push(token);
|
||
}
|
||
i++;
|
||
}
|
||
if (currentSegment.length > 0) {
|
||
innerSegments.push(currentSegment);
|
||
}
|
||
return { innerSegments, endIndex: i };
|
||
}
|
||
function hasUnclosedQuotes(command) {
|
||
let inSingle = false;
|
||
let inDouble = false;
|
||
let escaped = false;
|
||
for (const char of command) {
|
||
if (escaped) {
|
||
escaped = false;
|
||
continue;
|
||
}
|
||
if (char === "\\") {
|
||
escaped = true;
|
||
continue;
|
||
}
|
||
if (char === "'" && !inDouble) {
|
||
inSingle = !inSingle;
|
||
} else if (char === '"' && !inSingle) {
|
||
inDouble = !inDouble;
|
||
}
|
||
}
|
||
return inSingle || inDouble;
|
||
}
|
||
var ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
||
function parseEnvAssignment(token) {
|
||
if (!ENV_ASSIGNMENT_RE.test(token)) {
|
||
return null;
|
||
}
|
||
const eqIdx = token.indexOf("=");
|
||
if (eqIdx < 0) {
|
||
return null;
|
||
}
|
||
return { name: token.slice(0, eqIdx), value: token.slice(eqIdx + 1) };
|
||
}
|
||
function stripEnvAssignmentsWithInfo(tokens) {
|
||
const envAssignments = new Map;
|
||
let i = 0;
|
||
while (i < tokens.length) {
|
||
const token = tokens[i];
|
||
if (!token) {
|
||
break;
|
||
}
|
||
const assignment = parseEnvAssignment(token);
|
||
if (!assignment) {
|
||
break;
|
||
}
|
||
envAssignments.set(assignment.name, assignment.value);
|
||
i++;
|
||
}
|
||
return { tokens: tokens.slice(i), envAssignments };
|
||
}
|
||
function stripWrappers(tokens) {
|
||
return stripWrappersWithInfo(tokens).tokens;
|
||
}
|
||
function stripWrappersWithInfo(tokens) {
|
||
let result = [...tokens];
|
||
const allEnvAssignments = new Map;
|
||
for (let iteration = 0;iteration < MAX_STRIP_ITERATIONS; iteration++) {
|
||
const before = result.join(" ");
|
||
const { tokens: strippedTokens, envAssignments } = stripEnvAssignmentsWithInfo(result);
|
||
for (const [k, v] of envAssignments) {
|
||
allEnvAssignments.set(k, v);
|
||
}
|
||
result = strippedTokens;
|
||
if (result.length === 0)
|
||
break;
|
||
while (result.length > 0 && result[0]?.includes("=") && !ENV_ASSIGNMENT_RE.test(result[0] ?? "")) {
|
||
result = result.slice(1);
|
||
}
|
||
if (result.length === 0)
|
||
break;
|
||
const head = result[0]?.toLowerCase();
|
||
if (head !== "sudo" && head !== "env" && head !== "command") {
|
||
break;
|
||
}
|
||
if (head === "sudo") {
|
||
result = stripSudo(result);
|
||
}
|
||
if (head === "env") {
|
||
const envResult = stripEnvWithInfo(result);
|
||
result = envResult.tokens;
|
||
for (const [k, v] of envResult.envAssignments) {
|
||
allEnvAssignments.set(k, v);
|
||
}
|
||
}
|
||
if (head === "command") {
|
||
result = stripCommand(result);
|
||
}
|
||
if (result.join(" ") === before)
|
||
break;
|
||
}
|
||
const { tokens: finalTokens, envAssignments: finalAssignments } = stripEnvAssignmentsWithInfo(result);
|
||
for (const [k, v] of finalAssignments) {
|
||
allEnvAssignments.set(k, v);
|
||
}
|
||
return { tokens: finalTokens, envAssignments: allEnvAssignments };
|
||
}
|
||
var SUDO_OPTS_WITH_VALUE = new Set(["-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-T", "-U"]);
|
||
function stripSudo(tokens) {
|
||
let i = 1;
|
||
while (i < tokens.length) {
|
||
const token = tokens[i];
|
||
if (!token)
|
||
break;
|
||
if (token === "--") {
|
||
return tokens.slice(i + 1);
|
||
}
|
||
if (!token.startsWith("-")) {
|
||
break;
|
||
}
|
||
if (SUDO_OPTS_WITH_VALUE.has(token)) {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
i++;
|
||
}
|
||
return tokens.slice(i);
|
||
}
|
||
var ENV_OPTS_NO_VALUE = new Set(["-i", "-0", "--null"]);
|
||
var ENV_OPTS_WITH_VALUE = new Set([
|
||
"-u",
|
||
"--unset",
|
||
"-C",
|
||
"--chdir",
|
||
"-S",
|
||
"--split-string",
|
||
"-P"
|
||
]);
|
||
function stripEnvWithInfo(tokens) {
|
||
const envAssignments = new Map;
|
||
let i = 1;
|
||
while (i < tokens.length) {
|
||
const token = tokens[i];
|
||
if (!token)
|
||
break;
|
||
if (token === "--") {
|
||
return { tokens: tokens.slice(i + 1), envAssignments };
|
||
}
|
||
if (ENV_OPTS_NO_VALUE.has(token)) {
|
||
i++;
|
||
continue;
|
||
}
|
||
if (ENV_OPTS_WITH_VALUE.has(token)) {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (token.startsWith("-u=") || token.startsWith("--unset=")) {
|
||
i++;
|
||
continue;
|
||
}
|
||
if (token.startsWith("-C=") || token.startsWith("--chdir=")) {
|
||
i++;
|
||
continue;
|
||
}
|
||
if (token.startsWith("-P")) {
|
||
i++;
|
||
continue;
|
||
}
|
||
if (token.startsWith("-")) {
|
||
i++;
|
||
continue;
|
||
}
|
||
const assignment = parseEnvAssignment(token);
|
||
if (!assignment) {
|
||
break;
|
||
}
|
||
envAssignments.set(assignment.name, assignment.value);
|
||
i++;
|
||
}
|
||
return { tokens: tokens.slice(i), envAssignments };
|
||
}
|
||
function stripCommand(tokens) {
|
||
let i = 1;
|
||
while (i < tokens.length) {
|
||
const token = tokens[i];
|
||
if (!token)
|
||
break;
|
||
if (token === "-p" || token === "-v" || token === "-V") {
|
||
i++;
|
||
continue;
|
||
}
|
||
if (token === "--") {
|
||
return tokens.slice(i + 1);
|
||
}
|
||
if (token.startsWith("-") && !token.startsWith("--") && token.length > 1) {
|
||
const chars = token.slice(1);
|
||
if (!/^[pvV]+$/.test(chars)) {
|
||
break;
|
||
}
|
||
i++;
|
||
continue;
|
||
}
|
||
break;
|
||
}
|
||
return tokens.slice(i);
|
||
}
|
||
function extractShortOpts(tokens) {
|
||
const opts = new Set;
|
||
let pastDoubleDash = false;
|
||
for (const token of tokens) {
|
||
if (token === "--") {
|
||
pastDoubleDash = true;
|
||
continue;
|
||
}
|
||
if (pastDoubleDash)
|
||
continue;
|
||
if (token.startsWith("-") && !token.startsWith("--") && token.length > 1) {
|
||
for (let i = 1;i < token.length; i++) {
|
||
const char = token[i];
|
||
if (!char || !/[a-zA-Z]/.test(char)) {
|
||
break;
|
||
}
|
||
opts.add(`-${char}`);
|
||
}
|
||
}
|
||
}
|
||
return opts;
|
||
}
|
||
function normalizeCommandToken(token) {
|
||
return getBasename(token).toLowerCase();
|
||
}
|
||
function getBasename(token) {
|
||
return token.includes("/") ? token.split("/").pop() ?? token : token;
|
||
}
|
||
function isOperator(token) {
|
||
return typeof token === "object" && token !== null && "op" in token && SHELL_OPERATORS.has(token.op);
|
||
}
|
||
|
||
// src/core/analyze/dangerous-text.ts
|
||
function dangerousInText(text) {
|
||
const t = text.toLowerCase();
|
||
const stripped = t.trimStart();
|
||
const isEchoOrRg = stripped.startsWith("echo ") || stripped.startsWith("rg ");
|
||
const patterns = [
|
||
{
|
||
regex: /\brm\s+(-[^\s]*r[^\s]*\s+-[^\s]*f|-[^\s]*f[^\s]*\s+-[^\s]*r|-[^\s]*rf|-[^\s]*fr)\b/,
|
||
reason: "rm -rf"
|
||
},
|
||
{
|
||
regex: /\bgit\s+reset\s+--hard\b/,
|
||
reason: "git reset --hard"
|
||
},
|
||
{
|
||
regex: /\bgit\s+reset\s+--merge\b/,
|
||
reason: "git reset --merge"
|
||
},
|
||
{
|
||
regex: /\bgit\s+clean\s+(-[^\s]*f|-f)\b/,
|
||
reason: "git clean -f"
|
||
},
|
||
{
|
||
regex: /\bgit\s+push\s+[^|;]*(-f\b|--force\b)(?!-with-lease)/,
|
||
reason: "git push --force (use --force-with-lease instead)"
|
||
},
|
||
{
|
||
regex: /\bgit\s+branch\s+-D\b/,
|
||
reason: "git branch -D",
|
||
caseSensitive: true
|
||
},
|
||
{
|
||
regex: /\bgit\s+stash\s+(drop|clear)\b/,
|
||
reason: "git stash drop/clear"
|
||
},
|
||
{
|
||
regex: /\bgit\s+checkout\s+--\s/,
|
||
reason: "git checkout --"
|
||
},
|
||
{
|
||
regex: /\bgit\s+restore\b(?!.*--(staged|help))/,
|
||
reason: "git restore (without --staged)"
|
||
},
|
||
{
|
||
regex: /\bfind\b[^\n;|&]*\s-delete\b/,
|
||
reason: "find -delete",
|
||
skipForEchoRg: true
|
||
}
|
||
];
|
||
for (const { regex, reason, skipForEchoRg, caseSensitive } of patterns) {
|
||
if (skipForEchoRg && isEchoOrRg)
|
||
continue;
|
||
const target = caseSensitive ? text : t;
|
||
if (regex.test(target)) {
|
||
return reason;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// src/core/rules-custom.ts
|
||
function checkCustomRules(tokens, rules) {
|
||
if (tokens.length === 0 || rules.length === 0) {
|
||
return null;
|
||
}
|
||
const command = getBasename(tokens[0] ?? "");
|
||
const subcommand = extractSubcommand(tokens);
|
||
const shortOpts = extractShortOpts(tokens);
|
||
for (const rule of rules) {
|
||
if (!matchesCommand(command, rule.command)) {
|
||
continue;
|
||
}
|
||
if (rule.subcommand && subcommand !== rule.subcommand) {
|
||
continue;
|
||
}
|
||
if (matchesBlockArgs(tokens, rule.block_args, shortOpts)) {
|
||
return `[${rule.name}] ${rule.reason}`;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
function matchesCommand(command, ruleCommand) {
|
||
return command === ruleCommand;
|
||
}
|
||
var OPTIONS_WITH_VALUES = new Set([
|
||
"-c",
|
||
"-C",
|
||
"--git-dir",
|
||
"--work-tree",
|
||
"--namespace",
|
||
"--config-env"
|
||
]);
|
||
function extractSubcommand(tokens) {
|
||
let skipNext = false;
|
||
for (let i = 1;i < tokens.length; i++) {
|
||
const token = tokens[i];
|
||
if (!token)
|
||
continue;
|
||
if (skipNext) {
|
||
skipNext = false;
|
||
continue;
|
||
}
|
||
if (token === "--") {
|
||
const nextToken = tokens[i + 1];
|
||
if (nextToken && !nextToken.startsWith("-")) {
|
||
return nextToken;
|
||
}
|
||
return null;
|
||
}
|
||
if (OPTIONS_WITH_VALUES.has(token)) {
|
||
skipNext = true;
|
||
continue;
|
||
}
|
||
if (token.startsWith("-")) {
|
||
for (const opt of OPTIONS_WITH_VALUES) {
|
||
if (token.startsWith(`${opt}=`)) {
|
||
break;
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
return token;
|
||
}
|
||
return null;
|
||
}
|
||
function matchesBlockArgs(tokens, blockArgs, shortOpts) {
|
||
const blockArgsSet = new Set(blockArgs);
|
||
for (const token of tokens) {
|
||
if (blockArgsSet.has(token)) {
|
||
return true;
|
||
}
|
||
}
|
||
for (const opt of shortOpts) {
|
||
if (blockArgsSet.has(opt)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// src/core/rules-git.ts
|
||
var REASON_CHECKOUT_DOUBLE_DASH = "git checkout -- discards uncommitted changes permanently. Use 'git stash' first.";
|
||
var REASON_CHECKOUT_REF_PATH = "git checkout <ref> -- <path> overwrites working tree with ref version. Use 'git stash' first.";
|
||
var REASON_CHECKOUT_PATHSPEC_FROM_FILE = "git checkout --pathspec-from-file can overwrite multiple files. Use 'git stash' first.";
|
||
var REASON_CHECKOUT_AMBIGUOUS = "git checkout with multiple positional args may overwrite files. Use 'git switch' for branches or 'git restore' for files.";
|
||
var REASON_RESTORE = "git restore discards uncommitted changes. Use 'git stash' first, or use --staged to only unstage.";
|
||
var REASON_RESTORE_WORKTREE = "git restore --worktree explicitly discards working tree changes. Use 'git stash' first.";
|
||
var REASON_RESET_HARD = "git reset --hard destroys all uncommitted changes permanently. Use 'git stash' first.";
|
||
var REASON_RESET_MERGE = "git reset --merge can lose uncommitted changes. Use 'git stash' first.";
|
||
var REASON_CLEAN = "git clean -f removes untracked files permanently. Use 'git clean -n' to preview first.";
|
||
var REASON_PUSH_FORCE = "git push --force destroys remote history. Use --force-with-lease for safer force push.";
|
||
var REASON_BRANCH_DELETE = "git branch -D force-deletes without merge check. Use -d for safe delete.";
|
||
var REASON_STASH_DROP = "git stash drop permanently deletes stashed changes. Consider 'git stash list' first.";
|
||
var REASON_STASH_CLEAR = "git stash clear deletes ALL stashed changes permanently.";
|
||
var REASON_WORKTREE_REMOVE_FORCE = "git worktree remove --force can delete uncommitted changes. Remove --force flag.";
|
||
var GIT_GLOBAL_OPTS_WITH_VALUE = new Set([
|
||
"-c",
|
||
"-C",
|
||
"--git-dir",
|
||
"--work-tree",
|
||
"--namespace",
|
||
"--super-prefix",
|
||
"--config-env"
|
||
]);
|
||
var CHECKOUT_OPTS_WITH_VALUE = new Set([
|
||
"-b",
|
||
"-B",
|
||
"--orphan",
|
||
"--conflict",
|
||
"--pathspec-from-file",
|
||
"--unified"
|
||
]);
|
||
var CHECKOUT_OPTS_WITH_OPTIONAL_VALUE = new Set(["--recurse-submodules", "--track", "-t"]);
|
||
var CHECKOUT_KNOWN_OPTS_NO_VALUE = new Set([
|
||
"-q",
|
||
"--quiet",
|
||
"-f",
|
||
"--force",
|
||
"-d",
|
||
"--detach",
|
||
"-m",
|
||
"--merge",
|
||
"-p",
|
||
"--patch",
|
||
"--ours",
|
||
"--theirs",
|
||
"--no-track",
|
||
"--overwrite-ignore",
|
||
"--no-overwrite-ignore",
|
||
"--ignore-other-worktrees",
|
||
"--progress",
|
||
"--no-progress"
|
||
]);
|
||
function splitAtDoubleDash(tokens) {
|
||
const index = tokens.indexOf("--");
|
||
if (index === -1) {
|
||
return { index: -1, before: tokens, after: [] };
|
||
}
|
||
return {
|
||
index,
|
||
before: tokens.slice(0, index),
|
||
after: tokens.slice(index + 1)
|
||
};
|
||
}
|
||
function analyzeGit(tokens) {
|
||
const { subcommand, rest } = extractGitSubcommandAndRest(tokens);
|
||
if (!subcommand) {
|
||
return null;
|
||
}
|
||
switch (subcommand.toLowerCase()) {
|
||
case "checkout":
|
||
return analyzeGitCheckout(rest);
|
||
case "restore":
|
||
return analyzeGitRestore(rest);
|
||
case "reset":
|
||
return analyzeGitReset(rest);
|
||
case "clean":
|
||
return analyzeGitClean(rest);
|
||
case "push":
|
||
return analyzeGitPush(rest);
|
||
case "branch":
|
||
return analyzeGitBranch(rest);
|
||
case "stash":
|
||
return analyzeGitStash(rest);
|
||
case "worktree":
|
||
return analyzeGitWorktree(rest);
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
function extractGitSubcommandAndRest(tokens) {
|
||
if (tokens.length === 0) {
|
||
return { subcommand: null, rest: [] };
|
||
}
|
||
const firstToken = tokens[0];
|
||
const command = firstToken ? getBasename(firstToken).toLowerCase() : null;
|
||
if (command !== "git") {
|
||
return { subcommand: null, rest: [] };
|
||
}
|
||
let i = 1;
|
||
while (i < tokens.length) {
|
||
const token = tokens[i];
|
||
if (!token)
|
||
break;
|
||
if (token === "--") {
|
||
const nextToken = tokens[i + 1];
|
||
if (nextToken && !nextToken.startsWith("-")) {
|
||
return { subcommand: nextToken, rest: tokens.slice(i + 2) };
|
||
}
|
||
return { subcommand: null, rest: tokens.slice(i + 1) };
|
||
}
|
||
if (token.startsWith("-")) {
|
||
if (GIT_GLOBAL_OPTS_WITH_VALUE.has(token)) {
|
||
i += 2;
|
||
} else if (token.startsWith("-c") && token.length > 2) {
|
||
i++;
|
||
} else if (token.startsWith("-C") && token.length > 2) {
|
||
i++;
|
||
} else {
|
||
i++;
|
||
}
|
||
} else {
|
||
return { subcommand: token, rest: tokens.slice(i + 1) };
|
||
}
|
||
}
|
||
return { subcommand: null, rest: [] };
|
||
}
|
||
function analyzeGitCheckout(tokens) {
|
||
const { index: doubleDashIdx, before: beforeDash } = splitAtDoubleDash(tokens);
|
||
for (const token of tokens) {
|
||
if (token === "-b" || token === "-B" || token === "--orphan") {
|
||
return null;
|
||
}
|
||
if (token === "--pathspec-from-file") {
|
||
return REASON_CHECKOUT_PATHSPEC_FROM_FILE;
|
||
}
|
||
if (token.startsWith("--pathspec-from-file=")) {
|
||
return REASON_CHECKOUT_PATHSPEC_FROM_FILE;
|
||
}
|
||
}
|
||
if (doubleDashIdx !== -1) {
|
||
const hasRefBeforeDash = beforeDash.some((t) => !t.startsWith("-"));
|
||
if (hasRefBeforeDash) {
|
||
return REASON_CHECKOUT_REF_PATH;
|
||
}
|
||
return REASON_CHECKOUT_DOUBLE_DASH;
|
||
}
|
||
const positionalArgs = getCheckoutPositionalArgs(tokens);
|
||
if (positionalArgs.length >= 2) {
|
||
return REASON_CHECKOUT_AMBIGUOUS;
|
||
}
|
||
return null;
|
||
}
|
||
function getCheckoutPositionalArgs(tokens) {
|
||
const positional = [];
|
||
let i = 0;
|
||
while (i < tokens.length) {
|
||
const token = tokens[i];
|
||
if (!token)
|
||
break;
|
||
if (token === "--") {
|
||
break;
|
||
}
|
||
if (token.startsWith("-")) {
|
||
if (CHECKOUT_OPTS_WITH_VALUE.has(token)) {
|
||
i += 2;
|
||
} else if (token.startsWith("--") && token.includes("=")) {
|
||
i++;
|
||
} else if (CHECKOUT_OPTS_WITH_OPTIONAL_VALUE.has(token)) {
|
||
const nextToken = tokens[i + 1];
|
||
if (nextToken && !nextToken.startsWith("-") && (token === "--recurse-submodules" || token === "--track" || token === "-t")) {
|
||
const validModes = token === "--recurse-submodules" ? ["checkout", "on-demand"] : ["direct", "inherit"];
|
||
if (validModes.includes(nextToken)) {
|
||
i += 2;
|
||
} else {
|
||
i++;
|
||
}
|
||
} else {
|
||
i++;
|
||
}
|
||
} else if (token.startsWith("--") && !CHECKOUT_KNOWN_OPTS_NO_VALUE.has(token) && !CHECKOUT_OPTS_WITH_VALUE.has(token) && !CHECKOUT_OPTS_WITH_OPTIONAL_VALUE.has(token)) {
|
||
const nextToken = tokens[i + 1];
|
||
if (nextToken && !nextToken.startsWith("-")) {
|
||
i += 2;
|
||
} else {
|
||
i++;
|
||
}
|
||
} else {
|
||
i++;
|
||
}
|
||
} else {
|
||
positional.push(token);
|
||
i++;
|
||
}
|
||
}
|
||
return positional;
|
||
}
|
||
function analyzeGitRestore(tokens) {
|
||
let hasStaged = false;
|
||
for (const token of tokens) {
|
||
if (token === "--help" || token === "--version") {
|
||
return null;
|
||
}
|
||
if (token === "--worktree" || token === "-W") {
|
||
return REASON_RESTORE_WORKTREE;
|
||
}
|
||
if (token === "--staged" || token === "-S") {
|
||
hasStaged = true;
|
||
}
|
||
}
|
||
return hasStaged ? null : REASON_RESTORE;
|
||
}
|
||
function analyzeGitReset(tokens) {
|
||
for (const token of tokens) {
|
||
if (token === "--hard") {
|
||
return REASON_RESET_HARD;
|
||
}
|
||
if (token === "--merge") {
|
||
return REASON_RESET_MERGE;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
function analyzeGitClean(tokens) {
|
||
for (const token of tokens) {
|
||
if (token === "-n" || token === "--dry-run") {
|
||
return null;
|
||
}
|
||
}
|
||
const shortOpts = extractShortOpts(tokens.filter((t) => t !== "--"));
|
||
if (tokens.includes("--force") || shortOpts.has("-f")) {
|
||
return REASON_CLEAN;
|
||
}
|
||
return null;
|
||
}
|
||
function analyzeGitPush(tokens) {
|
||
let hasForceWithLease = false;
|
||
const shortOpts = extractShortOpts(tokens.filter((t) => t !== "--"));
|
||
const hasForce = tokens.includes("--force") || shortOpts.has("-f");
|
||
for (const token of tokens) {
|
||
if (token === "--force-with-lease" || token.startsWith("--force-with-lease=")) {
|
||
hasForceWithLease = true;
|
||
}
|
||
}
|
||
if (hasForce && !hasForceWithLease) {
|
||
return REASON_PUSH_FORCE;
|
||
}
|
||
return null;
|
||
}
|
||
function analyzeGitBranch(tokens) {
|
||
const shortOpts = extractShortOpts(tokens.filter((t) => t !== "--"));
|
||
if (shortOpts.has("-D")) {
|
||
return REASON_BRANCH_DELETE;
|
||
}
|
||
return null;
|
||
}
|
||
function analyzeGitStash(tokens) {
|
||
for (const token of tokens) {
|
||
if (token === "drop") {
|
||
return REASON_STASH_DROP;
|
||
}
|
||
if (token === "clear") {
|
||
return REASON_STASH_CLEAR;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
function analyzeGitWorktree(tokens) {
|
||
const hasRemove = tokens.includes("remove");
|
||
if (!hasRemove)
|
||
return null;
|
||
const { before } = splitAtDoubleDash(tokens);
|
||
for (const token of before) {
|
||
if (token === "--force" || token === "-f") {
|
||
return REASON_WORKTREE_REMOVE_FORCE;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// src/core/rules-rm.ts
|
||
import { realpathSync } from "node:fs";
|
||
import { homedir, tmpdir } from "node:os";
|
||
import { normalize, resolve } from "node:path";
|
||
|
||
// src/core/analyze/rm-flags.ts
|
||
function hasRecursiveForceFlags(tokens) {
|
||
let hasRecursive = false;
|
||
let hasForce = false;
|
||
for (const token of tokens) {
|
||
if (token === "--")
|
||
break;
|
||
if (token === "-r" || token === "-R" || token === "--recursive") {
|
||
hasRecursive = true;
|
||
} else if (token === "-f" || token === "--force") {
|
||
hasForce = true;
|
||
} else if (token.startsWith("-") && !token.startsWith("--")) {
|
||
if (token.includes("r") || token.includes("R"))
|
||
hasRecursive = true;
|
||
if (token.includes("f"))
|
||
hasForce = true;
|
||
}
|
||
}
|
||
return hasRecursive && hasForce;
|
||
}
|
||
|
||
// src/core/rules-rm.ts
|
||
var REASON_RM_RF = "rm -rf outside cwd is blocked. Use explicit paths within the current directory, or delete manually.";
|
||
var REASON_RM_RF_ROOT_HOME = "rm -rf targeting root or home directory is extremely dangerous and always blocked.";
|
||
function analyzeRm(tokens, options = {}) {
|
||
const {
|
||
cwd,
|
||
originalCwd,
|
||
paranoid = false,
|
||
allowTmpdirVar = true,
|
||
tmpdirOverridden = false
|
||
} = options;
|
||
const anchoredCwd = originalCwd ?? cwd ?? null;
|
||
const resolvedCwd = cwd ?? null;
|
||
const trustTmpdirVar = allowTmpdirVar && !tmpdirOverridden;
|
||
const ctx = {
|
||
anchoredCwd,
|
||
resolvedCwd,
|
||
paranoid,
|
||
trustTmpdirVar,
|
||
homeDir: getHomeDirForRmPolicy()
|
||
};
|
||
if (!hasRecursiveForceFlags(tokens)) {
|
||
return null;
|
||
}
|
||
const targets = extractTargets(tokens);
|
||
for (const target of targets) {
|
||
const classification = classifyTarget(target, ctx);
|
||
const reason = reasonForClassification(classification, ctx);
|
||
if (reason) {
|
||
return reason;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
function extractTargets(tokens) {
|
||
const targets = [];
|
||
let pastDoubleDash = false;
|
||
for (let i = 1;i < tokens.length; i++) {
|
||
const token = tokens[i];
|
||
if (!token)
|
||
continue;
|
||
if (token === "--") {
|
||
pastDoubleDash = true;
|
||
continue;
|
||
}
|
||
if (pastDoubleDash) {
|
||
targets.push(token);
|
||
continue;
|
||
}
|
||
if (!token.startsWith("-")) {
|
||
targets.push(token);
|
||
}
|
||
}
|
||
return targets;
|
||
}
|
||
function classifyTarget(target, ctx) {
|
||
if (isDangerousRootOrHomeTarget(target)) {
|
||
return { kind: "root_or_home_target" };
|
||
}
|
||
const anchoredCwd = ctx.anchoredCwd;
|
||
if (anchoredCwd) {
|
||
if (isCwdSelfTarget(target, anchoredCwd)) {
|
||
return { kind: "cwd_self_target" };
|
||
}
|
||
}
|
||
if (isTempTarget(target, ctx.trustTmpdirVar)) {
|
||
return { kind: "temp_target" };
|
||
}
|
||
if (anchoredCwd) {
|
||
if (isCwdHomeForRmPolicy(anchoredCwd, ctx.homeDir)) {
|
||
return { kind: "root_or_home_target" };
|
||
}
|
||
if (isTargetWithinCwd(target, anchoredCwd, ctx.resolvedCwd ?? anchoredCwd)) {
|
||
return { kind: "within_anchored_cwd" };
|
||
}
|
||
}
|
||
return { kind: "outside_anchored_cwd" };
|
||
}
|
||
function reasonForClassification(classification, ctx) {
|
||
switch (classification.kind) {
|
||
case "root_or_home_target":
|
||
return REASON_RM_RF_ROOT_HOME;
|
||
case "cwd_self_target":
|
||
return REASON_RM_RF;
|
||
case "temp_target":
|
||
return null;
|
||
case "within_anchored_cwd":
|
||
if (ctx.paranoid) {
|
||
return `${REASON_RM_RF} (SAFETY_NET_PARANOID_RM enabled)`;
|
||
}
|
||
return null;
|
||
case "outside_anchored_cwd":
|
||
return REASON_RM_RF;
|
||
}
|
||
}
|
||
function isDangerousRootOrHomeTarget(path) {
|
||
const normalized = path.trim();
|
||
if (normalized === "/" || normalized === "/*") {
|
||
return true;
|
||
}
|
||
if (normalized === "~" || normalized === "~/" || normalized.startsWith("~/")) {
|
||
if (normalized === "~" || normalized === "~/" || normalized === "~/*") {
|
||
return true;
|
||
}
|
||
}
|
||
if (normalized === "$HOME" || normalized === "$HOME/" || normalized === "$HOME/*") {
|
||
return true;
|
||
}
|
||
if (normalized === "${HOME}" || normalized === "${HOME}/" || normalized === "${HOME}/*") {
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
function isTempTarget(path, allowTmpdirVar) {
|
||
const normalized = path.trim();
|
||
if (normalized.includes("..")) {
|
||
return false;
|
||
}
|
||
if (normalized === "/tmp" || normalized.startsWith("/tmp/")) {
|
||
return true;
|
||
}
|
||
if (normalized === "/var/tmp" || normalized.startsWith("/var/tmp/")) {
|
||
return true;
|
||
}
|
||
const systemTmpdir = tmpdir();
|
||
if (normalized.startsWith(`${systemTmpdir}/`) || normalized === systemTmpdir) {
|
||
return true;
|
||
}
|
||
if (allowTmpdirVar) {
|
||
if (normalized === "$TMPDIR" || normalized.startsWith("$TMPDIR/")) {
|
||
return true;
|
||
}
|
||
if (normalized === "${TMPDIR}" || normalized.startsWith("${TMPDIR}/")) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
function getHomeDirForRmPolicy() {
|
||
return process.env.HOME ?? homedir();
|
||
}
|
||
function isCwdHomeForRmPolicy(cwd, homeDir) {
|
||
try {
|
||
const normalizedCwd = normalize(cwd);
|
||
const normalizedHome = normalize(homeDir);
|
||
return normalizedCwd === normalizedHome;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
function isCwdSelfTarget(target, cwd) {
|
||
if (target === "." || target === "./") {
|
||
return true;
|
||
}
|
||
try {
|
||
const resolved = resolve(cwd, target);
|
||
const realCwd = realpathSync(cwd);
|
||
const realResolved = realpathSync(resolved);
|
||
return realResolved === realCwd;
|
||
} catch {
|
||
try {
|
||
const resolved = resolve(cwd, target);
|
||
const normalizedCwd = normalize(cwd);
|
||
return resolved === normalizedCwd;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
function isTargetWithinCwd(target, originalCwd, effectiveCwd) {
|
||
const resolveCwd = effectiveCwd ?? originalCwd;
|
||
if (target.startsWith("~") || target.startsWith("$HOME") || target.startsWith("${HOME}")) {
|
||
return false;
|
||
}
|
||
if (target.includes("$") || target.includes("`")) {
|
||
return false;
|
||
}
|
||
if (target.startsWith("/")) {
|
||
try {
|
||
const normalizedTarget = normalize(target);
|
||
const normalizedCwd = `${normalize(originalCwd)}/`;
|
||
return normalizedTarget.startsWith(normalizedCwd);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
if (target.startsWith("./") || !target.includes("/")) {
|
||
try {
|
||
const resolved = resolve(resolveCwd, target);
|
||
const normalizedOriginalCwd = normalize(originalCwd);
|
||
return resolved.startsWith(`${normalizedOriginalCwd}/`) || resolved === normalizedOriginalCwd;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
if (target.startsWith("../")) {
|
||
return false;
|
||
}
|
||
try {
|
||
const resolved = resolve(resolveCwd, target);
|
||
const normalizedCwd = normalize(originalCwd);
|
||
return resolved.startsWith(`${normalizedCwd}/`) || resolved === normalizedCwd;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
function isHomeDirectory(cwd) {
|
||
const home = process.env.HOME ?? homedir();
|
||
try {
|
||
const normalizedCwd = normalize(cwd);
|
||
const normalizedHome = normalize(home);
|
||
return normalizedCwd === normalizedHome;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// src/core/analyze/constants.ts
|
||
var DISPLAY_COMMANDS = new Set([
|
||
"echo",
|
||
"printf",
|
||
"cat",
|
||
"head",
|
||
"tail",
|
||
"less",
|
||
"more",
|
||
"grep",
|
||
"rg",
|
||
"ag",
|
||
"ack",
|
||
"sed",
|
||
"awk",
|
||
"cut",
|
||
"tr",
|
||
"sort",
|
||
"uniq",
|
||
"wc",
|
||
"tee",
|
||
"man",
|
||
"help",
|
||
"info",
|
||
"type",
|
||
"which",
|
||
"whereis",
|
||
"whatis",
|
||
"apropos",
|
||
"file",
|
||
"stat",
|
||
"ls",
|
||
"ll",
|
||
"dir",
|
||
"tree",
|
||
"pwd",
|
||
"date",
|
||
"cal",
|
||
"uptime",
|
||
"whoami",
|
||
"id",
|
||
"groups",
|
||
"hostname",
|
||
"uname",
|
||
"env",
|
||
"printenv",
|
||
"set",
|
||
"export",
|
||
"alias",
|
||
"history",
|
||
"jobs",
|
||
"fg",
|
||
"bg",
|
||
"test",
|
||
"true",
|
||
"false",
|
||
"read",
|
||
"return",
|
||
"exit",
|
||
"break",
|
||
"continue",
|
||
"shift",
|
||
"wait",
|
||
"trap",
|
||
"basename",
|
||
"dirname",
|
||
"realpath",
|
||
"readlink",
|
||
"md5sum",
|
||
"sha256sum",
|
||
"base64",
|
||
"xxd",
|
||
"od",
|
||
"hexdump",
|
||
"strings",
|
||
"diff",
|
||
"cmp",
|
||
"comm",
|
||
"join",
|
||
"paste",
|
||
"column",
|
||
"fmt",
|
||
"fold",
|
||
"nl",
|
||
"pr",
|
||
"expand",
|
||
"unexpand",
|
||
"rev",
|
||
"tac",
|
||
"shuf",
|
||
"seq",
|
||
"yes",
|
||
"timeout",
|
||
"time",
|
||
"sleep",
|
||
"watch",
|
||
"logger",
|
||
"write",
|
||
"wall",
|
||
"mesg",
|
||
"notify-send"
|
||
]);
|
||
|
||
// src/core/analyze/find.ts
|
||
var REASON_FIND_DELETE = "find -delete permanently removes files. Use -print first to preview.";
|
||
function analyzeFind(tokens) {
|
||
if (findHasDelete(tokens.slice(1))) {
|
||
return REASON_FIND_DELETE;
|
||
}
|
||
for (let i = 0;i < tokens.length; i++) {
|
||
const token = tokens[i];
|
||
if (token === "-exec" || token === "-execdir") {
|
||
const execTokens = tokens.slice(i + 1);
|
||
const semicolonIdx = execTokens.indexOf(";");
|
||
const plusIdx = execTokens.indexOf("+");
|
||
const endIdx = semicolonIdx !== -1 && plusIdx !== -1 ? Math.min(semicolonIdx, plusIdx) : semicolonIdx !== -1 ? semicolonIdx : plusIdx !== -1 ? plusIdx : execTokens.length;
|
||
let execCommand = execTokens.slice(0, endIdx);
|
||
execCommand = stripWrappers(execCommand);
|
||
if (execCommand.length > 0) {
|
||
let head = getBasename(execCommand[0] ?? "");
|
||
if (head === "busybox" && execCommand.length > 1) {
|
||
execCommand = execCommand.slice(1);
|
||
head = getBasename(execCommand[0] ?? "");
|
||
}
|
||
if (head === "rm" && hasRecursiveForceFlags(execCommand)) {
|
||
return "find -exec rm -rf is dangerous. Use explicit file list instead.";
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
function findHasDelete(tokens) {
|
||
let i = 0;
|
||
let insideExec = false;
|
||
let execDepth = 0;
|
||
while (i < tokens.length) {
|
||
const token = tokens[i];
|
||
if (!token) {
|
||
i++;
|
||
continue;
|
||
}
|
||
if (token === "-exec" || token === "-execdir") {
|
||
insideExec = true;
|
||
execDepth++;
|
||
i++;
|
||
continue;
|
||
}
|
||
if (insideExec && (token === ";" || token === "+")) {
|
||
execDepth--;
|
||
if (execDepth === 0) {
|
||
insideExec = false;
|
||
}
|
||
i++;
|
||
continue;
|
||
}
|
||
if (insideExec) {
|
||
i++;
|
||
continue;
|
||
}
|
||
if (token === "-name" || token === "-iname" || token === "-path" || token === "-ipath" || token === "-regex" || token === "-iregex" || token === "-type" || token === "-user" || token === "-group" || token === "-perm" || token === "-size" || token === "-mtime" || token === "-ctime" || token === "-atime" || token === "-newer" || token === "-printf" || token === "-fprint" || token === "-fprintf") {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (token === "-delete") {
|
||
return true;
|
||
}
|
||
i++;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// src/core/analyze/interpreters.ts
|
||
function extractInterpreterCodeArg(tokens) {
|
||
for (let i = 1;i < tokens.length; i++) {
|
||
const token = tokens[i];
|
||
if (!token)
|
||
continue;
|
||
if ((token === "-c" || token === "-e") && tokens[i + 1]) {
|
||
return tokens[i + 1] ?? null;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
function containsDangerousCode(code) {
|
||
for (const pattern of DANGEROUS_PATTERNS) {
|
||
if (pattern.test(code)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// src/core/analyze/shell-wrappers.ts
|
||
function extractDashCArg(tokens) {
|
||
for (let i = 1;i < tokens.length; i++) {
|
||
const token = tokens[i];
|
||
if (!token)
|
||
continue;
|
||
if (token === "-c" && tokens[i + 1]) {
|
||
return tokens[i + 1] ?? null;
|
||
}
|
||
if (token.startsWith("-") && token.includes("c") && !token.startsWith("--")) {
|
||
const nextToken = tokens[i + 1];
|
||
if (nextToken && !nextToken.startsWith("-")) {
|
||
return nextToken;
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// src/core/analyze/parallel.ts
|
||
var REASON_PARALLEL_RM = "parallel rm -rf with dynamic input is dangerous. Use explicit file list instead.";
|
||
var REASON_PARALLEL_SHELL = "parallel with shell -c can execute arbitrary commands from dynamic input.";
|
||
function analyzeParallel(tokens, context) {
|
||
const parseResult = parseParallelCommand(tokens);
|
||
if (!parseResult) {
|
||
return null;
|
||
}
|
||
const { template, args, hasPlaceholder } = parseResult;
|
||
if (template.length === 0) {
|
||
for (const arg of args) {
|
||
const reason = context.analyzeNested(arg);
|
||
if (reason) {
|
||
return reason;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
let childTokens = stripWrappers([...template]);
|
||
let head = getBasename(childTokens[0] ?? "").toLowerCase();
|
||
if (head === "busybox" && childTokens.length > 1) {
|
||
childTokens = childTokens.slice(1);
|
||
head = getBasename(childTokens[0] ?? "").toLowerCase();
|
||
}
|
||
if (SHELL_WRAPPERS.has(head)) {
|
||
const dashCArg = extractDashCArg(childTokens);
|
||
if (dashCArg) {
|
||
if (dashCArg === "{}" || dashCArg === "{1}") {
|
||
return REASON_PARALLEL_SHELL;
|
||
}
|
||
if (dashCArg.includes("{}")) {
|
||
if (args.length > 0) {
|
||
for (const arg of args) {
|
||
const expandedScript = dashCArg.replace(/{}/g, arg);
|
||
const reason3 = context.analyzeNested(expandedScript);
|
||
if (reason3) {
|
||
return reason3;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
const reason2 = context.analyzeNested(dashCArg);
|
||
if (reason2) {
|
||
return reason2;
|
||
}
|
||
return null;
|
||
}
|
||
const reason = context.analyzeNested(dashCArg);
|
||
if (reason) {
|
||
return reason;
|
||
}
|
||
if (hasPlaceholder) {
|
||
return REASON_PARALLEL_SHELL;
|
||
}
|
||
return null;
|
||
}
|
||
if (args.length > 0) {
|
||
return REASON_PARALLEL_SHELL;
|
||
}
|
||
if (hasPlaceholder) {
|
||
return REASON_PARALLEL_SHELL;
|
||
}
|
||
return null;
|
||
}
|
||
if (head === "rm" && hasRecursiveForceFlags(childTokens)) {
|
||
if (hasPlaceholder && args.length > 0) {
|
||
for (const arg of args) {
|
||
const expandedTokens = childTokens.map((t) => t.replace(/{}/g, arg));
|
||
const rmResult = analyzeRm(expandedTokens, {
|
||
cwd: context.cwd,
|
||
originalCwd: context.originalCwd,
|
||
paranoid: context.paranoidRm,
|
||
allowTmpdirVar: context.allowTmpdirVar
|
||
});
|
||
if (rmResult) {
|
||
return rmResult;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
if (args.length > 0) {
|
||
const expandedTokens = [...childTokens, args[0] ?? ""];
|
||
const rmResult = analyzeRm(expandedTokens, {
|
||
cwd: context.cwd,
|
||
originalCwd: context.originalCwd,
|
||
paranoid: context.paranoidRm,
|
||
allowTmpdirVar: context.allowTmpdirVar
|
||
});
|
||
if (rmResult) {
|
||
return rmResult;
|
||
}
|
||
return null;
|
||
}
|
||
return REASON_PARALLEL_RM;
|
||
}
|
||
if (head === "find") {
|
||
const findResult = analyzeFind(childTokens);
|
||
if (findResult) {
|
||
return findResult;
|
||
}
|
||
}
|
||
if (head === "git") {
|
||
const gitResult = analyzeGit(childTokens);
|
||
if (gitResult) {
|
||
return gitResult;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
function parseParallelCommand(tokens) {
|
||
const parallelOptsWithValue = new Set([
|
||
"-S",
|
||
"--sshlogin",
|
||
"--slf",
|
||
"--sshloginfile",
|
||
"-a",
|
||
"--arg-file",
|
||
"--colsep",
|
||
"-I",
|
||
"--replace",
|
||
"--results",
|
||
"--result",
|
||
"--res"
|
||
]);
|
||
let i = 1;
|
||
const templateTokens = [];
|
||
let markerIndex = -1;
|
||
while (i < tokens.length) {
|
||
const token = tokens[i];
|
||
if (!token)
|
||
break;
|
||
if (token === ":::") {
|
||
markerIndex = i;
|
||
break;
|
||
}
|
||
if (token === "--") {
|
||
i++;
|
||
while (i < tokens.length) {
|
||
const token2 = tokens[i];
|
||
if (token2 === undefined || token2 === ":::")
|
||
break;
|
||
templateTokens.push(token2);
|
||
i++;
|
||
}
|
||
if (i < tokens.length && tokens[i] === ":::") {
|
||
markerIndex = i;
|
||
}
|
||
break;
|
||
}
|
||
if (token.startsWith("-")) {
|
||
if (token.startsWith("-j") && token.length > 2 && /^\d+$/.test(token.slice(2))) {
|
||
i++;
|
||
continue;
|
||
}
|
||
if (token.startsWith("--") && token.includes("=")) {
|
||
i++;
|
||
continue;
|
||
}
|
||
if (parallelOptsWithValue.has(token)) {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (token === "-j" || token === "--jobs") {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
i++;
|
||
} else {
|
||
while (i < tokens.length) {
|
||
const token2 = tokens[i];
|
||
if (token2 === undefined || token2 === ":::")
|
||
break;
|
||
templateTokens.push(token2);
|
||
i++;
|
||
}
|
||
if (i < tokens.length && tokens[i] === ":::") {
|
||
markerIndex = i;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
const args = [];
|
||
if (markerIndex !== -1) {
|
||
for (let j = markerIndex + 1;j < tokens.length; j++) {
|
||
const token = tokens[j];
|
||
if (token && token !== ":::") {
|
||
args.push(token);
|
||
}
|
||
}
|
||
}
|
||
const hasPlaceholder = templateTokens.some((t) => t.includes("{}") || t.includes("{1}") || t.includes("{.}"));
|
||
if (templateTokens.length === 0 && markerIndex === -1) {
|
||
return null;
|
||
}
|
||
return { template: templateTokens, args, hasPlaceholder };
|
||
}
|
||
|
||
// src/core/analyze/tmpdir.ts
|
||
import { tmpdir as tmpdir2 } from "node:os";
|
||
function isTmpdirOverriddenToNonTemp(envAssignments) {
|
||
if (!envAssignments.has("TMPDIR")) {
|
||
return false;
|
||
}
|
||
const tmpdirValue = envAssignments.get("TMPDIR") ?? "";
|
||
if (tmpdirValue === "") {
|
||
return true;
|
||
}
|
||
const sysTmpdir = tmpdir2();
|
||
if (isPathOrSubpath(tmpdirValue, "/tmp") || isPathOrSubpath(tmpdirValue, "/var/tmp") || isPathOrSubpath(tmpdirValue, sysTmpdir)) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
function isPathOrSubpath(path, basePath) {
|
||
if (path === basePath) {
|
||
return true;
|
||
}
|
||
const baseWithSlash = basePath.endsWith("/") ? basePath : `${basePath}/`;
|
||
return path.startsWith(baseWithSlash);
|
||
}
|
||
|
||
// src/core/analyze/xargs.ts
|
||
var REASON_XARGS_RM = "xargs rm -rf with dynamic input is dangerous. Use explicit file list instead.";
|
||
var REASON_XARGS_SHELL = "xargs with shell -c can execute arbitrary commands from dynamic input.";
|
||
function analyzeXargs(tokens, context) {
|
||
const { childTokens: rawChildTokens } = extractXargsChildCommandWithInfo(tokens);
|
||
let childTokens = stripWrappers(rawChildTokens);
|
||
if (childTokens.length === 0) {
|
||
return null;
|
||
}
|
||
let head = getBasename(childTokens[0] ?? "").toLowerCase();
|
||
if (head === "busybox" && childTokens.length > 1) {
|
||
childTokens = childTokens.slice(1);
|
||
head = getBasename(childTokens[0] ?? "").toLowerCase();
|
||
}
|
||
if (SHELL_WRAPPERS.has(head)) {
|
||
return REASON_XARGS_SHELL;
|
||
}
|
||
if (head === "rm" && hasRecursiveForceFlags(childTokens)) {
|
||
const rmResult = analyzeRm(childTokens, {
|
||
cwd: context.cwd,
|
||
originalCwd: context.originalCwd,
|
||
paranoid: context.paranoidRm,
|
||
allowTmpdirVar: context.allowTmpdirVar
|
||
});
|
||
if (rmResult) {
|
||
return rmResult;
|
||
}
|
||
return REASON_XARGS_RM;
|
||
}
|
||
if (head === "find") {
|
||
const findResult = analyzeFind(childTokens);
|
||
if (findResult) {
|
||
return findResult;
|
||
}
|
||
}
|
||
if (head === "git") {
|
||
const gitResult = analyzeGit(childTokens);
|
||
if (gitResult) {
|
||
return gitResult;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
function extractXargsChildCommandWithInfo(tokens) {
|
||
const xargsOptsWithValue = new Set([
|
||
"-L",
|
||
"-n",
|
||
"-P",
|
||
"-s",
|
||
"-a",
|
||
"-E",
|
||
"-e",
|
||
"-d",
|
||
"-J",
|
||
"--max-args",
|
||
"--max-procs",
|
||
"--max-chars",
|
||
"--arg-file",
|
||
"--eof",
|
||
"--delimiter",
|
||
"--max-lines"
|
||
]);
|
||
let replacementToken = null;
|
||
let i = 1;
|
||
while (i < tokens.length) {
|
||
const token = tokens[i];
|
||
if (!token)
|
||
break;
|
||
if (token === "--") {
|
||
return { childTokens: [...tokens.slice(i + 1)], replacementToken };
|
||
}
|
||
if (token.startsWith("-")) {
|
||
if (token === "-I") {
|
||
replacementToken = tokens[i + 1] ?? "{}";
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (token.startsWith("-I") && token.length > 2) {
|
||
replacementToken = token.slice(2);
|
||
i++;
|
||
continue;
|
||
}
|
||
if (token === "--replace") {
|
||
replacementToken = "{}";
|
||
i++;
|
||
continue;
|
||
}
|
||
if (token.startsWith("--replace=")) {
|
||
const value = token.slice("--replace=".length);
|
||
replacementToken = value === "" ? "{}" : value;
|
||
i++;
|
||
continue;
|
||
}
|
||
if (token === "-J") {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (xargsOptsWithValue.has(token)) {
|
||
i += 2;
|
||
} else if (token.startsWith("--") && token.includes("=")) {
|
||
i++;
|
||
} else if (token.startsWith("-L") || token.startsWith("-n") || token.startsWith("-P") || token.startsWith("-s")) {
|
||
i++;
|
||
} else {
|
||
i++;
|
||
}
|
||
} else {
|
||
return { childTokens: [...tokens.slice(i)], replacementToken };
|
||
}
|
||
}
|
||
return { childTokens: [], replacementToken };
|
||
}
|
||
|
||
// src/core/analyze/segment.ts
|
||
var REASON_INTERPRETER_DANGEROUS = "Detected potentially dangerous command in interpreter code.";
|
||
var REASON_INTERPRETER_BLOCKED = "Interpreter one-liners are blocked in paranoid mode.";
|
||
var REASON_RM_HOME_CWD = "rm -rf in home directory is dangerous. Change to a project directory first.";
|
||
function deriveCwdContext(options) {
|
||
const cwdUnknown = options.effectiveCwd === null;
|
||
const cwdForRm = cwdUnknown ? undefined : options.effectiveCwd ?? options.cwd;
|
||
const originalCwd = cwdUnknown ? undefined : options.cwd;
|
||
return { cwdUnknown, cwdForRm, originalCwd };
|
||
}
|
||
function analyzeSegment(tokens, depth, options) {
|
||
if (tokens.length === 0) {
|
||
return null;
|
||
}
|
||
const { tokens: strippedEnv, envAssignments: leadingEnvAssignments } = stripEnvAssignmentsWithInfo(tokens);
|
||
const { tokens: stripped, envAssignments: wrapperEnvAssignments } = stripWrappersWithInfo(strippedEnv);
|
||
const envAssignments = new Map(leadingEnvAssignments);
|
||
for (const [k, v] of wrapperEnvAssignments) {
|
||
envAssignments.set(k, v);
|
||
}
|
||
if (stripped.length === 0) {
|
||
return null;
|
||
}
|
||
const head = stripped[0];
|
||
if (!head) {
|
||
return null;
|
||
}
|
||
const normalizedHead = normalizeCommandToken(head);
|
||
const basename = getBasename(head);
|
||
const { cwdForRm, originalCwd } = deriveCwdContext(options);
|
||
const allowTmpdirVar = !isTmpdirOverriddenToNonTemp(envAssignments);
|
||
if (SHELL_WRAPPERS.has(normalizedHead)) {
|
||
const dashCArg = extractDashCArg(stripped);
|
||
if (dashCArg) {
|
||
return options.analyzeNested(dashCArg);
|
||
}
|
||
}
|
||
if (INTERPRETERS.has(normalizedHead)) {
|
||
const codeArg = extractInterpreterCodeArg(stripped);
|
||
if (codeArg) {
|
||
if (options.paranoidInterpreters) {
|
||
return REASON_INTERPRETER_BLOCKED + PARANOID_INTERPRETERS_SUFFIX;
|
||
}
|
||
const innerReason = options.analyzeNested(codeArg);
|
||
if (innerReason) {
|
||
return innerReason;
|
||
}
|
||
if (containsDangerousCode(codeArg)) {
|
||
return REASON_INTERPRETER_DANGEROUS;
|
||
}
|
||
}
|
||
}
|
||
if (normalizedHead === "busybox" && stripped.length > 1) {
|
||
return analyzeSegment(stripped.slice(1), depth, options);
|
||
}
|
||
const isGit = basename.toLowerCase() === "git";
|
||
const isRm = basename === "rm";
|
||
const isFind = basename === "find";
|
||
const isXargs = basename === "xargs";
|
||
const isParallel = basename === "parallel";
|
||
if (isGit) {
|
||
const gitResult = analyzeGit(stripped);
|
||
if (gitResult) {
|
||
return gitResult;
|
||
}
|
||
}
|
||
if (isRm) {
|
||
if (cwdForRm && isHomeDirectory(cwdForRm)) {
|
||
if (hasRecursiveForceFlags(stripped)) {
|
||
return REASON_RM_HOME_CWD;
|
||
}
|
||
}
|
||
const rmResult = analyzeRm(stripped, {
|
||
cwd: cwdForRm,
|
||
originalCwd,
|
||
paranoid: options.paranoidRm,
|
||
allowTmpdirVar
|
||
});
|
||
if (rmResult) {
|
||
return rmResult;
|
||
}
|
||
}
|
||
if (isFind) {
|
||
const findResult = analyzeFind(stripped);
|
||
if (findResult) {
|
||
return findResult;
|
||
}
|
||
}
|
||
if (isXargs) {
|
||
const xargsResult = analyzeXargs(stripped, {
|
||
cwd: cwdForRm,
|
||
originalCwd,
|
||
paranoidRm: options.paranoidRm,
|
||
allowTmpdirVar
|
||
});
|
||
if (xargsResult) {
|
||
return xargsResult;
|
||
}
|
||
}
|
||
if (isParallel) {
|
||
const parallelResult = analyzeParallel(stripped, {
|
||
cwd: cwdForRm,
|
||
originalCwd,
|
||
paranoidRm: options.paranoidRm,
|
||
allowTmpdirVar,
|
||
analyzeNested: options.analyzeNested
|
||
});
|
||
if (parallelResult) {
|
||
return parallelResult;
|
||
}
|
||
}
|
||
const matchedKnown = isGit || isRm || isFind || isXargs || isParallel;
|
||
if (!matchedKnown) {
|
||
if (!DISPLAY_COMMANDS.has(normalizedHead)) {
|
||
for (let i = 1;i < stripped.length; i++) {
|
||
const token = stripped[i];
|
||
if (!token)
|
||
continue;
|
||
const cmd = normalizeCommandToken(token);
|
||
if (cmd === "rm") {
|
||
const rmTokens = ["rm", ...stripped.slice(i + 1)];
|
||
const reason = analyzeRm(rmTokens, {
|
||
cwd: cwdForRm,
|
||
originalCwd,
|
||
paranoid: options.paranoidRm,
|
||
allowTmpdirVar
|
||
});
|
||
if (reason) {
|
||
return reason;
|
||
}
|
||
}
|
||
if (cmd === "git") {
|
||
const gitTokens = ["git", ...stripped.slice(i + 1)];
|
||
const reason = analyzeGit(gitTokens);
|
||
if (reason) {
|
||
return reason;
|
||
}
|
||
}
|
||
if (cmd === "find") {
|
||
const findTokens = ["find", ...stripped.slice(i + 1)];
|
||
const reason = analyzeFind(findTokens);
|
||
if (reason) {
|
||
return reason;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
const customRulesTopLevelOnly = isGit || isRm || isFind || isXargs || isParallel;
|
||
if (depth === 0 || !customRulesTopLevelOnly) {
|
||
const customResult = checkCustomRules(stripped, options.config.rules);
|
||
if (customResult) {
|
||
return customResult;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
var CWD_CHANGE_REGEX = /^\s*(?:\$\(\s*)?[({]*\s*(?:command\s+|builtin\s+)?(?:cd|pushd|popd)(?:\s|$)/;
|
||
function segmentChangesCwd(segment) {
|
||
const stripped = stripLeadingGrouping(segment);
|
||
const unwrapped = stripWrappers([...stripped]);
|
||
if (unwrapped.length === 0) {
|
||
return false;
|
||
}
|
||
let head = unwrapped[0] ?? "";
|
||
if (head === "builtin" && unwrapped.length > 1) {
|
||
head = unwrapped[1] ?? "";
|
||
}
|
||
if (head === "cd" || head === "pushd" || head === "popd") {
|
||
return true;
|
||
}
|
||
const joined = segment.join(" ");
|
||
return CWD_CHANGE_REGEX.test(joined);
|
||
}
|
||
function stripLeadingGrouping(tokens) {
|
||
let i = 0;
|
||
while (i < tokens.length) {
|
||
const token = tokens[i];
|
||
if (token === "{" || token === "(" || token === "$(") {
|
||
i++;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
return tokens.slice(i);
|
||
}
|
||
|
||
// src/core/analyze/analyze-command.ts
|
||
var REASON_STRICT_UNPARSEABLE = "Command could not be safely analyzed (strict mode). Verify manually.";
|
||
var REASON_RECURSION_LIMIT = "Command exceeds maximum recursion depth and cannot be safely analyzed.";
|
||
function analyzeCommandInternal(command, depth, options) {
|
||
if (depth >= MAX_RECURSION_DEPTH) {
|
||
return { reason: REASON_RECURSION_LIMIT, segment: command };
|
||
}
|
||
const segments = splitShellCommands(command);
|
||
if (options.strict && segments.length === 1 && segments[0]?.length === 1 && segments[0][0] === command && command.includes(" ")) {
|
||
return { reason: REASON_STRICT_UNPARSEABLE, segment: command };
|
||
}
|
||
const originalCwd = options.cwd;
|
||
let effectiveCwd = options.cwd;
|
||
for (const segment of segments) {
|
||
const segmentStr = segment.join(" ");
|
||
if (segment.length === 1 && segment[0]?.includes(" ")) {
|
||
const textReason = dangerousInText(segment[0]);
|
||
if (textReason) {
|
||
return { reason: textReason, segment: segmentStr };
|
||
}
|
||
if (segmentChangesCwd(segment)) {
|
||
effectiveCwd = null;
|
||
}
|
||
continue;
|
||
}
|
||
const reason = analyzeSegment(segment, depth, {
|
||
...options,
|
||
cwd: originalCwd,
|
||
effectiveCwd,
|
||
analyzeNested: (nestedCommand) => {
|
||
return analyzeCommandInternal(nestedCommand, depth + 1, options)?.reason ?? null;
|
||
}
|
||
});
|
||
if (reason) {
|
||
return { reason, segment: segmentStr };
|
||
}
|
||
if (segmentChangesCwd(segment)) {
|
||
effectiveCwd = null;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// src/core/config.ts
|
||
import { existsSync, readFileSync } from "node:fs";
|
||
import { homedir as homedir2 } from "node:os";
|
||
import { join, resolve as resolve2 } from "node:path";
|
||
var DEFAULT_CONFIG = {
|
||
version: 1,
|
||
rules: []
|
||
};
|
||
function loadConfig(cwd, options) {
|
||
const safeCwd = typeof cwd === "string" ? cwd : process.cwd();
|
||
const userConfigDir = options?.userConfigDir ?? join(homedir2(), ".cc-safety-net");
|
||
const userConfigPath = join(userConfigDir, "config.json");
|
||
const projectConfigPath = join(safeCwd, ".safety-net.json");
|
||
const userConfig = loadSingleConfig(userConfigPath);
|
||
const projectConfig = loadSingleConfig(projectConfigPath);
|
||
return mergeConfigs(userConfig, projectConfig);
|
||
}
|
||
function loadSingleConfig(path) {
|
||
if (!existsSync(path)) {
|
||
return null;
|
||
}
|
||
try {
|
||
const content = readFileSync(path, "utf-8");
|
||
if (!content.trim()) {
|
||
return null;
|
||
}
|
||
const parsed = JSON.parse(content);
|
||
const result = validateConfig(parsed);
|
||
if (result.errors.length > 0) {
|
||
return null;
|
||
}
|
||
const cfg = parsed;
|
||
return {
|
||
version: cfg.version,
|
||
rules: cfg.rules ?? []
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
function mergeConfigs(userConfig, projectConfig) {
|
||
if (!userConfig && !projectConfig) {
|
||
return DEFAULT_CONFIG;
|
||
}
|
||
if (!userConfig) {
|
||
return projectConfig ?? DEFAULT_CONFIG;
|
||
}
|
||
if (!projectConfig) {
|
||
return userConfig;
|
||
}
|
||
const projectRuleNames = new Set(projectConfig.rules.map((r) => r.name.toLowerCase()));
|
||
const mergedRules = [
|
||
...userConfig.rules.filter((r) => !projectRuleNames.has(r.name.toLowerCase())),
|
||
...projectConfig.rules
|
||
];
|
||
return {
|
||
version: 1,
|
||
rules: mergedRules
|
||
};
|
||
}
|
||
function validateConfig(config) {
|
||
const errors = [];
|
||
const ruleNames = new Set;
|
||
if (!config || typeof config !== "object") {
|
||
errors.push("Config must be an object");
|
||
return { errors, ruleNames };
|
||
}
|
||
const cfg = config;
|
||
if (cfg.version !== 1) {
|
||
errors.push("version must be 1");
|
||
}
|
||
if (cfg.rules !== undefined) {
|
||
if (!Array.isArray(cfg.rules)) {
|
||
errors.push("rules must be an array");
|
||
} else {
|
||
for (let i = 0;i < cfg.rules.length; i++) {
|
||
const rule = cfg.rules[i];
|
||
const ruleErrors = validateRule(rule, i, ruleNames);
|
||
errors.push(...ruleErrors);
|
||
}
|
||
}
|
||
}
|
||
return { errors, ruleNames };
|
||
}
|
||
function validateRule(rule, index, ruleNames) {
|
||
const errors = [];
|
||
const prefix = `rules[${index}]`;
|
||
if (!rule || typeof rule !== "object") {
|
||
errors.push(`${prefix}: must be an object`);
|
||
return errors;
|
||
}
|
||
const r = rule;
|
||
if (typeof r.name !== "string") {
|
||
errors.push(`${prefix}.name: required string`);
|
||
} else {
|
||
if (!NAME_PATTERN.test(r.name)) {
|
||
errors.push(`${prefix}.name: must match pattern (letters, numbers, hyphens, underscores; max 64 chars)`);
|
||
}
|
||
const lowerName = r.name.toLowerCase();
|
||
if (ruleNames.has(lowerName)) {
|
||
errors.push(`${prefix}.name: duplicate rule name "${r.name}"`);
|
||
} else {
|
||
ruleNames.add(lowerName);
|
||
}
|
||
}
|
||
if (typeof r.command !== "string") {
|
||
errors.push(`${prefix}.command: required string`);
|
||
} else if (!COMMAND_PATTERN.test(r.command)) {
|
||
errors.push(`${prefix}.command: must match pattern (letters, numbers, hyphens, underscores)`);
|
||
}
|
||
if (r.subcommand !== undefined) {
|
||
if (typeof r.subcommand !== "string") {
|
||
errors.push(`${prefix}.subcommand: must be a string if provided`);
|
||
} else if (!COMMAND_PATTERN.test(r.subcommand)) {
|
||
errors.push(`${prefix}.subcommand: must match pattern (letters, numbers, hyphens, underscores)`);
|
||
}
|
||
}
|
||
if (!Array.isArray(r.block_args)) {
|
||
errors.push(`${prefix}.block_args: required array`);
|
||
} else {
|
||
if (r.block_args.length === 0) {
|
||
errors.push(`${prefix}.block_args: must have at least one element`);
|
||
}
|
||
for (let i = 0;i < r.block_args.length; i++) {
|
||
const arg = r.block_args[i];
|
||
if (typeof arg !== "string") {
|
||
errors.push(`${prefix}.block_args[${i}]: must be a string`);
|
||
} else if (arg === "") {
|
||
errors.push(`${prefix}.block_args[${i}]: must not be empty`);
|
||
}
|
||
}
|
||
}
|
||
if (typeof r.reason !== "string") {
|
||
errors.push(`${prefix}.reason: required string`);
|
||
} else if (r.reason === "") {
|
||
errors.push(`${prefix}.reason: must not be empty`);
|
||
} else if (r.reason.length > MAX_REASON_LENGTH) {
|
||
errors.push(`${prefix}.reason: must be at most ${MAX_REASON_LENGTH} characters`);
|
||
}
|
||
return errors;
|
||
}
|
||
function validateConfigFile(path) {
|
||
const errors = [];
|
||
const ruleNames = new Set;
|
||
if (!existsSync(path)) {
|
||
errors.push(`File not found: ${path}`);
|
||
return { errors, ruleNames };
|
||
}
|
||
try {
|
||
const content = readFileSync(path, "utf-8");
|
||
if (!content.trim()) {
|
||
errors.push("Config file is empty");
|
||
return { errors, ruleNames };
|
||
}
|
||
const parsed = JSON.parse(content);
|
||
return validateConfig(parsed);
|
||
} catch (e) {
|
||
errors.push(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
||
return { errors, ruleNames };
|
||
}
|
||
}
|
||
function getUserConfigPath() {
|
||
return join(homedir2(), ".cc-safety-net", "config.json");
|
||
}
|
||
function getProjectConfigPath(cwd) {
|
||
return resolve2(cwd ?? process.cwd(), ".safety-net.json");
|
||
}
|
||
|
||
// src/core/analyze.ts
|
||
function analyzeCommand(command, options = {}) {
|
||
const config = options.config ?? loadConfig(options.cwd);
|
||
return analyzeCommandInternal(command, 0, { ...options, config });
|
||
}
|
||
|
||
// src/core/audit.ts
|
||
import { appendFileSync, existsSync as existsSync2, mkdirSync } from "node:fs";
|
||
import { homedir as homedir3 } from "node:os";
|
||
import { join as join2 } from "node:path";
|
||
function sanitizeSessionIdForFilename(sessionId) {
|
||
const raw = sessionId.trim();
|
||
if (!raw) {
|
||
return null;
|
||
}
|
||
let safe = raw.replace(/[^A-Za-z0-9_.-]+/g, "_");
|
||
safe = safe.replace(/^[._-]+|[._-]+$/g, "").slice(0, 128);
|
||
if (!safe || safe === "." || safe === "..") {
|
||
return null;
|
||
}
|
||
return safe;
|
||
}
|
||
function writeAuditLog(sessionId, command, segment, reason, cwd, options = {}) {
|
||
const safeSessionId = sanitizeSessionIdForFilename(sessionId);
|
||
if (!safeSessionId) {
|
||
return;
|
||
}
|
||
const home = options.homeDir ?? homedir3();
|
||
const logsDir = join2(home, ".cc-safety-net", "logs");
|
||
try {
|
||
if (!existsSync2(logsDir)) {
|
||
mkdirSync(logsDir, { recursive: true });
|
||
}
|
||
const logFile = join2(logsDir, `${safeSessionId}.jsonl`);
|
||
const entry = {
|
||
ts: new Date().toISOString(),
|
||
command: redactSecrets(command).slice(0, 300),
|
||
segment: redactSecrets(segment).slice(0, 300),
|
||
reason,
|
||
cwd
|
||
};
|
||
appendFileSync(logFile, `${JSON.stringify(entry)}
|
||
`, "utf-8");
|
||
} catch {}
|
||
}
|
||
function redactSecrets(text) {
|
||
let result = text;
|
||
result = result.replace(/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASS|KEY|CREDENTIALS)[A-Z0-9_]*)=([^\s]+)/gi, "$1=<redacted>");
|
||
result = result.replace(/(['"]?\s*authorization\s*:\s*)([^'"]+)(['"]?)/gi, "$1<redacted>$3");
|
||
result = result.replace(/(authorization\s*:\s*)([^\s"']+)(\s+[^\s"']+)?/gi, "$1<redacted>");
|
||
result = result.replace(/(https?:\/\/)([^\s/:@]+):([^\s@]+)@/gi, "$1<redacted>:<redacted>@");
|
||
result = result.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, "<redacted>");
|
||
return result;
|
||
}
|
||
|
||
// src/core/env.ts
|
||
function envTruthy(name) {
|
||
const value = process.env[name];
|
||
return value === "1" || value?.toLowerCase() === "true";
|
||
}
|
||
|
||
// src/core/format.ts
|
||
function formatBlockedMessage(input) {
|
||
const { reason, command, segment } = input;
|
||
const maxLen = input.maxLen ?? 200;
|
||
const redact = input.redact ?? ((t) => t);
|
||
let message = `BLOCKED by Safety Net
|
||
|
||
Reason: ${reason}`;
|
||
if (command) {
|
||
const safeCommand = redact(command);
|
||
message += `
|
||
|
||
Command: ${excerpt(safeCommand, maxLen)}`;
|
||
}
|
||
if (segment && segment !== command) {
|
||
const safeSegment = redact(segment);
|
||
message += `
|
||
|
||
Segment: ${excerpt(safeSegment, maxLen)}`;
|
||
}
|
||
message += `
|
||
|
||
If this operation is truly needed, ask the user for explicit permission and have them run the command manually.`;
|
||
return message;
|
||
}
|
||
function excerpt(text, maxLen) {
|
||
return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text;
|
||
}
|
||
|
||
// src/bin/claude-code.ts
|
||
function outputDeny(reason, command, segment) {
|
||
const message = formatBlockedMessage({
|
||
reason,
|
||
command,
|
||
segment,
|
||
redact: redactSecrets
|
||
});
|
||
const output = {
|
||
hookSpecificOutput: {
|
||
hookEventName: "PreToolUse",
|
||
permissionDecision: "deny",
|
||
permissionDecisionReason: message
|
||
}
|
||
};
|
||
console.log(JSON.stringify(output));
|
||
}
|
||
async function runClaudeCodeHook() {
|
||
const chunks = [];
|
||
for await (const chunk of process.stdin) {
|
||
chunks.push(chunk);
|
||
}
|
||
const inputText = Buffer.concat(chunks).toString("utf-8").trim();
|
||
if (!inputText) {
|
||
return;
|
||
}
|
||
let input;
|
||
try {
|
||
input = JSON.parse(inputText);
|
||
} catch {
|
||
if (envTruthy("SAFETY_NET_STRICT")) {
|
||
outputDeny("Failed to parse hook input JSON (strict mode)");
|
||
}
|
||
return;
|
||
}
|
||
if (input.tool_name !== "Bash") {
|
||
return;
|
||
}
|
||
const command = input.tool_input?.command;
|
||
if (!command) {
|
||
return;
|
||
}
|
||
const cwd = input.cwd ?? process.cwd();
|
||
const strict = envTruthy("SAFETY_NET_STRICT");
|
||
const paranoidAll = envTruthy("SAFETY_NET_PARANOID");
|
||
const paranoidRm = paranoidAll || envTruthy("SAFETY_NET_PARANOID_RM");
|
||
const paranoidInterpreters = paranoidAll || envTruthy("SAFETY_NET_PARANOID_INTERPRETERS");
|
||
const config = loadConfig(cwd);
|
||
const result = analyzeCommand(command, {
|
||
cwd,
|
||
config,
|
||
strict,
|
||
paranoidRm,
|
||
paranoidInterpreters
|
||
});
|
||
if (result) {
|
||
const sessionId = input.session_id;
|
||
if (sessionId) {
|
||
writeAuditLog(sessionId, command, result.segment, result.reason, cwd);
|
||
}
|
||
outputDeny(result.reason, command, result.segment);
|
||
}
|
||
}
|
||
|
||
// src/bin/custom-rules-doc.ts
|
||
var CUSTOM_RULES_DOC = `# Custom Rules Reference
|
||
|
||
Agent reference for generating \`.safety-net.json\` config files.
|
||
|
||
## Config Locations
|
||
|
||
| Scope | Path | Priority |
|
||
|-------|------|----------|
|
||
| User | \`~/.cc-safety-net/config.json\` | Lower |
|
||
| Project | \`.safety-net.json\` (cwd) | Higher (overrides user) |
|
||
|
||
Duplicate rule names (case-insensitive) → project wins.
|
||
|
||
## Schema
|
||
|
||
\`\`\`json
|
||
{
|
||
"$schema": "https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json",
|
||
"version": 1,
|
||
"rules": [...]
|
||
}
|
||
\`\`\`
|
||
|
||
- \`$schema\`: Optional. Enables IDE autocomplete and inline validation.
|
||
- \`version\`: Required. Must be \`1\`.
|
||
- \`rules\`: Optional. Defaults to \`[]\`.
|
||
|
||
**Always include \`$schema\`** when generating config files for IDE support.
|
||
|
||
## Rule Fields
|
||
|
||
| Field | Required | Constraints |
|
||
|-------|----------|-------------|
|
||
| \`name\` | Yes | \`^[a-zA-Z][a-zA-Z0-9_-]{0,63}$\` — unique (case-insensitive) |
|
||
| \`command\` | Yes | \`^[a-zA-Z][a-zA-Z0-9_-]*$\` — basename only, not path |
|
||
| \`subcommand\` | No | Same pattern as command. Omit to match any. |
|
||
| \`block_args\` | Yes | Non-empty array of non-empty strings |
|
||
| \`reason\` | Yes | Non-empty string, max 256 chars |
|
||
|
||
## Guidelines:
|
||
|
||
- \`name\`: kebab-case, descriptive (e.g., \`block-git-add-all\`)
|
||
- \`command\`: binary name only, lowercase
|
||
- \`subcommand\`: omit if rule applies to any subcommand
|
||
- \`block_args\`: include all variants (e.g., both \`-g\` and \`--global\`)
|
||
- \`reason\`: explain why blocked AND suggest alternative
|
||
|
||
## Matching Behavior
|
||
|
||
- **Command**: Normalized to basename (\`/usr/bin/git\` → \`git\`)
|
||
- **Subcommand**: First non-option argument after command
|
||
- **Arguments**: Matched literally. Command blocked if **any** \`block_args\` item present.
|
||
- **Short options**: Expanded (\`-Ap\` matches \`-A\`)
|
||
- **Long options**: Exact match (\`--all-files\` does NOT match \`--all\`)
|
||
- **Execution order**: Built-in rules first, then custom rules (additive only)
|
||
|
||
## Examples
|
||
|
||
### Block \`git add -A\`
|
||
|
||
\`\`\`json
|
||
{
|
||
"$schema": "https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json",
|
||
"version": 1,
|
||
"rules": [
|
||
{
|
||
"name": "block-git-add-all",
|
||
"command": "git",
|
||
"subcommand": "add",
|
||
"block_args": ["-A", "--all", "."],
|
||
"reason": "Use 'git add <specific-files>' instead."
|
||
}
|
||
]
|
||
}
|
||
\`\`\`
|
||
|
||
### Block global npm install
|
||
|
||
\`\`\`json
|
||
{
|
||
"$schema": "https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json",
|
||
"version": 1,
|
||
"rules": [
|
||
{
|
||
"name": "block-npm-global",
|
||
"command": "npm",
|
||
"subcommand": "install",
|
||
"block_args": ["-g", "--global"],
|
||
"reason": "Use npx or local install."
|
||
}
|
||
]
|
||
}
|
||
\`\`\`
|
||
|
||
### Block docker system prune
|
||
|
||
\`\`\`json
|
||
{
|
||
"$schema": "https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json",
|
||
"version": 1,
|
||
"rules": [
|
||
{
|
||
"name": "block-docker-prune",
|
||
"command": "docker",
|
||
"subcommand": "system",
|
||
"block_args": ["prune"],
|
||
"reason": "Use targeted cleanup instead."
|
||
}
|
||
]
|
||
}
|
||
\`\`\`
|
||
|
||
## Error Handling
|
||
|
||
Invalid config → silent fallback to built-in rules only. No custom rules applied.
|
||
`;
|
||
|
||
// src/bin/gemini-cli.ts
|
||
function outputGeminiDeny(reason, command, segment) {
|
||
const message = formatBlockedMessage({
|
||
reason,
|
||
command,
|
||
segment,
|
||
redact: redactSecrets
|
||
});
|
||
const output = {
|
||
decision: "deny",
|
||
reason: message,
|
||
systemMessage: message
|
||
};
|
||
console.log(JSON.stringify(output));
|
||
}
|
||
async function runGeminiCLIHook() {
|
||
const chunks = [];
|
||
for await (const chunk of process.stdin) {
|
||
chunks.push(chunk);
|
||
}
|
||
const inputText = Buffer.concat(chunks).toString("utf-8").trim();
|
||
if (!inputText) {
|
||
return;
|
||
}
|
||
let input;
|
||
try {
|
||
input = JSON.parse(inputText);
|
||
} catch {
|
||
if (envTruthy("SAFETY_NET_STRICT")) {
|
||
outputGeminiDeny("Failed to parse hook input JSON (strict mode)");
|
||
}
|
||
return;
|
||
}
|
||
if (input.hook_event_name !== "BeforeTool") {
|
||
return;
|
||
}
|
||
if (input.tool_name !== "run_shell_command") {
|
||
return;
|
||
}
|
||
const command = input.tool_input?.command;
|
||
if (!command) {
|
||
return;
|
||
}
|
||
const cwd = input.cwd ?? process.cwd();
|
||
const strict = envTruthy("SAFETY_NET_STRICT");
|
||
const paranoidAll = envTruthy("SAFETY_NET_PARANOID");
|
||
const paranoidRm = paranoidAll || envTruthy("SAFETY_NET_PARANOID_RM");
|
||
const paranoidInterpreters = paranoidAll || envTruthy("SAFETY_NET_PARANOID_INTERPRETERS");
|
||
const config = loadConfig(cwd);
|
||
const result = analyzeCommand(command, {
|
||
cwd,
|
||
config,
|
||
strict,
|
||
paranoidRm,
|
||
paranoidInterpreters
|
||
});
|
||
if (result) {
|
||
const sessionId = input.session_id;
|
||
if (sessionId) {
|
||
writeAuditLog(sessionId, command, result.segment, result.reason, cwd);
|
||
}
|
||
outputGeminiDeny(result.reason, command, result.segment);
|
||
}
|
||
}
|
||
|
||
// src/bin/help.ts
|
||
var version = "0.5.1";
|
||
function printHelp() {
|
||
console.log(`cc-safety-net v${version}
|
||
|
||
Blocks destructive git and filesystem commands before execution.
|
||
|
||
USAGE:
|
||
cc-safety-net -cc, --claude-code Run as Claude Code PreToolUse hook (reads JSON from stdin)
|
||
cc-safety-net -gc, --gemini-cli Run as Gemini CLI BeforeTool hook (reads JSON from stdin)
|
||
cc-safety-net -vc, --verify-config Validate config files
|
||
cc-safety-net --custom-rules-doc Print custom rules documentation
|
||
cc-safety-net --statusline Print status line with mode indicators
|
||
cc-safety-net -h, --help Show this help
|
||
cc-safety-net -V, --version Show version
|
||
|
||
ENVIRONMENT VARIABLES:
|
||
SAFETY_NET_STRICT=1 Fail-closed on unparseable commands
|
||
SAFETY_NET_PARANOID=1 Enable all paranoid checks
|
||
SAFETY_NET_PARANOID_RM=1 Block non-temp rm -rf within cwd
|
||
SAFETY_NET_PARANOID_INTERPRETERS=1 Block interpreter one-liners
|
||
|
||
CONFIG FILES:
|
||
~/.cc-safety-net/config.json User-scope config
|
||
.safety-net.json Project-scope config`);
|
||
}
|
||
function printVersion() {
|
||
console.log(version);
|
||
}
|
||
|
||
// src/bin/statusline.ts
|
||
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "node:fs";
|
||
import { homedir as homedir4 } from "node:os";
|
||
import { join as join3 } from "node:path";
|
||
async function readStdinAsync() {
|
||
if (process.stdin.isTTY) {
|
||
return null;
|
||
}
|
||
return new Promise((resolve3) => {
|
||
let data = "";
|
||
process.stdin.setEncoding("utf-8");
|
||
process.stdin.on("data", (chunk) => {
|
||
data += chunk;
|
||
});
|
||
process.stdin.on("end", () => {
|
||
const trimmed = data.trim();
|
||
resolve3(trimmed || null);
|
||
});
|
||
process.stdin.on("error", () => {
|
||
resolve3(null);
|
||
});
|
||
});
|
||
}
|
||
function getSettingsPath() {
|
||
if (process.env.CLAUDE_SETTINGS_PATH) {
|
||
return process.env.CLAUDE_SETTINGS_PATH;
|
||
}
|
||
return join3(homedir4(), ".claude", "settings.json");
|
||
}
|
||
function isPluginEnabled() {
|
||
const settingsPath = getSettingsPath();
|
||
if (!existsSync3(settingsPath)) {
|
||
return false;
|
||
}
|
||
try {
|
||
const content = readFileSync2(settingsPath, "utf-8");
|
||
const settings = JSON.parse(content);
|
||
if (!settings.enabledPlugins) {
|
||
return false;
|
||
}
|
||
const pluginKey = "safety-net@cc-marketplace";
|
||
if (!(pluginKey in settings.enabledPlugins)) {
|
||
return false;
|
||
}
|
||
return settings.enabledPlugins[pluginKey] === true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
async function printStatusline() {
|
||
const enabled = isPluginEnabled();
|
||
let status;
|
||
if (!enabled) {
|
||
status = "\uD83D\uDEE1️ Safety Net ❌";
|
||
} else {
|
||
const strict = envTruthy("SAFETY_NET_STRICT");
|
||
const paranoidAll = envTruthy("SAFETY_NET_PARANOID");
|
||
const paranoidRm = paranoidAll || envTruthy("SAFETY_NET_PARANOID_RM");
|
||
const paranoidInterpreters = paranoidAll || envTruthy("SAFETY_NET_PARANOID_INTERPRETERS");
|
||
let modeEmojis = "";
|
||
if (strict) {
|
||
modeEmojis += "\uD83D\uDD12";
|
||
}
|
||
if (paranoidAll || paranoidRm && paranoidInterpreters) {
|
||
modeEmojis += "\uD83D\uDC41️";
|
||
} else if (paranoidRm) {
|
||
modeEmojis += "\uD83D\uDDD1️";
|
||
} else if (paranoidInterpreters) {
|
||
modeEmojis += "\uD83D\uDC1A";
|
||
}
|
||
const statusEmoji = modeEmojis || "✅";
|
||
status = `\uD83D\uDEE1️ Safety Net ${statusEmoji}`;
|
||
}
|
||
const stdinInput = await readStdinAsync();
|
||
if (stdinInput && !stdinInput.startsWith("{")) {
|
||
console.log(`${stdinInput} | ${status}`);
|
||
} else {
|
||
console.log(status);
|
||
}
|
||
}
|
||
|
||
// src/bin/verify-config.ts
|
||
import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync } from "node:fs";
|
||
import { resolve as resolve3 } from "node:path";
|
||
var HEADER = "Safety Net Config";
|
||
var SEPARATOR = "═".repeat(HEADER.length);
|
||
var SCHEMA_URL = "https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json";
|
||
function printHeader() {
|
||
console.log(HEADER);
|
||
console.log(SEPARATOR);
|
||
}
|
||
function printValidConfig(scope, path, result) {
|
||
console.log(`
|
||
✓ ${scope} config: ${path}`);
|
||
if (result.ruleNames.size > 0) {
|
||
console.log(" Rules:");
|
||
let i = 1;
|
||
for (const name of result.ruleNames) {
|
||
console.log(` ${i}. ${name}`);
|
||
i++;
|
||
}
|
||
} else {
|
||
console.log(" Rules: (none)");
|
||
}
|
||
}
|
||
function printInvalidConfig(scope, path, errors) {
|
||
console.error(`
|
||
✗ ${scope} config: ${path}`);
|
||
console.error(" Errors:");
|
||
let errorNum = 1;
|
||
for (const error of errors) {
|
||
for (const part of error.split("; ")) {
|
||
console.error(` ${errorNum}. ${part}`);
|
||
errorNum++;
|
||
}
|
||
}
|
||
}
|
||
function addSchemaIfMissing(path) {
|
||
try {
|
||
const content = readFileSync3(path, "utf-8");
|
||
const parsed = JSON.parse(content);
|
||
if (parsed.$schema) {
|
||
return false;
|
||
}
|
||
const updated = { $schema: SCHEMA_URL, ...parsed };
|
||
writeFileSync(path, JSON.stringify(updated, null, 2), "utf-8");
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
function verifyConfig(options = {}) {
|
||
const userConfig = options.userConfigPath ?? getUserConfigPath();
|
||
const projectConfig = options.projectConfigPath ?? getProjectConfigPath();
|
||
let hasErrors = false;
|
||
const configsChecked = [];
|
||
printHeader();
|
||
if (existsSync4(userConfig)) {
|
||
const result = validateConfigFile(userConfig);
|
||
configsChecked.push({ scope: "User", path: userConfig, result });
|
||
if (result.errors.length > 0) {
|
||
hasErrors = true;
|
||
}
|
||
}
|
||
if (existsSync4(projectConfig)) {
|
||
const result = validateConfigFile(projectConfig);
|
||
configsChecked.push({
|
||
scope: "Project",
|
||
path: resolve3(projectConfig),
|
||
result
|
||
});
|
||
if (result.errors.length > 0) {
|
||
hasErrors = true;
|
||
}
|
||
}
|
||
if (configsChecked.length === 0) {
|
||
console.log(`
|
||
No config files found. Using built-in rules only.`);
|
||
return 0;
|
||
}
|
||
for (const { scope, path, result } of configsChecked) {
|
||
if (result.errors.length > 0) {
|
||
printInvalidConfig(scope, path, result.errors);
|
||
} else {
|
||
if (addSchemaIfMissing(path)) {
|
||
console.log(`
|
||
Added $schema to ${scope.toLowerCase()} config.`);
|
||
}
|
||
printValidConfig(scope, path, result);
|
||
}
|
||
}
|
||
if (hasErrors) {
|
||
console.error(`
|
||
Config validation failed.`);
|
||
return 1;
|
||
}
|
||
console.log(`
|
||
All configs valid.`);
|
||
return 0;
|
||
}
|
||
|
||
// src/bin/cc-safety-net.ts
|
||
function printCustomRulesDoc() {
|
||
console.log(CUSTOM_RULES_DOC);
|
||
}
|
||
function handleCliFlags() {
|
||
const args = process.argv.slice(2);
|
||
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
||
printHelp();
|
||
process.exit(0);
|
||
}
|
||
if (args.includes("--version") || args.includes("-V")) {
|
||
printVersion();
|
||
process.exit(0);
|
||
}
|
||
if (args.includes("--verify-config") || args.includes("-vc")) {
|
||
process.exit(verifyConfig());
|
||
}
|
||
if (args.includes("--custom-rules-doc")) {
|
||
printCustomRulesDoc();
|
||
process.exit(0);
|
||
}
|
||
if (args.includes("--statusline")) {
|
||
return "statusline";
|
||
}
|
||
if (args.includes("--claude-code") || args.includes("-cc")) {
|
||
return "claude-code";
|
||
}
|
||
if (args.includes("--gemini-cli") || args.includes("-gc")) {
|
||
return "gemini-cli";
|
||
}
|
||
console.error(`Unknown option: ${args[0]}`);
|
||
console.error("Run 'cc-safety-net --help' for usage.");
|
||
process.exit(1);
|
||
}
|
||
async function main() {
|
||
const mode = handleCliFlags();
|
||
if (mode === "claude-code") {
|
||
await runClaudeCodeHook();
|
||
} else if (mode === "gemini-cli") {
|
||
await runGeminiCLIHook();
|
||
} else if (mode === "statusline") {
|
||
await printStatusline();
|
||
}
|
||
}
|
||
main().catch((error) => {
|
||
console.error("Safety Net error:", error);
|
||
process.exit(1);
|
||
});
|